Files
jiu/backend/internal/handler/finance.go
T
wangjia 4d1d69595b fix(backend): 财务汇总 500(only_full_group_by 预处理协议回归)+ summary 加 draft_count
- /finance/summary:SEC-001 给 JOIN 加 shop_id 条件后,MySQL 预处理协议推导不出
  p.name 函数依赖 → 1055(文本协议可过,CLI 无法复现)。GROUP BY 补 p.name 修复,
  并给该 handler 加错误日志(此前吞错致排查困难)。v1.0.85 起全店受影响。
- 出入库 summary 新增 draft_count(草稿单数 KPI 卡数据源)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 14:31:50 +08:00

276 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"log"
"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, p.name
ORDER BY total_amount DESC
`, shopID).Scan(&rows).Error; err != nil {
// p.name 必须入 GROUP BYJOIN 带 shop_id 条件后,MySQL 预处理协议
// 推导不出 p.name 的函数依赖 → only_full_group_by 1055(文本协议却能过,
// 故 CLI 直跑无法复现)。p.name 由 partner_id 唯一决定,加入不改分组粒度。
log.Printf("finance summary query failed (shop %d): %v", shopID, err)
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
}