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

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})
}