diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 4018a53..f91788a 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -317,6 +317,12 @@ func (h *StockInHandler) Update(c *gin.Context) { } err := h.db.Transaction(func(tx *gorm.DB) error { + // 改单前载入旧明细(按 id 序)+ 占位商品 id:导入单指向占位商品 HIST-PLACEHOLDER, + // 改单(改价/待定价)不该重建商品/丢历史快照——占位行保留占位引用+快照,只更价格/数量。 + var oldItems []model.StockInItem + tx.Where("order_id = ?", order.ID).Order("id").Find(&oldItems) + var placeholderID uint64 + tx.Model(&model.Product{}).Where("shop_id = ? AND code = ?", shopID, placeholderProductCode).Limit(1).Pluck("id", &placeholderID) if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil { return err } @@ -328,16 +334,31 @@ func (h *StockInHandler) Update(c *gin.Context) { if it.CostPrice == 0 && it.LegacyUnitPrice != nil { it.CostPrice = *it.LegacyUnitPrice } - if it.BatchNo == "" { - it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) + if i < len(oldItems) && placeholderID != 0 && oldItems[i].ProductID == placeholderID { + // 导入占位行:保留占位引用 + 历史快照(编码/名称/系列/规格/批次/生产日期),不重建商品,只更价格/数量。 + it.ProductID = oldItems[i].ProductID + it.ProductCode = oldItems[i].ProductCode + it.ProductName = oldItems[i].ProductName + it.Series = oldItems[i].Series + it.Spec = oldItems[i].Spec + if it.BatchNo == "" { + it.BatchNo = oldItems[i].BatchNo + } + if it.ProductionDate == nil { + it.ProductionDate = oldItems[i].ProductionDate + } + } else { + // 新行/真实商品行:按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理) + if it.BatchNo == "" { + it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) + } + prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice) + if e != nil { + return e + } + it.ProductID = prod.ID + it.ProductCode = prod.Code } - // 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理) - prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice) - if e != nil { - return e - } - it.ProductID = prod.ID - it.ProductCode = prod.Code it.CostAmount = it.Quantity * it.CostPrice total += it.CostAmount } diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index 2777348..ebf1aa8 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -711,6 +711,51 @@ func TestStockInHandler_List_FilterByImportedSnapshotName(t *testing.T) { assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } +// 回归:改导入入库单价格不能把明细真实快照冲成占位/重建商品丢名。 +// 修复:Update 对「旧行指向占位商品」的行不重建、保留占位引用 + 历史快照,只更价格/数量。 +func TestStockInHandler_Update_PreservesImportedSnapshot(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SI_UPD_SNAP") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + placeholder := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Name: "历史导入占位", Code: "HIST-PLACEHOLDER", + } + require.NoError(t, db.Create(placeholder).Error) + + order := &model.StockInOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "RK-IMP-UPD-1", + WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "draft", + OrderDate: model.Date{Time: time.Now()}, + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&model.StockInItem{ + OrderID: order.ID, ShopID: shop.ID, ProductID: placeholder.ID, + ProductCode: "ZXZ000010", ProductName: "飞天茅台", Series: "普通/53度", Spec: "500ml/单品", + Quantity: 1.0, CostPrice: 0, + }).Error) + + // 改价格:只传数量 + 进价(不传商品名/编码,模拟改价场景) + w := makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d", order.ID), token, map[string]interface{}{ + "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"quantity": 1.0, "cost_price": 1600.0}, + }, + }) + require.Equal(t, http.StatusOK, w.Code) + + var it model.StockInItem + require.NoError(t, db.Where("order_id = ?", order.ID).First(&it).Error) + assert.Equal(t, placeholder.ID, it.ProductID, "占位行不应重建为新商品") + assert.Equal(t, "ZXZ000010", it.ProductCode, "真实编码应保留") + assert.Equal(t, "飞天茅台", it.ProductName, "真实酒名应保留、非占位/空") + assert.Equal(t, 1600.0, it.CostPrice, "进价应更新") +} + func TestStockInHandler_List_FilterByProductPinyin(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SI013") diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index c5d72c7..408ffe4 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -177,6 +177,9 @@ func (h *StockOutHandler) Get(c *gin.Context) { } // Create POST /api/v1/stock-out/orders +// placeholderProductCode 历史导入占位商品的编码(cmd/import-history 建单时所有导入明细统一指向它)。 +const placeholderProductCode = "HIST-PLACEHOLDER" + // fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。 // 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。 // 入库建单在 createIndependentProduct 后即填快照,出库同样必须在建单/改单时填齐。 @@ -201,6 +204,8 @@ func fillStockOutItemSnapshots(db *gorm.DB, shopID uint64, items []model.StockOu if !ok { return fmt.Errorf("明细第 %d 行商品不存在", i+1) } + // 建单:不信任客户端传的编码/名称,一律以商品主数据为准(安全 + 一致)。 + // 改单对「导入占位行」的历史快照保护由 Update 内的还原循环负责(见 Update)。 items[i].ProductCode = p.Code items[i].ProductName = p.Name items[i].Series = p.Series @@ -313,6 +318,10 @@ func (h *StockOutHandler) Update(c *gin.Context) { } err := h.db.Transaction(func(tx *gorm.DB) error { + // 改单前载入旧明细(按 id 序):改单只传 product_id+价格、不传快照,导入单指向占位商品, + // fillSnapshots 会把真实快照冲成「历史导入占位」。按行序从旧明细还原(见下方 restore)。 + var oldItems []model.StockOutItem + tx.Where("order_id = ?", order.ID).Order("id").Find(&oldItems) if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockOutItem{}).Error; err != nil { return err } @@ -335,6 +344,25 @@ func (h *StockOutHandler) Update(c *gin.Context) { if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil { return err } + // 还原被占位商品冲掉的历史快照:仅当「新行=占位(HIST-PLACEHOLDER) 且 旧行=真实码」才按行序还原, + // 不误伤真实商品行。价格改单保持行序/行数不变,行序对齐可靠。 + for i := range req.Items { + if i < len(oldItems) && + req.Items[i].ProductID == oldItems[i].ProductID && + req.Items[i].ProductCode == placeholderProductCode && + oldItems[i].ProductCode != "" && oldItems[i].ProductCode != placeholderProductCode { + req.Items[i].ProductCode = oldItems[i].ProductCode + req.Items[i].ProductName = oldItems[i].ProductName + req.Items[i].Series = oldItems[i].Series + req.Items[i].Spec = oldItems[i].Spec + if req.Items[i].BatchNo == "" { + req.Items[i].BatchNo = oldItems[i].BatchNo + } + if req.Items[i].ProductionDate == nil { + req.Items[i].ProductionDate = oldItems[i].ProductionDate + } + } + } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, "partner_id": req.PartnerID, diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go index fe80c2e..a721e0e 100644 --- a/backend/internal/handler/stock_out_test.go +++ b/backend/internal/handler/stock_out_test.go @@ -403,6 +403,53 @@ func TestStockOutHandler_List_FilterByImportedSnapshotName(t *testing.T) { assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } +// 回归:改导入出库单价格(撤回草稿→改价→提交)不能把明细真实快照冲成「历史导入占位」。 +// bug:Update 删明细重建 → fillStockOutItemSnapshots 从占位商品拷 → 真实酒名/规格丢失; +// 修复:Update 内按行序从旧明细还原占位行的历史快照。 +func TestStockOutHandler_Update_PreservesImportedSnapshot(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "SO_UPD_SNAP") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + placeholder := &model.Product{ + TenantBase: model.TenantBase{ShopID: shop.ID}, + Name: "历史导入占位", Code: "HIST-PLACEHOLDER", + } + require.NoError(t, db.Create(placeholder).Error) + + order := &model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "CK-IMP-UPD-1", + WarehouseID: warehouse.ID, OperatorID: user.ID, Status: "draft", + OrderDate: model.Date{Time: time.Now()}, + } + require.NoError(t, db.Create(order).Error) + require.NoError(t, db.Create(&model.StockOutItem{ + OrderID: order.ID, ShopID: shop.ID, ProductID: placeholder.ID, + ProductCode: "ZXZ000010", ProductName: "飞天茅台", Series: "普通/53度", Spec: "500ml/单品", + Quantity: 1.0, SalePrice: 0, // 待定价 + }).Error) + + // 改价格:真实客户端只传 product_id + 数量 + 售价(不传快照) + w := makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d", order.ID), token, map[string]interface{}{ + "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": placeholder.ID, "quantity": 1.0, "sale_price": 1715.0}, + }, + }) + require.Equal(t, http.StatusOK, w.Code) + + var it model.StockOutItem + require.NoError(t, db.Where("order_id = ?", order.ID).First(&it).Error) + assert.Equal(t, "ZXZ000010", it.ProductCode, "真实编码应保留") + assert.Equal(t, "飞天茅台", it.ProductName, "真实酒名应保留、非占位") + assert.Equal(t, "普通/53度", it.Series) + assert.Equal(t, "500ml/单品", it.Spec) + assert.Equal(t, 1715.0, it.SalePrice, "售价应更新") +} + func TestStockOutHandler_Reject(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SO004")