4fd0bb8b83
- 字段重命名(旧列弃用保留,启动幂等回填 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
213 lines
9.3 KiB
Go
213 lines
9.3 KiB
Go
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"])
|
||
}
|