package handler import ( "net/http" "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" 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.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").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 } 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}) } // 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 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 }