feat(backend): 出入库定价字段消歧 + 总利润落库 + 成本仅管理员可见
- 字段重命名(旧列弃用保留,启动幂等回填 BackfillPricingColumns): 入库 unit_price/total_price/total_amount → cost_price/cost_amount/cost_total; 出库同前缀 + 新增 sale_amount(售价小计)/ sale_total(应收)/ profit_total(总利润) - 建单落三值;确认售价联动重算 sale_amount/sale_total/profit_total; 确认进价回填成本后联动重算受影响出库单利润(recalcStockOutProfit) - 出库 List/Get 对 operator/readonly 抹零成本与利润(stripStockOutCost 服务端兜底) - 兼容一版:Create/Update 接受旧 key unit_price 回落(v1.0.87 及之前客户端) - SUM 聚合/价格趋势/导入/种子工具同步切新列;测试全量改名 + 6 个新回归 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "应收应按售价×数量")
|
||||
|
||||
Reference in New Issue
Block a user