增加支付功能
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beelog "github.com/beego/beego/v2/core/logs"
|
||||
)
|
||||
|
||||
/* =============================================================
|
||||
* 日终对账:渠道账单导入 -> 与本地支付流水比对 -> 产出差异 -> 人工标记处理
|
||||
* 差异类型:long 长款(渠道有本地无)/ short 短款(本地有渠道无)/ status_mismatch 状态不一致
|
||||
* ============================================================= */
|
||||
|
||||
// BillRow 渠道账单明细行(导入用)
|
||||
type BillRow struct {
|
||||
ChannelTradeNo string // 渠道流水号
|
||||
OutTradeNo string // 渠道侧商户订单号(下单时传的 pay_no)
|
||||
Amount int64 // 渠道金额(分)
|
||||
Fee int64 // 渠道手续费(分)
|
||||
TradeState string // 渠道侧交易状态
|
||||
TradeTime *time.Time // 渠道交易时间
|
||||
}
|
||||
|
||||
// ImportChannelBill 导入某渠道某日的账单明细(幂等:按 channel + channel_trade_no 更新或插入)
|
||||
func ImportChannelBill(ctx context.Context, channel, billDate string, rows []BillRow) (int, error) {
|
||||
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
imported := 0
|
||||
for _, r := range rows {
|
||||
if strings.TrimSpace(r.ChannelTradeNo) == "" {
|
||||
continue
|
||||
}
|
||||
exist := &models.PlatformPaymentChannelBill{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("channel", channel).
|
||||
Filter("channel_trade_no", r.ChannelTradeNo).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(exist)
|
||||
if err == nil {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("id", exist.ID).
|
||||
Update(orm.Params{
|
||||
"amount": r.Amount, "fee": r.Fee, "trade_state": r.TradeState,
|
||||
"update_time": time.Now(),
|
||||
})
|
||||
imported++
|
||||
continue
|
||||
}
|
||||
row := &models.PlatformPaymentChannelBill{
|
||||
Channel: channel,
|
||||
BillDate: day,
|
||||
ChannelTradeNo: r.ChannelTradeNo,
|
||||
OutTradeNo: r.OutTradeNo,
|
||||
Amount: r.Amount,
|
||||
Fee: r.Fee,
|
||||
TradeState: r.TradeState,
|
||||
TradeTime: r.TradeTime,
|
||||
}
|
||||
if _, err := models.Orm.Insert(row); err == nil {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
// ParseChannelBillCSV 解析账单 CSV。
|
||||
// 约定列:channel_trade_no, out_trade_no, amount(元), fee(元), trade_state, trade_time
|
||||
// TODO(渠道): 各渠道账单文件列名/编码不同(微信为制表符分隔、支付宝为 GBK CSV),
|
||||
// 对接时按渠道导出文件写各自的解析器,统一转成 BillRow。
|
||||
func ParseChannelBillCSV(data []byte) ([]BillRow, error) {
|
||||
reader := csv.NewReader(strings.NewReader(string(data)))
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]BillRow, 0, len(records))
|
||||
for i, rec := range records {
|
||||
if i == 0 || len(rec) == 0 {
|
||||
continue
|
||||
}
|
||||
get := func(idx int) string {
|
||||
if idx < len(rec) {
|
||||
return strings.TrimSpace(rec[idx])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
row := BillRow{
|
||||
ChannelTradeNo: get(0),
|
||||
OutTradeNo: get(1),
|
||||
TradeState: get(4),
|
||||
}
|
||||
if row.ChannelTradeNo == "" {
|
||||
continue
|
||||
}
|
||||
if fen, err := YuanToFen(get(2)); err == nil {
|
||||
row.Amount = fen
|
||||
}
|
||||
if fen, err := YuanToFen(get(3)); err == nil {
|
||||
row.Fee = fen
|
||||
}
|
||||
if t := get(5); t != "" {
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, "2006-01-02"} {
|
||||
if tt, err := time.ParseInLocation(layout, t, time.Local); err == nil {
|
||||
row.TradeTime = &tt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ReconcileSummary 一次对账的执行结果
|
||||
type ReconcileSummary struct {
|
||||
BatchNo string `json:"batch_no"`
|
||||
Channel string `json:"channel"`
|
||||
BillDate string `json:"bill_date"`
|
||||
BillCount int `json:"bill_count"`
|
||||
Matched int `json:"matched"`
|
||||
LongCount int `json:"long_count"`
|
||||
ShortCount int `json:"short_count"`
|
||||
MismatchCount int `json:"mismatch_count"`
|
||||
}
|
||||
|
||||
// RunReconcile 对某渠道某日的账单与本地支付流水做比对,产出差异(幂等:按唯一键更新已有差异)。
|
||||
// 匹配策略:渠道账单的商户订单号即本地下单时传的 pay_no,优先按 pay_no 匹配,其次按 out_trade_no;
|
||||
// 本地侧只统计支付成功日(paid_at)落在对账日内的支付单。
|
||||
func RunReconcile(ctx context.Context, channel, billDate string) (*ReconcileSummary, error) {
|
||||
day, err := time.ParseInLocation("2006-01-02", billDate, time.Local)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("对账日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
if _, err := LoadChannelConfig(channel); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var bills []models.PlatformPaymentChannelBill
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("channel", channel).
|
||||
Filter("bill_date", day).
|
||||
Filter("delete_time__isnull", true).
|
||||
All(&bills)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var locals []models.PlatformPaymentOrder
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformPaymentOrder)).
|
||||
Filter("delete_time__isnull", true).
|
||||
Filter("status", models.PayStatusPaid).
|
||||
Filter("paid_at__gte", day).
|
||||
Filter("paid_at__lt", day.AddDate(0, 0, 1)).
|
||||
All(&locals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localByPayNo := map[string]*models.PlatformPaymentOrder{}
|
||||
localByOutTrade := map[string]*models.PlatformPaymentOrder{}
|
||||
for i := range locals {
|
||||
localByPayNo[locals[i].PayNo] = &locals[i]
|
||||
localByOutTrade[locals[i].OutTradeNo] = &locals[i]
|
||||
}
|
||||
|
||||
batchNo := NextBatchNo()
|
||||
summary := &ReconcileSummary{BatchNo: batchNo, Channel: channel, BillDate: billDate, BillCount: len(bills)}
|
||||
matchedPayNos := map[string]bool{}
|
||||
|
||||
for i := range bills {
|
||||
bill := bills[i]
|
||||
local := localByPayNo[bill.OutTradeNo]
|
||||
if local == nil {
|
||||
local = localByOutTrade[bill.OutTradeNo]
|
||||
}
|
||||
|
||||
if local == nil {
|
||||
summary.LongCount++
|
||||
_ = upsertDiff(channel, day, batchNo, "", bill.ChannelTradeNo,
|
||||
models.DiffTypeLong, nil, &bill.Amount, "渠道账单存在该流水,本地无对应支付单")
|
||||
continue
|
||||
}
|
||||
|
||||
if local.Status == models.PayStatusPaid && bill.Amount == local.Amount {
|
||||
summary.Matched++
|
||||
matchedPayNos[local.PayNo] = true
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformPaymentChannelBill)).
|
||||
Filter("id", bill.ID).
|
||||
Update(orm.Params{"is_matched": 1, "matched_pay_no": local.PayNo, "update_time": time.Now()})
|
||||
continue
|
||||
}
|
||||
|
||||
summary.MismatchCount++
|
||||
reason := "本地与渠道金额不一致"
|
||||
if bill.Amount == local.Amount {
|
||||
reason = fmt.Sprintf("渠道已成功但本地状态为 %s", local.Status)
|
||||
}
|
||||
_ = upsertDiff(channel, day, batchNo, local.PayNo, bill.ChannelTradeNo,
|
||||
models.DiffTypeStatusMismatch, &local.Amount, &bill.Amount, reason)
|
||||
}
|
||||
|
||||
for i := range locals {
|
||||
if matchedPayNos[locals[i].PayNo] {
|
||||
continue
|
||||
}
|
||||
summary.ShortCount++
|
||||
_ = upsertDiff(channel, day, batchNo, locals[i].PayNo, "",
|
||||
models.DiffTypeShort, &locals[i].Amount, nil, "本地已支付,渠道账单无对应流水")
|
||||
}
|
||||
|
||||
beelog.Info("对账完成: 渠道=%s 日期=%s 账单 %d 笔,匹配 %d,长款 %d,短款 %d,不一致 %d(批次 %s)",
|
||||
channel, billDate, summary.BillCount, summary.Matched, summary.LongCount, summary.ShortCount, summary.MismatchCount, batchNo)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// upsertDiff 写入/更新差异记录(幂等:唯一键 channel+bill_date+diff_type+channel_trade_no+local_trade_no)
|
||||
func upsertDiff(channel string, day time.Time, batchNo, localTradeNo, channelTradeNo, diffType string, localAmount, channelAmount *int64, remark string) error {
|
||||
exist := &models.PlatformPaymentReconcileDiff{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("channel", channel).
|
||||
Filter("bill_date", day).
|
||||
Filter("diff_type", diffType).
|
||||
Filter("channel_trade_no", channelTradeNo).
|
||||
Filter("local_trade_no", localTradeNo).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(exist)
|
||||
|
||||
diffAmount := int64(0)
|
||||
switch {
|
||||
case channelAmount != nil && localAmount != nil:
|
||||
diffAmount = *channelAmount - *localAmount
|
||||
case channelAmount != nil:
|
||||
diffAmount = *channelAmount
|
||||
case localAmount != nil:
|
||||
diffAmount = -*localAmount
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
_, uerr := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", exist.ID).
|
||||
Update(orm.Params{
|
||||
"batch_no": batchNo,
|
||||
"local_amount": localAmount,
|
||||
"channel_amount": channelAmount,
|
||||
"diff_amount": diffAmount,
|
||||
"update_time": time.Now(),
|
||||
})
|
||||
return uerr
|
||||
}
|
||||
|
||||
row := &models.PlatformPaymentReconcileDiff{
|
||||
Channel: channel,
|
||||
BillDate: day,
|
||||
BatchNo: batchNo,
|
||||
LocalTradeNo: localTradeNo,
|
||||
ChannelTradeNo: channelTradeNo,
|
||||
LocalAmount: localAmount,
|
||||
ChannelAmount: channelAmount,
|
||||
DiffAmount: diffAmount,
|
||||
DiffType: diffType,
|
||||
HandleStatus: models.DiffHandleUnhandled,
|
||||
Remark: remark,
|
||||
}
|
||||
_, err = models.Orm.Insert(row)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReconcileListInput 差异列表查询条件
|
||||
type ReconcileListInput struct {
|
||||
Channel string
|
||||
BillDate string
|
||||
DiffType string
|
||||
HandleStatus string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
// ListReconcileDiffs 对账差异分页
|
||||
func ListReconcileDiffs(in ReconcileListInput) ([]models.PlatformPaymentReconcileDiff, int64, error) {
|
||||
qs := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).Filter("delete_time__isnull", true)
|
||||
if in.Channel != "" {
|
||||
qs = qs.Filter("channel", in.Channel)
|
||||
}
|
||||
if in.BillDate != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", in.BillDate, time.Local); err == nil {
|
||||
qs = qs.Filter("bill_date", t)
|
||||
}
|
||||
}
|
||||
if in.DiffType != "" {
|
||||
qs = qs.Filter("diff_type", in.DiffType)
|
||||
}
|
||||
if in.HandleStatus != "" {
|
||||
qs = qs.Filter("handle_status", in.HandleStatus)
|
||||
}
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if in.Page <= 0 {
|
||||
in.Page = 1
|
||||
}
|
||||
if in.PageSize <= 0 || in.PageSize > 200 {
|
||||
in.PageSize = 10
|
||||
}
|
||||
var rows []models.PlatformPaymentReconcileDiff
|
||||
_, err = qs.OrderBy("-id").Limit(in.PageSize, (in.Page-1)*in.PageSize).All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
// MarkDiffHandledInput 标记差异处理入参
|
||||
type MarkDiffHandledInput struct {
|
||||
ID uint64
|
||||
HandleStatus string // handled / handling / ignored
|
||||
Remark string
|
||||
OperatorID string
|
||||
OperatorName string
|
||||
}
|
||||
|
||||
// MarkDiffHandled 标记对账差异处理状态
|
||||
func MarkDiffHandled(ctx context.Context, in MarkDiffHandledInput) (*models.PlatformPaymentReconcileDiff, error) {
|
||||
row := &models.PlatformPaymentReconcileDiff{}
|
||||
if err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", in.ID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row); err != nil {
|
||||
return nil, fmt.Errorf("差异记录不存在")
|
||||
}
|
||||
switch in.HandleStatus {
|
||||
case models.DiffHandleHandled, models.DiffHandleHandling, models.DiffHandleIgnored:
|
||||
default:
|
||||
return nil, fmt.Errorf("处理状态不合法")
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", row.ID).
|
||||
Update(orm.Params{
|
||||
"handle_status": in.HandleStatus,
|
||||
"remark": in.Remark,
|
||||
"handle_user_id": in.OperatorID,
|
||||
"handle_user_name": in.OperatorName,
|
||||
"handle_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetReconcileDiffByID(in.ID)
|
||||
}
|
||||
|
||||
// GetReconcileDiffByID 按 ID 查询差异记录
|
||||
func GetReconcileDiffByID(id uint64) (*models.PlatformPaymentReconcileDiff, error) {
|
||||
row := &models.PlatformPaymentReconcileDiff{}
|
||||
err := models.Orm.QueryTable(new(models.PlatformPaymentReconcileDiff)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(row)
|
||||
return row, err
|
||||
}
|
||||
Reference in New Issue
Block a user