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 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}) }