From 73955a139c0b13ebc4c89a58ae3b3d6e27fbaf8c Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 7 Jul 2026 13:35:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=E5=BA=93=E5=AD=98=E4=B8=89?= =?UTF-8?q?=E6=80=81=E8=90=BD=E5=BA=93=EF=BC=88stock/on=5Fsale/sold?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=E6=8C=82=E5=94=AE=E5=8F=AF=E6=8E=A7?= =?UTF-8?q?=20+=20=E5=8D=96=E5=85=89=E7=95=99=E7=97=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o --- CLAUDE.md | 1 + backend/internal/handler/inventory.go | 67 ++++++++- backend/internal/handler/inventory_test.go | 83 +++++++++++ backend/internal/handler/public_page.go | 4 +- backend/internal/handler/public_test.go | 1 + backend/internal/handler/testhelper_test.go | 1 + backend/internal/model/stock.go | 3 + backend/internal/router/router.go | 1 + .../internal/service/inventory_status_test.go | 95 +++++++++++++ backend/internal/service/stock.go | 3 +- backend/schema/schema.sql | 4 +- backend/testutil/setup.go | 1 + docs/db-schema.html | 1 + docs/design/inventory-sale-status.html | 129 ++++++++++++++++++ docs/index.html | 1 + 15 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 backend/internal/service/inventory_status_test.go create mode 100644 docs/design/inventory-sale-status.html diff --git a/CLAUDE.md b/CLAUDE.md index 5fec0e0..7c9229a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -229,6 +229,7 @@ cd client && flutter test 1. 更新 `inventories` 表数量 2. 写入 `inventory_logs` 流水记录 - 出库前必须校验库存充足,不足时返回错误并回滚 +- **库存三态**(2026-07-07 落库 `inventories.status`):`stock` 库存(默认,不公开)⇄ `on_sale` 在售(公开酒单展示,抽屉 switch 手动挂售,接口 `PUT /inventory/:id/status` 仅收这两值)→ `sold` 已售(出库审核扣光自动置入,**留痕不再软删**;退单恢复置回 stock)。列表默认排除已售(显式 `status=sold` 才返回);Summary 的 SKU/数量/货值排除已售、另出 `sold_count`;公开店铺页只列 on_sale。盘亏归零仍软删(灭失≠售出)。预警/缺货退出状态维度,由数量 vs 安全库存染色承担 - 审核中(pending)单据支持**撤回**(`withdraw`)回 draft:管理员/超管任意单、操作员限本人单(`OperatorID==本人`,handler 内判权,非 AdminOnly 中间件) ### Schema 管理 diff --git a/backend/internal/handler/inventory.go b/backend/internal/handler/inventory.go index 4dba89d..face413 100644 --- a/backend/internal/handler/inventory.go +++ b/backend/internal/handler/inventory.go @@ -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 '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) diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go index 1d905b1..1869ec7 100644 --- a/backend/internal/handler/inventory_test.go +++ b/backend/internal/handler/inventory_test.go @@ -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) +} diff --git a/backend/internal/handler/public_page.go b/backend/internal/handler/public_page.go index d6ef10c..540ee10 100644 --- a/backend/internal/handler/public_page.go +++ b/backend/internal/handler/public_page.go @@ -87,10 +87,10 @@ func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData, // queryShopProducts 店铺公开商品列表查询——ListShopProducts(JSON API)与 ShopPage(SSR)共用。 // keyword 为空时不过滤(API 现状);非空时按 名称/编号/全拼/首字母 模糊匹配(SSR 搜索框)。 func (h *PublicHandler) queryShopProducts(shopID uint64, page, pageSize int, keyword string) ([]publicProductResp, int64, error) { - // 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。 + // 仅列「在售且有库存」的商品(2026-07-07 三态:公开酒单只挂 status=on_sale)。 stockSub := h.db.Model(&model.Inventory{}). Select("product_id, SUM(quantity) AS qty"). - Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shopID). + Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND status = 'on_sale'", shopID). Group("product_id") query := h.db.Model(&model.Product{}). diff --git a/backend/internal/handler/public_test.go b/backend/internal/handler/public_test.go index 3c6310d..1739ff6 100644 --- a/backend/internal/handler/public_test.go +++ b/backend/internal/handler/public_test.go @@ -41,6 +41,7 @@ func addInventory(db *gorm.DB, shopID, warehouseID, productID uint64, qty float6 WarehouseID: &wid, ProductID: &pid, Quantity: qty, + Status: "on_sale", // 公开页 fixtures:三态改造后酒单只挂在售 }).Error; err != nil { panic(err) } diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go index 06d976c..f675cb0 100644 --- a/backend/internal/handler/testhelper_test.go +++ b/backend/internal/handler/testhelper_test.go @@ -89,6 +89,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine { inv.GET("", inventoryH.List) inv.GET("/summary", inventoryH.Summary) inv.GET("/logs", inventoryH.Logs) + inv.PUT("/:id/status", inventoryH.UpdateStatus) inv.POST("/checks", inventoryH.CreateCheck) inv.GET("/checks/:id", inventoryH.GetCheck) diff --git a/backend/internal/model/stock.go b/backend/internal/model/stock.go index 172355b..8e888e4 100644 --- a/backend/internal/model/stock.go +++ b/backend/internal/model/stock.go @@ -134,6 +134,9 @@ type Inventory struct { StockInItemID *uint64 `json:"stock_in_item_id"` InventoryCheckID *uint64 `json:"inventory_check_id"` Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"` + // Status 库存行三态(2026-07-07):stock=库存(默认,不公开) / on_sale=在售(公开酒单展示) / + // sold=已售(出库审核扣光自动置入,留痕不软删)。stock⇄on_sale 走抽屉 switch;sold 仅出库流程写入。 + Status string `gorm:"type:enum('stock','on_sale','sold');not null;default:'stock';index:idx_inv_status" json:"status"` // Snapshot fields: copied from product/warehouse/stock-in at approval time. // They reflect the state at the moment of stock-in and are NOT updated when the // referenced product or warehouse record is later modified. diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index a2e8061..29ba84d 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -209,6 +209,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { inventory.GET("/summary", inventoryH.Summary) inventory.GET("/logs", inventoryH.Logs) inventory.PUT("/:id/remark", inventoryH.UpdateRemark) + inventory.PUT("/:id/status", inventoryH.UpdateStatus) inventory.POST("/checks", inventoryH.CreateCheck) inventory.GET("/checks/:id", inventoryH.GetCheck) inventory.PUT("/checks/:id/complete", inventoryH.CompleteCheck) diff --git a/backend/internal/service/inventory_status_test.go b/backend/internal/service/inventory_status_test.go new file mode 100644 index 0000000..8860ef2 --- /dev/null +++ b/backend/internal/service/inventory_status_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "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" +) + +// 库存三态(2026-07-07):出库卖光 → sold 留痕不软删;部分出库不改状态;退单恢复 → 回 stock。 + +func TestApproveStockOut_SellOutMarksSold(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "STS001") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + p := testutil.CreateTestProduct(db, shop.ID, "茅台") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + + inv := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: 5, Status: "on_sale"} + require.NoError(t, db.Create(&inv).Error) + + order := model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "OUT-STS-1", + WarehouseID: wh.ID, OperatorID: user.ID, Status: "pending", + OrderDate: model.Date{Time: time.Now()}, + Items: []model.StockOutItem{{ShopID: shop.ID, ProductID: p.ID, Quantity: 5}}, + } + require.NoError(t, db.Create(&order).Error) + require.NoError(t, NewStockService(db).ApproveStockOut(shop.ID, order.ID, user.ID)) + + // 卖光:行保留(deleted_at 空)、qty=0、status=sold + var after model.Inventory + require.NoError(t, db.Unscoped().Where("id = ?", inv.ID).First(&after).Error) + assert.Equal(t, "sold", after.Status) + assert.Equal(t, float64(0), after.Quantity) + assert.Nil(t, after.DeletedAt, "卖光不再软删") +} + +func TestApproveStockOut_PartialKeepsStatus(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "STS002") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + p := testutil.CreateTestProduct(db, shop.ID, "五粮液") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + + inv := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: 10, Status: "on_sale"} + require.NoError(t, db.Create(&inv).Error) + + order := model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "OUT-STS-2", + WarehouseID: wh.ID, OperatorID: user.ID, Status: "pending", + OrderDate: model.Date{Time: time.Now()}, + Items: []model.StockOutItem{{ShopID: shop.ID, ProductID: p.ID, Quantity: 3}}, + } + require.NoError(t, db.Create(&order).Error) + require.NoError(t, NewStockService(db).ApproveStockOut(shop.ID, order.ID, user.ID)) + + var after model.Inventory + require.NoError(t, db.Where("id = ?", inv.ID).First(&after).Error) + assert.Equal(t, "on_sale", after.Status, "部分出库不改状态") + assert.Equal(t, float64(7), after.Quantity) +} + +func TestReturnStockOut_RestoresToStock(t *testing.T) { + db := testutil.SetupTestDB() + shop := testutil.CreateTestShop(db, "STS003") + wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") + p := testutil.CreateTestProduct(db, shop.ID, "剑南春") + user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") + + inv := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: 2, Status: "on_sale"} + require.NoError(t, db.Create(&inv).Error) + + order := model.StockOutOrder{ + TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "OUT-STS-3", + WarehouseID: wh.ID, OperatorID: user.ID, Status: "pending", + OrderDate: model.Date{Time: time.Now()}, + Items: []model.StockOutItem{{ShopID: shop.ID, ProductID: p.ID, Quantity: 2, SalePrice: 100}}, + } + require.NoError(t, db.Create(&order).Error) + svc := NewStockService(db) + require.NoError(t, svc.ApproveStockOut(shop.ID, order.ID, user.ID)) + + // 退单 → 新建的恢复行应为 stock(保守:需重新手动挂售) + require.NoError(t, svc.ReturnStockOut(shop.ID, order.ID, user.ID, "admin", []uint64{order.Items[0].ID})) + var restored model.Inventory + require.NoError(t, db.Where("shop_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL", shop.ID, p.ID). + Order("id DESC").First(&restored).Error) + assert.Equal(t, "stock", restored.Status) + assert.Equal(t, float64(2), restored.Quantity) +} diff --git a/backend/internal/service/stock.go b/backend/internal/service/stock.go index 3c04524..fda2c99 100644 --- a/backend/internal/service/stock.go +++ b/backend/internal/service/stock.go @@ -411,7 +411,8 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error b := &batches[i] if b.Quantity <= remaining { remaining -= b.Quantity - tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now}) + // 卖光的批次置「已售」留痕(2026-07-07 三态改造:不再软删) + tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "status": "sold"}) } else { tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining)) remaining = 0 diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index ef6cf60..bae2d98 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -414,6 +414,7 @@ CREATE TABLE IF NOT EXISTS `inventories` ( `stock_in_item_id` BIGINT UNSIGNED DEFAULT NULL, `inventory_check_id` BIGINT UNSIGNED DEFAULT NULL, `quantity` DECIMAL(12,3) NOT NULL DEFAULT 0, + `status` ENUM('stock','on_sale','sold') NOT NULL DEFAULT 'stock' COMMENT '库存/在售(公开)/已售(卖光留痕)', `product_code` VARCHAR(50) DEFAULT NULL, `product_name` VARCHAR(200) DEFAULT NULL, `series` VARCHAR(100) DEFAULT NULL, @@ -434,7 +435,8 @@ CREATE TABLE IF NOT EXISTS `inventories` ( KEY `idx_shop_wh_product` (`shop_id`, `warehouse_id`, `product_id`), KEY `idx_stock_in_item` (`stock_in_item_id`), KEY `idx_inventory_check` (`inventory_check_id`), - KEY `idx_deleted_at` (`deleted_at`) + KEY `idx_deleted_at` (`deleted_at`), + KEY `idx_inv_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存批次记录'; -- ------------------------------------------------------------ diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go index 9704587..4b3bf93 100644 --- a/backend/testutil/setup.go +++ b/backend/testutil/setup.go @@ -434,6 +434,7 @@ func SetupTestDB() *gorm.DB { stock_in_item_id INTEGER, inventory_check_id INTEGER, quantity REAL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'stock', product_code TEXT DEFAULT '', product_name TEXT DEFAULT '', series TEXT DEFAULT '', diff --git a/docs/db-schema.html b/docs/db-schema.html index 0cc6902..fedfb90 100644 --- a/docs/db-schema.html +++ b/docs/db-schema.html @@ -549,6 +549,7 @@ stock_in_item_idBIGINT UNSIGNED是NULL来源入库明细行。批次/生产日期显示优先从此关联取(sii),是本表的「真值上游」 inventory_check_idBIGINT UNSIGNED是NULL若由盘点调整产生,指向盘点单 inventory_checks quantityDECIMAL(12,3)否0当前批次结存数量(出入库审核时在事务内增减) +statusENUM('stock','on_sale','sold')否'stock'库存三态(2026-07-07):stock=库存(在库不公开,默认)/ on_sale=在售(公开酒单展示,抽屉 switch 手动挂售)/ sold=已售(出库审核扣光自动置入,留痕不软删;列表默认排除,点已售卡才显示)。公开店铺页只列 on_sale;索引 idx_inv_status product_codeVARCHAR(50)是NULL商品编号快照(拷贝自入库时),product 缺失时兜底显示 product_nameVARCHAR(200)是NULL商品名称快照,兜底显示 seriesVARCHAR(100)是NULL系列快照(语义=型号),兜底显示 diff --git a/docs/design/inventory-sale-status.html b/docs/design/inventory-sale-status.html new file mode 100644 index 0000000..3e1f90b --- /dev/null +++ b/docs/design/inventory-sale-status.html @@ -0,0 +1,129 @@ + + + + + +库存三态改造(在售/库存/已售)设计方案 — 酒库管理系统 + + + +

