fix(backend): 改导入出入库单价格不再把明细快照冲成「历史导入占位」
- 出库 Update:删明细重建后按行序从旧明细还原占位行(HIST-PLACEHOLDER)的历史快照 (编码/酒名/系列/规格/批次/生产日期),修复「撤回草稿→改价→提交」把真实酒名冲成占位 - 入库 Update:旧行指向占位商品时不重建商品、保留占位引用 + 历史快照,只更价格/数量 - 建单行为不变(fillStockOutItemSnapshots 仍以商品主数据为准,不信任客户端编码) - 新增出/入库两个回归测试 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user