Files
jiu/backend/internal/handler/inventory.go
T
wangjia 8d3f902f3b feat(client): Phase 2 库存屏照原型重建(去tab/KPI货值/库存·成本价·单价列)
照原型 inventory.html 把库存屏从「3 tab」重排为单视图:
- 去 3 tab(库存查询/预警/流水)→ 单视图;预警并入 KPI 缺货卡点击筛选;流水移商品详情
- 头部:库存管理 + 共 N 个 SKU·数据实时 + 列设置/导出(盘点/新增入库去掉,走专门菜单)
- KPI 4 卡走全店汇总接口:SKU总数/库存货值(Σqty×进价)/在库数量/缺货预警(可点筛选)
- 表格 12 列:加 库存(低库存染色)/成本价(进价)/单价(售价);操作列只「查看详情」;状态圆点
- 仓库/备注默认隐藏;工具栏 搜索+状态筛选+重置
- 后端 GET /inventory/summary 全店汇总(守 shop_id);前端 model/repository/provider 配套
- golden_harness:monospace 指向 Noto(消等宽数字黑块,全 golden 受益);库存屏存量 grey 清零

后端编译/库存测试通过;前端 analyze 0 error,flutter test 242 通过;代码端颜色闸 0 违规。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-07-01 20:46:53 +08:00

