package console import ( "fmt" "strings" "time" "yunyan/comm" "yunyan/lego/sys/mysql" "yunyan/lego/sys/postgres" "yunyan/pb" "gorm.io/gorm/clause" ) // 月结算数据模型。结算单存 console 主库(与品牌商/渠道商同库), // 结算基数来自【各应用业务库】的订单表 payorder——订单分散在各部署,生成时逐库聚合再汇总。 // // 分成算法(2026-09-02 起按【生产批次】结算,比例在生成时快照冻结进结算行): // // channel_amount = base * channel_rate / 10000 渠道商分成,比例取自该批次 // platform_amount = base - channel_amount 平台留存 // // 为什么按批次出行:比例是一批一谈的(存 production_batch.sharerate), // 同一家渠道商不同批次比例可以不同,一个渠道商一行装不下两个比例。 // 品牌商维度已移除——生产链路不再涉及品牌商。 // // 退款口径:结算只统计【当前状态为已支付】的订单。同月内发生的退款会让订单状态离开 // 已支付,重算时自然从基数里消失;跨月退款不回改已确认的历史月份(历史账不可变), // 需要冲正时由管理员在当月结算单的备注里登记处理。 // 结算单状态取值(与 DBSettlementMonth.status 及前端一致)。 const ( settlementStatusDraft int32 = 0 // 草稿:可被重算覆盖 settlementStatusConfirmed int32 = 1 // 已确认:锁定,重算跳过 settlementStatusPaid int32 = 2 // 已支付 ) // rateBase 分成比例的分母(万分比:2000 = 20%)。 const rateBase int64 = 10000 // ensureSettlementTable 在 console 主库建好结算单表,并把旧维度的表迁移到新主键(幂等)。 // // 2026-09-02 结算维度从 (月, 品牌商, 渠道商) 改成 (月, 渠道商, 生产批次),主键跟着变。 // // ⚠️ 不能用「DROP 掉再 CreateTable」来换主键:postgres.CreateTable 内部缓存了启动时扫到的 // 表清单,DROP 之后那份缓存仍认为表存在,于是直接返回、根本不建——结果是表被删了又没建回来, // 结算页查询直接 42P01。所以这里一律走 ALTER,不删表。 // // 有数据的旧表拒绝自动迁移:历史结算单是钱账,行怎么拆到批次上没有唯一解,得人工定。 func ensureSettlementTable() error { if err := postgres.CreateTable(comm.TableSettlementMonth, &pb.DBSettlementMonth{}); err != nil { return err } var brandCol *string if err := postgres.Raw( "SELECT column_name::text FROM information_schema.columns WHERE table_name=? AND column_name='brandid'", comm.TableSettlementMonth).Scan(&brandCol).Error; err != nil { return err } if brandCol == nil || *brandCol == "" { return nil // 已是新维度 } var n int64 if err := postgres.Table(comm.TableSettlementMonth).Count(&n).Error; err != nil { return err } if n > 0 { return fmt.Errorf("结算单表 %s 仍是旧维度(带 brandid)且有 %d 行数据,无法自动迁移到「按批次结算」的新主键;"+ "请人工备份后清空该表再启动", comm.TableSettlementMonth, n) } t := comm.TableSettlementMonth for _, ddl := range []string{ "ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS batchno varchar(16) DEFAULT ''", "ALTER TABLE " + t + " ADD COLUMN IF NOT EXISTS productid bigint DEFAULT 0", // 换主键:先卸掉旧的复合主键,再按新维度加回去。 "ALTER TABLE " + t + " DROP CONSTRAINT IF EXISTS " + t + "_pkey", "ALTER TABLE " + t + " ALTER COLUMN batchno SET NOT NULL", "ALTER TABLE " + t + " ADD PRIMARY KEY (period, channelid, batchno)", // 旧维度的列删掉:留着会让人以为还能按品牌商对账。 "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brandid", "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brand_rate", "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brand_gross", "ALTER TABLE " + t + " DROP COLUMN IF EXISTS brand_net", } { if res := postgres.Exec(ddl); res.Error != nil { return res.Error } } return nil } // settleAgg 一台来源设备在某月的订单聚合结果(从业务库按设备维度聚合出来的中间态)。 // // 为什么聚到「设备」这么细:结算要按生产批次出行,而订单上只有来源设备快照 // (src_license / src_mac / src_productid),批次得拿设备去 license 分表反查。 // 业务库与 console 主库不是同一个库,join 不了,只能先按设备聚合再在 Go 里映射。 // 基数 = 当月有下单的设备数,通常远小于订单数。 type settleAgg struct { Channelid string `gorm:"column:channelid"` Productid uint32 `gorm:"column:productid"` License string `gorm:"column:license"` Mac string `gorm:"column:mac"` OrderCount int64 `gorm:"column:order_count"` BaseAmount int64 `gorm:"column:base_amount"` } // deviceKey 这台来源设备在 license 分表里的主键候选:优先 license,为空退 MAC。 // // ⚠️ 两者都可能为空(用户从未绑过设备就下单),那种订单归不到批次, // 落 batchno=” 的行、比例 0、全归平台。 func (a *settleAgg) deviceKey() string { if k := strings.TrimSpace(a.License); k != "" { return k } return strings.TrimSpace(a.Mac) } // periodRange 把结算月 YYYYMM 换算成 [起, 止) 的 unix 秒区间(按统计时区,与统计口径一致)。 func periodRange(period uint32) (start, end int64, err error) { if period < 200001 || period > 999912 { return 0, 0, fmt.Errorf("结算月格式非法(应为 YYYYMM): %d", period) } y := int(period / 100) m := int(period % 100) if m < 1 || m > 12 { return 0, 0, fmt.Errorf("结算月月份非法: %d", period) } loc := statLocation() from := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, loc) return from.Unix(), from.AddDate(0, 1, 0).Unix(), nil } // periodOf 把时间转成结算月 YYYYMM(按统计时区)。 func periodOf(t time.Time) uint32 { tt := t.In(statLocation()) return uint32(tt.Year()*100 + int(tt.Month())) } // aggOrdersOnDB 在一个应用业务库上,按【来源设备】聚合该月已支付订单。 // 归属取订单自己的来源设备快照(src_*):下单时冻结,事后换绑/换设备不影响历史账。 // 未迁移出 src_* 列的老库会查询报错,由调用方按「跳过该库并记录」处理。 func aggOrdersOnDB(db mysql.ISys, start, end int64) ([]*settleAgg, error) { rows := make([]*settleAgg, 0) err := db.Table(comm.TablePayOrder). Select("src_channelid AS channelid, src_productid AS productid, src_license AS license, src_mac AS mac, "+ "COUNT(*) AS order_count, COALESCE(SUM(amount),0) AS base_amount"). Where("status = ? AND pay_time >= ? AND pay_time < ?", int32(pb.PayOrderStatus_PAY_ORDER_PAID), start, end). Group("src_channelid, src_productid, src_license, src_mac"). Scan(&rows).Error if err != nil { return nil, err } return rows, nil } // resolveBatchOfDevices 把「产品 → 一批设备键」映射成「设备键 → 批次号」。 // // 设备键可能是 license 串也可能是 MAC,两者分别对应 license_ 表的 code 与 devicemac 列, // 所以一个产品查两次、合并成一张表。分表不存在(产品从未投产)时跳过。 // // ⚠️ 一次 IN 查询而不是逐个查:当月有下单的设备可能上千台,逐条查会把生成结算单拖成分钟级。 func resolveBatchOfDevices(byProduct map[uint32][]string) map[string]string { out := make(map[string]string) for pid, keys := range byProduct { if pid == 0 || len(keys) == 0 { continue } type row struct { Code string `gorm:"column:code"` Devicemac string `gorm:"column:devicemac"` Batchno string `gorm:"column:batchno"` } rows := make([]row, 0) // 2026-09-04 合表:设备都在 device_mac,按 productid 收窄到本产品。 // 产品从未投产时查出 0 行,与原来「分表不存在就跳过」等价。 if err := postgres.Table(comm.TableDeviceMac). Select("code, devicemac, batchno"). Where("productid=? AND (code IN ? OR devicemac IN ?)", pid, keys, keys). Scan(&rows).Error; err != nil { continue } for _, r := range rows { if r.Batchno == "" { continue } // code 与 devicemac 都登记进去:调用方拿到的设备键两种形态都能命中。 if r.Code != "" { out[batchMapKey(pid, r.Code)] = r.Batchno } if r.Devicemac != "" { out[batchMapKey(pid, r.Devicemac)] = r.Batchno } } } return out } // batchMapKey 设备键带上产品:调用方本来就按产品分组查,键里带 pid 保持一一对应。 // (合表后 MAC 已全局唯一,但结算按 产品×渠道 出账,键里保留 pid 更贴近账目口径。) func batchMapKey(pid uint32, deviceKey string) string { return fmt.Sprintf("%d/%s", pid, deviceKey) } // batchRates 批量取这些批次的分成比例与所属产品。查不到的批次(已撤销)不在返回里, // 调用方按比例 0 处理——货已经卖出去了,批次台账没了不能反过来把订单也吞掉。 func batchRates(batchnos []string) map[string]*pb.DBProductionBatch { out := make(map[string]*pb.DBProductionBatch) if len(batchnos) == 0 { return out } rows := make([]*pb.DBProductionBatch, 0) if err := postgres.Table(comm.TableProductionBatch).Where("batchno IN ?", batchnos).Find(&rows).Error; err != nil { return out } for _, r := range rows { out[r.Batchno] = r } return out } // computeSettlement 按渠道分成比例算出一行结算的金额(分,整数运算,末位截断)。 // 比例来自该批次的 sharerate 快照;批次归不到或比例为 0 时全归平台。 func computeSettlement(base int64, channelRate int32) (channelAmount, platform int64) { if channelRate < 0 { channelRate = 0 } if int64(channelRate) > rateBase { channelRate = int32(rateBase) // 比例上限 100%,防脏数据把平台留存算成负数 } channelAmount = base * int64(channelRate) / rateBase platform = base - channelAmount return } // upsertSettlements 批量覆盖写入结算行(按复合主键 period+channelid+batchno)。 func upsertSettlements(rows []*pb.DBSettlementMonth) error { if len(rows) == 0 { return nil } return postgres.Table(comm.TableSettlementMonth). Clauses(clause.OnConflict{UpdateAll: true}). CreateInBatches(rows, 200).Error } // listSettlements 按条件列结算单。channelid 用指针区分「不限」与「空串(无渠道归属)」; // batchno 空串=不限;status<0 不限。 func listSettlements(period uint32, channelid *string, batchno string, status int32) (models []*pb.DBSettlementMonth, err error) { models = make([]*pb.DBSettlementMonth, 0) conds := make([]string, 0, 4) args := make([]interface{}, 0, 4) if period > 0 { conds = append(conds, "period=?") args = append(args, period) } if channelid != nil { conds = append(conds, "channelid=?") args = append(args, *channelid) } if b := strings.TrimSpace(batchno); b != "" { conds = append(conds, "batchno=?") args = append(args, b) } if status >= 0 { conds = append(conds, "status=?") args = append(args, status) } if len(conds) == 0 { conds = append(conds, "1=1") } err = postgres.Table(comm.TableSettlementMonth). Where(strings.Join(conds, " AND "), args...). Order("period DESC, channelid ASC, batchno ASC"). Find(&models).Error return } // latestSettlementPeriod 取库里最近一个有结算数据的月份(前端不传月份时的默认值)。 func latestSettlementPeriod() (period uint32) { postgres.Table(comm.TableSettlementMonth). Select("COALESCE(MAX(period),0)").Scan(&period) return } // getSettlement 按复合主键取一行结算单。 func getSettlement(period uint32, channelid, batchno string) (model *pb.DBSettlementMonth, err error) { model = &pb.DBSettlementMonth{} err = postgres.FindOne(comm.TableSettlementMonth, model, "period=? AND channelid=? AND batchno=?", period, channelid, batchno) return } // saveSettlement 覆盖保存一行结算单。 func saveSettlement(model *pb.DBSettlementMonth) error { return postgres.Table(comm.TableSettlementMonth). Clauses(clause.OnConflict{UpdateAll: true}). Create(model).Error }