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
+1
View File
@@ -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 管理
+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)
@@ -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)
// Summarysku_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)
}
+2 -2
View File
@@ -87,10 +87,10 @@ func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData,
// queryShopProducts 店铺公开商品列表查询——ListShopProductsJSON API)与 ShopPageSSR)共用。
// 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{}).
+1
View File
@@ -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)
}
@@ -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)
+3
View File
@@ -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 走抽屉 switchsold 仅出库流程写入。
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.
+1
View File
@@ -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)
@@ -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)
}
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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='库存批次记录';
-- ------------------------------------------------------------
+1
View File
@@ -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 '',
+1
View File
@@ -549,6 +549,7 @@
<tr><td class="mono">stock_in_item_id</td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr"></td><td class="mono df">NULL</td><td>来源入库明细行。批次/生产日期显示优先从此关联取(sii),是本表的「真值上游」</td></tr>
<tr><td class="mono">inventory_check_id</td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr"></td><td class="mono df">NULL</td><td>若由盘点调整产生,指向盘点单 inventory_checks</td></tr>
<tr><td class="mono">quantity</td><td class="mono ty">DECIMAL(12,3)</td><td class="ctr"></td><td class="mono df">0</td><td>当前批次结存数量(出入库审核时在事务内增减)</td></tr>
<tr><td class="mono">status</td><td class="mono ty">ENUM('stock','on_sale','sold')</td><td class="ctr"></td><td class="mono df">'stock'</td><td>库存三态(2026-07-07):stock=库存(在库不公开,默认)/ on_sale=在售(公开酒单展示,抽屉 switch 手动挂售)/ sold=已售(出库审核扣光自动置入,<b>留痕不软删</b>;列表默认排除,点已售卡才显示)。公开店铺页只列 on_sale;索引 idx_inv_status</td></tr>
<tr><td class="mono">product_code</td><td class="mono ty">VARCHAR(50)</td><td class="ctr"></td><td class="mono df">NULL</td><td>商品编号快照(拷贝自入库时),product 缺失时兜底显示</td></tr>
<tr><td class="mono">product_name</td><td class="mono ty">VARCHAR(200)</td><td class="ctr"></td><td class="mono df">NULL</td><td>商品名称快照,兜底显示</td></tr>
<tr><td class="mono">series</td><td class="mono ty">VARCHAR(100)</td><td class="ctr"></td><td class="mono df">NULL</td><td>系列快照(语义=型号),兜底显示</td></tr>
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>库存三态改造(在售/库存/已售)设计方案 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:22px;margin:0 0 4px;}
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
h3{font-size:14px;margin:18px 0 8px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;}
.lead{font-size:13.5px;color:var(--ink);}
table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);}
td{padding:8px 10px;border-bottom:1px solid #EEF1F5;vertical-align:top;}
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
pre{background:#1E2430;color:#D8E0EC;border-radius:8px;padding:12px 14px;overflow:auto;font-size:12px;line-height:1.55;}
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;}
.tag.ok{background:var(--success-bg);color:var(--success);}
.tag.info{background:var(--head);color:var(--primary);}
.tag.gray{background:#EEF1F5;color:var(--muted);}
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:12px 0;font-size:13px;}
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
ul,ol{margin:6px 0;padding-left:22px;}
li{margin:3px 0;font-size:13px;}
</style>
</head>
<body>
<h1>库存三态改造:在售 / 库存 / 已售</h1>
<div class="sub">2026-07-07 设计 · 用户需求:库存行持久化三态 + 公开酒单只挂「在售」+ 卖光留痕「已售」+ 抽屉 switch 切换(桌面/移动)</div>
<h2>一、现状与目标</h2>
<div class="card lead">
<p><b>现状</b>:库存「状态」是显示时派生的(在售/预警/缺货 = 数量 vs 安全库存),数据库无字段;出库扣减到 0 的批次被<b>软删除</b>,从列表彻底消失;公开店铺页展示<b>全部</b>有库存商品(3841 件全裸奔)。</p>
<p><b>目标</b>:状态落库为三态,门店可控地把商品挂上/撤下公开酒单,卖光的序列号留痕可查。</p>
<table>
<tr><th>状态</th><th>DB 值</th><th>语义</th><th>进入方式</th><th>公开酒单</th></tr>
<tr><td><span class="tag info">库存</span></td><td class="mono">stock</td><td>在库、不对外展示(默认)</td><td>入库审核默认 / switch 撤售 / 退单恢复</td><td>不显示</td></tr>
<tr><td><span class="tag ok">在售</span></td><td class="mono">on_sale</td><td>在库、公开酒单可见</td><td>抽屉 switch 手动挂售</td><td><b>显示</b></td></tr>
<tr><td><span class="tag gray">已售</span></td><td class="mono">sold</td><td>出库卖光,留痕台账</td><td>出库审核扣减到 0 自动置入(<b>不再软删</b></td><td>不显示</td></tr>
</table>
<p>流转:<code>stock ⇄ on_sale</code>(手动)→ <code>sold</code>(出库审核自动,不可手动进出);出库<b>退单</b>恢复数量时置回 <code>stock</code>(保守,需重新手动挂售)。盘点<b>盘亏</b>归零维持现状软删(灭失≠售出,不算已售)。</p>
</div>
<div class="callout danger"><b>⚠️ 上线即时影响,需确认:</b>存量 3841 行全部迁移为「库存」(你拍板的口径)→ <b>公开店铺页上线瞬间清空</b>,之后靠门店逐个把商品切到「在售」才会出现在酒单里。这是预期行为(酒单从「全量裸奔」变「人工精选」),但要提前告知门店有这步运营动作。</div>
<h2>二、数据模型</h2>
<div class="card lead">
<ul>
<li><code>inventories</code> 加列:<code>status enum('stock','on_sale','sold') NOT NULL DEFAULT 'stock'</code> + 索引 <code>idx_inv_shop_status(shop_id, status)</code><code>schema.sql</code> 与 model 同步,AutoMigrate 自动加列,存量行落默认值 <code>stock</code>——「现在所有状态改成库存」零迁移脚本达成。</li>
<li><b>为什么不走 custom_fields JSON</b>(破例说明):这是参与列表筛选、公开页过滤、KPI 统计的核心查询列,需要索引与枚举约束,JSON 扩展列不适用。</li>
<li>软删语义收窄:<code>deleted_at</code> 今后只表示「行不存在」(盘亏灭失/数据修正),「卖光」不再软删。</li>
</ul>
</div>
<h2>三、后端改动</h2>
<div class="card lead">
<table>
<tr><th>#</th><th>位置</th><th>改动</th></tr>
<tr><td>1</td><td class="mono">model/stock.go + schema/schema.sql</td><td>Inventory 加 Status 字段;<code>docs/db-schema.html</code> 同步</td></tr>
<tr><td>2</td><td class="mono">service/stock.go:414(出库 FIFO</td><td>批次扣到 0<code>quantity=0 + status='sold'</code><b>不再打 deleted_at</b>;部分扣减不改状态</td></tr>
<tr><td>3</td><td class="mono">service/stock.go(出库退单)</td><td>恢复数量的行若为 sold → 置回 <code>stock</code>(并清 quantity 归还)</td></tr>
<tr><td>4</td><td>新接口</td><td><code>PUT /api/v1/inventory/:id/status</code>body <code>{"status":"on_sale"|"stock"}</code>——只收这两值(sold 拒 400);shop_id 归属校验;受 ReadOnly() 保护,operator 可用(店员挂酒单是日常操作)</td></tr>
<tr><td>5</td><td>库存 List</td><td>返回 <code>status</code>;新增 <code>status=</code> 筛选参数(可多值逗号分隔);<b>默认排除已售</b>2026-07-07 用户拍板:仅点「已售」KPI 卡或状态筛选选已售时才显示),显式传 <code>status=sold</code> 才返回已售行</td></tr>
<tr><td>6</td><td>库存 Summary</td><td><code>sku_count / in_stock_qty / stock_value / 月初快照</code> 一律排除 sold;新增 <code>sold_count</code><code>shortage_count</code>(qty≤0 未删行)语义被 sold 取代 → 保留字段但预期归零</td></tr>
<tr><td>7</td><td>公开接口 + SSR 店铺页</td><td><code>ListShopProducts</code>/<code>queryShopProducts</code> 的库存子查询加 <code>status='on_sale'</code>qty&gt;0 保留)——公开酒单只剩在售;<b>商品单页不过滤</b>(历史标签扫码永远可看,已售商品批次块自然消失)</td></tr>
</table>
</div>
<h2>四、设计系统(L1 先行——switch 组件不存在)</h2>
<div class="card lead">
<ol>
<li>原型 <code>.superpowers/prototype/atoms.css</code> 登记 <code>.switch</code> 组件(38×22 pill 轨道 + 圆点滑块,on=<code>--primary</code>、off=<code>--border</code>,禁用态 faint;全走 tokens+ <code>index.html</code> 组件清单登记;<code>mobile-atoms.css</code> 如需尺寸变体一并登记。</li>
<li>Flutter 对照新建 <code>client/lib/widgets/ds/ds_switch.dart</code>(颜色单源过 <code>check_ds_code</code> 闸)。</li>
<li>状态徽章三态色复用现有 DsBadge tone:在售=ok 绿、库存=info 蓝;「已售」需中性灰 tone——确认 DsBadge 有无 neutral,无则原型 badge 先补 neutral 变体再同步。</li>
<li>原型 <code>inventory.html / m-inventory.html</code>(同步态屏)状态列/徽章文案同步为三态。</li>
</ol>
</div>
<h2>五、前端改动(client</h2>
<div class="card lead">
<table>
<tr><th>#</th><th>位置</th><th>改动</th></tr>
<tr><td>1</td><td class="mono">models/inventory.dart</td><td><code>status</code> 解析(缺省 'stock' 容错旧响应)</td></tr>
<tr><td>2</td><td>库存列表(桌面+移动)</td><td>状态列/卡片徽章改三态显示;状态筛选改「全部/在售/库存/已售」走后端参数;<b>预警/缺货退出状态列</b>——低于安全库存仍由数量列染色(既有 qty.low)承担;KPI 第四卡「缺货预警」改为「已售」(主数 sold_count,点击筛选已售;副行保留「需补货 N 项」预警数)</td></tr>
<tr><td>3</td><td class="mono">product_editor_drawer.dart</td><td>「状态」行 = 三态徽章 + <b>徽章后紧跟 DsSwitch</b>(在售⇄库存);已售时只显示灰徽章无 switch;切换调 PUT 接口,成功即时更新徽章并刷新列表;readonly 用户 WriteGuard 禁用。桌面 drawer 与移动 sheet 同组件,一处生效</td></tr>
<tr><td>4</td><td>_openEditor 调用</td><td>传入库存行 id 与 statusswitch 操作对象=该 product 的库存行;序列号模型下 1 product≈1 行)</td></tr>
<tr><td>5</td><td>golden</td><td>库存列表桌面/移动 + 抽屉相关 golden 重录(管理员形态)</td></tr>
</table>
</div>
<h2>六、兼容与发版顺序</h2>
<div class="card lead">
<ol>
<li><b>server-v1.1.10</b> 先发:AutoMigrate 加列默认 stock;旧 client 无 switch、仍显示旧派生状态(升级窗口内两套口径并存,可接受);公开酒单即刻只剩在售(上线即空,见顶部提醒)。</li>
<li><b>client-v1.1.6</b> 跟发:三态列表 + 抽屉 switch。</li>
<li>回滚:server 回退上一 tag——新列留在库里无害;已置 sold 的行回退后在旧逻辑下表现为「qty=0 未删」即旧「缺货」态,数据不损。</li>
</ol>
</div>
<h2>七、测试与验收</h2>
<div class="card lead">
<ul>
<li>单测:出库审核卖光 → 行 status=sold 且 deleted_at 为空;部分出库不改状态;退单恢复 → 回 stock;状态接口(合法值/拒 sold/跨租户 404/readonly 403);公开列表与 SSR 店铺页只含 on_saleSummary 排除 sold + sold_count 正确。</li>
<li>手工验收:抽屉切「在售」→ 公开酒单出现该商品;出库卖光 → 列表变「已售」灰徽章、酒单消失、KPI 已售 +1;已售商品抽屉无 switch;手机端 sheet 内 switch 可操作。</li>
</ul>
</div>
<h2>八、明确不做(本期)</h2>
<div class="card lead">
<ul>
<li>不做「批量挂售」(列表多选批量切在售)——首期逐个切,量大再加。</li>
<li>不做已售行的自动归档/清理——台账语义,先留着观察增长。</li>
<li>不动商品公开单页的可见性——历史标签永远可扫。</li>
<li>盘亏归零不置已售——维持软删。</li>
</ul>
</div>
</body>
</html>
+1
View File
@@ -38,6 +38,7 @@
<h2>🏗 设计方案</h2>
<ul>
<li><a href="pay支付对接开发指南.html">pay 支付对接开发指南</a><span class="tag html">HTML</span> <span class="hint">— jiu 门店应用内购买/续费授权:付款走 pay,付成功后 pay 签名 webhook 回调 jiu 直接续期。含签名算法、下单/回调接口契约、套餐 biz_code→权益映射、续期逻辑、安全红线、客户端(Web+App webview)、联调清单。pay 侧已就绪,本文档=jiu 侧要实现的部分</span></li>
<li><a href="design/inventory-sale-status.html">库存三态改造(在售/库存/已售)设计方案</a><span class="tag html">HTML</span> <span class="hint">— 库存行持久化状态 + 公开酒单只挂在售 + 卖光留痕已售 + 抽屉 switch2026-07-07</span></li>
<li><a href="design/public-page-speedup.html">公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</a><span class="tag html">HTML</span> <span class="hint">— 扫码首开 30s 根因与两级修复:CI 预压缩+gzip_static+协商缓存 / 商品页+店铺列表页后端 SSR,公开动线零 Flutter2026-07-07</span></li>
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>