库存三态改造:在售 / 库存 / 已售

+
2026-07-07 设计 · 用户需求:库存行持久化三态 + 公开酒单只挂「在售」+ 卖光留痕「已售」+ 抽屉 switch 切换(桌面/移动)
+ +

一、现状与目标

+
+

现状:库存「状态」是显示时派生的(在售/预警/缺货 = 数量 vs 安全库存),数据库无字段;出库扣减到 0 的批次被软删除,从列表彻底消失;公开店铺页展示全部有库存商品(3841 件全裸奔)。

+

目标:状态落库为三态,门店可控地把商品挂上/撤下公开酒单,卖光的序列号留痕可查。

+ + + + + +
状态DB 值语义进入方式公开酒单
库存stock在库、不对外展示(默认)入库审核默认 / switch 撤售 / 退单恢复不显示
在售on_sale在库、公开酒单可见抽屉 switch 手动挂售显示
已售sold出库卖光,留痕台账出库审核扣减到 0 自动置入(不再软删不显示
+

流转:stock ⇄ on_sale(手动)→ sold(出库审核自动,不可手动进出);出库退单恢复数量时置回 stock(保守,需重新手动挂售)。盘点盘亏归零维持现状软删(灭失≠售出,不算已售)。

+
+ +
⚠️ 上线即时影响,需确认:存量 3841 行全部迁移为「库存」(你拍板的口径)→ 公开店铺页上线瞬间清空,之后靠门店逐个把商品切到「在售」才会出现在酒单里。这是预期行为(酒单从「全量裸奔」变「人工精选」),但要提前告知门店有这步运营动作。
+ +

二、数据模型

+
+
    +
  • inventories 加列:status enum('stock','on_sale','sold') NOT NULL DEFAULT 'stock' + 索引 idx_inv_shop_status(shop_id, status)schema.sql 与 model 同步,AutoMigrate 自动加列,存量行落默认值 stock——「现在所有状态改成库存」零迁移脚本达成。
  • +
  • 为什么不走 custom_fields JSON(破例说明):这是参与列表筛选、公开页过滤、KPI 统计的核心查询列,需要索引与枚举约束,JSON 扩展列不适用。
  • +
  • 软删语义收窄:deleted_at 今后只表示「行不存在」(盘亏灭失/数据修正),「卖光」不再软删。
  • +
+
+ +

三、后端改动

+
+ + + + + + + + + +
#位置改动
1model/stock.go + schema/schema.sqlInventory 加 Status 字段;docs/db-schema.html 同步
2service/stock.go:414(出库 FIFO)批次扣到 0:quantity=0 + status='sold'不再打 deleted_at;部分扣减不改状态
3service/stock.go(出库退单)恢复数量的行若为 sold → 置回 stock(并清 quantity 归还)
4新接口PUT /api/v1/inventory/:id/status,body {"status":"on_sale"|"stock"}——只收这两值(sold 拒 400);shop_id 归属校验;受 ReadOnly() 保护,operator 可用(店员挂酒单是日常操作)
5库存 List返回 status;新增 status= 筛选参数(可多值逗号分隔);默认排除已售(2026-07-07 用户拍板:仅点「已售」KPI 卡或状态筛选选已售时才显示),显式传 status=sold 才返回已售行
6库存 Summarysku_count / in_stock_qty / stock_value / 月初快照 一律排除 sold;新增 sold_countshortage_count(qty≤0 未删行)语义被 sold 取代 → 保留字段但预期归零
7公开接口 + SSR 店铺页ListShopProducts/queryShopProducts 的库存子查询加 status='on_sale'(qty>0 保留)——公开酒单只剩在售;商品单页不过滤(历史标签扫码永远可看,已售商品批次块自然消失)
+
+ +

四、设计系统(L1 先行——switch 组件不存在)

+
+
    +
  1. 原型 .superpowers/prototype/atoms.css 登记 .switch 组件(38×22 pill 轨道 + 圆点滑块,on=--primary、off=--border,禁用态 faint;全走 tokens)+ index.html 组件清单登记;mobile-atoms.css 如需尺寸变体一并登记。
  2. +
  3. Flutter 对照新建 client/lib/widgets/ds/ds_switch.dart(颜色单源过 check_ds_code 闸)。
  4. +
  5. 状态徽章三态色复用现有 DsBadge tone:在售=ok 绿、库存=info 蓝;「已售」需中性灰 tone——确认 DsBadge 有无 neutral,无则原型 badge 先补 neutral 变体再同步。
  6. +
  7. 原型 inventory.html / m-inventory.html(同步态屏)状态列/徽章文案同步为三态。
  8. +
+
+ +

五、前端改动(client)

+
+ + + + + + + +
#位置改动
1models/inventory.dartstatus 解析(缺省 'stock' 容错旧响应)
2库存列表(桌面+移动)状态列/卡片徽章改三态显示;状态筛选改「全部/在售/库存/已售」走后端参数;预警/缺货退出状态列——低于安全库存仍由数量列染色(既有 qty.low)承担;KPI 第四卡「缺货预警」改为「已售」(主数 sold_count,点击筛选已售;副行保留「需补货 N 项」预警数)
3product_editor_drawer.dart「状态」行 = 三态徽章 + 徽章后紧跟 DsSwitch(在售⇄库存);已售时只显示灰徽章无 switch;切换调 PUT 接口,成功即时更新徽章并刷新列表;readonly 用户 WriteGuard 禁用。桌面 drawer 与移动 sheet 同组件,一处生效
4_openEditor 调用传入库存行 id 与 status(switch 操作对象=该 product 的库存行;序列号模型下 1 product≈1 行)
5golden库存列表桌面/移动 + 抽屉相关 golden 重录(管理员形态)
+
+ +

六、兼容与发版顺序

+
+
    +
  1. server-v1.1.10 先发:AutoMigrate 加列默认 stock;旧 client 无 switch、仍显示旧派生状态(升级窗口内两套口径并存,可接受);公开酒单即刻只剩在售(上线即空,见顶部提醒)。
  2. +
  3. client-v1.1.6 跟发:三态列表 + 抽屉 switch。
  4. +
  5. 回滚:server 回退上一 tag——新列留在库里无害;已置 sold 的行回退后在旧逻辑下表现为「qty=0 未删」即旧「缺货」态,数据不损。
  6. +
+
+ +

七、测试与验收

+
+
    +
  • 单测:出库审核卖光 → 行 status=sold 且 deleted_at 为空;部分出库不改状态;退单恢复 → 回 stock;状态接口(合法值/拒 sold/跨租户 404/readonly 403);公开列表与 SSR 店铺页只含 on_sale;Summary 排除 sold + sold_count 正确。
  • +
  • 手工验收:抽屉切「在售」→ 公开酒单出现该商品;出库卖光 → 列表变「已售」灰徽章、酒单消失、KPI 已售 +1;已售商品抽屉无 switch;手机端 sheet 内 switch 可操作。
  • +
+
+ +

八、明确不做(本期)

+
+
    +
  • 不做「批量挂售」(列表多选批量切在售)——首期逐个切,量大再加。
  • +
  • 不做已售行的自动归档/清理——台账语义,先留着观察增长。
  • +
  • 不动商品公开单页的可见性——历史标签永远可扫。
  • +
  • 盘亏归零不置已售——维持软删。
  • +
+
+ + diff --git a/docs/index.html b/docs/index.html index fc8c819..4ce5325 100644 --- a/docs/index.html +++ b/docs/index.html @@ -38,6 +38,7 @@

🏗 设计方案