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"` 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)) } } // 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) AS unit_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}) } // 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}) }