package handler import ( "errors" "fmt" "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/service" "github.com/wangjia/jiu/backend/internal/util" ) func timeNow() *time.Time { t := time.Now() return &t } type StockInHandler struct { db *gorm.DB stockSvc *service.StockService } func NewStockInHandler(db *gorm.DB, svc *service.StockService) *StockInHandler { return &StockInHandler{db: db, stockSvc: svc} } // List GET /api/v1/stock-in/orders func (h *StockInHandler) List(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, 200) query := h.db.Model(&model.StockInOrder{}). Where("shop_id = ? AND deleted_at IS NULL", shopID) if status := c.Query("status"); status != "" { query = query.Where("status IN ?", strings.Split(status, ",")) } if startDate := c.Query("start_date"); startDate != "" { query = query.Where("order_date >= ?", startDate) } if endDate := c.Query("end_date"); endDate != "" { query = query.Where("order_date <= ?", endDate) } if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { like := "%" + kw + "%" // keyword 命中:单号 / 酒名(含全拼+首字母)/ 商品编码 —— 反查含该明细的单据。 // (不含往来单位;供应商/客户改由工具栏下拉筛选。) // 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的 // 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持, // 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。 // 编码同理「主数据 p.code OR 快照 it.product_code」双匹配,与页面显示口径(主数据优先)对齐。 query = query.Where( "order_no LIKE ?"+ " OR id IN (SELECT it.order_id FROM stock_in_items it"+ " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+ " WHERE it.shop_id = ? AND it.deleted_at IS NULL"+ " AND (p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+ " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))", like, shopID, like, like, like, like, like, like, ) } // 详细搜索多字段(各参数独立可选、组合为 AND) query = applyStockOrderDetailFilters(query, shopID, c, "stock_in_items") var total int64 query.Count(&total) orders := make([]model.StockInOrder, 0) query.Preload("Warehouse", "shop_id = ?", shopID). Preload("Partner", "shop_id = ?", shopID). Preload("Operator", "shop_id = ?", shopID). Preload("Reviewer", "shop_id = ?", shopID). Offset((page - 1) * pageSize).Limit(pageSize). Order("order_date DESC, id DESC").Find(&orders) c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) } // applyStockOrderDetailFilters 详细搜索多字段过滤(出入库共用,itemsTable 传 stock_in_items / stock_out_items, // 为硬编码常量非用户输入,无注入风险)。各参数独立可选:单据表字段直接过滤,明细字段走子查询 IN。 func applyStockOrderDetailFilters(q *gorm.DB, shopID uint64, c *gin.Context, itemsTable string) *gorm.DB { if v := strings.TrimSpace(c.Query("order_no")); v != "" { q = q.Where("order_no LIKE ?", "%"+v+"%") } if v := c.Query("partner_id"); v != "" { q = q.Where("partner_id = ?", v) } if v := c.Query("operator_id"); v != "" { q = q.Where("operator_id = ?", v) } if v := c.Query("reviewer_id"); v != "" { q = q.Where("reviewer_id = ?", v) } // 退单状态(none/partial/full;单据表直接过滤,供「部分退单/已退单」筛选) if v := c.Query("return_state"); v != "" { q = q.Where("return_state = ?", v) } // 明细/商品级条件合并为「一条」子查询:命中要求**同一条明细**同时满足全部条件 // (product/编码/系列/规格/批次/分类),而非「各条件各自由不同明细凑齐」。 // 一次扫描 items(走 idx_order_id 回连单据),语义更准、比多条 id IN 更省。 var itemConds []string var itemArgs []interface{} if v := strings.TrimSpace(c.Query("product")); v != "" { like := "%" + v + "%" itemConds = append(itemConds, "(p.name LIKE ? OR it.product_name LIKE ? OR it.product_code LIKE ?"+ " OR p.code LIKE ? OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?)") itemArgs = append(itemArgs, like, like, like, like, like, like) } // 编码「主数据 OR 快照」双匹配:存量出库明细快照有空串,只查快照会漏单 if v := strings.TrimSpace(c.Query("product_code")); v != "" { like := "%" + v + "%" itemConds = append(itemConds, "(it.product_code LIKE ? OR p.code LIKE ?)") itemArgs = append(itemArgs, like, like) } if v := strings.TrimSpace(c.Query("series")); v != "" { itemConds = append(itemConds, "it.series = ?") itemArgs = append(itemArgs, v) } if v := strings.TrimSpace(c.Query("spec")); v != "" { itemConds = append(itemConds, "it.spec = ?") itemArgs = append(itemArgs, v) } if v := strings.TrimSpace(c.Query("batch")); v != "" { itemConds = append(itemConds, "it.batch_no LIKE ?") itemArgs = append(itemArgs, "%"+v+"%") } if v := c.Query("category_id"); v != "" { itemConds = append(itemConds, "p.category_id = ?") itemArgs = append(itemArgs, v) } if len(itemConds) > 0 { sub := "id IN (SELECT it.order_id FROM " + itemsTable + " it" + " LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id" + " WHERE it.shop_id = ? AND it.deleted_at IS NULL AND " + strings.Join(itemConds, " AND ") + ")" args := append([]interface{}{shopID}, itemArgs...) q = q.Where(sub, args...) } return q } // stockSummary 出/入库 KPI 汇总(本月 + 上月同期供环比 + 待审核)。 type stockSummary struct { MonthCount int64 `json:"month_count"` MonthAmount float64 `json:"month_amount"` PendingCount int64 `json:"pending_count"` DraftCount int64 `json:"draft_count"` LastMonthCount int64 `json:"last_month_count"` LastMonthAmount float64 `json:"last_month_amount"` } // monthBounds 本月起 / 下月起 / 上月起(YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植)。 func monthBounds(now time.Time) (mStart, next, prev string) { s := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) const f = "2006-01-02" return s.Format(f), s.AddDate(0, 1, 0).Format(f), s.AddDate(0, -1, 0).Format(f) } // summaryBounds 依 ?window= 选统计口径: // - rolling30:近 30 天滚动窗 [今-29d, 明),对照窗为再往前 30 天——出入库列表 // KPI 用(月初自然月全 0 的观感问题,用户拍板改滚动窗); // - 默认:自然月 + 上月(原型/财务屏口径)。 // // 返回 YYYY-MM-DD 串,日期串比较跨 MySQL/SQLite 可移植。 func summaryBounds(c *gin.Context, now time.Time) (curFrom, curTo, prevFrom, prevTo string) { const f = "2006-01-02" if c.Query("window") == "rolling30" { d := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) return d.AddDate(0, 0, -29).Format(f), d.AddDate(0, 0, 1).Format(f), d.AddDate(0, 0, -59).Format(f), d.AddDate(0, 0, -29).Format(f) } mStart, next, prev := monthBounds(now) return mStart, next, prev, mStart } // Summary GET /api/v1/stock-in/summary —— 全店入库 KPI(守多租户;?window=rolling30 见 summaryBounds) func (h *StockInHandler) Summary(c *gin.Context) { shopID := middleware.GetShopID(c) curFrom, curTo, prevFrom, prevTo := summaryBounds(c, time.Now()) agg := func(from, to string) (int64, float64) { var r struct { Cnt int64 Amt float64 } h.db.Model(&model.StockInOrder{}). Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). Select("COUNT(*) AS cnt, COALESCE(SUM(cost_total),0) AS amt").Scan(&r) return r.Cnt, r.Amt } var s stockSummary s.MonthCount, s.MonthAmount = agg(curFrom, curTo) s.LastMonthCount, s.LastMonthAmount = agg(prevFrom, prevTo) h.db.Model(&model.StockInOrder{}). Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount) h.db.Model(&model.StockInOrder{}). Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "draft").Count(&s.DraftCount) c.JSON(http.StatusOK, s) } // Get GET /api/v1/stock-in/orders/:id func (h *StockInHandler) Get(c *gin.Context) { shopID := middleware.GetShopID(c) var order model.StockInOrder if err := h.db.Preload("Items.Product", "shop_id = ?", shopID).Preload("Items.Product.Origin"). Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage"). Preload("Warehouse", "shop_id = ?", shopID).Preload("Partner", "shop_id = ?", shopID). Preload("Operator", "shop_id = ?", shopID).Preload("Reviewer", "shop_id = ?", shopID). Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID). First(&order).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } util.RespondSuccess(c, order) } // Create POST /api/v1/stock-in/orders func (h *StockInHandler) Create(c *gin.Context) { shopID := middleware.GetShopID(c) operatorID := middleware.GetUserID(c) var req model.StockInOrder if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // SEC-001:外键必须属于当前店(防跨租户引用泄露) if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.ShopID = shopID req.OperatorID = operatorID req.Status = "draft" // 生成单号 orderNo, err := h.stockSvc.GenerateOrderNo(shopID, "stock_in") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } req.OrderNo = orderNo // 事务内:每条明细新建一个独立产品(特有产品/序列号,不按名称复用),回填 product_id,再建单。 err = h.db.Transaction(func(tx *gorm.DB) error { var total float64 for i := range req.Items { it := &req.Items[i] it.ShopID = shopID // 兼容旧客户端(≤1.0.87):unit_price 回落为 cost_price(新 key 优先) if it.CostPrice == 0 && it.LegacyUnitPrice != nil { it.CostPrice = *it.LegacyUnitPrice } if it.BatchNo == "" { it.BatchNo = fmt.Sprintf("%s-%02d", req.OrderNo, i+1) } prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice) if e != nil { return e } it.ProductID = prod.ID it.ProductCode = prod.Code // 保留快照,兼容现有查询(瘦身阶段再去) it.CostAmount = it.Quantity * it.CostPrice total += it.CostAmount } req.CostTotal = total return tx.Create(&req).Error }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } util.RespondCreated(c, req) } // Update PUT /api/v1/stock-in/orders/:id (只允许草稿状态) func (h *StockInHandler) Update(c *gin.Context) { shopID := middleware.GetShopID(c) var order model.StockInOrder if err := h.db.Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID). First(&order).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "入库单不存在或不可修改"}) return } var req model.StockInOrder if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // SEC-001:外键必须属于当前店(防跨租户引用泄露) if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } err := h.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("order_id = ?", order.ID).Delete(&model.StockInItem{}).Error; err != nil { return err } var total float64 for i := range req.Items { it := &req.Items[i] it.ShopID = shopID it.OrderID = order.ID if it.CostPrice == 0 && it.LegacyUnitPrice != nil { it.CostPrice = *it.LegacyUnitPrice } if it.BatchNo == "" { it.BatchNo = fmt.Sprintf("%s-%02d", order.OrderNo, i+1) } // 编辑草稿:旧明细已删,每条按新模型重建独立产品(旧草稿 product 无库存,暂留待后续清理) prod, e := createIndependentProduct(tx, shopID, it.ProductName, it.Series, it.Spec, it.BatchNo, it.ProductionDate, it.CostPrice, it.SalePrice) if e != nil { return e } it.ProductID = prod.ID it.ProductCode = prod.Code it.CostAmount = it.Quantity * it.CostPrice total += it.CostAmount } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, "partner_id": req.PartnerID, "order_date": req.OrderDate, "remark": req.Remark, "cost_total": total, } if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } if len(req.Items) > 0 { if err := tx.Create(&req.Items).Error; err != nil { return err } } return nil }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "updated"}) } // Delete DELETE /api/v1/stock-in/orders/:id (只允许草稿状态) func (h *StockInHandler) Delete(c *gin.Context) { shopID := middleware.GetShopID(c) now := timeNow() result := h.db.Model(&model.StockInOrder{}). Where("id = ? AND shop_id = ? AND status = 'draft' AND deleted_at IS NULL", c.Param("id"), shopID). Update("deleted_at", now) if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "入库单不存在或不可删除"}) return } c.JSON(http.StatusOK, gin.H{"message": "deleted"}) } // Submit PUT /api/v1/stock-in/orders/:id/submit (草稿→待审核) func (h *StockInHandler) Submit(c *gin.Context) { shopID := middleware.GetShopID(c) result := h.db.Model(&model.StockInOrder{}). Where("id = ? AND shop_id = ? AND status = 'draft'", c.Param("id"), shopID). Update("status", "pending") if result.RowsAffected == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"}) return } c.JSON(http.StatusOK, gin.H{"message": "submitted"}) } // Approve PUT /api/v1/stock-in/orders/:id/approve func (h *StockInHandler) Approve(c *gin.Context) { shopID := middleware.GetShopID(c) reviewerID := middleware.GetUserID(c) id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := h.stockSvc.ApproveStockIn(shopID, id, reviewerID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "approved"}) } // Reject PUT /api/v1/stock-in/orders/:id/reject func (h *StockInHandler) Reject(c *gin.Context) { shopID := middleware.GetShopID(c) reviewerID := middleware.GetUserID(c) now := timeNow() result := h.db.Model(&model.StockInOrder{}). Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID). Updates(map[string]interface{}{ "status": "rejected", "reviewer_id": reviewerID, "reviewed_at": now, }) if result.RowsAffected == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"}) return } c.JSON(http.StatusOK, gin.H{"message": "rejected"}) } // Withdraw PUT /api/v1/stock-in/orders/:id/withdraw // 审核中(pending)撤回为草稿(draft),修改后可重新提交。仅 pending 可撤回; // 已审核(approved)单据只读、库存已变动,不可撤回。 // 权限:管理员/超管可撤回任意单;普通操作员只能撤回本人提交的单(operator_id 为本人)。 func (h *StockInHandler) Withdraw(c *gin.Context) { shopID := middleware.GetShopID(c) userID := middleware.GetUserID(c) role := middleware.GetRole(c) var order model.StockInOrder if err := h.db.Where("id = ? AND shop_id = ? AND status = 'pending'", c.Param("id"), shopID). First(&order).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in pending status"}) return } isAdmin := role == "admin" || role == "superadmin" if !isAdmin && order.OperatorID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "只能撤回本人提交的单据"}) return } h.db.Model(&model.StockInOrder{}). Where("id = ? AND shop_id = ?", order.ID, shopID). Update("status", "draft") c.JSON(http.StatusOK, gin.H{"message": "withdrawn"}) } // Return POST /api/v1/stock-in/orders/:id/return // 入库退单:退掉指定明细,对应库存从库存删除、冲减应付。仅 approved 单、管理员/超管。 func (h *StockInHandler) Return(c *gin.Context) { shopID := middleware.GetShopID(c) userID := middleware.GetUserID(c) role := middleware.GetRole(c) id, _ := strconv.ParseUint(c.Param("id"), 10, 64) var req struct { ItemIDs []uint64 `json:"item_ids"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.ItemIDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "未选择退单明细"}) return } if err := h.stockSvc.ReturnStockIn(shopID, id, userID, role, req.ItemIDs); err != nil { if errors.Is(err, service.ErrForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "无权退单"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "returned"}) } // ConfirmCost POST /api/v1/stock-in/orders/:id/confirm-cost // 确认进价:调货等以 0 价(暂估)入库并已审核后,价格确定时补填真实进价。 // 前向补偿(更新库存成本、回填已出库成本快照、补应付差额),不反审核。 // 仅 approved 单、管理员/超管(service 内判权)。 func (h *StockInHandler) ConfirmCost(c *gin.Context) { shopID := middleware.GetShopID(c) userID := middleware.GetUserID(c) role := middleware.GetRole(c) id, _ := strconv.ParseUint(c.Param("id"), 10, 64) var req struct { Items []service.CostConfirmItem `json:"items"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.Items) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "未提供要确认的明细"}) return } for _, it := range req.Items { if it.UnitPrice <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "进价必须大于 0"}) return } } if err := h.stockSvc.ConfirmStockInCost(shopID, id, userID, role, req.Items); err != nil { if errors.Is(err, service.ErrForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "无权确认进价"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "cost confirmed"}) }