You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
233 lines
8.0 KiB
233 lines
8.0 KiB
package console
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"yunyan/lego/sys/log"
|
|
"yunyan/pb"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ============================ 月结算域 (settlement) ============================
|
|
//
|
|
// 平台 → 渠道商 的分成月结算,**按生产批次出行**(每 结算月×渠道商×批次 一行)。
|
|
//
|
|
// base = 该月归属该 (渠道商, 批次) 的已支付订单实付金额合计
|
|
// channel_amount = base × 该批次的分成比例
|
|
// platform_amount = base - channel_amount
|
|
//
|
|
// 为什么按批次:比例是一批一谈的(production_batch.sharerate,在「生成MAC」表单里填),
|
|
// 同一家渠道商不同批次可以不同比例,一个渠道商一行装不下。品牌商维度已移除。
|
|
//
|
|
// 订单怎么落到批次:订单上有下单时冻结的来源设备快照(src_license / src_mac / src_productid),
|
|
// 拿它去 license_<pid> 分表反查 batchno。订单散在各应用业务库、与 console 主库不同库,
|
|
// 所以流程是「逐库按设备聚合 → 回主库把设备映射成批次 → 按批次汇总算钱」。
|
|
//
|
|
// 比例在生成那一刻快照进结算行,之后调批次比例不影响已生成的月份。
|
|
// 已确认(status>=1)的行重算时跳过,历史账不可变。
|
|
|
|
// getSettlements 结算单列表。渠道商账号由后端按归属强制收敛。
|
|
func (this *serverComp) getSettlements(c *gin.Context) {
|
|
var req pb.ApiGetSettlementsReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
period := req.Period
|
|
if period == 0 {
|
|
period = latestSettlementPeriod() // 不传月份 → 最近一个有数据的月
|
|
}
|
|
// 渠道商账号锁死到自己那一个渠道商;其余按前端传值(空=不限)。
|
|
var channelPtr *string
|
|
if cid := effectiveChannelId(c, req.Channelid); cid != "" {
|
|
channelPtr = &cid
|
|
}
|
|
items, err := listSettlements(period, channelPtr, req.Batchno, req.Status)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
resp := &pb.ApiGetSettlementsResp{Items: items}
|
|
for _, it := range items {
|
|
resp.TotalBase += it.BaseAmount
|
|
resp.TotalChannel += it.ChannelAmount
|
|
resp.TotalPlatform += it.PlatformAmount
|
|
}
|
|
writeOK(c, resp)
|
|
}
|
|
|
|
// genSettlement 生成/重算某月结算单(仅超管/管理员)。
|
|
// 已确认的行原样保留并计入 skipped;其余行按当前比例重算覆盖。
|
|
func (this *serverComp) genSettlement(c *gin.Context) {
|
|
if !canAuditChannel(c) { // 与渠道审批同权限:超管 + 管理员
|
|
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可生成结算单")
|
|
return
|
|
}
|
|
var req pb.ApiGenSettlementReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
period := req.Period
|
|
if period == 0 {
|
|
// 不传月份 → 结算上一个自然月(月初跑当月生成上月账的常见用法)。
|
|
period = periodOf(time.Now().AddDate(0, -1, 0))
|
|
}
|
|
start, end, err := periodRange(period)
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error())
|
|
return
|
|
}
|
|
|
|
// 1) 逐个应用业务库聚合该月已支付订单,先按【来源设备】汇总(批次要靠设备反查)。
|
|
devAggs := make([]*settleAgg, 0, 64)
|
|
apps, err := this.module.model.listApps()
|
|
if err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
var scanned, failed int
|
|
for _, app := range apps {
|
|
if !app.Enabled {
|
|
continue
|
|
}
|
|
db, derr := this.module.registry.getServiceDB(app.Id)
|
|
if derr != nil {
|
|
failed++
|
|
log.Warn("console.settlement: 应用业务库不可用,已跳过",
|
|
log.Field{Key: "app", Value: app.Name}, log.Field{Key: "err", Value: derr.Error()})
|
|
continue
|
|
}
|
|
rows, aerr := aggOrdersOnDB(db, start, end)
|
|
if aerr != nil {
|
|
failed++
|
|
// 常见原因:该库还没跑渠道维度迁移,payorder 缺 src_* 列。
|
|
log.Warn("console.settlement: 订单聚合失败,已跳过该应用",
|
|
log.Field{Key: "app", Value: app.Name}, log.Field{Key: "err", Value: aerr.Error()})
|
|
continue
|
|
}
|
|
scanned++
|
|
devAggs = append(devAggs, rows...)
|
|
}
|
|
|
|
// 2) 把设备映射成批次。按产品分组一次性查 license 分表,避免逐设备查库。
|
|
byProduct := make(map[uint32][]string)
|
|
for _, a := range devAggs {
|
|
if k := a.deviceKey(); k != "" && a.Productid != 0 {
|
|
byProduct[a.Productid] = append(byProduct[a.Productid], k)
|
|
}
|
|
}
|
|
dev2batch := resolveBatchOfDevices(byProduct)
|
|
|
|
// 3) 按 (渠道商, 批次) 汇总。归不到批次的订单落 batchno='' 那一行,比例 0、全归平台——
|
|
// 常见于「用户从未绑过设备就下单」和「批次已被撤销」,这些钱不该凭空分给某个渠道商。
|
|
type key struct {
|
|
channelid string
|
|
batchno string
|
|
}
|
|
type sumRow struct {
|
|
orderCount int64
|
|
baseAmount int64
|
|
productid uint32
|
|
}
|
|
sums := make(map[key]*sumRow)
|
|
for _, a := range devAggs {
|
|
batchno := ""
|
|
if k := a.deviceKey(); k != "" && a.Productid != 0 {
|
|
batchno = dev2batch[batchMapKey(a.Productid, k)]
|
|
}
|
|
kk := key{channelid: a.Channelid, batchno: batchno}
|
|
cur, ok := sums[kk]
|
|
if !ok {
|
|
cur = &sumRow{productid: a.Productid}
|
|
sums[kk] = cur
|
|
}
|
|
cur.orderCount += a.OrderCount
|
|
cur.baseAmount += a.BaseAmount
|
|
}
|
|
|
|
// 4) 取每个批次的比例快照,算钱,落库(已确认的跳过)。
|
|
batchnos := make([]string, 0, len(sums))
|
|
for k := range sums {
|
|
if k.batchno != "" {
|
|
batchnos = append(batchnos, k.batchno)
|
|
}
|
|
}
|
|
batches := batchRates(batchnos)
|
|
|
|
now := time.Now().Unix()
|
|
out := make([]*pb.DBSettlementMonth, 0, len(sums))
|
|
var skipped int64
|
|
for k, agg := range sums {
|
|
if old, oerr := getSettlement(period, k.channelid, k.batchno); oerr == nil && old != nil && old.Status >= settlementStatusConfirmed {
|
|
skipped++ // 已确认:锁定,不覆盖
|
|
continue
|
|
}
|
|
var channelRate int32
|
|
productid := agg.productid
|
|
if b := batches[k.batchno]; b != nil {
|
|
channelRate = b.Sharerate
|
|
productid = b.Productid // 以批次台账为准,比订单快照可靠
|
|
}
|
|
channelAmount, platform := computeSettlement(agg.baseAmount, channelRate)
|
|
out = append(out, &pb.DBSettlementMonth{
|
|
Period: period,
|
|
Channelid: k.channelid,
|
|
Batchno: k.batchno,
|
|
Productid: productid,
|
|
OrderCount: agg.orderCount,
|
|
BaseAmount: agg.baseAmount,
|
|
ChannelRate: channelRate,
|
|
ChannelAmount: channelAmount,
|
|
PlatformAmount: platform,
|
|
Status: settlementStatusDraft,
|
|
GenTime: now,
|
|
})
|
|
}
|
|
if err = upsertSettlements(out); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
log.Infof("console.settlement: 生成结算单 period=%d 行数=%d 跳过(已确认)=%d 应用库 成功=%d 跳过=%d",
|
|
period, len(out), skipped, scanned, failed)
|
|
writeOK(c, &pb.ApiGenSettlementResp{Period: period, Rows: int64(len(out)), Skipped: skipped})
|
|
}
|
|
|
|
// confirmSettlement 确认 / 标记已支付 / 退回草稿(仅超管/管理员)。
|
|
// 确认后该行被锁定,重算不再覆盖。
|
|
func (this *serverComp) confirmSettlement(c *gin.Context) {
|
|
if !canAuditChannel(c) {
|
|
writeErr(c, pb.ErrorCode_InsufficientPermissions, "仅超管/管理员可确认结算单")
|
|
return
|
|
}
|
|
var req pb.ApiConfirmSettlementReq
|
|
_ = c.ShouldBindJSON(&req)
|
|
if req.Period == 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "period 必填")
|
|
return
|
|
}
|
|
switch req.Status {
|
|
case settlementStatusDraft, settlementStatusConfirmed, settlementStatusPaid:
|
|
default:
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "status 仅支持 0(退回草稿) / 1(已确认) / 2(已支付)")
|
|
return
|
|
}
|
|
channelid := strings.TrimSpace(req.Channelid)
|
|
model, err := getSettlement(req.Period, channelid, strings.TrimSpace(req.Batchno))
|
|
if err != nil || model.Period == 0 {
|
|
writeErr(c, pb.ErrorCode_DBError, "结算单不存在")
|
|
return
|
|
}
|
|
model.Status = req.Status
|
|
model.Remark = req.Remark
|
|
if req.Status == settlementStatusDraft {
|
|
// 退回草稿:清掉确认痕迹,下次重算会重新覆盖这一行。
|
|
model.ConfirmAccount = ""
|
|
model.ConfirmTime = 0
|
|
} else {
|
|
model.ConfirmAccount = ctxStr(c, "username")
|
|
model.ConfirmTime = time.Now().Unix()
|
|
}
|
|
if err = saveSettlement(model); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
writeOK(c, &pb.ApiConfirmSettlementResp{Item: model})
|
|
}
|
|
|