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_ 分表反查 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}) }