package console import ( "strings" "time" "yunyan/lego/sys/log" "yunyan/pb" "github.com/gin-gonic/gin" ) // ============================ 月结算域 (settlement) ============================ // // 平台 → 品牌商 → 渠道商 的两级分成月结算。 // // base = 该月归属该 (品牌商, 渠道商) 的已支付订单实付金额合计 // brand_gross = base × 品牌商比例 // channel_amount = brand_gross × 渠道商比例 ← 渠道商从品牌商那份里再切 // brand_net = brand_gross - channel_amount // platform_amount = base - brand_gross // // 订单散在各应用业务库,生成时逐库聚合再按 (brandid, channelid) 汇总;比例在生成那一刻 // 快照进结算行,之后调比例不影响已生成的月份。已确认(status>=1)的行重算时跳过,历史账不可变。 // getSettlements 结算单列表。品牌商/渠道商账号由后端按归属强制收敛。 func (this *serverComp) getSettlements(c *gin.Context) { var req pb.ApiGetSettlementsReq _ = c.ShouldBindJSON(&req) s := scopeOf(c) period := req.Period if period == 0 { period = latestSettlementPeriod() // 不传月份 → 最近一个有数据的月 } brandid := effectiveBrandId(c, req.Brandid) // 渠道商账号锁死到自己那一个渠道商;其余按前端传值(空=不限)。 var channelPtr *string if cid := effectiveChannelId(c, req.Channelid); cid != "" { channelPtr = &cid } // 品牌商账号即便不传 brandid 也只能看自己那一个(effectiveBrandId 已强制), // 这里再给一层品牌商白名单,防止将来新增入口漏改。 var brandScope []uint32 if !s.unlimited && s.brandId != 0 { brandScope = []uint32{s.brandId} } items, err := listSettlements(period, brandid, channelPtr, req.Status, brandScope) 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.TotalBrandNet += it.BrandNet 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) 逐个应用业务库聚合该月已支付订单,按 (品牌商, 渠道商) 汇总。 type key struct { brandid uint32 channelid string } sums := make(map[key]*settleAgg) 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++ for _, r := range rows { k := key{brandid: r.Brandid, channelid: r.Channelid} if cur, ok := sums[k]; ok { cur.OrderCount += r.OrderCount cur.BaseAmount += r.BaseAmount } else { sums[k] = &settleAgg{Brandid: r.Brandid, Channelid: r.Channelid, OrderCount: r.OrderCount, BaseAmount: r.BaseAmount} } } } // 2) 取品牌商/渠道商当前比例,算钱,落库(已确认的跳过)。 sys := consoleDeviceConn() 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.brandid, k.channelid); oerr == nil && old != nil && old.Status >= settlementStatusConfirmed { skipped++ // 已确认:锁定,不覆盖 continue } var brandRate, channelRate int32 if k.brandid != 0 { if b, berr := dvBrand(sys, k.brandid); berr == nil && b != nil { brandRate = b.Sharerate } } if k.channelid != "" { if ch, cerr := dvChannel(sys, k.channelid); cerr == nil && ch != nil { channelRate = ch.Sharerate } } brandGross, channelAmount, brandNet, platform := computeSettlement(agg.BaseAmount, brandRate, channelRate) out = append(out, &pb.DBSettlementMonth{ Period: period, Brandid: k.brandid, Channelid: k.channelid, OrderCount: agg.OrderCount, BaseAmount: agg.BaseAmount, BrandRate: brandRate, BrandGross: brandGross, ChannelRate: channelRate, ChannelAmount: channelAmount, BrandNet: brandNet, 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, req.Brandid, channelid) 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}) }