Files
jiu/backend/internal/handler/inventory.go
T
wangjia 75d0accb14 feat(backend): 库存成本仅管理员可见——List 抹空 unit_price、Summary 抹零货值
与出库 stripStockOutCost 同口径:operator/readonly 服务端兜底抹除,
防抓包看到成本;sale_price 与数量等字段不受影响。CLAUDE.md 口径同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:05 +08:00

434 lines
15 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"`
}
// stripInventoryCost 服务端兜底:库存成本仅管理员可见(与出库 stripStockOutCost 同口径)。
// operator/readonly 的 List 响应把 unit_price(成本进价)置空,sale_price 不受影响。
func stripInventoryCost(role string, rows []inventoryRow) {
if role == "admin" || role == "superadmin" {
return
}
for i := range rows {
rows[i].UnitPrice = nil
}
}
// 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)
}
// 商品编码独立筛选(库存屏工具栏第二个搜索框,对齐原型 codeInput)
if code := c.Query("code"); code != "" {
baseWhere += " AND COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code, '') LIKE ?"
args = append(args, "%"+code+"%")
}
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.cost_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
}
stripInventoryCost(middleware.GetRole(c), rows)
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<安全库存)
// 月初快照(供 KPI 环比):本月净变动由 inventory_logs 反推
LastMonthSku int64 `json:"last_month_sku"` // 月初存在的库存行数
LastMonthQty float64 `json:"last_month_qty"` // 月初在库总数量
LastMonthValue float64 `json:"last_month_value"` // 月初库存货值
}
// Summary GET /api/v1/inventory/summary —— 全店汇总(守多租户)
func (h *InventoryHandler) Summary(c *gin.Context) {
shopID := middleware.GetShopID(c)
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// 月初数量 = 当前数量 − 本月净变动(product=序列号模型下 log 与库存行按 product_id+warehouse_id 一一对应)
const sql = `
SELECT
COUNT(*) 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.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,
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN (inv.quantity - COALESCE(lg.net, 0)) * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price) ELSE 0 END), 0) AS last_month_value
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 (
SELECT product_id, warehouse_id,
SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END) AS net
FROM inventory_logs
WHERE shop_id = ? AND created_at >= ?
GROUP BY product_id, warehouse_id
) lg ON lg.product_id = inv.product_id AND lg.warehouse_id = inv.warehouse_id
WHERE inv.shop_id = ? AND inv.deleted_at IS NULL`
var s inventorySummary
if err := h.db.Raw(sql, monthStart, monthStart, monthStart, shopID, monthStart, shopID).Scan(&s).Error; err != nil {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// 库存货值 = Σ(qty×进价),成本口径,仅管理员可见(同 stripInventoryCost
if role := middleware.GetRole(c); role != "admin" && role != "superadmin" {
s.StockValue = 0
s.LastMonthValue = 0
}
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", "shop_id = ?", shopID).
Preload("Warehouse", "shop_id = ?", shopID).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"
// SEC-P01:外键归属校验——仓库与明细商品必须属于本店,拒绝跨租户脏引用
if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for i := range req.Items {
if err := ensureShopRef(h.db, "products", req.Items[i].ProductID, shopID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// 自动填入系统库存数量(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", "shop_id = ?", shopID).
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", "shop_id = ?", shopID).
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})
}