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)
@@ -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 '',