diff --git a/docs/MMS-用户故事-v2.3.md b/docs/MMS-用户故事-v2.3.md index 1dbe9f27..780a91cc 100644 --- a/docs/MMS-用户故事-v2.3.md +++ b/docs/MMS-用户故事-v2.3.md @@ -405,6 +405,14 @@ - 预警以可用库存而非账面库存判断。 - 查询支持组织、仓库、分类、物料类型和处理状态筛选。 +#### 当前实现记录(2026-09-23) + +- “库存管理”菜单下保持“库存查询”“入库管理”“出库管理”三个入口。 +- 仓库和库位基础数据已可在“库存查询 → 仓库设置”中维护,但多仓库和多库位业务流程尚未完全实现。 +- 系统目前仍主要使用默认仓库 `DEFAULT` 和主库位 `MAIN` 处理入库、出库及库存相关数据。 +- 待实现:在“库存查询”页面的“仓库设置”旁增加“切换仓库”入口,将所选仓库作为全局当前仓库,并统一应用到库存查询、库存预警、库存流水、入库管理和出库管理。 +- 待进一步设计:多库位场景下入库、出库的库位选择规则,以及库存查询按库位筛选和展示的交互。 + --- ### 5.6 库存变动管理 diff --git a/frontend/src/components/business/BasePagination.test.js b/frontend/src/components/business/BasePagination.test.js new file mode 100644 index 00000000..b25a5862 --- /dev/null +++ b/frontend/src/components/business/BasePagination.test.js @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { describe, expect, test } from 'vitest' +import BasePagination from './BasePagination.vue' + +function mountPagination(props) { + return mount(BasePagination, { + props, + global: { plugins: [ElementPlus] } + }) +} + +function infoText(wrapper) { + return wrapper.find('.page-info').text() +} + +describe('分页文案', () => { + test('中间页显示当前区间', () => { + const wrapper = mountPagination({ currentPage: 2, pageSize: 20, total: 41 }) + expect(infoText(wrapper)).toBe('显示 21-40 条,共 41 条') + }) + + test('末页不足一页时按实际条数收尾', () => { + const wrapper = mountPagination({ currentPage: 3, pageSize: 20, total: 41 }) + expect(infoText(wrapper)).toBe('显示 41-41 条,共 41 条') + }) + + test('空列表显示 0-0,不显示 1-0', () => { + const wrapper = mountPagination({ currentPage: 1, pageSize: 20, total: 0 }) + expect(infoText(wrapper)).toBe('显示 0-0 条,共 0 条') + }) + + // 删掉末页最后一条:total 已经缩到 40,父组件的 currentPage 还停在 3, + // 文案得按收敛后的第 2 页算,不能出现「显示 41-40 条」 + test('total 缩减导致当前页超界时按收敛后的页码算', async () => { + const wrapper = mountPagination({ currentPage: 3, pageSize: 20, total: 41 }) + await wrapper.setProps({ total: 40 }) + expect(infoText(wrapper)).toBe('显示 21-40 条,共 40 条') + }) + + test('当前页远超总页数时收敛到末页', () => { + const wrapper = mountPagination({ currentPage: 99, pageSize: 20, total: 41 }) + expect(infoText(wrapper)).toBe('显示 41-41 条,共 41 条') + }) + + test('pageSize 缺省时按 10 兜底,不出现 NaN', () => { + const wrapper = mountPagination({ currentPage: 1, total: 5 }) + expect(infoText(wrapper)).toBe('显示 1-5 条,共 5 条') + }) +}) diff --git a/frontend/src/components/business/BasePagination.vue b/frontend/src/components/business/BasePagination.vue index 30942a42..b47e8dcd 100644 --- a/frontend/src/components/business/BasePagination.vue +++ b/frontend/src/components/business/BasePagination.vue @@ -28,9 +28,20 @@ const currentPage = computed({ set: (val) => emit('update:currentPage', val) }) +// el-pagination 的 pageSize 缺省是 10,这里跟着兜底,免得算出 NaN +const pageSize = computed(() => props.pageSize || 10) +// 总页数至少 1,和 el-pagination 内部 pageCountBridge 的算法保持一致 +const totalPages = computed(() => Math.max(1, Math.ceil((props.total || 0) / pageSize.value))) +// 页码可能超出总页数:删掉末页最后一条时父组件还没把 currentPage 收敛下来。 +// el-pagination 自己会收敛并 emit change 让父组件重拉,但那之间会闪一帧倒挂文案 +// (「显示 41-40 条」),父组件那次请求失败的话更是永久留在页面上。 +// 这里按收敛后的页码算区间,让文案和 el-pagination 实际渲染出的页码一致。 +const safePage = computed(() => Math.min(Math.max(1, props.currentPage || 1), totalPages.value)) + const pageInfo = computed(() => { - const start = (props.currentPage - 1) * props.pageSize + 1 - const end = Math.min(props.currentPage * props.pageSize, props.total) + if (!props.total) return '显示 0-0 条,共 0 条' + const start = (safePage.value - 1) * pageSize.value + 1 + const end = Math.min(safePage.value * pageSize.value, props.total) return `显示 ${start}-${end} 条,共 ${props.total} 条` }) diff --git a/internal/repository/stock.go b/internal/repository/stock.go index ec0b8f95..e28109f9 100644 --- a/internal/repository/stock.go +++ b/internal/repository/stock.go @@ -355,6 +355,22 @@ func (r *StockRepo) GetStock(materialID int64) (decimal.Decimal, error) { return stock, err } +// GetDefaultStock reads the balance that legacy outbound orders actually deduct. +func (r *StockRepo) GetDefaultStock(materialID int64) (decimal.Decimal, error) { + query := `SELECT ib.on_hand + FROM inventory_balances ib + JOIN organizations o ON o.id = ib.organization_id AND o.code = 'DEFAULT' + JOIN warehouses w ON w.id = ib.warehouse_id AND w.code = 'DEFAULT' + JOIN warehouse_locations l ON l.id = ib.location_id AND l.code = 'MAIN' + WHERE ib.material_id = ?` + var stock decimal.Decimal + err := database.DB.QueryRow(query, materialID).Scan(&stock) + if err == sql.ErrNoRows { + return decimal.Zero, nil + } + return stock, err +} + // GetStockForUpdateTx locks the legacy default location balance used by current inbound/outbound flows. func (r *StockRepo) GetStockForUpdateTx(tx *sql.Tx, materialID int64) (decimal.Decimal, error) { query := `SELECT ib.on_hand diff --git a/internal/service/outbound.go b/internal/service/outbound.go index 3576c93a..050ba121 100644 --- a/internal/service/outbound.go +++ b/internal/service/outbound.go @@ -52,7 +52,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri return nil, ErrMaterialObsolete } - currentStock, err := s.stockRepo.GetStock(item.MaterialID) + currentStock, err := s.stockRepo.GetDefaultStock(item.MaterialID) if err != nil { return nil, err } @@ -71,11 +71,7 @@ func (s *OutboundService) Create(req *model.CreateOutboundRequest, operator stri if err != nil { return nil, err } - defer func() { - if err != nil { - tx.Rollback() - } - }() + defer tx.Rollback() // Re-check stock with row lock (SELECT FOR UPDATE) to prevent overselling for i := range order.Items { @@ -159,11 +155,7 @@ func (s *OutboundService) Delete(id int64) error { if err != nil { return err } - defer func() { - if err != nil { - tx.Rollback() - } - }() + defer tx.Rollback() // Reverse stock for each item (add back) within transaction for _, item := range order.Items { @@ -217,7 +209,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope return nil, ErrMaterialObsolete } - currentStock, err := s.stockRepo.GetStock(item.MaterialID) + currentStock, err := s.stockRepo.GetDefaultStock(item.MaterialID) if err != nil { return nil, err } @@ -240,11 +232,7 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope if err != nil { return nil, err } - defer func() { - if err != nil { - tx.Rollback() - } - }() + defer tx.Rollback() // Reverse stock for old items (add back what was previously subtracted) for _, item := range oldOrder.Items { @@ -260,15 +248,8 @@ func (s *OutboundService) Update(id int64, req *model.UpdateOutboundRequest, ope if err != nil { return nil, err } - oldQty := 0 - for _, oldItem := range oldOrder.Items { - if oldItem.MaterialID == item.MaterialID { - oldQty = oldItem.Quantity - break - } - } - effectiveStock := lockedStock.Add(decimal.NewFromInt(int64(oldQty))) - if effectiveStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) { + // Old quantities have already been restored above. + if lockedStock.LessThan(decimal.NewFromInt(int64(item.Quantity))) { err = ErrInsufficientStock return nil, err } diff --git a/internal/service/outbound_test.go b/internal/service/outbound_test.go new file mode 100644 index 00000000..5a41c2ff --- /dev/null +++ b/internal/service/outbound_test.go @@ -0,0 +1,116 @@ +package service + +import ( + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" +) + +func outboundMock(t *testing.T) sqlmock.Sqlmock { + t.Helper() + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + previous := database.DB + database.DB = db + t.Cleanup(func() { database.DB = previous; db.Close() }) + return mock +} + +func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) { + for i := 0; i < 2; i++ { + mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}). + AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now())) + } +} + +func createOutboundRequest() *model.CreateOutboundRequest { + req := &model.CreateOutboundRequest{Receiver: "receiver"} + req.Items = append(req.Items, struct { + MaterialID int64 `json:"material_id" binding:"required"` + Quantity int `json:"quantity" binding:"required,gt=0"` + }{MaterialID: 7, Quantity: 2}) + return req +} + +func TestOutboundCreateRollsBackWhenLockedStockRunsOut(t *testing.T) { + mock := outboundMock(t) + expectActiveMaterial(mock, 7) + mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2")) + mock.ExpectBegin() + mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1")) + mock.ExpectRollback() + _, err := NewOutboundService().Create(createOutboundRequest(), "operator") + if !errors.Is(err, ErrInsufficientStock) { + t.Fatalf("got %v, want insufficient stock", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestOutboundCreateChecksDefaultLocationBeforeTransaction(t *testing.T) { + mock := outboundMock(t) + expectActiveMaterial(mock, 7) + mock.ExpectQuery("SELECT ib.on_hand.*FROM inventory_balances").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("1")) + _, err := NewOutboundService().Create(createOutboundRequest(), "operator") + if !errors.Is(err, ErrInsufficientStock) { + t.Fatalf("got %v, want insufficient stock", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing.T) { + mock := outboundMock(t) + expectActiveMaterial(mock, 7) + mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2")) + mock.ExpectBegin() + mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2")) + mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1)) + mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1)) + mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(7)).WillReturnError(errors.New("material read failed")) + mock.ExpectRollback() + _, err := NewOutboundService().Create(createOutboundRequest(), "operator") + if err == nil || err.Error() != "material read failed" { + t.Fatalf("got %v, want material read failure", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestOutboundUpdateDoesNotCountRestoredStockTwice(t *testing.T) { + mock := outboundMock(t) + now := time.Now() + mock.ExpectQuery("SELECT id, COALESCE\\(outbound_no").WithArgs(int64(11)).WillReturnRows( + sqlmock.NewRows([]string{"id", "outbound_no", "operator", "outbound_date", "handler", "receiver", "remark", "created_at", "updated_at"}).AddRow(11, "OUT-11", "operator", now, "operator", "receiver", "", now, now)) + mock.ExpectQuery("SELECT oi.id, oi.outbound_order_id").WithArgs(int64(11)).WillReturnRows( + sqlmock.NewRows([]string{"id", "outbound_order_id", "material_id", "quantity", "material_code", "name", "status"}).AddRow(12, 11, 7, 2, "M-1", "Material", "active")) + expectActiveMaterial(mock, 7) + mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("2")) + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("SELECT ib.on_hand").WithArgs(int64(7)).WillReturnRows(sqlmock.NewRows([]string{"on_hand"}).AddRow("3")) + mock.ExpectRollback() + req := &model.UpdateOutboundRequest{Receiver: "receiver"} + req.Items = append(req.Items, struct { + MaterialID int64 `json:"material_id" binding:"required"` + Quantity int `json:"quantity" binding:"required,gt=0"` + }{MaterialID: 7, Quantity: 4}) + _, err := NewOutboundService().Update(11, req, "operator") + if !errors.Is(err, ErrInsufficientStock) { + t.Fatalf("got %v, want insufficient stock", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}