package handler import ( "net/http" "strconv" "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 FinanceHandler struct { db *gorm.DB } func NewFinanceHandler(db *gorm.DB) *FinanceHandler { return &FinanceHandler{db: db} } // ListRecords GET /api/v1/finance/records func (h *FinanceHandler) ListRecords(c *gin.Context) { shopID := middleware.GetShopID(c) var q struct { Type string `form:"type"` Month string `form:"month"` // e.g. "2026-04" StartDate string `form:"start_date"` // YYYY-MM-DD(区间过滤,优先于 month) EndDate string `form:"end_date"` PartnerID uint64 `form:"partner_id"` Page int `form:"page,default=1"` PageSize int `form:"page_size,default=50"` } if err := c.ShouldBindQuery(&q); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } q.PageSize = util.ValidatePageSize(q.PageSize, 50, 500) if q.Page <= 0 { q.Page = 1 } base := h.db.Model(&model.FinanceRecord{}).Where("shop_id = ?", shopID) if q.Type != "" { base = base.Where("type = ?", q.Type) } if q.PartnerID != 0 { base = base.Where("partner_id = ?", q.PartnerID) } // 日期过滤:区间(本季/自定义)优先;否则按单月。日期串比较跨 MySQL/SQLite 可移植。 if q.StartDate != "" || q.EndDate != "" { if q.StartDate != "" { base = base.Where("record_date >= ?", q.StartDate) } if q.EndDate != "" { base = base.Where("record_date <= ?", q.EndDate) } } else if q.Month != "" { base = base.Where("DATE_FORMAT(record_date, '%Y-%m') = ?", q.Month) } var total int64 base.Count(&total) records := make([]model.FinanceRecord, 0) offset := (q.Page - 1) * q.PageSize base.Preload("Partner", "shop_id = ?", shopID).Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records) c.JSON(http.StatusOK, gin.H{ "data": records, "total": total, "page": q.Page, "page_size": q.PageSize, }) } // Create POST /api/v1/finance/records — 手动录入付款/收款 func (h *FinanceHandler) Create(c *gin.Context) { shopID := middleware.GetShopID(c) userID := middleware.GetUserID(c) var req struct { PartnerID *uint64 `json:"partner_id"` Type string `json:"type" binding:"required"` Amount float64 `json:"amount" binding:"required,gt=0"` RecordDate string `json:"record_date" binding:"required"` Remark string `json:"remark"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // SEC-001:往来单位必须属于当前店(防跨租户引用泄露) if err := ensureShopRefOpt(h.db, "partners", req.PartnerID, shopID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Type != "payment" && req.Type != "receipt" { c.JSON(http.StatusBadRequest, gin.H{"error": "type must be payment or receipt"}) return } date, err := time.Parse("2006-01-02", req.RecordDate) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid record_date, use YYYY-MM-DD"}) return } // 付款/收款 均为减少余额(抵消应付/应收) prevBalance := partnerLastBalance(h.db, shopID, req.PartnerID) bal := prevBalance - req.Amount rec := model.FinanceRecord{ ShopID: shopID, PartnerID: req.PartnerID, Type: req.Type, Amount: req.Amount, Balance: bal, Status: "closed", OperatorID: userID, RecordDate: date, Remark: req.Remark, } if err := h.db.Create(&rec).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, rec) } // Close PUT /api/v1/finance/records/:id/close — 标记结清 func (h *FinanceHandler) Close(c *gin.Context) { shopID := middleware.GetShopID(c) id := c.Param("id") var rec model.FinanceRecord if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID). First(&rec).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "record not found"}) return } if rec.Type != "payable" && rec.Type != "receivable" { c.JSON(http.StatusBadRequest, gin.H{"error": "only payable/receivable can be closed"}) return } if rec.Status == "closed" { c.JSON(http.StatusBadRequest, gin.H{"error": "already closed"}) return } if err := h.db.Model(&rec).Update("status", "closed").Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // CloseByRef PUT /api/v1/finance/records/close-by-ref?ref_type=stock_in&ref_id=123 func (h *FinanceHandler) CloseByRef(c *gin.Context) { shopID := middleware.GetShopID(c) refType := c.Query("ref_type") refID := c.Query("ref_id") if refType == "" || refID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "ref_type and ref_id are required"}) return } var rec model.FinanceRecord err := h.db.Where("shop_id = ? AND ref_type = ? AND ref_id = ? AND status = 'open' AND deleted_at IS NULL", shopID, refType, refID).First(&rec).Error if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "no open finance record found"}) return } if err := h.db.Model(&rec).Update("status", "closed").Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // Trend GET /api/v1/finance/trend?months=6 — 近 N 个自然月收支趋势 // in=当月出库(销售)额、out=当月入库(采购)额;与 stock Summary 同口径 // (按 order_date、不筛状态、deleted_at IS NULL),日期串比较跨 MySQL/SQLite 可移植。 func (h *FinanceHandler) Trend(c *gin.Context) { shopID := middleware.GetShopID(c) months := 6 if v := c.Query("months"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 24 { months = n } } now := time.Now() first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) type point struct { Month string `json:"month"` In float64 `json:"in"` Out float64 `json:"out"` } // 出库=应收合计(sale_total),入库=应付合计(cost_total)——2026-07 定价字段消歧后分列 sum := func(table, col, from, to string) float64 { var v float64 h.db.Table(table). Where("shop_id = ? AND deleted_at IS NULL AND order_date >= ? AND order_date < ?", shopID, from, to). Select("COALESCE(SUM(" + col + "),0)").Scan(&v) return v } const f = "2006-01-02" points := make([]point, 0, months) for i := months - 1; i >= 0; i-- { mStart := first.AddDate(0, -i, 0) mEnd := mStart.AddDate(0, 1, 0) points = append(points, point{ Month: mStart.Format("2006-01"), In: sum("stock_out_orders", "sale_total", mStart.Format(f), mEnd.Format(f)), Out: sum("stock_in_orders", "cost_total", mStart.Format(f), mEnd.Format(f)), }) } c.JSON(http.StatusOK, gin.H{"data": points}) } // Summary GET /api/v1/finance/summary — 按往来单位汇总未结清 func (h *FinanceHandler) Summary(c *gin.Context) { shopID := middleware.GetShopID(c) type row struct { PartnerID *uint64 `json:"partner_id"` PartnerName string `json:"partner_name"` Type string `json:"type"` RecordCount int `json:"record_count"` TotalAmount float64 `json:"total_amount"` } var rows []row if err := h.db.Raw(` SELECT f.partner_id, COALESCE(p.name, '') AS partner_name, f.type, COUNT(*) AS record_count, SUM(f.amount) AS total_amount FROM finance_records f LEFT JOIN partners p ON p.id = f.partner_id AND p.shop_id = f.shop_id WHERE f.shop_id = ? AND f.deleted_at IS NULL AND f.type IN ('payable','receivable') AND f.status = 'open' GROUP BY f.partner_id, f.type ORDER BY total_amount DESC `, shopID).Scan(&rows).Error; err != nil { util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败") return } util.RespondSuccess(c, rows) } // partnerLastBalance 查询该往来单位最后一条财务记录的余额 func partnerLastBalance(db *gorm.DB, shopID uint64, partnerID *uint64) float64 { var last model.FinanceRecord q := db.Where("shop_id = ? AND deleted_at IS NULL", shopID) if partnerID != nil { q = q.Where("partner_id = ?", *partnerID) } else { q = q.Where("partner_id IS NULL") } q.Order("id DESC").First(&last) return last.Balance }