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" ) type StockOutHandler struct { db *gorm.DB stockSvc *service.StockService } func NewStockOutHandler(db *gorm.DB, svc *service.StockService) *StockOutHandler { return &StockOutHandler{db: db, stockSvc: svc} } // List GET /api/v1/stock-out/orders // stripStockOutCost 服务端兜底:成本/利润仅管理员可见(2026-07 用户拍板)。 // operator/readonly 的 List/Get 响应把 cost_price/cost_amount/profit_total 抹零, // 防止非管理员通过抓包看到成本与利润(前端同时隐藏对应列)。 func stripStockOutCost(role string, orders []model.StockOutOrder) { if role == "admin" || role == "superadmin" { return } for i := range orders { orders[i].ProfitTotal = 0 for j := range orders[i].Items { orders[i].Items[j].CostPrice = 0 orders[i].Items[j].CostAmount = 0 } } } func (h *StockOutHandler) 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.StockOutOrder{}). 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_out_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_out_items") var total int64 query.Count(&total) orders := make([]model.StockOutOrder, 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) stripStockOutCost(middleware.GetRole(c), orders) c.JSON(http.StatusOK, gin.H{"data": orders, "total": total, "page": page, "page_size": pageSize}) } // Summary GET /api/v1/stock-out/summary —— 全店出库 KPI(守多租户;?window=rolling30 见 summaryBounds) func (h *StockOutHandler) 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.StockOutOrder{}). Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). Select("COUNT(*) AS cnt, COALESCE(SUM(sale_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.StockOutOrder{}). Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "pending").Count(&s.PendingCount) h.db.Model(&model.StockOutOrder{}). Where("shop_id = ? AND status = ? AND deleted_at IS NULL", shopID, "draft").Count(&s.DraftCount) c.JSON(http.StatusOK, s) } // ConfirmSale PUT/POST /api/v1/stock-out/orders/:id/confirm-sale —— 确认售价(先出后定价) func (h *StockOutHandler) ConfirmSale(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.SaleConfirmItem `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.SalePrice <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "售价必须大于 0"}) return } } if err := h.stockSvc.ConfirmStockOutSale(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": "sale confirmed"}) } // Get GET /api/v1/stock-out/orders/:id func (h *StockOutHandler) Get(c *gin.Context) { shopID := middleware.GetShopID(c) var order model.StockOutOrder if err := h.db.Preload("Items.Product", "shop_id = ?", shopID). 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 } one := []model.StockOutOrder{order} stripStockOutCost(middleware.GetRole(c), one) util.RespondSuccess(c, one[0]) } // Create POST /api/v1/stock-out/orders // fillStockOutItemSnapshots 按 product_id 从商品主数据拷明细快照列(编码/名称/系列/规格/批次/生产日期)。 // 明细 = product 引用 + 快照(历史保真:商品日后改名/删除,单据仍能还原当时信息;搜索/退单提示读快照)。 // 入库建单在 createIndependentProduct 后即填快照,出库同样必须在建单/改单时填齐。 func fillStockOutItemSnapshots(db *gorm.DB, shopID uint64, items []model.StockOutItem) error { if len(items) == 0 { return nil } ids := make([]uint64, 0, len(items)) for i := range items { ids = append(ids, items[i].ProductID) } var prods []model.Product if err := db.Where("shop_id = ? AND id IN ?", shopID, ids).Find(&prods).Error; err != nil { return err } byID := make(map[uint64]*model.Product, len(prods)) for i := range prods { byID[prods[i].ID] = &prods[i] } for i := range items { p, ok := byID[items[i].ProductID] if !ok { return fmt.Errorf("明细第 %d 行商品不存在", i+1) } items[i].ProductCode = p.Code items[i].ProductName = p.Name items[i].Series = p.Series items[i].Spec = p.Spec if items[i].BatchNo == "" { items[i].BatchNo = p.BatchNo } if items[i].ProductionDate == nil { items[i].ProductionDate = p.ProductionDate } } return nil } func (h *StockOutHandler) Create(c *gin.Context) { shopID := middleware.GetShopID(c) operatorID := middleware.GetUserID(c) var req model.StockOutOrder 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 // 状态只允许 draft 或 pending;直接提交审核时校验库存 if req.Status == "pending" { if err := h.stockSvc.CheckInventoryAvailability(shopID, req.WarehouseID, req.Items); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } } else { req.Status = "draft" } orderNo, err := h.stockSvc.GenerateOrderNo(shopID, "stock_out") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } req.OrderNo = orderNo var total, profit 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 } // 成本小计 / 售价小计分列;应收(SaleTotal)与总利润按售价口径 it.CostAmount = it.Quantity * it.CostPrice it.SaleAmount = it.Quantity * it.SalePrice total += it.SaleAmount if it.SalePrice > 0 { profit += (it.SalePrice - it.CostPrice) * it.Quantity } } req.SaleTotal = total req.ProfitTotal = profit if err := fillStockOutItemSnapshots(h.db, shopID, req.Items); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.db.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } util.RespondCreated(c, req) } // Update PUT /api/v1/stock-out/orders/:id (只允许草稿状态) func (h *StockOutHandler) Update(c *gin.Context) { shopID := middleware.GetShopID(c) var order model.StockOutOrder 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.StockOutOrder 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.StockOutItem{}).Error; err != nil { return err } var total float64 var profit 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 } it.CostAmount = it.Quantity * it.CostPrice it.SaleAmount = it.Quantity * it.SalePrice total += it.SaleAmount if it.SalePrice > 0 { profit += (it.SalePrice - it.CostPrice) * it.Quantity } } if err := fillStockOutItemSnapshots(tx, shopID, req.Items); err != nil { return err } updates := map[string]interface{}{ "warehouse_id": req.WarehouseID, "partner_id": req.PartnerID, "order_date": req.OrderDate, "remark": req.Remark, "sale_total": total, "profit_total": profit, } 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-out/orders/:id (只允许草稿状态) func (h *StockOutHandler) Delete(c *gin.Context) { shopID := middleware.GetShopID(c) now := timeNow() result := h.db.Model(&model.StockOutOrder{}). 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-out/orders/:id/submit func (h *StockOutHandler) Submit(c *gin.Context) { shopID := middleware.GetShopID(c) // 加载订单及明细,校验库存后再改状态 var order model.StockOutOrder if err := h.db.Preload("Items"). Where("id = ? AND shop_id = ? AND status = 'draft'", c.Param("id"), shopID). First(&order).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "order not found or not in draft status"}) return } if err := h.stockSvc.CheckInventoryAvailability(shopID, order.WarehouseID, order.Items); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } h.db.Model(&order).Update("status", "pending") c.JSON(http.StatusOK, gin.H{"message": "submitted"}) } // Approve PUT /api/v1/stock-out/orders/:id/approve func (h *StockOutHandler) Approve(c *gin.Context) { shopID := middleware.GetShopID(c) reviewerID := middleware.GetUserID(c) id, _ := strconv.ParseUint(c.Param("id"), 10, 64) if err := h.stockSvc.ApproveStockOut(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-out/orders/:id/reject func (h *StockOutHandler) Reject(c *gin.Context) { shopID := middleware.GetShopID(c) reviewerID := middleware.GetUserID(c) now := timeNow() result := h.db.Model(&model.StockOutOrder{}). 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-out/orders/:id/withdraw // 审核中(pending)撤回为草稿(draft),修改后可重新提交。仅 pending 可撤回; // 已审核(approved)单据只读、库存已变动,不可撤回。 // 权限:管理员/超管可撤回任意单;普通操作员只能撤回本人提交的单(operator_id 为本人)。 func (h *StockOutHandler) Withdraw(c *gin.Context) { shopID := middleware.GetShopID(c) userID := middleware.GetUserID(c) role := middleware.GetRole(c) var order model.StockOutOrder 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.StockOutOrder{}). Where("id = ? AND shop_id = ?", order.ID, shopID). Update("status", "draft") c.JSON(http.StatusOK, gin.H{"message": "withdrawn"}) } // Return POST /api/v1/stock-out/orders/:id/return // 出库退单:退掉指定明细,数量加回库存、冲减应收。仅 approved 单;管理员/超管 或 本人。 func (h *StockOutHandler) 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.ReturnStockOut(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"}) }