feat(backend): 库存三态落库(stock/on_sale/sold)——挂售可控 + 卖光留痕
- inventories 加 status 枚举列(默认 stock,AutoMigrate 存量即全部「库存」)+ 索引 - 出库审核扣光置 sold 不再软删(留痕台账);退单恢复行置回 stock;盘亏仍软删 - 新接口 PUT /inventory/:id/status(仅收 stock/on_sale,已售行拒改,租户校验) - List 默认排除已售(显式 status=sold 才显示)+ status 多值筛选 - Summary:SKU/货值/数量排除已售,新增 sold_count - 公开店铺页/API 只列 on_sale——酒单从全量裸奔改为人工精选 - 测试:卖光置售/部分不改/退单回库/接口边界/跨租户/汇总口径 8 用例 - 设计方案 docs/design/inventory-sale-status.html + db-schema/CLAUDE.md 同步 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -442,3 +442,86 @@ func TestInventoryHandler_CostVisibility(t *testing.T) {
|
||||
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
|
||||
}
|
||||
}
|
||||
|
||||
// 库存三态(2026-07-07):List 默认排除已售、status 筛选、切换接口、Summary sold_count。
|
||||
func TestInventoryHandler_StatusFlow(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "INV_ST")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
p1 := testutil.CreateTestProduct(db, shop.ID, "库存酒")
|
||||
p2 := testutil.CreateTestProduct(db, shop.ID, "在售酒")
|
||||
p3 := testutil.CreateTestProduct(db, shop.ID, "已售酒")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
price := 50.0
|
||||
invStock := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p1.ID, Quantity: 3, UnitPrice: &price, Status: "stock"}
|
||||
invSale := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p2.ID, Quantity: 2, UnitPrice: &price, Status: "on_sale"}
|
||||
invSold := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p3.ID, Quantity: 0, UnitPrice: &price, Status: "sold"}
|
||||
require.NoError(t, db.Create(&invStock).Error)
|
||||
require.NoError(t, db.Create(&invSale).Error)
|
||||
require.NoError(t, db.Create(&invSold).Error)
|
||||
|
||||
// List 默认:不含已售(2 行)
|
||||
w := makeRequest(r, "GET", "/api/v1/inventory", token, nil)
|
||||
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
|
||||
|
||||
// status=sold:只看已售
|
||||
w = makeRequest(r, "GET", "/api/v1/inventory?status=sold", token, nil)
|
||||
resp := parseResponse(w)
|
||||
assert.Equal(t, float64(1), resp["total"].(float64))
|
||||
row := resp["data"].([]interface{})[0].(map[string]interface{})
|
||||
assert.Equal(t, "sold", row["status"])
|
||||
|
||||
// status 多值
|
||||
w = makeRequest(r, "GET", "/api/v1/inventory?status=stock,on_sale,sold", token, nil)
|
||||
assert.Equal(t, float64(3), parseResponse(w)["total"].(float64))
|
||||
|
||||
// 切换:stock → on_sale
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invStock.ID), token,
|
||||
map[string]interface{}{"status": "on_sale"})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var got model.Inventory
|
||||
require.NoError(t, db.Where("id = ?", invStock.ID).First(&got).Error)
|
||||
assert.Equal(t, "on_sale", got.Status)
|
||||
|
||||
// 拒收 sold 值
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSale.ID), token,
|
||||
map[string]interface{}{"status": "sold"})
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// 已售行不可改
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSold.ID), token,
|
||||
map[string]interface{}{"status": "stock"})
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Summary:sku_count 排除已售、sold_count=1、货值只算未售
|
||||
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
|
||||
sm := parseResponse(w)
|
||||
assert.Equal(t, float64(2), sm["sku_count"].(float64))
|
||||
assert.Equal(t, float64(1), sm["sold_count"].(float64))
|
||||
assert.Equal(t, float64(3*50+2*50), sm["stock_value"].(float64))
|
||||
assert.Equal(t, float64(0), sm["shortage_count"].(float64), "sold 不计入缺货")
|
||||
}
|
||||
|
||||
// 跨租户:不能改他店库存状态
|
||||
func TestInventoryHandler_StatusTenantIsolation(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shopA := testutil.CreateTestShop(db, "INV_STA")
|
||||
shopB := testutil.CreateTestShop(db, "INV_STB")
|
||||
userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin")
|
||||
whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B")
|
||||
pB := testutil.CreateTestProduct(db, shopB.ID, "他店酒")
|
||||
invB := model.Inventory{ShopID: shopB.ID, WarehouseID: &whB.ID, ProductID: &pB.ID, Quantity: 1, Status: "stock"}
|
||||
require.NoError(t, db.Create(&invB).Error)
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
|
||||
w := makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invB.ID), tokenA,
|
||||
map[string]interface{}{"status": "on_sale"})
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
var got model.Inventory
|
||||
require.NoError(t, db.Where("id = ?", invB.ID).First(&got).Error)
|
||||
assert.Equal(t, "stock", got.Status)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user