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:
wangjia
2026-07-07 13:35:03 +08:00
parent b0b38db39f
commit 73955a139c
15 changed files with 386 additions and 9 deletions
+62 -5
View File
@@ -30,6 +30,7 @@ type inventoryRow struct {
ProductID *uint64 `json:"product_id"`
StockInItemID *uint64 `json:"stock_in_item_id"`
Quantity float64 `json:"quantity"`
Status string `json:"status"` // stock=库存 / on_sale=在售 / sold=已售
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
Series string `json:"series"`
@@ -77,6 +78,26 @@ func (h *InventoryHandler) List(c *gin.Context) {
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
args := []interface{}{shopID}
// 状态筛选(2026-07-07 三态):默认排除已售——仅显式传 status(含 sold)才显示已售行
if statusStr := c.Query("status"); statusStr != "" {
parts := make([]string, 0, 3)
for _, s := range strings.Split(statusStr, ",") {
s = strings.TrimSpace(s)
if s == "stock" || s == "on_sale" || s == "sold" {
parts = append(parts, s)
}
}
if len(parts) > 0 {
placeholders := strings.Repeat("?,", len(parts))
baseWhere += " AND inv.status IN (" + placeholders[:len(placeholders)-1] + ")"
for _, s := range parts {
args = append(args, s)
}
}
} else {
baseWhere += " AND inv.status <> 'sold'"
}
keyword := c.Query("keyword")
warehouseIDStr := c.Query("warehouse_id")
inStock := c.Query("in_stock")
@@ -141,7 +162,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
dataSQL := `
SELECT
inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id,
inv.quantity,
inv.quantity, inv.status,
COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code, '') AS product_code,
COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name, '') AS product_name,
COALESCE(NULLIF(p.series,''), NULLIF(sii.series,''), inv.series, '') AS series,
@@ -179,10 +200,11 @@ func (h *InventoryHandler) List(c *gin.Context) {
// inventorySummary 全店库存 KPI 汇总(不随分页,供库存屏 KPI 卡)
type inventorySummary struct {
SkuCount int64 `json:"sku_count"` // SKU 总数(库存行数)
SkuCount int64 `json:"sku_count"` // SKU 总数(未售库存行数)
StockValue float64 `json:"stock_value"` // 库存货值 Σ(qty×进价)
InStockQty float64 `json:"in_stock_qty"` // 在库总数量
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0
SoldCount int64 `json:"sold_count"` // 已售数(status=sold 留痕行
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0 且未售,预期归零)
WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存)
// 月初快照(供 KPI 环比):本月净变动由 inventory_logs 反推
LastMonthSku int64 `json:"last_month_sku"` // 月初存在的库存行数
@@ -198,10 +220,11 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
// 月初数量 = 当前数量 − 本月净变动(product=序列号模型下 log 与库存行按 product_id+warehouse_id 一一对应)
const sql = `
SELECT
COUNT(*) AS sku_count,
COALESCE(SUM(CASE WHEN inv.status <> 'sold' THEN 1 ELSE 0 END), 0) AS sku_count,
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.status = 'sold' THEN 1 ELSE 0 END), 0) AS sold_count,
COALESCE(SUM(CASE WHEN inv.quantity <= 0 AND inv.status <> 'sold' 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,
@@ -403,6 +426,40 @@ func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "盘点完成"})
}
// UpdateStatus PUT /api/v1/inventory/:id/status —— 库存⇄在售 手动切换(抽屉 switch)。
// sold 由出库审核流程专属写入,本接口拒收;已售行不可改(WHERE 兜底)。
func (h *InventoryHandler) UpdateStatus(c *gin.Context) {
shopID := middleware.GetShopID(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req struct {
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Status != "stock" && req.Status != "on_sale" {
c.JSON(http.StatusBadRequest, gin.H{"error": "状态仅支持 stock / on_sale"})
return
}
result := h.db.Model(&model.Inventory{}).
Where("id = ? AND shop_id = ? AND deleted_at IS NULL AND status <> 'sold'", id, shopID).
Update("status", req.Status)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
return
}
if result.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "库存记录不存在或已售出"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "status": req.Status})
}
// UpdateRemark PUT /api/v1/inventory/:id/remark
func (h *InventoryHandler) UpdateRemark(c *gin.Context) {
shopID := middleware.GetShopID(c)