382 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
type InventoryHandler struct {
db *gorm.DB
}
func NewInventoryHandler(db *gorm.DB) *InventoryHandler {
return &InventoryHandler{db: db}
}
// inventoryRow is the response shape for GET /api/v1/inventory
type inventoryRow struct {
ID uint64 `json:"id"`
ShopID uint64 `json:"shop_id"`
WarehouseID *uint64 `json:"warehouse_id"`
ProductID *uint64 `json:"product_id"`
StockInItemID *uint64 `json:"stock_in_item_id"`
Quantity float64 `json:"quantity"`
ProductCode string `json:"product_code"`
ProductName string `json:"product_name"`
Series string `json:"series"`
Spec string `json:"spec"`
Unit string `json:"unit"`
WarehouseName string `json:"warehouse_name"`
UnitPrice *float64 `json:"unit_price"` // 成本价(入库进价)
SalePrice *float64 `json:"sale_price"` // 单价(product 售价)
ProductionDate *string `json:"production_date"`
BatchNo string `json:"batch_no"`
SupplierName string `json:"supplier_name"`
Remark string `json:"remark"`
Brand string `json:"brand"`
MinStock *int `json:"min_stock"`
CreatedAt string `json:"created_at"`
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
// 超出上限钳到上限(此前是回退默认 20,导致出库选货请求大 page_size 被打回只剩 20 条)
if pageSize > 5000 {
pageSize = 5000
}
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
args := []interface{}{shopID}
keyword := c.Query("keyword")
warehouseIDStr := c.Query("warehouse_id")
inStock := c.Query("in_stock")
seriesStr := c.Query("series")
specStr := c.Query("spec")
if keyword != "" {
baseWhere += " AND (COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name) LIKE ? OR COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code) LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like, like, like)
}
if warehouseIDStr != "" {
baseWhere += " AND inv.warehouse_id = ?"
args = append(args, warehouseIDStr)
}
if inStock == "1" {
baseWhere += " AND inv.quantity > 0"
}
if seriesStr != "" {
parts := strings.Split(seriesStr, ",")
placeholders := strings.Repeat("?,", len(parts))
baseWhere += " AND COALESCE(NULLIF(p.series,''), inv.series, '') IN (" + placeholders[:len(placeholders)-1] + ")"
for _, s := range parts {
args = append(args, strings.TrimSpace(s))
}
}
if specStr != "" {
parts := strings.Split(specStr, ",")
placeholders := strings.Repeat("?,", len(parts))
baseWhere += " AND COALESCE(NULLIF(p.spec,''), inv.spec, '') IN (" + placeholders[:len(placeholders)-1] + ")"
for _, s := range parts {
args = append(args, strings.TrimSpace(s))
}
}
// 按商品过滤(商品详情:当前库存 / 货值 / 各仓库分布)
if productIDStr := c.Query("product_id"); productIDStr != "" {
baseWhere += " AND inv.product_id = ?"
args = append(args, productIDStr)
}
// Count query
countSQL := `
SELECT COUNT(*)
FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
WHERE ` + baseWhere
var total int64
if err := h.db.Raw(countSQL, args...).Scan(&total).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// Data query
dataSQL := `
SELECT
inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id,
inv.quantity,
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,
COALESCE(NULLIF(p.spec,''), NULLIF(sii.spec,''), inv.spec, '') AS spec,
COALESCE(NULLIF(p.unit,''), inv.unit, '') AS unit,
COALESCE(NULLIF(w.name,''), inv.warehouse_name, '') AS warehouse_name,
COALESCE(sii.unit_price, inv.unit_price, p.purchase_price) AS unit_price,
p.sale_price AS sale_price,
COALESCE(DATE(sii.production_date), DATE(inv.production_date)) AS production_date,
COALESCE(NULLIF(sii.batch_no,''), inv.batch_no, '') AS batch_no,
inv.supplier_name,
inv.remark,
COALESCE(p.brand, '') AS brand,
p.min_stock,
inv.created_at
FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
WHERE ` + baseWhere + `
ORDER BY inv.id DESC
LIMIT ? OFFSET ?`
dataArgs := append(args, pageSize, (page-1)*pageSize)
var rows []inventoryRow
if err := h.db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
// inventorySummary 全店库存 KPI 汇总(不随分页,供库存屏 KPI 卡)
type inventorySummary struct {
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
WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存)
}
// Summary GET /api/v1/inventory/summary —— 全店汇总(守多租户)
func (h *InventoryHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c)
const sql = `
SELECT
COUNT(*) AS sku_count,
COALESCE(SUM(inv.quantity * COALESCE(sii.unit_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.quantity > 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count
FROM inventories inv
LEFT JOIN stock_in_items sii ON sii.id = inv.stock_in_item_id
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
WHERE inv.shop_id = ? AND inv.deleted_at IS NULL`
var s inventorySummary
if err := h.db.Raw(sql, shopID).Scan(&s).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
c.JSON(http.StatusOK, s)
}
// Logs GET /api/v1/inventory/logs
func (h *InventoryHandler) Logs(c *gin.Context) {
shopID := middleware.GetShopID(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
pageSize = util.ValidatePageSize(pageSize, 20, 500)
query := h.db.Model(&model.InventoryLog{}).Where("shop_id = ?", shopID)
if productID := c.Query("product_id"); productID != "" {
query = query.Where("product_id = ?", productID)
}
var total int64
query.Count(&total)
logs := make([]model.InventoryLog, 0)
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
}
// CreateCheck POST /api/v1/inventory/checks
func (h *InventoryHandler) CreateCheck(c *gin.Context) {
shopID := middleware.GetShopID(c)
operatorID := middleware.GetUserID(c)
var req model.InventoryCheck
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.ShopID = shopID
req.OperatorID = operatorID
req.Status = "draft"
// 自动填入系统库存数量(SUM 聚合)
for i := range req.Items {
req.Items[i].ShopID = shopID
warehouseID := req.WarehouseID
productID := req.Items[i].ProductID
var systemQty float64
h.db.Model(&model.Inventory{}).
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND deleted_at IS NULL",
shopID, warehouseID, productID).
Select("COALESCE(SUM(quantity), 0)").Scan(&systemQty)
req.Items[i].SystemQty = systemQty
}
if err := h.db.Create(&req).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondCreated(c, req)
}
// GetCheck GET /api/v1/inventory/checks/:id
func (h *InventoryHandler) GetCheck(c *gin.Context) {
shopID := middleware.GetShopID(c)
var check model.InventoryCheck
if err := h.db.Preload("Items.Product").
Where("id = ? AND shop_id = ?", c.Param("id"), shopID).
First(&check).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
util.RespondSuccess(c, check)
}
// CompleteCheck PUT /api/v1/inventory/checks/:id/complete
func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
shopID := middleware.GetShopID(c)
checkID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
var check model.InventoryCheck
if err := h.db.Preload("Items.Product").
Where("id = ? AND shop_id = ?", checkID, shopID).
First(&check).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if check.Status == "completed" {
c.JSON(http.StatusBadRequest, gin.H{"error": "已完成"})
return
}
tx := h.db.Begin()
now := time.Now()
for _, item := range check.Items {
diff := item.ActualQty - item.SystemQty
if diff == 0 {
continue
}
if diff > 0 {
// 盘盈:新增一条库存批次记录
checkIDCopy := checkID
productIDCopy := item.ProductID
warehouseIDCopy := check.WarehouseID
productCode := ""
productName := ""
series := ""
spec := ""
unit := ""
if item.Product != nil {
productCode = item.Product.Code
productName = item.Product.Name
series = item.Product.Series
spec = item.Product.Spec
unit = item.Product.Unit
}
inv := model.Inventory{
ShopID: shopID,
WarehouseID: &warehouseIDCopy,
ProductID: &productIDCopy,
InventoryCheckID: &checkIDCopy,
Quantity: diff,
ProductCode: productCode,
ProductName: productName,
Series: series,
Spec: spec,
Unit: unit,
}
tx.Create(&inv)
} else {
// 盘亏:FIFO 扣减
remaining := -diff
var batches []model.Inventory
tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND warehouse_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL",
shopID, check.WarehouseID, item.ProductID).
Order("created_at ASC").Find(&batches)
for i := range batches {
if remaining <= 0 {
break
}
b := &batches[i]
if b.Quantity <= remaining {
remaining -= b.Quantity
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
} else {
tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
remaining = 0
}
}
}
}
tx.Model(&check).Update("status", "completed")
if err := tx.Commit().Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "盘点完成"})
}
// UpdateRemark PUT /api/v1/inventory/:id/remark
func (h *InventoryHandler) UpdateRemark(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 {
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result := h.db.Model(&model.Inventory{}).
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
Update("remark", req.Remark)
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})
}