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.
297 lines
12 KiB
297 lines
12 KiB
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_<pid> 表的 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
|
|
}
|
|
|