diff --git a/backend/cmd/import-history/main.go b/backend/cmd/import-history/main.go index 4d63e84..c83abc0 100644 --- a/backend/cmd/import-history/main.go +++ b/backend/cmd/import-history/main.go @@ -497,7 +497,7 @@ func (ic *importCtx) writeOrder( TenantBase: model.TenantBase{ShopID: ic.shopID}, OrderNo: orderNo, Type: orderType, WarehouseID: ic.whID, PartnerID: partnerID, OperatorID: operatorID, CreatorID: creatorID, - ReviewerID: reviewerID, Status: status, OrderDate: orderDate, TotalAmount: totalAmount, + ReviewerID: reviewerID, Status: status, OrderDate: orderDate, CostTotal: totalAmount, } if err := tx.Create(&o).Error; err != nil { return err @@ -510,8 +510,8 @@ func (ic *importCtx) writeOrder( Series: get(dr, dCols, "系列"), Spec: get(dr, dCols, "规格"), Quantity: parseFloatLoose(get(dr, dCols, "数量")), - UnitPrice: parseFloatLoose(get(dr, dCols, "单价")), - TotalPrice: parseFloatLoose(get(dr, dCols, "金额")), + CostPrice: parseFloatLoose(get(dr, dCols, "单价")), + CostAmount: parseFloatLoose(get(dr, dCols, "金额")), BatchNo: get(dr, dCols, "批次号"), } it.ProductionDate = parseDatePtr(get(dr, dCols, "生产日期")) @@ -529,7 +529,8 @@ func (ic *importCtx) writeOrder( TenantBase: model.TenantBase{ShopID: ic.shopID}, OrderNo: orderNo, Type: orderType, WarehouseID: ic.whID, PartnerID: partnerID, OperatorID: operatorID, CreatorID: creatorID, - ReviewerID: reviewerID, Status: status, OrderDate: orderDate, TotalAmount: totalAmount, + // 忠实导入:源「金额」合计沿旧行为落应收合计(sale_price=0 待定价) + ReviewerID: reviewerID, Status: status, OrderDate: orderDate, SaleTotal: totalAmount, } if err := tx.Create(&o).Error; err != nil { return err @@ -542,8 +543,8 @@ func (ic *importCtx) writeOrder( Series: get(dr, dCols, "系列"), Spec: get(dr, dCols, "规格"), Quantity: parseFloatLoose(get(dr, dCols, "数量")), - UnitPrice: parseFloatLoose(get(dr, dCols, "单价")), - TotalPrice: parseFloatLoose(get(dr, dCols, "金额")), + CostPrice: parseFloatLoose(get(dr, dCols, "单价")), + CostAmount: parseFloatLoose(get(dr, dCols, "金额")), BatchNo: get(dr, dCols, "批次号"), } it.ProductionDate = parseDatePtr(get(dr, dCols, "生产日期")) diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index a71b435..dd834a9 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -408,14 +408,14 @@ func main() { // 财务记录(与已审核出库单对应的应收款,按售价口径) // ═══════════════════════════════════════════════════ createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID, - "receivable", out1.order.TotalAmount, out1.order.TotalAmount, "stock_out", out1.order.ID, + "receivable", out1.order.SaleTotal, out1.order.SaleTotal, "stock_out", out1.order.ID, d(3), "君悦大酒店4月供货应收款") createFinanceRecord(db, shop.ID, partners["CUS002"].ID, admin.ID, - "receivable", out2.order.TotalAmount, out2.order.TotalAmount, "stock_out", out2.order.ID, + "receivable", out2.order.SaleTotal, out2.order.SaleTotal, "stock_out", out2.order.ID, d(2), "外滩华尔道夫进口酒应收款") // 模拟一笔已收款 createFinanceRecord(db, shop.ID, partners["CUS001"].ID, admin.ID, - "receipt", out1.order.TotalAmount, 0, "stock_out", out1.order.ID, + "receipt", out1.order.SaleTotal, 0, "stock_out", out1.order.ID, d(1), "君悦大酒店回款,结清") // ═══════════════════════════════════════════════════ @@ -556,19 +556,19 @@ func (s *seeder) createStockIn( Series: g.series, Spec: g.spec, Quantity: ln.qty, - UnitPrice: ln.price, - TotalPrice: ln.qty * ln.price, + CostPrice: ln.price, + CostAmount: ln.qty * ln.price, BatchNo: ln.batch, ProductionDate: ln.prodDate, Remark: ln.remark, } s.db.Create(&item) - total += item.TotalPrice + total += item.CostAmount items = append(items, item) prods = append(prods, p) } - s.db.Model(&o).Update("total_amount", total) - o.TotalAmount = total + s.db.Model(&o).Update("cost_total", total) + o.CostTotal = total fmt.Printf("✅ 入库单:%s [%s] 供应商ID=%d 仓库=%s 行数=%d 金额=%.0f\n", orderNo, status, partnerID, whName, len(lines), total) @@ -604,7 +604,7 @@ func (s *seeder) applyStockInToInventory(r stockInResult) { whIDCopy := r.order.WarehouseID pidCopy := it.ProductID itemIDCopy := it.ID - price := it.UnitPrice + price := it.CostPrice inv := model.Inventory{ ShopID: s.shopID, WarehouseID: &whIDCopy, @@ -664,7 +664,7 @@ func (s *seeder) createStockOut( s.db.Create(&o) var ( - total float64 // 应收 = Σ 售价×数量 + total, profit float64 // 应收 = Σ 售价×数量;利润 = Σ(售价-成本)×数量 items []model.StockOutItem ) for _, ln := range lines { @@ -678,18 +678,21 @@ func (s *seeder) createStockOut( Series: p.Series, Spec: p.Spec, Quantity: ln.qty, - UnitPrice: p.PurchasePrice, // 成本单价(快照) - SalePrice: ln.sale, // 售价 - TotalPrice: ln.qty * p.PurchasePrice, // 成本小计 + CostPrice: p.PurchasePrice, // 成本单价(快照) + SalePrice: ln.sale, // 售价 + CostAmount: ln.qty * p.PurchasePrice, // 成本小计 + SaleAmount: ln.qty * ln.sale, // 售价小计 BatchNo: p.BatchNo, Remark: ln.remark, } s.db.Create(&item) total += ln.qty * ln.sale + profit += (ln.sale - p.PurchasePrice) * ln.qty items = append(items, item) } - s.db.Model(&o).Update("total_amount", total) - o.TotalAmount = total + s.db.Model(&o).Updates(map[string]interface{}{"sale_total": total, "profit_total": profit}) + o.SaleTotal = total + o.ProfitTotal = profit fmt.Printf("✅ 出库单:%s [%s] 客户ID=%d 仓库ID=%d 行数=%d 应收=%.0f\n", orderNo, status, partnerID, whID, len(lines), total) diff --git a/backend/import-history b/backend/import-history new file mode 100755 index 0000000..5517121 Binary files /dev/null and b/backend/import-history differ diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index dac317d..a7a4bd1 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -200,12 +200,13 @@ func (h *FinanceHandler) Trend(c *gin.Context) { In float64 `json:"in"` Out float64 `json:"out"` } - sum := func(table, from, to string) float64 { + // 出库=应收合计(sale_total),入库=应付合计(cost_total)——2026-07 定价字段消歧后分列 + sum := func(table, col, from, to string) float64 { var v float64 h.db.Table(table). Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). - Select("COALESCE(SUM(total_amount),0)").Scan(&v) + Select("COALESCE(SUM(" + col + "),0)").Scan(&v) return v } const f = "2006-01-02" @@ -215,8 +216,8 @@ func (h *FinanceHandler) Trend(c *gin.Context) { mEnd := mStart.AddDate(0, 1, 0) points = append(points, point{ Month: mStart.Format("2006-01"), - In: sum("stock_out_orders", mStart.Format(f), mEnd.Format(f)), - Out: sum("stock_in_orders", mStart.Format(f), mEnd.Format(f)), + In: sum("stock_out_orders", "sale_total", mStart.Format(f), mEnd.Format(f)), + Out: sum("stock_in_orders", "cost_total", mStart.Format(f), mEnd.Format(f)), }) } c.JSON(http.StatusOK, gin.H{"data": points}) diff --git a/backend/internal/handler/finance_test.go b/backend/internal/handler/finance_test.go index 4141c4f..39b102a 100644 --- a/backend/internal/handler/finance_test.go +++ b/backend/internal/handler/finance_test.go @@ -35,7 +35,7 @@ func TestFinanceHandler_Trend(t *testing.T) { OperatorID: user.ID, Status: "approved", OrderDate: model.Date{Time: date}, - TotalAmount: amount, + SaleTotal: amount, }).Error) } mkIn := func(shopID uint64, date time.Time, amount float64) { @@ -46,7 +46,7 @@ func TestFinanceHandler_Trend(t *testing.T) { OperatorID: user.ID, Status: "approved", OrderDate: model.Date{Time: date}, - TotalAmount: amount, + CostTotal: amount, }).Error) } mkOut(shop.ID, thisMonth, 1000) diff --git a/backend/internal/handler/import.go b/backend/internal/handler/import.go index a689bb4..a81d61c 100644 --- a/backend/internal/handler/import.go +++ b/backend/internal/handler/import.go @@ -339,8 +339,8 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) { ShopID: shopID, ProductID: prod.ID, Quantity: qty, - UnitPrice: price, - TotalPrice: total, + CostPrice: price, + CostAmount: total, BatchNo: batchNo, }) } @@ -359,7 +359,7 @@ func (h *ImportHandler) ImportStockIn(c *gin.Context) { OperatorID: userID, Status: "draft", OrderDate: orderDate, - TotalAmount: totalAmount, + CostTotal: totalAmount, } if err := h.db.Create(&order).Error; err != nil { @@ -442,8 +442,8 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) { ShopID: shopID, ProductID: prod.ID, Quantity: qty, - UnitPrice: price, - TotalPrice: total, + CostPrice: price, + CostAmount: total, }) } @@ -461,7 +461,8 @@ func (h *ImportHandler) ImportStockOut(c *gin.Context) { OperatorID: userID, Status: "draft", OrderDate: orderDate, - TotalAmount: totalAmount, + // 导入单沿旧行为:合计=源金额(明细 sale_price=0 待定价,确认售价后按差额调整) + SaleTotal: totalAmount, } if err := h.db.Create(&order).Error; err != nil { diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index ae4d0d8..03511d6 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -137,7 +137,7 @@ func (h *InventoryHandler) List(c *gin.Context) { COALESCE(NULLIF(p.spec,''), NULLIF(sii.spec,''), inv.spec, '') AS spec, COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit, COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name, - COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) AS unit_price, + COALESCE(sii.cost_price, inv.unit_price, p.purchase_price) AS unit_price, p.sale_price AS sale_price, COALESCE(DATE(sii.production_date), DATE(inv.production_date)) AS production_date, COALESCE(NULLIF(sii.batch_no,''), inv.batch_no, '') AS batch_no, @@ -187,13 +187,13 @@ func (h *InventoryHandler) Summary(c *gin.Context) { const sql = ` SELECT COUNT(*) AS sku_count, - COALESCE(SUM(inv.quantity * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price)), 0) AS stock_value, + COALESCE(SUM(inv.quantity * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)), 0) AS stock_value, COALESCE(SUM(inv.quantity), 0) AS in_stock_qty, COALESCE(SUM(CASE WHEN inv.quantity <= 0 THEN 1 ELSE 0 END), 0) AS shortage_count, COALESCE(SUM(CASE WHEN inv.quantity > 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count, COALESCE(SUM(CASE WHEN inv.created_at < ? THEN 1 ELSE 0 END), 0) AS last_month_sku, COALESCE(SUM(CASE WHEN inv.created_at < ? THEN inv.quantity - COALESCE(lg.net, 0) ELSE 0 END), 0) AS last_month_qty, - COALESCE(SUM(CASE WHEN inv.created_at < ? THEN (inv.quantity - COALESCE(lg.net, 0)) * COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) ELSE 0 END), 0) AS last_month_value + COALESCE(SUM(CASE WHEN inv.created_at < ? THEN (inv.quantity - COALESCE(lg.net, 0)) * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price) ELSE 0 END), 0) AS last_month_value FROM inventories inv LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL diff --git a/backend/internal/handler/pricing_fields_test.go b/backend/internal/handler/pricing_fields_test.go new file mode 100644 index 0000000..4e9692e --- /dev/null +++ b/backend/internal/handler/pricing_fields_test.go @@ -0,0 +1,212 @@ +package handler + +// 2026-07 定价字段消歧(cost_price/cost_amount/cost_total/sale_amount/sale_total/profit_total) +// 的专项回归:建单三值落库、确认售价联动、确认进价→利润联动、operator 响应抹零。 +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// 建单即落三值:sale_amount(行)、sale_total、profit_total(单据) +func TestStockOutHandler_Create_PricingFields(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PRC1") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + prod := testutil.CreateTestProduct(db, shop.ID, "Gin") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100}) + + // 成本 355 × 2,售价 400 × 2 —— 即线上暴露口径问题的那组数 + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": prod.ID, "quantity": 2.0, "cost_price": 355.0, "sale_price": 400.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + orderID := extractID(w) + + var it model.StockOutItem + db.Where("order_id = ?", orderID).First(&it) + assert.Equal(t, float64(355), it.CostPrice) + assert.Equal(t, float64(710), it.CostAmount) // 2×355 + assert.Equal(t, float64(800), it.SaleAmount) // 2×400 + var o model.StockOutOrder + db.First(&o, orderID) + assert.Equal(t, float64(800), o.SaleTotal) + assert.Equal(t, float64(90), o.ProfitTotal) // (400-355)×2 +} + +// 兼容回归:旧客户端发 unit_price(无 cost_price)→ 回落生效 +func TestStockOutHandler_Create_LegacyUnitPriceKey(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PRC2") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + prod := testutil.CreateTestProduct(db, shop.ID, "Rum") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 10}) + + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": prod.ID, "quantity": 3.0, "unit_price": 20.0, "sale_price": 25.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + var it model.StockOutItem + db.Where("order_id = ?", extractID(w)).First(&it) + assert.Equal(t, float64(20), it.CostPrice) + assert.Equal(t, float64(60), it.CostAmount) +} + +// 确认售价:sale_price/sale_amount/sale_total/profit_total 四值联动重算 +func TestStockOutHandler_ConfirmSale_RecalcsAmounts(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PRC3") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + prod := testutil.CreateTestProduct(db, shop.ID, "Sake") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 100}) + + // 待定价出库:成本 5 × 10 + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": prod.ID, "quantity": 10.0, "cost_price": 5.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + orderID := extractID(w) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil) + + var it model.StockOutItem + db.Where("order_id = ?", orderID).First(&it) + assert.Equal(t, float64(0), it.SaleAmount) // 待定价 + + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-out/orders/%d/confirm-sale", orderID), token, map[string]interface{}{ + "items": []map[string]interface{}{{"item_id": it.ID, "sale_price": 8.0}}, + }) + require.Equal(t, http.StatusOK, w.Code) + + var got model.StockOutItem + db.First(&got, it.ID) + assert.Equal(t, float64(8), got.SalePrice) + assert.Equal(t, float64(80), got.SaleAmount) + var o model.StockOutOrder + db.First(&o, orderID) + assert.Equal(t, float64(80), o.SaleTotal) + assert.Equal(t, float64(30), o.ProfitTotal) // (8-5)×10 +} + +// 确认进价(成本回填)后,受影响出库单的 profit_total 联动重算 +func TestConfirmStockInCost_RecalcsStockOutProfit(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PRC4") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + token := getAuthToken(user.ID, shop.ID, "admin") + r := setupProtectedRouter(db) + + // 0 价暂估入库(新建独立产品)→ 审核 + w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_name": "调货茅台", "series": "普通", "spec": "500ml", "quantity": 10.0, "cost_price": 0.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + inOrderID := extractID(w) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", inOrderID), token, nil) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", inOrderID), token, nil) + + var inItem model.StockInItem + require.NoError(t, db.Where("order_id = ?", inOrderID).First(&inItem).Error) + + // 以 0 成本出库 2 瓶、售价 10 → 审核;利润此刻 = (10-0)×2 = 20 + w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": inItem.ProductID, "quantity": 2.0, "cost_price": 0.0, "sale_price": 10.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + outOrderID := extractID(w) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", outOrderID), token, nil) + makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", outOrderID), token, nil) + + var beforeO model.StockOutOrder + db.First(&beforeO, outOrderID) + assert.Equal(t, float64(20), beforeO.ProfitTotal) + + // 确认进价 4 → 出库成本快照回填 → 利润重算 (10-4)×2 = 12 + w = makeRequest(r, "POST", fmt.Sprintf("/api/v1/stock-in/orders/%d/confirm-cost", inOrderID), token, map[string]interface{}{ + "items": []map[string]interface{}{{"item_id": inItem.ID, "unit_price": 4.0}}, + }) + require.Equal(t, http.StatusOK, w.Code) + + var outItem model.StockOutItem + db.Where("order_id = ?", outOrderID).First(&outItem) + assert.Equal(t, float64(4), outItem.CostPrice) + assert.Equal(t, float64(8), outItem.CostAmount) + var afterO model.StockOutOrder + db.First(&afterO, outOrderID) + assert.Equal(t, float64(12), afterO.ProfitTotal) +} + +// 成本/利润仅管理员可见:operator 的 List/Get 响应中 cost_price/cost_amount/profit_total 被抹零 +func TestStockOutHandler_CostHiddenFromOperator(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "PRC5") + admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + op := testutil.CreateTestUser(db, shop.ID, "op1", "pass", "operator") + wh := testutil.CreateTestWarehouse(db, shop.ID, "W") + prod := testutil.CreateTestProduct(db, shop.ID, "Vermouth") + adminToken := getAuthToken(admin.ID, shop.ID, "admin") + opToken := getAuthToken(op.ID, shop.ID, "operator") + r := setupProtectedRouter(db) + db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &prod.ID, Quantity: 10}) + + w := makeRequest(r, "POST", "/api/v1/stock-out/orders", adminToken, map[string]interface{}{ + "warehouse_id": wh.ID, "order_date": time.Now().Format(time.RFC3339), + "items": []map[string]interface{}{ + {"product_id": prod.ID, "quantity": 2.0, "cost_price": 30.0, "sale_price": 50.0}, + }, + }) + require.Equal(t, http.StatusCreated, w.Code) + orderID := extractID(w) + + // 管理员可见成本与利润 + w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), adminToken, nil) + require.Equal(t, http.StatusOK, w.Code) + data := parseResponse(w)["data"].(map[string]interface{}) + assert.Equal(t, float64(40), data["profit_total"]) + items := data["items"].([]interface{}) + assert.Equal(t, float64(30), items[0].(map[string]interface{})["cost_price"]) + + // operator:成本/利润抹零,售价照常 + w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), opToken, nil) + require.Equal(t, http.StatusOK, w.Code) + data = parseResponse(w)["data"].(map[string]interface{}) + assert.Equal(t, float64(0), data["profit_total"]) + items = data["items"].([]interface{}) + line := items[0].(map[string]interface{}) + assert.Equal(t, float64(0), line["cost_price"]) + assert.Equal(t, float64(0), line["cost_amount"]) + assert.Equal(t, float64(50), line["sale_price"]) + assert.Equal(t, float64(100), line["sale_amount"]) +} diff --git a/backend/internal/handler/product.go b/backend/internal/handler/product.go index 48e74d2..d09d873 100644 --- a/backend/internal/handler/product.go +++ b/backend/internal/handler/product.go @@ -251,11 +251,11 @@ func (h *ProductHandler) PriceHistory(c *gin.Context) { points := make([]pricePoint, 0) // 守多租户:order 的 shop_id 必须等于当前店;只取已审核单的正单价。 h.db.Raw(` - SELECT o.order_date AS date, sii.unit_price AS price + SELECT o.order_date AS date, sii.cost_price AS price FROM stock_in_items sii JOIN stock_in_orders o ON o.id = sii.order_id WHERE sii.product_id = ? AND o.shop_id = ? AND o.status = 'approved' - AND sii.unit_price > 0 + AND sii.cost_price > 0 ORDER BY o.order_date DESC, sii.id DESC LIMIT 20`, id, shopID).Scan(&points) diff --git a/backend/internal/handler/product_test.go b/backend/internal/handler/product_test.go index 73a62af..7aaa9fb 100644 --- a/backend/internal/handler/product_test.go +++ b/backend/internal/handler/product_test.go @@ -313,7 +313,7 @@ func TestProductHandler_PriceHistory(t *testing.T) { require.NoError(t, db.Create(o).Error) require.NoError(t, db.Create(&model.StockInItem{ OrderID: o.ID, ShopID: shop.ID, ProductID: product.ID, - UnitPrice: price, Quantity: 1, + CostPrice: price, Quantity: 1, }).Error) } mkOrder("RK-PH-1", 60, 2580, "approved") diff --git a/backend/internal/handler/stock_cost_confirm_test.go b/backend/internal/handler/stock_cost_confirm_test.go index e35b888..44a4248 100644 --- a/backend/internal/handler/stock_cost_confirm_test.go +++ b/backend/internal/handler/stock_cost_confirm_test.go @@ -37,7 +37,7 @@ func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) { require.NoError(t, db.Where("order_id = ?", inID).First(&inItem).Error) pid := inItem.ProductID // 序列号模型:入库为该行新建独立 product - // 库存批次成本待定(UnitPrice 为 NULL) + // 库存批次成本待定(CostPrice 为 NULL) var inv model.Inventory require.NoError(t, db.Where("shop_id = ? AND stock_in_item_id = ?", shop.ID, inItem.ID).First(&inv).Error) assert.Nil(t, inv.UnitPrice, "0 价入库时库存成本应为待定(NULL)") @@ -58,8 +58,8 @@ func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) { // 1. 入库明细成本回填 db.First(&inItem, inItem.ID) - assert.Equal(t, 50.0, inItem.UnitPrice) - assert.Equal(t, 500.0, inItem.TotalPrice) + assert.Equal(t, 50.0, inItem.CostPrice) + assert.Equal(t, 500.0, inItem.CostAmount) // 2. 剩余库存批次成本回填(6 件) db.First(&inv, inv.ID) @@ -69,18 +69,18 @@ func TestConfirmCost_BackfillsCostAndAdjustsPayable(t *testing.T) { // 3. 已出库行成本快照回填,售价/应收不变 var outItem model.StockOutItem require.NoError(t, db.Where("order_id = ?", outID).First(&outItem).Error) - assert.Equal(t, 50.0, outItem.UnitPrice, "已出库成本应回填") - assert.Equal(t, 200.0, outItem.TotalPrice, "成本小计 = 50×4") + assert.Equal(t, 50.0, outItem.CostPrice, "已出库成本应回填") + assert.Equal(t, 200.0, outItem.CostAmount, "成本小计 = 50×4") assert.Equal(t, 80.0, outItem.SalePrice, "售价不应被改动") var outOrder model.StockOutOrder db.First(&outOrder, outID) - assert.Equal(t, 320.0, outOrder.TotalAmount, "应收按售价 4×80,确认进价不影响") + assert.Equal(t, 320.0, outOrder.SaleTotal, "应收按售价 4×80,确认进价不影响") // 4. 入库单总额按差额重算:0 → 500 var inOrder model.StockInOrder db.First(&inOrder, inID) - assert.Equal(t, 500.0, inOrder.TotalAmount) + assert.Equal(t, 500.0, inOrder.CostTotal) // 5. 应付差额调整流水:+500(balance 是应收应付混合滚动总账:0 入 + 320 应收 + 500 = 820) var adj model.FinanceRecord @@ -175,11 +175,11 @@ func TestConfirmCost_RepeatableByDiff(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) db.First(&inItem, inItem.ID) - assert.Equal(t, 60.0, inItem.UnitPrice) + assert.Equal(t, 60.0, inItem.CostPrice) var inOrder model.StockInOrder db.First(&inOrder, inID) - assert.Equal(t, 600.0, inOrder.TotalAmount, "总额 = 60×10") + assert.Equal(t, 600.0, inOrder.CostTotal, "总额 = 60×10") // 两条调整流水:+500 与 +100,余额滚动到 600 var adjs []model.FinanceRecord diff --git a/backend/internal/handler/stock_in.go b/backend/internal/handler/stock_in.go index 0b0ade5..57e7d2c 100644 --- a/backend/internal/handler/stock_in.go +++ b/backend/internal/handler/stock_in.go @@ -193,7 +193,7 @@ func (h *StockInHandler) Summary(c *gin.Context) { } h.db.Model(&model.StockInOrder{}). Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). - Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r) + Select("COUNT(*) AS cnt, COALESCE(SUM(cost_total),0) AS amt").Scan(&r) return r.Cnt, r.Amt } var s stockSummary @@ -258,19 +258,23 @@ func (h *StockInHandler) Create(c *gin.Context) { for i := range req.Items { it := &req.Items[i] it.ShopID = shopID + // 兼容旧客户端(≤1.0.87):unit_price 回落为 cost_price(新 key 优先) + if it.CostPrice == 0 && it.LegacyUnitPrice != nil { + it.CostPrice = *it.LegacyUnitPrice + } if it.BatchNo == "" { it.BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1) } - prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice, it.SalePrice) + 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.TotalPrice = it.Quantity * it.UnitPrice - total += it.TotalPrice + it.CostAmount = it.Quantity * it.CostPrice + total += it.CostAmount } - req.TotalAmount = total + req.CostTotal = total return tx.Create(&req).Error }) if err != nil { @@ -316,25 +320,28 @@ func (h *StockInHandler) Update(c *gin.Context) { it := &req.Items[i] it.ShopID = shopID it.OrderID = order.ID + if it.CostPrice == 0 && it.LegacyUnitPrice != nil { + it.CostPrice = *it.LegacyUnitPrice + } if it.BatchNo == "" { it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) } // 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理) - prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.UnitPrice, it.SalePrice) + 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.TotalPrice = it.Quantity * it.UnitPrice - total += it.TotalPrice + it.CostAmount = it.Quantity * it.CostPrice + total += it.CostAmount } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, "partner_id": req.PartnerID, "order_date": req.OrderDate, "remark": req.Remark, - "total_amount": total, + "cost_total": total, } if err := tx.Model(&order).Updates(updates).Error; err != nil { return err diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go index df0d2dd..2777348 100644 --- a/backend/internal/handler/stock_in_test.go +++ b/backend/internal/handler/stock_in_test.go @@ -402,6 +402,7 @@ func TestStockInHandler_TotalAmount(t *testing.T) { w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), + // 故意发旧 key unit_price:v1.0.87 及之前客户端的兼容回归(回落到 cost_price) "items": []map[string]interface{}{ {"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50 {"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60 @@ -409,7 +410,7 @@ func TestStockInHandler_TotalAmount(t *testing.T) { }) require.Equal(t, http.StatusCreated, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) - assert.Equal(t, float64(110), data["total_amount"]) + assert.Equal(t, float64(110), data["cost_total"]) } // 回归:编辑草稿入库单后 total_amount 必须按新明细重算(此前 Update 漏了累加,编辑后被清成 0)。 @@ -437,8 +438,8 @@ func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) { "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ - {"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "unit_price": 3100.0}, - {"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "unit_price": 4500.0}, + {"product_name": "茅台2009", "series": "大件", "spec": "500ml", "quantity": 12.0, "cost_price": 3100.0}, + {"product_name": "茅台十五年", "series": "大件", "spec": "500ml", "quantity": 1.0, "cost_price": 4500.0}, }, }) require.Equal(t, http.StatusOK, w.Code) @@ -447,7 +448,7 @@ func TestStockInHandler_UpdateRecomputesTotal(t *testing.T) { w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil) require.Equal(t, http.StatusOK, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) - assert.Equal(t, float64(41700), data["total_amount"], "编辑后金额应按新明细重算") + assert.Equal(t, float64(41700), data["cost_total"], "编辑后金额应按新明细重算") } func TestStockInHandler_NoAuth(t *testing.T) { diff --git a/backend/internal/handler/stock_out.go b/backend/internal/handler/stock_out.go index c56d511..b1e3af6 100644 --- a/backend/internal/handler/stock_out.go +++ b/backend/internal/handler/stock_out.go @@ -27,6 +27,22 @@ func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler } // List GET /api/v1/stock-out/orders +// stripStockOutCost 服务端兜底:成本/利润仅管理员可见(2026-07 用户拍板)。 +// operator/readonly 的 List/Get 响应把 cost_price/cost_amount/profit_total 抹零, +// 防止非管理员通过抓包看到成本与利润(前端同时隐藏对应列)。 +func stripStockOutCost(role string, orders []model.StockOutOrder) { + if role == "admin" || role == "superadmin" { + return + } + for i := range orders { + orders[i].ProfitTotal = 0 + for j := range orders[i].Items { + orders[i].Items[j].CostPrice = 0 + orders[i].Items[j].CostAmount = 0 + } + } +} + func (h *StockOutHandler) List(c *gin.Context) { shopID := middleware.GetShopID(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) @@ -78,6 +94,7 @@ func (h *StockOutHandler) List(c *gin.Context) { Offset((page - 1) * pageSize).Limit(pageSize). Order("order_date DESC, id DESC").Find(&orders) + stripStockOutCost(middleware.GetRole(c), orders) c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) } @@ -92,7 +109,7 @@ func (h *StockOutHandler) Summary(c *gin.Context) { } h.db.Model(&model.StockOutOrder{}). Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). - Select("COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS amt").Scan(&r) + Select("COUNT(*) AS cnt, COALESCE(SUM(sale_total),0) AS amt").Scan(&r) return r.Cnt, r.Amt } var s stockSummary @@ -150,7 +167,9 @@ func (h *StockOutHandler) Get(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - util.RespondSuccess(c, order) + one := []model.StockOutOrder{order} + stripStockOutCost(middleware.GetRole(c), one) + util.RespondSuccess(c, one[0]) } // Create POST /api/v1/stock-out/orders @@ -231,14 +250,24 @@ func (h *StockOutHandler) Create(c *gin.Context) { } req.OrderNo = orderNo - var total float64 + var total, profit float64 for i := range req.Items { - req.Items[i].ShopID = shopID - // TotalPrice 记成本小计;应收(TotalAmount)按售价×数量 - req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice - total += req.Items[i].Quantity * req.Items[i].SalePrice + it := &req.Items[i] + it.ShopID = shopID + // 兼容旧客户端(≤1.0.87):unit_price 回落为 cost_price(新 key 优先) + if it.CostPrice == 0 && it.LegacyUnitPrice != nil { + it.CostPrice = *it.LegacyUnitPrice + } + // 成本小计 / 售价小计分列;应收(SaleTotal)与总利润按售价口径 + it.CostAmount = it.Quantity * it.CostPrice + it.SaleAmount = it.Quantity * it.SalePrice + total += it.SaleAmount + if it.SalePrice > 0 { + profit += (it.SalePrice - it.CostPrice) * it.Quantity + } } - req.TotalAmount = total + req.SaleTotal = total + req.ProfitTotal = profit if err := fillStockOutItemSnapshots(h.db, shopID, req.Items); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -284,12 +313,20 @@ func (h *StockOutHandler) Update(c *gin.Context) { return err } var total float64 + var profit float64 for i := range req.Items { - req.Items[i].ShopID = shopID - req.Items[i].OrderID = order.ID - // TotalPrice 记成本小计;应收(TotalAmount)按售价×数量 - req.Items[i].TotalPrice = req.Items[i].Quantity * req.Items[i].UnitPrice - total += req.Items[i].Quantity * req.Items[i].SalePrice + it := &req.Items[i] + it.ShopID = shopID + it.OrderID = order.ID + if it.CostPrice == 0 && it.LegacyUnitPrice != nil { + it.CostPrice = *it.LegacyUnitPrice + } + it.CostAmount = it.Quantity * it.CostPrice + it.SaleAmount = it.Quantity * it.SalePrice + total += it.SaleAmount + if it.SalePrice > 0 { + profit += (it.SalePrice - it.CostPrice) * it.Quantity + } } if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil { return err @@ -299,7 +336,8 @@ func (h *StockOutHandler) Update(c *gin.Context) { "partner_id": req.PartnerID, "order_date": req.OrderDate, "remark": req.Remark, - "total_amount": total, + "sale_total": total, + "profit_total": profit, } if err := tx.Model(&order).Updates(updates).Error; err != nil { return err diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go index c49cbd0..fe80c2e 100644 --- a/backend/internal/handler/stock_out_test.go +++ b/backend/internal/handler/stock_out_test.go @@ -639,7 +639,7 @@ func TestStockOutHandler_ConfirmSale(t *testing.T) { assert.Equal(t, float64(8), got.SalePrice) var order model.StockOutOrder db.First(&order, orderID) - assert.Equal(t, float64(80), order.TotalAmount) // 8 × 10 + assert.Equal(t, float64(80), order.SaleTotal) // 8 × 10 var fr model.FinanceRecord require.NoError(t, db.Where("shop_id = ? AND ref_type = ?", shop.ID, "stock_out_sale_adjust").First(&fr).Error) assert.Equal(t, float64(80), fr.Amount) diff --git a/backend/internal/handler/stock_return_test.go b/backend/internal/handler/stock_return_test.go index e34152a..2b6138a 100644 --- a/backend/internal/handler/stock_return_test.go +++ b/backend/internal/handler/stock_return_test.go @@ -37,7 +37,7 @@ func TestStockReturn_StockOut_OwnAllowed(t *testing.T) { // 应收按售价口径:TotalAmount = 15 × 25 = 375(不是成本 15×20) var soOrder model.StockOutOrder require.NoError(t, db.First(&soOrder, orderID).Error) - assert.Equal(t, float64(375), soOrder.TotalAmount) + assert.Equal(t, float64(375), soOrder.SaleTotal) var recv model.FinanceRecord require.NoError(t, db.Where("shop_id = ? AND type = 'receivable' AND ref_type = 'stock_out'", shop.ID).First(&recv).Error) assert.Equal(t, float64(375), recv.Amount, "应收应按售价×数量") diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 53e9027..172355b 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -13,10 +13,11 @@ type StockInOrder struct { OperatorID uint64 `gorm:"not null" json:"operator_id"` CreatorID *uint64 `json:"creator_id"` ReviewerID *uint64 `json:"reviewer_id"` - Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` - OrderDate Date `gorm:"type:date" json:"order_date"` - TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"` - ReviewedAt *time.Time `json:"reviewed_at"` + Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` + OrderDate Date `gorm:"type:date" json:"order_date"` + // 应付合计 = Σ 明细总进价(2026-07 定价字段消歧:旧列 total_amount 弃用,启动回填) + CostTotal float64 `gorm:"column:cost_total;type:decimal(16,2);default:0" json:"cost_total"` + ReviewedAt *time.Time `json:"reviewed_at"` // 退单状态:none=无 / partial=部分退单 / full=已全退(仅 approved 单可退) ReturnState string `gorm:"size:20;default:'none'" json:"return_state"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` @@ -40,11 +41,15 @@ type StockInItem struct { ProductName string `gorm:"size:255" json:"product_name"` Series string `gorm:"size:100" json:"series"` Spec string `gorm:"size:100" json:"spec"` - Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` - UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` - // 建议售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。 - SalePrice float64 `gorm:"-" json:"sale_price"` - TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + // 进价(单瓶成本)。2026-07 定价字段消歧:unit_price → cost_price(旧列弃用,启动回填) + CostPrice float64 `gorm:"column:cost_price;type:decimal(16,2);default:0" json:"cost_price"` + // 兼容一版:旧客户端(≤1.0.87)建单仍发 unit_price,入参回落到 CostPrice(不落库不出参) + LegacyUnitPrice *float64 `gorm:"-" json:"unit_price,omitempty"` + // 参考售价:仅用于入库建产品时写入 product.SalePrice,不落 stock_in_items 表(gorm:"-")。 + SalePrice float64 `gorm:"-" json:"sale_price"` + // 总进价 = quantity × cost_price(旧列 total_price 弃用) + CostAmount float64 `gorm:"column:cost_amount;type:decimal(16,2);default:0" json:"cost_amount"` // 已退数量:0=未退,=Quantity 表示整行已退单(整行退,不做部分数量) ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"` BatchNo string `gorm:"size:50" json:"batch_no"` @@ -67,9 +72,13 @@ type StockOutOrder struct { OperatorID uint64 `gorm:"not null" json:"operator_id"` CreatorID *uint64 `json:"creator_id"` ReviewerID *uint64 `json:"reviewer_id"` - Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` - OrderDate Date `gorm:"type:date" json:"order_date"` - TotalAmount float64 `gorm:"type:decimal(16,2);default:0" json:"total_amount"` + Status string `gorm:"type:enum('draft','pending','approved','rejected');default:'draft'" json:"status"` + OrderDate Date `gorm:"type:date" json:"order_date"` + // 应收合计 = Σ 明细售价小计(2026-07 定价字段消歧:旧列 total_amount 弃用,启动回填) + SaleTotal float64 `gorm:"column:sale_total;type:decimal(16,2);default:0" json:"sale_total"` + // 总利润 = Σ(sale_price>0 ? (sale_price-cost_price)×qty : 0),建单落库, + // 确认售价 / 确认进价(成本回填)时联动重算 + ProfitTotal float64 `gorm:"column:profit_total;type:decimal(16,2);default:0" json:"profit_total"` ReviewedAt *time.Time `json:"reviewed_at"` // 退单状态:none / partial / full ReturnState string `gorm:"size:20;default:'none'" json:"return_state"` @@ -94,11 +103,17 @@ type StockOutItem struct { ProductName string `gorm:"size:255" json:"product_name"` Series string `gorm:"size:100" json:"series"` Spec string `gorm:"size:100" json:"spec"` - Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` - UnitPrice float64 `gorm:"type:decimal(16,2);default:0" json:"unit_price"` - // 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 UnitPrice(入库成本) 区分 - SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"` - TotalPrice float64 `gorm:"type:decimal(16,2);default:0" json:"total_price"` + Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity"` + // 成本单价(入库成本快照)。2026-07 消歧:unit_price → cost_price(旧列弃用) + CostPrice float64 `gorm:"column:cost_price;type:decimal(16,2);default:0" json:"cost_price"` + // 兼容一版:旧客户端(≤1.0.87)建单仍发 unit_price,入参回落到 CostPrice(不落库不出参) + LegacyUnitPrice *float64 `gorm:"-" json:"unit_price,omitempty"` + // 售价:出库实际销售单价(可编辑),应收账款按 售价×数量 计;与 CostPrice(入库成本) 区分 + SalePrice float64 `gorm:"type:decimal(16,2);default:0" json:"sale_price"` + // 成本小计 = quantity × cost_price(旧列 total_price 弃用) + CostAmount float64 `gorm:"column:cost_amount;type:decimal(16,2);default:0" json:"cost_amount"` + // 售价小计 = quantity × sale_price(待定价=0;确认售价时重算) + SaleAmount float64 `gorm:"column:sale_amount;type:decimal(16,2);default:0" json:"sale_amount"` // 已退数量:0=未退,=Quantity 表示整行已退单 ReturnedQuantity float64 `gorm:"type:decimal(12,3);default:0" json:"returned_quantity"` BatchNo string `gorm:"size:50" json:"batch_no"` diff --git a/backend/internal/service/migrate.go b/backend/internal/service/migrate.go new file mode 100644 index 0000000..40b6a90 --- /dev/null +++ b/backend/internal/service/migrate.go @@ -0,0 +1,51 @@ +package service + +import ( + "log" + + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/model" +) + +// BackfillPricingColumns 2026-07 定价字段消歧的一次性数据迁移(幂等,可反复执行): +// - stock_in_items / stock_out_items:unit_price→cost_price、total_price→cost_amount +// - stock_in_orders:total_amount→cost_total;stock_out_orders:total_amount→sale_total +// - stock_out_items.sale_amount = sale_price×quantity(待定价行保持 0) +// - stock_out_orders.profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×qty : 0) +// +// 只在新列为 0 且旧列非 0 时拷贝,不修改旧列(旧列观察一版后手动 DROP)。 +// profit_total 以「为 0 且存在已定价明细」为待回填判据——利润恰为 0 的单重算一次 +// 结果不变,幂等。全新安装(schema.sql 已无旧列)时整体跳过。 +func BackfillPricingColumns(db *gorm.DB) { + // 旧列存在性守卫:四张表的旧列是同批产生的,抽查一张即可 + // (model 已无 UnitPrice 字段,HasColumn 会按原始列名探测) + if !db.Migrator().HasColumn(&model.StockOutItem{}, "unit_price") { + return + } + stmts := []string{ + `UPDATE stock_in_items SET cost_price = unit_price WHERE cost_price = 0 AND unit_price <> 0`, + `UPDATE stock_in_items SET cost_amount = total_price WHERE cost_amount = 0 AND total_price <> 0`, + `UPDATE stock_in_orders SET cost_total = total_amount WHERE cost_total = 0 AND total_amount <> 0`, + `UPDATE stock_out_items SET cost_price = unit_price WHERE cost_price = 0 AND unit_price <> 0`, + `UPDATE stock_out_items SET cost_amount = total_price WHERE cost_amount = 0 AND total_price <> 0`, + `UPDATE stock_out_items SET sale_amount = sale_price * quantity WHERE sale_amount = 0 AND sale_price > 0`, + `UPDATE stock_out_orders SET sale_total = total_amount WHERE sale_total = 0 AND total_amount <> 0`, + `UPDATE stock_out_orders SET profit_total = ( + SELECT COALESCE(SUM(CASE WHEN i.sale_price > 0 + THEN (i.sale_price - i.cost_price) * i.quantity ELSE 0 END), 0) + FROM stock_out_items i WHERE i.order_id = stock_out_orders.id + ) WHERE profit_total = 0 AND EXISTS ( + SELECT 1 FROM stock_out_items i2 + WHERE i2.order_id = stock_out_orders.id AND i2.sale_price > 0 + )`, + } + for _, q := range stmts { + res := db.Exec(q) + if res.Error != nil { + log.Printf("backfill pricing columns failed (%.60s...): %v", q, res.Error) + } else if res.RowsAffected > 0 { + log.Printf("backfill pricing: %d rows (%.60s...)", res.RowsAffected, q) + } + } +} diff --git a/backend/internal/service/migrate_test.go b/backend/internal/service/migrate_test.go new file mode 100644 index 0000000..ac8dc01 --- /dev/null +++ b/backend/internal/service/migrate_test.go @@ -0,0 +1,89 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wangjia/jiu/backend/internal/model" + "github.com/wangjia/jiu/backend/testutil" +) + +// BackfillPricingColumns:旧列(unit_price/total_price/total_amount)拷入新列,幂等。 +// 测试库 DDL 已是新列,这里手动补建旧列模拟升级前的生产库形态。 +func TestBackfillPricingColumns_Idempotent(t *testing.T) { + db := testutil.SetupTestDB() + for _, ddl := range []string{ + `ALTER TABLE stock_in_items ADD COLUMN unit_price REAL DEFAULT 0`, + `ALTER TABLE stock_in_items ADD COLUMN total_price REAL DEFAULT 0`, + `ALTER TABLE stock_in_orders ADD COLUMN total_amount REAL DEFAULT 0`, + `ALTER TABLE stock_out_items ADD COLUMN unit_price REAL DEFAULT 0`, + `ALTER TABLE stock_out_items ADD COLUMN total_price REAL DEFAULT 0`, + `ALTER TABLE stock_out_orders ADD COLUMN total_amount REAL DEFAULT 0`, + } { + require.NoError(t, db.Exec(ddl).Error) + } + + shop := testutil.CreateTestShop(db, "MIG1") + + // 升级前形态:只有旧列有值(新列 0) + inOrder := model.StockInOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "R1", WarehouseID: 1, OperatorID: 1, Status: "approved"} + require.NoError(t, db.Create(&inOrder).Error) + db.Exec(`UPDATE stock_in_orders SET total_amount = 500 WHERE id = ?`, inOrder.ID) + inItem := model.StockInItem{OrderID: inOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 10} + require.NoError(t, db.Create(&inItem).Error) + db.Exec(`UPDATE stock_in_items SET unit_price = 50, total_price = 500 WHERE id = ?`, inItem.ID) + + outOrder := model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "C1", WarehouseID: 1, OperatorID: 1, Status: "approved"} + require.NoError(t, db.Create(&outOrder).Error) + db.Exec(`UPDATE stock_out_orders SET total_amount = 800 WHERE id = ?`, outOrder.ID) + outItem := model.StockOutItem{OrderID: outOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 2, SalePrice: 400} + require.NoError(t, db.Create(&outItem).Error) + db.Exec(`UPDATE stock_out_items SET unit_price = 355, total_price = 710, sale_amount = 0 WHERE id = ?`, outItem.ID) + + // 待定价出库单:sale_price=0 → sale_amount/profit 均应保持 0 + pendOrder := model.StockOutOrder{TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "C2", WarehouseID: 1, OperatorID: 1, Status: "approved"} + require.NoError(t, db.Create(&pendOrder).Error) + pendItem := model.StockOutItem{OrderID: pendOrder.ID, ShopID: shop.ID, ProductID: 1, Quantity: 3} + require.NoError(t, db.Create(&pendItem).Error) + db.Exec(`UPDATE stock_out_items SET unit_price = 100, total_price = 300 WHERE id = ?`, pendItem.ID) + + BackfillPricingColumns(db) + + var gotIn model.StockInItem + db.First(&gotIn, inItem.ID) + assert.Equal(t, float64(50), gotIn.CostPrice) + assert.Equal(t, float64(500), gotIn.CostAmount) + var gotInOrder model.StockInOrder + db.First(&gotInOrder, inOrder.ID) + assert.Equal(t, float64(500), gotInOrder.CostTotal) + + var gotOut model.StockOutItem + db.First(&gotOut, outItem.ID) + assert.Equal(t, float64(355), gotOut.CostPrice) + assert.Equal(t, float64(710), gotOut.CostAmount) + assert.Equal(t, float64(800), gotOut.SaleAmount) // 400×2 + var gotOutOrder model.StockOutOrder + db.First(&gotOutOrder, outOrder.ID) + assert.Equal(t, float64(800), gotOutOrder.SaleTotal) + assert.Equal(t, float64(90), gotOutOrder.ProfitTotal) // (400-355)×2 + + var gotPend model.StockOutItem + db.First(&gotPend, pendItem.ID) + assert.Equal(t, float64(100), gotPend.CostPrice) + assert.Equal(t, float64(0), gotPend.SaleAmount) + var gotPendOrder model.StockOutOrder + db.First(&gotPendOrder, pendOrder.ID) + assert.Equal(t, float64(0), gotPendOrder.ProfitTotal) + + // 幂等:第二次执行不改变结果(人为改一个新列值验证不会被旧值覆盖——新列非 0 即跳过) + db.Exec(`UPDATE stock_out_items SET sale_price = 450, sale_amount = 900 WHERE id = ?`, outItem.ID) + BackfillPricingColumns(db) + var again model.StockOutItem + db.First(&again, outItem.ID) + assert.Equal(t, float64(900), again.SaleAmount) // 未被回填覆盖 + var againOrder model.StockOutOrder + db.First(&againOrder, outOrder.ID) + assert.Equal(t, float64(800), againOrder.SaleTotal) // 已有值不重拷 +} diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 64d3b81..3c04524 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -63,8 +63,8 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error } var unitPricePtr *float64 - if itemCopy.UnitPrice != 0 { - unitPricePtr = &itemCopy.UnitPrice + if itemCopy.CostPrice != 0 { + unitPricePtr = &itemCopy.CostPrice } // 优先使用明细自带快照列(历史导入单的真实商品信息在此), @@ -130,13 +130,13 @@ func (s *StockService) ApproveStockIn(shopID, orderID, reviewerID uint64) error // 自动创建应付账款财务记录 { - bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount + bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.CostTotal orderID := order.ID rec := model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "payable", - Amount: order.TotalAmount, + Amount: order.CostTotal, Balance: bal, Status: "open", RefType: "stock_in", @@ -172,11 +172,11 @@ type SaleConfirmItem struct { // ConfirmStockInCost 「确认进价」:调货等场景以 0 价(暂估)入库并已审核/已出库后, // 价格确定时补填真实进价。不反审核、不撤销任何已发生的动作,而是在一个事务内 // 前向写补偿: -// 1. 入库明细 UnitPrice / TotalPrice 改为真实值 +// 1. 入库明细 CostPrice / CostAmount 改为真实值 // 2. 该明细对应的剩余库存批次 Inventory.UnitPrice 由 NULL→真实值 -// 3. 该 product 已出库行 StockOutItem.UnitPrice/TotalPrice(成本快照)回填真实值 +// 3. 该 product 已出库行 StockOutItem.CostPrice/CostAmount(成本快照)回填真实值 // (product↔入库明细 1:1,按 product_id 精确命中;不动 SalePrice/应收) -// 4. 入库单 TotalAmount 按差额重算 +// 4. 入库单 CostTotal 按差额重算 // 5. 对供应商应付按差额补一条调整流水(滚动余额) // // 仅 approved 单可确认(draft 直接编辑即可);权限:管理员/超管。 @@ -202,21 +202,22 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s now := time.Now() var totalDiff float64 + var changedProducts []uint64 // 成本被改的商品:其已出库单的利润需联动重算 for i := range order.Items { it := &order.Items[i] newPrice, ok := want[it.ID] - if !ok || newPrice == it.UnitPrice { + if !ok || newPrice == it.CostPrice { continue } - oldPrice := it.UnitPrice + oldPrice := it.CostPrice diff := (newPrice - oldPrice) * it.Quantity totalDiff += diff // 1. 入库明细 if err := tx.Model(it).Updates(map[string]interface{}{ - "unit_price": newPrice, - "total_price": newPrice * it.Quantity, + "cost_price": newPrice, + "cost_amount": newPrice * it.Quantity, }).Error; err != nil { return err } @@ -233,8 +234,8 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s if err := tx.Model(&model.StockOutItem{}). Where("shop_id = ? AND product_id = ?", shopID, it.ProductID). Updates(map[string]interface{}{ - "unit_price": newPrice, - "total_price": gorm.Expr("? * quantity", newPrice), + "cost_price": newPrice, + "cost_amount": gorm.Expr("? * quantity", newPrice), }).Error; err != nil { return err } @@ -245,15 +246,31 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s tx.Model(&model.Product{}).Where("id = ? AND shop_id = ?", it.ProductID, shopID). Update("purchase_price", newPrice) } + if it.ProductID != 0 { + changedProducts = append(changedProducts, it.ProductID) + } + } + + // 3.5 成本快照变了 → 受影响出库单的总利润联动重算 + if len(changedProducts) > 0 { + var outOrderIDs []uint64 + if err := tx.Model(&model.StockOutItem{}). + Where("shop_id = ? AND product_id IN ?", shopID, changedProducts). + Distinct().Pluck("order_id", &outOrderIDs).Error; err != nil { + return err + } + if err := recalcStockOutProfit(tx, shopID, outOrderIDs); err != nil { + return err + } } if totalDiff == 0 { return nil } - // 4. 入库单总额按差额重算 + // 4. 入库单应付合计按差额重算 if err := tx.Model(&order). - Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil { + Update("cost_total", gorm.Expr("cost_total + ?", totalDiff)).Error; err != nil { return err } @@ -268,8 +285,22 @@ func (s *StockService) ConfirmStockInCost(shopID, orderID, userID uint64, role s }) } +// recalcStockOutProfit 按行利润口径整单重算总利润: +// profit_total = Σ(sale_price>0 ? (sale_price-cost_price)×quantity : 0)。 +// 确认售价 / 确认进价(成本回填)后调用,保证利润与两侧价格始终一致。 +func recalcStockOutProfit(tx *gorm.DB, shopID uint64, orderIDs []uint64) error { + if len(orderIDs) == 0 { + return nil + } + return tx.Exec(`UPDATE stock_out_orders SET profit_total = ( + SELECT COALESCE(SUM(CASE WHEN i.sale_price > 0 + THEN (i.sale_price - i.cost_price) * i.quantity ELSE 0 END), 0) + FROM stock_out_items i WHERE i.order_id = stock_out_orders.id + ) WHERE shop_id = ? AND id IN ?`, shopID, orderIDs).Error +} + // ConfirmStockOutSale 出库「先出后定价」:对 sale_price<=0 的明细写真实售价, -// 重算应收(total_amount=Σ售价×数量)并补应收差额流水。仅 admin/superadmin、已审核单可确认。 +// 重算应收(sale_total=Σ售价小计)与总利润,并补应收差额流水。仅 admin/superadmin、已审核单可确认。 func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role string, items []SaleConfirmItem) error { if role != "admin" && role != "superadmin" { return ErrForbidden @@ -299,7 +330,11 @@ func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role } diff := (newPrice - it.SalePrice) * it.Quantity totalDiff += diff - if err := tx.Model(it).Update("sale_price", newPrice).Error; err != nil { + // 售价与售价小计同步改,避免「单价×数量 ≠ 金额」的口径漂移 + if err := tx.Model(it).Updates(map[string]interface{}{ + "sale_price": newPrice, + "sale_amount": newPrice * it.Quantity, + }).Error; err != nil { return err } } @@ -308,9 +343,12 @@ func (s *StockService) ConfirmStockOutSale(shopID, orderID, userID uint64, role return nil } - // 应收总额按差额重算 + // 应收合计按差额重算;总利润整单重算 if err := tx.Model(&order). - Update("total_amount", gorm.Expr("total_amount + ?", totalDiff)).Error; err != nil { + Update("sale_total", gorm.Expr("sale_total + ?", totalDiff)).Error; err != nil { + return err + } + if err := recalcStockOutProfit(tx, shopID, []uint64{order.ID}); err != nil { return err } @@ -400,13 +438,13 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error // 自动创建应收账款财务记录 { - bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.TotalAmount + bal := partnerLastBalance(tx, shopID, order.PartnerID) + order.SaleTotal oid := order.ID rec := model.FinanceRecord{ ShopID: shopID, PartnerID: order.PartnerID, Type: "receivable", - Amount: order.TotalAmount, + Amount: order.SaleTotal, Balance: bal, Status: "open", RefType: "stock_out", @@ -497,7 +535,7 @@ func (s *StockService) ReturnStockIn(shopID, orderID, userID uint64, role string return err } it.ReturnedQuantity = it.Quantity - returnedAmount += it.TotalPrice + returnedAmount += it.CostAmount } // 冲减应付(负向调整记录,滚动余额) @@ -549,8 +587,8 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin Select("COALESCE(SUM(quantity), 0)").Scan(&qtyBefore) var unitPricePtr *float64 - if it.UnitPrice != 0 { - up := it.UnitPrice + if it.CostPrice != 0 { + up := it.CostPrice unitPricePtr = &up } unit := "" @@ -584,7 +622,7 @@ func (s *StockService) ReturnStockOut(shopID, orderID, userID uint64, role strin return err } it.ReturnedQuantity = it.Quantity - // 冲应收按售价口径(与审核建应收 TotalAmount=Σ售价×数量 一致) + // 冲应收按售价口径(与审核建应收 SaleTotal=Σ售价×数量 一致) returnedAmount += it.Quantity * it.SalePrice } diff --git a/backend/internal/service/stock_test.go b/backend/internal/service/stock_test.go index ef0ed27..d89ed41 100644 --- a/backend/internal/service/stock_test.go +++ b/backend/internal/service/stock_test.go @@ -32,8 +32,8 @@ func TestStockService_ApproveStockIn_Success(t *testing.T) { ShopID: shop.ID, ProductID: product.ID, Quantity: 10, - UnitPrice: 5.0, - TotalPrice: 50.0, + CostPrice: 5.0, + CostAmount: 50.0, }, }, } @@ -157,8 +157,8 @@ func TestStockService_ApproveStockOut_Success(t *testing.T) { ShopID: shop.ID, ProductID: product.ID, Quantity: 5, - UnitPrice: 10.0, - TotalPrice: 50.0, + CostPrice: 10.0, + CostAmount: 50.0, }, }, } diff --git a/backend/main.go b/backend/main.go index c39e1f4..f185115 100644 --- a/backend/main.go +++ b/backend/main.go @@ -45,6 +45,10 @@ func main() { // 回填存量往来单位的拼音搜索列(幂等,只处理空值行) backfillPartnerPinyin(db) + // 2026-07 定价字段消歧迁移:旧列(unit_price/total_price/total_amount)值拷入新列, + // 幂等(新列为 0 才拷),旧列保留不再读写、观察一版后手动 DROP + service.BackfillPricingColumns(db) + // 启动会话/失败登录保留期清理任务(后台 goroutine) service.StartSessionCleanup(db, config.C.Session.RetentionDays) @@ -183,3 +187,4 @@ func backfillPartnerPinyin(db *gorm.DB) { log.Printf("backfill partner pinyin: %d rows", filled) } } + diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index c020d9b..a15bd46 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -279,7 +279,7 @@ CREATE TABLE IF NOT EXISTS `stock_in_orders` ( `reviewer_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '审核人', `status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft', `order_date` DATE NOT NULL, - `total_amount` DECIMAL(16,2) NOT NULL DEFAULT 0, + `cost_total` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '应付合计=Σ总进价(2026-07 消歧,旧列 total_amount 弃用)', `reviewed_at` DATETIME DEFAULT NULL, `custom_fields` JSON DEFAULT NULL, `remark` VARCHAR(500) DEFAULT NULL, @@ -307,8 +307,8 @@ CREATE TABLE IF NOT EXISTS `stock_in_items` ( `series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)', `spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)', `quantity` DECIMAL(12,3) NOT NULL COMMENT '数量', - `unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '单价', - `total_price` DECIMAL(16,2) NOT NULL DEFAULT 0, + `cost_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '进价·单瓶(2026-07 消歧,旧列 unit_price 弃用)', + `cost_amount` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '总进价=quantity×cost_price(旧列 total_price 弃用)', `batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号', `production_date` DATE DEFAULT NULL COMMENT '生产日期', `expire_date` DATE DEFAULT NULL COMMENT '有效期', @@ -334,7 +334,8 @@ CREATE TABLE IF NOT EXISTS `stock_out_orders` ( `reviewer_id` BIGINT UNSIGNED DEFAULT NULL, `status` ENUM('draft','pending','approved','rejected') NOT NULL DEFAULT 'draft', `order_date` DATE NOT NULL, - `total_amount` DECIMAL(16,2) NOT NULL DEFAULT 0, + `sale_total` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '应收合计=Σ售价小计(2026-07 消歧,旧列 total_amount 弃用)', + `profit_total` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '总利润=Σ(sale_price>0?(sale_price-cost_price)×qty:0),建单落库、确认售价/进价联动重算', `reviewed_at` DATETIME DEFAULT NULL, `custom_fields` JSON DEFAULT NULL, `remark` VARCHAR(500) DEFAULT NULL, @@ -362,9 +363,10 @@ CREATE TABLE IF NOT EXISTS `stock_out_items` ( `series` VARCHAR(100) DEFAULT NULL COMMENT '系列(历史导入快照)', `spec` VARCHAR(100) DEFAULT NULL COMMENT '规格(历史导入快照)', `quantity` DECIMAL(12,3) NOT NULL, - `unit_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价(入库成本快照)', + `cost_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本单价·入库成本快照(旧列 unit_price 弃用)', `sale_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价(出库实际销售单价,应收按售价×数量)', - `total_price` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计(unit_price×quantity)', + `cost_amount` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '成本小计=quantity×cost_price(旧列 total_price 弃用)', + `sale_amount` DECIMAL(16,2) NOT NULL DEFAULT 0 COMMENT '售价小计=quantity×sale_price(待定价=0)', `batch_no` VARCHAR(50) DEFAULT NULL COMMENT '批次号', `production_date` DATE DEFAULT NULL COMMENT '生产日期', `custom_fields` JSON DEFAULT NULL, diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 1902638..1a1354b 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -327,7 +327,7 @@ func SetupTestDB() *gorm.DB { reviewer_id INTEGER, status TEXT DEFAULT 'draft', order_date DATETIME, - total_amount REAL DEFAULT 0, + cost_total REAL DEFAULT 0, reviewed_at DATETIME, return_state TEXT DEFAULT 'none', custom_fields TEXT, @@ -347,8 +347,8 @@ func SetupTestDB() *gorm.DB { series TEXT, spec TEXT, quantity REAL NOT NULL, - unit_price REAL DEFAULT 0, - total_price REAL DEFAULT 0, + cost_price REAL DEFAULT 0, + cost_amount REAL DEFAULT 0, returned_quantity REAL DEFAULT 0, batch_no TEXT, production_date DATETIME, @@ -370,7 +370,8 @@ func SetupTestDB() *gorm.DB { reviewer_id INTEGER, status TEXT DEFAULT 'draft', order_date DATETIME, - total_amount REAL DEFAULT 0, + sale_total REAL DEFAULT 0, + profit_total REAL DEFAULT 0, reviewed_at DATETIME, return_state TEXT DEFAULT 'none', custom_fields TEXT, @@ -390,9 +391,10 @@ func SetupTestDB() *gorm.DB { series TEXT, spec TEXT, quantity REAL NOT NULL, - unit_price REAL DEFAULT 0, + cost_price REAL DEFAULT 0, sale_price REAL DEFAULT 0, - total_price REAL DEFAULT 0, + cost_amount REAL DEFAULT 0, + sale_amount REAL DEFAULT 0, returned_quantity REAL DEFAULT 0, batch_no TEXT, production_date DATETIME,