Files
junhong_cmp_fiber/scripts/legacy_device_export/main.go
huang 95fc0b0a1b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s
修复一些问题,主要是生效套餐
2026-05-12 10:32:38 +08:00

2051 lines
75 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/csv"
"flag"
"fmt"
"math"
"math/big"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const (
defaultLegacySchema = "kyhl"
defaultBatchSize = 500
timeFormat = "2006-01-02 15:04:05"
sourceCardLife = "tbl_card_life"
sourceNextMonthLife = "tbl_next_month_card_life"
)
var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
type inputAsset struct {
Line int
DeviceVirtualNo string
IMEI string
SN string
SlotPosition int
ICCID string
ResolvedBy string
ResolveNote string
}
type cardSnapshot struct {
ICCID string `gorm:"column:iccid"`
CardID string `gorm:"column:card_id"`
Phone string `gorm:"column:phone"`
PhoneZuhe string `gorm:"column:phone_zuhe"`
OldIMEI string `gorm:"column:old_imei"`
OldVCode string `gorm:"column:old_vcode"`
Status string `gorm:"column:status"`
Type string `gorm:"column:type"`
Category string `gorm:"column:category"`
CategoryName string `gorm:"column:category_name"`
CardFlow string `gorm:"column:card_flow"`
SetMealID string `gorm:"column:set_meal_id"`
CardPackage string `gorm:"column:card_package"`
ExpireTime string `gorm:"column:expire_time"`
FlowSize string `gorm:"column:flow_size"`
TotalBytesCnt string `gorm:"column:total_bytes_cnt"`
SynchroTime string `gorm:"column:synchro_time"`
ServCreateAt *time.Time `gorm:"column:serv_create_at"`
BalanceMoney string `gorm:"column:balance_money"`
AgentID string `gorm:"column:agent_id"`
AgentName string `gorm:"column:agent_name"`
AccountID string `gorm:"column:account_id"`
AccountName string `gorm:"column:account_name"`
AgentThreeID string `gorm:"column:agent_three_id"`
}
type packageSnapshot struct {
SourceTable string `gorm:"column:source_table"`
ID string `gorm:"column:id"`
ICCID string `gorm:"column:iccid"`
MealID string `gorm:"column:meal_id"`
MealName string `gorm:"column:meal_name"`
OrderID string `gorm:"column:order_id"`
StartAt *time.Time `gorm:"column:start_at"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
Status string `gorm:"column:status"`
Type string `gorm:"column:type"`
FlowSize string `gorm:"column:flow_size"`
EffectType string `gorm:"column:effect_type"`
UpdateDateTime string `gorm:"column:update_date_time"`
EffectBeginTime *time.Time `gorm:"column:effect_begin_time"`
EffectCompleteTime *time.Time `gorm:"column:effect_complete_time"`
OrderType string `gorm:"column:order_type"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
type walletSnapshot struct {
ICCID string `gorm:"column:iccid"`
MoneyBalance string `gorm:"column:money_balance"`
TxVersion string `gorm:"column:tx_version"`
MealAutoRenewStatus string `gorm:"column:meal_auto_renew_status"`
AutoRenewMealID string `gorm:"column:auto_renew_meal_id"`
AddonAutoRenewStatus string `gorm:"column:addon_auto_renew_status"`
AddonAutoRenewMealID string `gorm:"column:addon_auto_renew_meal_id"`
MealAutoRenewUpdatedAt *time.Time `gorm:"column:meal_auto_renew_updated_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
}
type commissionAccountSnapshot struct {
AgentID string
AgentName string
AgentLoginAccount string
TotalCommissionAmount string
TotalActiveAwardCommissionAmount string
MFCommissionAmount string
NoMFCommissionAmount string
TotalDrawAmount string
CanDrawAmount string
ApplyingDrawAmount string
Remark string
TxVersion string
CreatedAt *time.Time
UpdatedAt *time.Time
}
type commissionDetailSnapshot struct {
ID string `gorm:"column:id"`
CommissionAccountID string `gorm:"column:commission_account_id"`
WxOrderID string `gorm:"column:wx_order_id"`
WxOrderNumber string `gorm:"column:wx_order_number"`
ICCID string `gorm:"column:iccid"`
Phone string `gorm:"column:phone"`
WxOrderAt *time.Time `gorm:"column:wx_order_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
CommissionAmount string `gorm:"column:commission_amount"`
TotalCommissionAmount string `gorm:"column:total_commission_amount"`
MFStatus string `gorm:"column:mf_status"`
CommissionType string `gorm:"column:commission_type"`
ActiveAwardCommissionAmount string `gorm:"column:active_award_commission_amount"`
TotalActiveAwardCommissionAmount string `gorm:"column:total_active_award_commission_amount"`
}
type legacyCardIdentity struct {
ICCID string `gorm:"column:iccid"`
IMEI string `gorm:"column:imei"`
VCode string `gorm:"column:vcode"`
ResolvedBy string
ResolveNote string
SlotPosition int
}
type cardRelation struct {
MainICCID string `gorm:"column:main_iccid"`
FuICCID string `gorm:"column:fu_iccid"`
FuICCID2 string `gorm:"column:fu_iccid2"`
}
type exportData struct {
Inputs []inputAsset
UnresolvedInputs []inputAsset
InputByICCID map[string][]inputAsset
Cards map[string]cardSnapshot
PackagesByICCID map[string][]packageSnapshot
Wallets map[string]walletSnapshot
CommissionAccounts map[string]commissionAccountSnapshot
CommissionDetails []commissionDetailSnapshot
MissingICCIDs []string
}
func main() {
inputPath := flag.String("input", "", "设备/卡关系 CSV 路径")
outputDir := flag.String("out", "legacy_device_export_output", "导出目录")
dsn := flag.String("dsn", os.Getenv("LEGACY_MYSQL_DSN"), "老 MySQL DSN也可使用 LEGACY_MYSQL_DSN")
schema := flag.String("schema", getenvDefault("LEGACY_MYSQL_SCHEMA", defaultLegacySchema), "老库 schema/database 名称")
includeCommissionDetails := flag.Bool("include-commission-details", false, "是否导出输入 ICCID 对应的佣金明细")
flag.Parse()
if *inputPath == "" {
exitWithError("请通过 -input 指定设备/卡关系 CSV")
}
if *dsn == "" {
exitWithError("请通过 -dsn 或 LEGACY_MYSQL_DSN 提供老 MySQL 连接串")
}
if !identifierPattern.MatchString(*schema) {
exitWithError("schema 只能包含字母、数字和下划线")
}
ctx := context.Background()
inputs, err := readInputCSV(*inputPath)
if err != nil {
exitWithError("读取输入 CSV 失败: %v", err)
}
if len(inputs) == 0 {
exitWithError("输入 CSV 中没有识别到任何设备/卡线索")
}
db, err := openLegacyDB(*dsn)
if err != nil {
exitWithError("连接老 MySQL 失败: %v", err)
}
resolvedInputs, unresolvedInputs, err := resolveInputAssets(ctx, db, *schema, inputs)
if err != nil {
exitWithError("按设备号/IMEI 解析 ICCID 失败: %v", err)
}
data := &exportData{
Inputs: resolvedInputs,
UnresolvedInputs: unresolvedInputs,
InputByICCID: groupInputsByICCID(resolvedInputs),
Cards: make(map[string]cardSnapshot),
PackagesByICCID: make(map[string][]packageSnapshot),
Wallets: make(map[string]walletSnapshot),
CommissionAccounts: make(map[string]commissionAccountSnapshot),
}
iccids := sortedKeys(data.InputByICCID)
if err := loadCardSnapshots(ctx, db, *schema, iccids, data.Cards); err != nil {
exitWithError("查询老卡本体失败: %v", err)
}
if err := loadPackageSnapshots(ctx, db, *schema, iccids, data.PackagesByICCID); err != nil {
exitWithError("查询老套餐快照失败: %v", err)
}
if err := loadWalletSnapshots(ctx, db, *schema, iccids, data.Wallets); err != nil {
exitWithError("查询老卡钱包失败: %v", err)
}
agentIDs := collectAgentIDs(data.Cards)
if len(agentIDs) > 0 {
if err := loadCommissionAccounts(ctx, db, *schema, agentIDs, data.CommissionAccounts); err != nil {
exitWithError("查询老佣金账户失败: %v", err)
}
}
if *includeCommissionDetails {
details, err := loadCommissionDetails(ctx, db, *schema, iccids)
if err != nil {
exitWithError("查询老佣金明细失败: %v", err)
}
data.CommissionDetails = details
}
for _, iccid := range iccids {
if _, ok := data.Cards[iccid]; !ok {
data.MissingICCIDs = append(data.MissingICCIDs, iccid)
}
}
if err := os.MkdirAll(*outputDir, 0755); err != nil {
exitWithError("创建导出目录失败: %v", err)
}
if err := writeOutputs(*outputDir, data, *includeCommissionDetails); err != nil {
exitWithError("写入导出文件失败: %v", err)
}
fmt.Printf("导出完成: %s\n", *outputDir)
fmt.Printf("已解析关系: %d 条ICCID: %d 个,未解析输入: %d 条,老库缺失: %d 个\n", len(data.Inputs), len(iccids), len(data.UnresolvedInputs), len(data.MissingICCIDs))
fmt.Println("已跳过日记录导出,仅保留当前套餐/流量/钱包/佣金快照。")
}
func openLegacyDB(rawDSN string) (*gorm.DB, error) {
dsn := ensureMySQLDSNOptions(rawDSN)
return gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
}
func ensureMySQLDSNOptions(dsn string) string {
options := []string{"parseTime=true", "loc=Asia%2FShanghai", "charset=utf8mb4"}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
lower := strings.ToLower(dsn)
for _, option := range options {
key := strings.ToLower(strings.SplitN(option, "=", 2)[0]) + "="
if strings.Contains(lower, key) {
continue
}
dsn += sep + option
sep = "&"
}
return dsn
}
func readInputCSV(path string) ([]inputAsset, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
return nil, err
}
if len(rows) < 2 {
return nil, nil
}
header := make(map[string]int)
for i, h := range rows[0] {
header[normalizeHeader(h)] = i
}
var result []inputAsset
for rowIndex := 1; rowIndex < len(rows); rowIndex++ {
row := rows[rowIndex]
base := inputAsset{
Line: rowIndex + 1,
DeviceVirtualNo: pickValue(row, header, "device_virtual_no", "device_no", "virtual_no", "mchno", "设备号", "设备虚拟号", "机器码"),
IMEI: pickValue(row, header, "imei", "device_imei", "mchno", "设备imei", "机器码"),
SN: pickValue(row, header, "sn", "设备sn"),
}
if singleICCID := pickValue(row, header, "iccid", "卡号", "主卡", "main_iccid"); singleICCID != "" {
slot := parseIntDefault(pickValue(row, header, "slot", "slot_position", "卡槽", "槽位"), 0)
result = append(result, withICCID(base, slot, singleICCID, "input_iccid"))
}
for slot := 1; slot <= 4; slot++ {
keys := slotHeaderCandidates(slot)
iccid := pickValue(row, header, keys...)
if iccid == "" {
continue
}
result = append(result, withICCID(base, slot, iccid, "input_slot_iccid"))
}
if base.ICCID == "" && base.DeviceVirtualNo == "" && base.IMEI == "" && base.SN == "" {
continue
}
if !rowHasICCID(result, base.Line) {
base.ResolvedBy = "pending_device_key"
if base.IMEI == "" && base.DeviceVirtualNo == "" {
base.ResolvedBy = "unresolved"
base.ResolveNote = "未提供 ICCID 或设备号/IMEI无法从老库反查卡号"
}
result = append(result, base)
}
}
return dedupeInputAssets(result), nil
}
func normalizeHeader(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = strings.TrimPrefix(s, "\ufeff")
replacer := strings.NewReplacer(" ", "_", "-", "_", "", "", "", "", "(", "", ")", "")
return replacer.Replace(s)
}
func slotHeaderCandidates(slot int) []string {
n := strconv.Itoa(slot)
return []string{
"iccid" + n,
"iccid_" + n,
"slot" + n + "_iccid",
"slot_" + n + "_iccid",
"card" + n,
"card_" + n,
"sim" + n,
"sim_" + n,
"卡" + n,
"卡" + n + "iccid",
"卡" + n + "_iccid",
}
}
func pickValue(row []string, header map[string]int, keys ...string) string {
for _, key := range keys {
idx, ok := header[normalizeHeader(key)]
if !ok || idx >= len(row) {
continue
}
if value := normalizeCell(row[idx]); value != "" {
return value
}
}
return ""
}
func normalizeCell(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "'")
s = strings.ReplaceAll(s, "\u00a0", "")
return s
}
func withICCID(base inputAsset, slot int, iccid string, resolvedBy string) inputAsset {
base.SlotPosition = slot
base.ICCID = normalizeCell(iccid)
base.ResolvedBy = resolvedBy
return base
}
func dedupeInputAssets(inputs []inputAsset) []inputAsset {
seen := make(map[string]bool)
result := make([]inputAsset, 0, len(inputs))
for _, item := range inputs {
key := fmt.Sprintf("%d|%s|%s|%s|%d", item.Line, item.DeviceVirtualNo, item.IMEI, item.ICCID, item.SlotPosition)
if seen[key] {
continue
}
seen[key] = true
result = append(result, item)
}
return result
}
func groupInputsByICCID(inputs []inputAsset) map[string][]inputAsset {
result := make(map[string][]inputAsset)
for _, item := range inputs {
if item.ICCID == "" {
continue
}
result[item.ICCID] = append(result[item.ICCID], item)
}
return result
}
func rowHasICCID(inputs []inputAsset, line int) bool {
for _, item := range inputs {
if item.Line == line && item.ICCID != "" {
return true
}
}
return false
}
func resolveInputAssets(ctx context.Context, db *gorm.DB, schema string, inputs []inputAsset) ([]inputAsset, []inputAsset, error) {
deviceKeys := collectPendingDeviceKeys(inputs)
cardsByDeviceKey := make(map[string][]legacyCardIdentity)
if len(deviceKeys) > 0 {
var err error
cardsByDeviceKey, err = loadCardIdentitiesByDeviceKey(ctx, db, schema, deviceKeys)
if err != nil {
return nil, nil, err
}
}
resolved := make([]inputAsset, 0, len(inputs))
unresolved := make([]inputAsset, 0)
for _, item := range inputs {
if item.ICCID != "" {
if item.ResolvedBy == "" {
item.ResolvedBy = "input_iccid"
}
resolved = append(resolved, item)
continue
}
keys := inputDeviceKeys(item)
if len(keys) == 0 {
item.ResolvedBy = "unresolved"
if item.ResolveNote == "" {
item.ResolveNote = "未提供 ICCID 或设备号/IMEI无法从老库反查卡号"
}
unresolved = append(unresolved, item)
continue
}
matches := uniqueIdentitiesForKeys(cardsByDeviceKey, keys)
if len(matches) == 0 {
item.ResolvedBy = "unresolved_device_key"
item.ResolveNote = "老库 tbl_card 未按 vcode/imei 找到设备号对应的 ICCID"
unresolved = append(unresolved, item)
continue
}
for _, match := range matches {
resolvedItem := item
resolvedItem.ICCID = match.ICCID
resolvedItem.ResolvedBy = match.ResolvedBy
resolvedItem.ResolveNote = match.ResolveNote
if match.SlotPosition > 0 {
resolvedItem.SlotPosition = match.SlotPosition
}
if resolvedItem.ResolveNote == "" && len(matches) > 1 {
resolvedItem.ResolveNote = fmt.Sprintf("同一设备号在老库命中 %d 张卡,已按 tbl_card_relate 展开", len(matches))
}
if resolvedItem.ResolveNote == "" {
if match.VCode != "" {
resolvedItem.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID"
} else if match.IMEI != "" {
resolvedItem.ResolveNote = "按老库 tbl_card.imei 反查 ICCID"
}
}
resolved = append(resolved, resolvedItem)
}
}
return dedupeInputAssets(resolved), dedupeInputAssets(unresolved), nil
}
func collectPendingDeviceKeys(inputs []inputAsset) []string {
set := make(map[string]bool)
for _, item := range inputs {
if item.ICCID == "" {
for _, key := range inputDeviceKeys(item) {
addNonEmpty(set, key)
}
}
}
return sortedKeys(set)
}
func inputDeviceKeys(item inputAsset) []string {
set := make(map[string]bool)
addNonEmpty(set, item.IMEI)
addNonEmpty(set, item.DeviceVirtualNo)
return sortedKeys(set)
}
func uniqueIdentitiesForKeys(source map[string][]legacyCardIdentity, keys []string) []legacyCardIdentity {
byICCID := make(map[string]legacyCardIdentity)
for _, key := range keys {
for _, item := range source[key] {
if item.ICCID == "" {
continue
}
existing, ok := byICCID[item.ICCID]
if !ok || identityPriority(item) > identityPriority(existing) {
byICCID[item.ICCID] = item
}
}
}
iccids := sortedKeys(byICCID)
result := make([]legacyCardIdentity, 0, len(iccids))
for _, iccid := range iccids {
result = append(result, byICCID[iccid])
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].SlotPosition != result[j].SlotPosition {
if result[i].SlotPosition == 0 {
return false
}
if result[j].SlotPosition == 0 {
return true
}
return result[i].SlotPosition < result[j].SlotPosition
}
return result[i].ICCID < result[j].ICCID
})
return result
}
func identityPriority(item legacyCardIdentity) int {
if item.SlotPosition > 0 && strings.Contains(item.ResolvedBy, "relate") {
return 3
}
if item.VCode != "" {
return 2
}
return 1
}
func loadCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) {
directByKey, err := loadDirectCardIdentitiesByDeviceKey(ctx, db, schema, keys)
if err != nil {
return nil, err
}
if len(directByKey) == 0 {
return directByKey, nil
}
directICCIDs := collectDirectIdentityICCIDs(directByKey)
relations, err := loadCardRelationsByICCIDs(ctx, db, schema, directICCIDs)
if err != nil {
return nil, err
}
if len(relations) == 0 {
return directByKey, nil
}
expanded := make(map[string][]legacyCardIdentity, len(directByKey))
for key, directRows := range directByKey {
byICCID := make(map[string]legacyCardIdentity)
for _, row := range directRows {
byICCID[row.ICCID] = row
for _, relation := range relations {
if !relationContains(relation, row.ICCID) {
continue
}
addRelationIdentity(byICCID, relation.MainICCID, 1, "主卡,按 tbl_card_relate 从设备号命中卡展开")
addRelationIdentity(byICCID, relation.FuICCID, 2, "副卡1按 tbl_card_relate 从设备号命中卡展开")
addRelationIdentity(byICCID, relation.FuICCID2, 3, "副卡2按 tbl_card_relate 从设备号命中卡展开")
}
}
expanded[key] = identitiesFromMap(byICCID)
}
return expanded, nil
}
func loadDirectCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) {
result := make(map[string][]legacyCardIdentity)
keySet := make(map[string]bool, len(keys))
for _, key := range keys {
keySet[key] = true
}
for _, batch := range chunkStrings(keys, defaultBatchSize) {
var rows []legacyCardIdentity
sql := fmt.Sprintf(`
SELECT
iccid_mark AS iccid,
imei,
vcode
FROM %s.tbl_card
WHERE (vcode IN ? OR imei IN ?)
AND iccid_mark IS NOT NULL
AND iccid_mark <> ''
ORDER BY vcode ASC, imei ASC, iccid_mark ASC
`, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(sql, batch, batch).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.ICCID == "" {
continue
}
if row.VCode != "" && keySet[row.VCode] {
item := row
item.ResolvedBy = "old_tbl_card_vcode"
item.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID"
result[row.VCode] = append(result[row.VCode], item)
}
if row.IMEI != "" && keySet[row.IMEI] {
item := row
item.ResolvedBy = "old_tbl_card_imei"
item.ResolveNote = "按老库 tbl_card.imei 反查 ICCID"
result[row.IMEI] = append(result[row.IMEI], item)
}
}
}
return result, nil
}
func collectDirectIdentityICCIDs(directByKey map[string][]legacyCardIdentity) []string {
set := make(map[string]bool)
for _, rows := range directByKey {
for _, row := range rows {
addNonEmpty(set, row.ICCID)
}
}
return sortedKeys(set)
}
func loadCardRelationsByICCIDs(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]cardRelation, error) {
var result []cardRelation
for _, batch := range chunkStrings(iccids, defaultBatchSize) {
var rows []cardRelation
sql := fmt.Sprintf(`
SELECT
main_iccid,
fu_iccid,
fu_iccid2
FROM %s.tbl_card_relate
WHERE main_iccid IN ? OR fu_iccid IN ? OR fu_iccid2 IN ?
`, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(sql, batch, batch, batch).Scan(&rows).Error; err != nil {
return nil, err
}
result = append(result, rows...)
}
return result, nil
}
func relationContains(relation cardRelation, iccid string) bool {
return relation.MainICCID == iccid || relation.FuICCID == iccid || relation.FuICCID2 == iccid
}
func addRelationIdentity(byICCID map[string]legacyCardIdentity, iccid string, slot int, note string) {
if iccid == "" {
return
}
item := legacyCardIdentity{
ICCID: iccid,
ResolvedBy: "old_tbl_card_relate",
ResolveNote: note,
SlotPosition: slot,
}
existing, ok := byICCID[iccid]
if ok {
item.IMEI = existing.IMEI
item.VCode = existing.VCode
if existing.ResolvedBy == "old_tbl_card_vcode" || existing.ResolvedBy == "old_tbl_card_imei" {
item.ResolvedBy = existing.ResolvedBy + "+old_tbl_card_relate"
}
}
if !ok || identityPriority(item) > identityPriority(existing) {
byICCID[iccid] = item
}
}
func identitiesFromMap(byICCID map[string]legacyCardIdentity) []legacyCardIdentity {
iccids := sortedKeys(byICCID)
result := make([]legacyCardIdentity, 0, len(iccids))
for _, iccid := range iccids {
result = append(result, byICCID[iccid])
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].SlotPosition != result[j].SlotPosition {
if result[i].SlotPosition == 0 {
return false
} else {
if result[j].SlotPosition == 0 {
return true
}
}
return result[i].SlotPosition < result[j].SlotPosition
}
return result[i].ICCID < result[j].ICCID
})
return result
}
func loadCardSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]cardSnapshot) error {
for _, batch := range chunkStrings(iccids, defaultBatchSize) {
var rows []cardSnapshot
sql := fmt.Sprintf(`
SELECT
c.id AS card_id,
c.iccid_mark AS iccid,
c.phone,
c.phone_zuhe,
c.imei AS old_imei,
c.vcode AS old_vcode,
CAST(c.status AS CHAR) AS status,
CAST(c.type AS CHAR) AS type,
CAST(c.category AS CHAR) AS category,
vc.category_name AS category_name,
c.card_flow,
c.set_meal_id,
c.card_package,
c.expire_time,
CAST(c.flow_size AS CHAR) AS flow_size,
c.total_bytes_cnt,
c.synchro_time,
c.serv_create_date AS serv_create_at,
c.balance_money,
c.agent_id,
c.agent_name,
c.account_id,
c.account_name,
c.agent_three_id
FROM %s.tbl_card c
LEFT JOIN %s.tbl_vendor_category vc ON vc.category = c.category
WHERE c.iccid_mark IN ?
`, quoteSchema(schema), quoteSchema(schema))
if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if row.ICCID == "" {
continue
}
out[row.ICCID] = row
}
}
return nil
}
func loadPackageSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string][]packageSnapshot) error {
for _, batch := range chunkStrings(iccids, defaultBatchSize) {
var currentRows []packageSnapshot
currentSQL := fmt.Sprintf(`
SELECT
'%s' AS source_table,
id,
iccid_mark AS iccid,
meal_id,
meal_name,
order_id,
start_date AS start_at,
expire_date AS expires_at,
CAST(status AS CHAR) AS status,
CAST(type AS CHAR) AS type,
CAST(flow_size AS CHAR) AS flow_size,
CAST(effect_type AS CHAR) AS effect_type,
update_date_time,
create_time AS created_at,
update_time AS updated_at
FROM %s.tbl_card_life
WHERE iccid_mark IN ?
`, sourceCardLife, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(currentSQL, batch).Scan(&currentRows).Error; err != nil {
return err
}
for _, row := range currentRows {
if row.ICCID == "" {
continue
}
out[row.ICCID] = append(out[row.ICCID], row)
}
var pendingRows []packageSnapshot
pendingSQL := fmt.Sprintf(`
SELECT
'%s' AS source_table,
id,
iccid_mark AS iccid,
meal_id,
meal_name,
agent_wx_order_id AS order_id,
effect_begin_time AS effect_begin_time,
effect_complete_time AS effect_complete_time,
CAST(status AS CHAR) AS status,
CAST(flow_size AS CHAR) AS flow_size,
order_type,
create_date AS created_at
FROM %s.tbl_next_month_card_life
WHERE iccid_mark IN ?
`, sourceNextMonthLife, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(pendingSQL, batch).Scan(&pendingRows).Error; err != nil {
return err
}
for _, row := range pendingRows {
if row.ICCID == "" {
continue
}
out[row.ICCID] = append(out[row.ICCID], row)
}
}
for iccid := range out {
sort.SliceStable(out[iccid], func(i, j int) bool {
left := out[iccid][i]
right := out[iccid][j]
if left.SourceTable != right.SourceTable {
return left.SourceTable == sourceCardLife
}
return timePtrBefore(left.StartAt, right.StartAt)
})
}
return nil
}
func loadWalletSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]walletSnapshot) error {
for _, batch := range chunkStrings(iccids, defaultBatchSize) {
var rows []walletSnapshot
sql := fmt.Sprintf(`
SELECT
iccid_mark AS iccid,
CAST(money_balance AS CHAR) AS money_balance,
CAST(tx_version AS CHAR) AS tx_version,
CAST(meal_auto_renew_status AS CHAR) AS meal_auto_renew_status,
auto_renew_meal_id,
CAST(djb_meal_auto_renew_status AS CHAR) AS addon_auto_renew_status,
djb_auto_renew_meal_id AS addon_auto_renew_meal_id,
meal_auto_renew_udpate_time AS meal_auto_renew_updated_at,
create_date AS created_at
FROM %s.tbl_card_wallet
WHERE iccid_mark IN ?
`, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
if row.ICCID == "" {
continue
}
out[row.ICCID] = row
}
}
return nil
}
func loadCommissionAccounts(ctx context.Context, db *gorm.DB, schema string, agentIDs []string, out map[string]commissionAccountSnapshot) error {
for _, batch := range chunkStrings(agentIDs, defaultBatchSize) {
var rows []commissionAccountSnapshot
sql := fmt.Sprintf(`
SELECT
agent_id,
agent_name,
agent_login_account,
CAST(total_commision_amount AS CHAR) AS total_commission_amount,
CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount,
CAST(mf_commision_amount AS CHAR) AS mf_commission_amount,
CAST(nomf_commision_amount AS CHAR) AS no_mf_commission_amount,
CAST(total_draw_amount AS CHAR) AS total_draw_amount,
CAST(can_draw_amount AS CHAR) AS can_draw_amount,
CAST(applying_draw_amount AS CHAR) AS applying_draw_amount,
remark,
CAST(tx_version AS CHAR) AS tx_version,
create_date AS created_at,
update_date AS updated_at
FROM %s.tbl_agent_commission_account
WHERE agent_id IN ?
`, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
out[row.AgentID] = row
}
}
return nil
}
func loadCommissionDetails(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]commissionDetailSnapshot, error) {
var result []commissionDetailSnapshot
for _, batch := range chunkStrings(iccids, defaultBatchSize) {
var rows []commissionDetailSnapshot
sql := fmt.Sprintf(`
SELECT
id,
commission_account_id,
wx_order_id,
wx_order_number,
iccid_mark AS iccid,
phone,
wx_order_date AS wx_order_at,
create_date AS created_at,
CAST(commission_amount AS CHAR) AS commission_amount,
CAST(total_commission_amount AS CHAR) AS total_commission_amount,
CAST(mf_status AS CHAR) AS mf_status,
CAST(commission_type AS CHAR) AS commission_type,
CAST(active_award_commision_amount AS CHAR) AS active_award_commission_amount,
CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount
FROM %s.tbl_agent_commission_detail
WHERE iccid_mark IN ?
ORDER BY create_date ASC
`, quoteSchema(schema))
if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.ICCID == "" {
continue
}
result = append(result, row)
}
}
return result, nil
}
func writeOutputs(outputDir string, data *exportData, includeCommissionDetails bool) error {
writers := []struct {
name string
writer func(string, *exportData) error
}{
{"input_assets.csv", writeInputAssets},
{"unresolved_inputs.csv", writeUnresolvedInputs},
{"card_snapshot.csv", writeCardSnapshot},
{"package_usage_snapshot.csv", writePackageUsageSnapshot},
{"wallet_snapshot.csv", writeWalletSnapshot},
{"commission_account_snapshot.csv", writeCommissionAccountSnapshot},
{"missing_cards.csv", writeMissingCards},
{"summary.txt", writeSummary},
{"field_dictionary.csv", writeFieldDictionary},
{"enum_dictionary.csv", writeEnumDictionary},
{"README_zh.md", writeOutputGuide},
}
if includeCommissionDetails {
writers = append(writers, struct {
name string
writer func(string, *exportData) error
}{"commission_detail_snapshot.csv", writeCommissionDetailSnapshot})
}
for _, item := range writers {
if err := item.writer(filepath.Join(outputDir, item.name), data); err != nil {
return fmt.Errorf("%s: %w", item.name, err)
}
}
return nil
}
func writeInputAssets(path string, data *exportData) error {
return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error {
for _, item := range data.Inputs {
if err := w.Write([]string{
strconv.Itoa(item.Line),
item.DeviceVirtualNo,
item.IMEI,
item.SN,
intString(item.SlotPosition),
slotPositionName(item.SlotPosition),
item.ICCID,
item.ResolvedBy,
resolvedByName(item.ResolvedBy),
item.ResolveNote,
}); err != nil {
return err
}
}
return nil
})
}
func writeUnresolvedInputs(path string, data *exportData) error {
return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error {
for _, item := range data.UnresolvedInputs {
if err := w.Write([]string{
strconv.Itoa(item.Line),
item.DeviceVirtualNo,
item.IMEI,
item.SN,
intString(item.SlotPosition),
slotPositionName(item.SlotPosition),
item.ICCID,
item.ResolvedBy,
resolvedByName(item.ResolvedBy),
item.ResolveNote,
}); err != nil {
return err
}
}
return nil
})
}
func writeCardSnapshot(path string, data *exportData) error {
header := []string{
"iccid", "input_device_virtual_no", "input_imei", "input_sn", "input_slot_positions",
"old_card_id", "old_phone", "old_phone_zuhe", "old_imei", "old_vcode", "old_status", "old_status_name",
"old_type", "old_type_name", "old_category", "old_category_name",
"old_account_id", "old_account_name", "old_agent_id", "old_agent_name", "old_agent_three_id",
"old_set_meal_id", "old_card_package", "old_expire_time", "old_flow_size_mb", "old_total_bytes_cnt_mb",
"current_used_mb_decimal", "current_used_mb_int", "old_synchro_time", "old_serv_create_at", "old_balance_money",
"suggested_tb_iot_card_data_usage_mb", "suggested_tb_iot_card_current_month_usage_mb", "suggested_tb_iot_card_last_gateway_reading_mb",
}
return writeCSV(path, header, func(w *csv.Writer) error {
for _, iccid := range sortedKeys(data.InputByICCID) {
card := data.Cards[iccid]
inputSummary := summarizeInputs(data.InputByICCID[iccid])
usedDecimal := card.TotalBytesCnt
usedInt := int64FloorString(usedDecimal)
if err := w.Write([]string{
iccid,
inputSummary.DeviceVirtualNos,
inputSummary.IMEIs,
inputSummary.SNs,
inputSummary.Slots,
card.CardID,
card.Phone,
card.PhoneZuhe,
card.OldIMEI,
card.OldVCode,
card.Status,
oldCardStatusName(card.Status),
card.Type,
oldCardTypeName(card.Type),
card.Category,
oldCardCategoryName(card.Category, card.CategoryName),
card.AccountID,
card.AccountName,
card.AgentID,
card.AgentName,
card.AgentThreeID,
card.SetMealID,
card.CardPackage,
card.ExpireTime,
card.FlowSize,
card.TotalBytesCnt,
usedDecimal,
int64String(usedInt),
card.SynchroTime,
formatTime(card.ServCreateAt),
card.BalanceMoney,
int64String(usedInt),
usedDecimal,
usedDecimal,
}); err != nil {
return err
}
}
return nil
})
}
func writePackageUsageSnapshot(path string, data *exportData) error {
header := []string{
"iccid", "input_device_virtual_no", "source_table", "source_table_name", "old_usage_id", "old_meal_id", "old_meal_name",
"old_order_id", "start_at", "expires_at", "effective_start_at", "effective_expires_at", "date_source_note",
"effect_begin_time", "effect_complete_time", "record_created_at", "record_updated_at",
"old_status", "old_status_name", "old_type", "old_type_name", "effect_type", "effect_type_name", "order_type", "order_type_name",
"flow_limit_mb", "card_current_used_mb_decimal", "allocated_used_mb", "remaining_mb",
"suggested_new_status", "suggested_new_status_name", "is_pending_next_month",
}
now := time.Now()
return writeCSV(path, header, func(w *csv.Writer) error {
for _, iccid := range sortedKeys(data.InputByICCID) {
card := data.Cards[iccid]
currentUsed := int64FloorString(card.TotalBytesCnt)
remainingUsed := currentUsed
inputSummary := summarizeInputs(data.InputByICCID[iccid])
rows := data.PackagesByICCID[iccid]
for _, pkg := range rows {
limit := int64FloorString(pkg.FlowSize)
allocatedUsed := int64(0)
if pkg.SourceTable == sourceCardLife && isActiveOldPackage(pkg, now) {
allocatedUsed = minInt64(remainingUsed, limit)
remainingUsed -= allocatedUsed
}
remaining := limit - allocatedUsed
if remaining < 0 {
remaining = 0
}
suggestedStatus := suggestPackageUsageStatus(pkg, allocatedUsed, limit, now)
isPendingNextMonth := ""
if pkg.SourceTable == sourceNextMonthLife {
isPendingNextMonth = "true"
} else {
isPendingNextMonth = "false"
}
if err := w.Write([]string{
iccid,
inputSummary.DeviceVirtualNos,
pkg.SourceTable,
sourceTableName(pkg.SourceTable),
pkg.ID,
pkg.MealID,
pkg.MealName,
pkg.OrderID,
formatTime(pkg.StartAt),
formatTime(pkg.ExpiresAt),
formatTime(effectivePackageStartAt(pkg)),
formatTime(effectivePackageExpiresAt(pkg)),
packageDateSourceNote(pkg),
formatTime(pkg.EffectBeginTime),
formatTime(pkg.EffectCompleteTime),
formatTime(pkg.CreatedAt),
formatTime(pkg.UpdatedAt),
pkg.Status,
oldPackageStatusName(pkg.SourceTable, pkg.Status),
pkg.Type,
oldPackageTypeName(pkg.Type),
pkg.EffectType,
oldEffectTypeName(pkg.EffectType),
pkg.OrderType,
oldOrderTypeName(pkg.OrderType),
int64String(limit),
card.TotalBytesCnt,
int64String(allocatedUsed),
int64String(remaining),
suggestedStatus,
newPackageUsageStatusName(suggestedStatus),
isPendingNextMonth,
}); err != nil {
return err
}
}
}
return nil
})
}
func writeWalletSnapshot(path string, data *exportData) error {
header := []string{
"iccid", "money_balance_yuan", "money_balance_fen", "tx_version",
"meal_auto_renew_status", "meal_auto_renew_status_name", "auto_renew_meal_id",
"addon_auto_renew_status", "addon_auto_renew_status_name", "addon_auto_renew_meal_id",
"meal_auto_renew_updated_at", "created_at",
}
return writeCSV(path, header, func(w *csv.Writer) error {
for _, iccid := range sortedKeys(data.InputByICCID) {
wallet := data.Wallets[iccid]
if err := w.Write([]string{
iccid,
wallet.MoneyBalance,
decimalStringToCentsString(wallet.MoneyBalance),
wallet.TxVersion,
wallet.MealAutoRenewStatus,
autoRenewStatusName(wallet.MealAutoRenewStatus),
wallet.AutoRenewMealID,
wallet.AddonAutoRenewStatus,
autoRenewStatusName(wallet.AddonAutoRenewStatus),
wallet.AddonAutoRenewMealID,
formatTime(wallet.MealAutoRenewUpdatedAt),
formatTime(wallet.CreatedAt),
}); err != nil {
return err
}
}
return nil
})
}
func writeCommissionAccountSnapshot(path string, data *exportData) error {
header := []string{
"old_agent_id", "old_agent_name", "old_agent_login_account", "suggested_new_shop_id",
"total_commission_yuan", "total_commission_fen",
"total_active_award_commission_yuan", "total_active_award_commission_fen",
"mf_commission_yuan", "mf_commission_fen",
"nomf_commission_yuan", "nomf_commission_fen",
"total_draw_yuan", "total_draw_fen",
"can_draw_yuan", "can_draw_fen",
"applying_draw_yuan", "applying_draw_fen",
"remark", "tx_version", "created_at", "updated_at",
}
return writeCSV(path, header, func(w *csv.Writer) error {
for _, agentID := range sortedKeys(data.CommissionAccounts) {
account := data.CommissionAccounts[agentID]
if err := w.Write([]string{
account.AgentID,
account.AgentName,
account.AgentLoginAccount,
"",
account.TotalCommissionAmount,
decimalStringToCentsString(account.TotalCommissionAmount),
account.TotalActiveAwardCommissionAmount,
decimalStringToCentsString(account.TotalActiveAwardCommissionAmount),
account.MFCommissionAmount,
decimalStringToCentsString(account.MFCommissionAmount),
account.NoMFCommissionAmount,
decimalStringToCentsString(account.NoMFCommissionAmount),
account.TotalDrawAmount,
decimalStringToCentsString(account.TotalDrawAmount),
account.CanDrawAmount,
decimalStringToCentsString(account.CanDrawAmount),
account.ApplyingDrawAmount,
decimalStringToCentsString(account.ApplyingDrawAmount),
account.Remark,
account.TxVersion,
formatTime(account.CreatedAt),
formatTime(account.UpdatedAt),
}); err != nil {
return err
}
}
return nil
})
}
func writeCommissionDetailSnapshot(path string, data *exportData) error {
header := []string{
"id", "commission_account_id", "wx_order_id", "wx_order_number", "iccid", "phone",
"wx_order_at", "created_at", "commission_amount_yuan", "commission_amount_fen",
"total_commission_amount_yuan", "total_commission_amount_fen", "mf_status", "mf_status_name", "commission_type", "commission_type_name",
"active_award_commission_amount_yuan", "active_award_commission_amount_fen",
"total_active_award_commission_amount_yuan", "total_active_award_commission_amount_fen",
}
return writeCSV(path, header, func(w *csv.Writer) error {
for _, item := range data.CommissionDetails {
if err := w.Write([]string{
item.ID,
item.CommissionAccountID,
item.WxOrderID,
item.WxOrderNumber,
item.ICCID,
item.Phone,
formatTime(item.WxOrderAt),
formatTime(item.CreatedAt),
item.CommissionAmount,
decimalStringToCentsString(item.CommissionAmount),
item.TotalCommissionAmount,
decimalStringToCentsString(item.TotalCommissionAmount),
item.MFStatus,
mfStatusName(item.MFStatus),
item.CommissionType,
commissionTypeName(item.CommissionType),
item.ActiveAwardCommissionAmount,
decimalStringToCentsString(item.ActiveAwardCommissionAmount),
item.TotalActiveAwardCommissionAmount,
decimalStringToCentsString(item.TotalActiveAwardCommissionAmount),
}); err != nil {
return err
}
}
return nil
})
}
func writeMissingCards(path string, data *exportData) error {
return writeCSV(path, []string{"iccid"}, func(w *csv.Writer) error {
for _, iccid := range data.MissingICCIDs {
if err := w.Write([]string{iccid}); err != nil {
return err
}
}
return nil
})
}
func writeSummary(path string, data *exportData) error {
packageCount := 0
for _, rows := range data.PackagesByICCID {
packageCount += len(rows)
}
content := strings.Builder{}
content.WriteString("老系统设备迁移快照导出摘要\n")
content.WriteString("================================\n")
content.WriteString(fmt.Sprintf("生成时间: %s\n", time.Now().Format(timeFormat)))
content.WriteString(fmt.Sprintf("已解析输入关系行数: %d\n", len(data.Inputs)))
content.WriteString(fmt.Sprintf("未解析输入行数: %d\n", len(data.UnresolvedInputs)))
content.WriteString(fmt.Sprintf("输入 ICCID 数: %d\n", len(data.InputByICCID)))
content.WriteString(fmt.Sprintf("老库卡本体命中数: %d\n", len(data.Cards)))
content.WriteString(fmt.Sprintf("老库缺失 ICCID 数: %d\n", len(data.MissingICCIDs)))
content.WriteString(fmt.Sprintf("套餐快照行数: %d\n", packageCount))
content.WriteString(fmt.Sprintf("钱包快照行数: %d\n", len(data.Wallets)))
content.WriteString(fmt.Sprintf("佣金账户行数: %d\n", len(data.CommissionAccounts)))
content.WriteString(fmt.Sprintf("佣金明细行数: %d\n", len(data.CommissionDetails)))
content.WriteString("\n说明:\n")
content.WriteString("- 本工具只导出当前业务快照,不导出日流量记录。\n")
content.WriteString("- 输入 CSV 可以不提供 SN 和 ICCID不提供 ICCID 时会按老库 tbl_card.vcode/imei 反查 ICCID并通过 tbl_card_relate 展开关联卡。\n")
content.WriteString("- unresolved_inputs.csv 中的行没有解析出 ICCID需要补设备号/IMEI/ICCID 后再重新导出。\n")
content.WriteString("- 字段含义看 field_dictionary.csv状态码和类型码看 enum_dictionary.csv阅读入口看 README_zh.md。\n")
content.WriteString("- package_usage_snapshot.csv 中 allocated_used_mb/remaining_mb 是按老卡当前已用量顺序分摊后的建议值,后续导入新库前仍需结合套餐映射复核。\n")
content.WriteString("- commission_account_snapshot.csv 预留 suggested_new_shop_id 空列,后续导入时填新系统 shop_id。\n")
return os.WriteFile(path, []byte(content.String()), 0644)
}
func writeCSV(path string, header []string, fill func(*csv.Writer) error) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
writer := csv.NewWriter(file)
if err := writer.Write(header); err != nil {
return err
}
if err := fill(writer); err != nil {
return err
}
writer.Flush()
return writer.Error()
}
type inputSummary struct {
DeviceVirtualNos string
IMEIs string
SNs string
Slots string
}
func summarizeInputs(inputs []inputAsset) inputSummary {
deviceNos := make(map[string]bool)
imeis := make(map[string]bool)
sns := make(map[string]bool)
slots := make(map[string]bool)
for _, item := range inputs {
addNonEmpty(deviceNos, item.DeviceVirtualNo)
addNonEmpty(imeis, item.IMEI)
addNonEmpty(sns, item.SN)
if item.SlotPosition > 0 {
slots[strconv.Itoa(item.SlotPosition)] = true
}
}
return inputSummary{
DeviceVirtualNos: strings.Join(sortedKeys(deviceNos), "|"),
IMEIs: strings.Join(sortedKeys(imeis), "|"),
SNs: strings.Join(sortedKeys(sns), "|"),
Slots: strings.Join(sortedKeys(slots), "|"),
}
}
func addNonEmpty(set map[string]bool, value string) {
if value != "" {
set[value] = true
}
}
func collectAgentIDs(cards map[string]cardSnapshot) []string {
set := make(map[string]bool)
for _, card := range cards {
addNonEmpty(set, card.AgentID)
}
return sortedKeys(set)
}
func isActiveOldPackage(pkg packageSnapshot, now time.Time) bool {
if pkg.Status != "1" {
return false
}
if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) {
return false
}
return true
}
func suggestPackageUsageStatus(pkg packageSnapshot, used, limit int64, now time.Time) string {
if pkg.SourceTable == sourceNextMonthLife {
return "0"
}
if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) {
return "3"
}
if limit > 0 && used >= limit {
return "2"
}
if pkg.Status == "1" {
return "1"
}
return pkg.Status
}
func effectivePackageStartAt(pkg packageSnapshot) *time.Time {
switch pkg.SourceTable {
case sourceNextMonthLife:
if pkg.EffectBeginTime != nil {
return pkg.EffectBeginTime
}
case sourceCardLife:
if pkg.StartAt != nil {
return pkg.StartAt
}
}
return pkg.CreatedAt
}
func effectivePackageExpiresAt(pkg packageSnapshot) *time.Time {
if pkg.ExpiresAt != nil {
return pkg.ExpiresAt
}
return nil
}
func packageDateSourceNote(pkg packageSnapshot) string {
if pkg.SourceTable == sourceNextMonthLife {
if pkg.EffectBeginTime != nil {
return "待生效套餐开始时间来自 tbl_next_month_card_life.effect_begin_time老库未提供到期时间"
}
if pkg.CreatedAt != nil {
return "待生效套餐 effect_begin_time 为空effective_start_at 使用 create_date 兜底;老库未提供到期时间"
}
return "待生效套餐没有开始/到期时间"
}
if pkg.StartAt != nil && pkg.ExpiresAt != nil {
return "开始时间来自 tbl_card_life.start_date到期时间来自 tbl_card_life.expire_date"
}
if pkg.StartAt == nil && pkg.CreatedAt != nil && pkg.ExpiresAt != nil {
return "tbl_card_life.start_date 为空effective_start_at 使用 create_time 兜底;到期时间来自 expire_date"
}
if pkg.StartAt == nil && pkg.CreatedAt != nil {
return "tbl_card_life.start_date 为空effective_start_at 使用 create_time 兜底"
}
if pkg.ExpiresAt != nil {
return "开始时间为空;到期时间来自 tbl_card_life.expire_date"
}
return "老库套餐记录没有开始/到期时间"
}
func decimalStringToCentsString(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
r, ok := new(big.Rat).SetString(strings.TrimSpace(value))
if !ok {
return ""
}
r.Mul(r, big.NewRat(100, 1))
result := new(big.Int)
// 金额只用于迁移快照,采用四舍五入到分。
if r.Sign() >= 0 {
r.Add(r, big.NewRat(1, 2))
} else {
r.Sub(r, big.NewRat(1, 2))
}
result.Quo(r.Num(), r.Denom())
return result.String()
}
func int64FloorString(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
f, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return int64(math.Floor(f))
}
func parseIntDefault(value string, defaultValue int) int {
if value == "" {
return defaultValue
}
n, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return n
}
func formatTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.In(time.Local).Format(timeFormat)
}
func timePtrBefore(left, right *time.Time) bool {
if left == nil && right == nil {
return false
}
if left == nil {
return false
}
if right == nil {
return true
}
return left.Before(*right)
}
func chunkStrings(values []string, size int) [][]string {
if size <= 0 {
size = defaultBatchSize
}
var chunks [][]string
for start := 0; start < len(values); start += size {
end := start + size
if end > len(values) {
end = len(values)
}
chunks = append(chunks, values[start:end])
}
return chunks
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func quoteSchema(schema string) string {
return "`" + schema + "`"
}
func minInt64(a, b int64) int64 {
if a < b {
return a
}
return b
}
func intString(value int) string {
if value == 0 {
return ""
}
return strconv.Itoa(value)
}
func int64String(value int64) string {
return strconv.FormatInt(value, 10)
}
func getenvDefault(key, defaultValue string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return defaultValue
}
return value
}
func exitWithError(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
type fieldDefinition struct {
FileName string
ColumnName string
ChineseName string
Description string
}
type enumDefinition struct {
FileName string
ColumnName string
Value string
Meaning string
Source string
Note string
}
var oldCardStatusNames = map[string]string{
"1": "待激活",
"2": "正常",
"3": "断网",
"4": "停机",
"5": "异常",
"6": "强制停机",
"7": "注销",
}
var oldCardTypeNames = map[string]string{
"1": "包月",
"2": "包年",
}
var oldCategoryNames = map[string]string{
"1": "电信",
"2": "其他卡务",
"3": "移动",
"4": "联通",
"5": "dcp电信",
"9": "中国联通",
"10": "D移动",
"21": "S物联",
"22": "W物联",
"23": "L物联",
"24": "P物联",
"25": "D物联",
"26": "M物联",
"27": "WX物联",
"28": "CH物联",
"31": "YW物联",
"40": "XS物联",
"56": "ZT物联",
"67": "TY物联",
"70": "XR物联",
"71": "ZYM物联",
"89": "LW物联",
"94": "DC物联",
"108": "CMP5G",
"112": "GD物联",
"113": "GDIot",
"118": "GDWL",
"124": "GS电信",
"125": "GS联通",
"126": "GS移动",
}
var oldCardLifeStatusNames = map[string]string{
"1": "正常",
"2": "停卡",
}
var oldNextMonthLifeStatusNames = map[string]string{
"1": "未生效",
"2": "已生效,已插入 tbl_card_life",
}
var oldPackageTypeNames = map[string]string{
"1": "叠加包",
"2": "月卡",
"3": "季卡",
"4": "半年卡",
"5": "年卡",
}
var oldEffectTypeNames = map[string]string{
"1": "本月生效",
"2": "次月生效",
}
var oldOrderTypeNames = map[string]string{
"1": "微信订单",
"2": "后台订单",
}
var autoRenewStatusNames = map[string]string{
"1": "启用",
"2": "关闭",
}
var newPackageUsageStatusNames = map[string]string{
"0": "待生效",
"1": "生效中",
"2": "已用完",
"3": "已过期",
"4": "已失效",
}
var mfStatusNames = map[string]string{
"0": "成功",
"1": "失败",
}
var commissionTypeNames = map[string]string{
"1": "微信订单佣金",
"2": "激活奖励佣金",
"3": "微信订单佣金扣回",
"4": "激活奖励佣金扣回",
}
func writeFieldDictionary(path string, data *exportData) error {
fields := []fieldDefinition{
{"input_assets.csv", "line", "输入行号", "原始输入 CSV 中的行号,便于回查业务给的源数据。"},
{"input_assets.csv", "device_virtual_no", "输入设备号", "输入 CSV 中识别到的设备号/机器码。"},
{"input_assets.csv", "imei", "输入 IMEI", "输入 CSV 中识别到的 IMEI老系统接口参数 mchno 实际通常对应 tbl_card.vcode。"},
{"input_assets.csv", "sn", "输入 SN", "输入 CSV 中透传的 SN可为空。"},
{"input_assets.csv", "slot_position", "卡槽序号", "脚本按老系统关联表展开后的卡槽1 主卡2 副卡13 副卡2。"},
{"input_assets.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"},
{"input_assets.csv", "iccid", "ICCID", "解析出来的老系统卡 ICCID。"},
{"input_assets.csv", "resolved_by", "解析方式", "说明 ICCID 是由输入直接提供,还是按 tbl_card.vcode/imei 及 tbl_card_relate 反查得到。"},
{"input_assets.csv", "resolved_by_name", "解析方式名称", "解析方式的中文摘要。"},
{"input_assets.csv", "resolve_note", "解析说明", "更详细的解析过程说明。"},
{"unresolved_inputs.csv", "line", "输入行号", "原始输入 CSV 中无法解析出 ICCID 的行号。"},
{"unresolved_inputs.csv", "device_virtual_no", "输入设备号", "未解析行中的设备号/机器码。"},
{"unresolved_inputs.csv", "imei", "输入 IMEI", "未解析行中的 IMEI。"},
{"unresolved_inputs.csv", "sn", "输入 SN", "未解析行中的 SN。"},
{"unresolved_inputs.csv", "slot_position", "卡槽序号", "未解析行中的卡槽序号。"},
{"unresolved_inputs.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"},
{"unresolved_inputs.csv", "iccid", "ICCID", "未解析行通常为空;若有值仍未解析,需要人工核对。"},
{"unresolved_inputs.csv", "resolved_by", "解析方式", "未解析状态码。"},
{"unresolved_inputs.csv", "resolved_by_name", "解析方式名称", "未解析状态的中文摘要。"},
{"unresolved_inputs.csv", "resolve_note", "未解析原因", "说明为什么无法从老系统反查到 ICCID。"},
{"card_snapshot.csv", "iccid", "ICCID", "老系统卡 ICCID也是后续新系统导入时对齐卡的关键标识。"},
{"card_snapshot.csv", "input_device_virtual_no", "输入设备号", "该卡来自哪些输入设备号;多个值用 | 分隔。"},
{"card_snapshot.csv", "input_imei", "输入 IMEI", "该卡来自哪些输入 IMEI多个值用 | 分隔。"},
{"card_snapshot.csv", "input_sn", "输入 SN", "该卡来自哪些输入 SN多个值用 | 分隔。"},
{"card_snapshot.csv", "input_slot_positions", "输入卡槽", "该卡在设备关联中的卡槽序号;多个值用 | 分隔。"},
{"card_snapshot.csv", "old_card_id", "老卡 ID", "老系统 tbl_card.id。"},
{"card_snapshot.csv", "old_phone", "老系统号码", "老系统 tbl_card.phone一般为 MSISDN/卡号展示字段。"},
{"card_snapshot.csv", "old_phone_zuhe", "组合号码", "老系统 tbl_card.phone_zuhe。"},
{"card_snapshot.csv", "old_imei", "老系统 IMEI", "老系统 tbl_card.imei本批样本中很多为空。"},
{"card_snapshot.csv", "old_vcode", "老系统设备码", "老系统 tbl_card.vcode老接口 mchno 通常按这个字段查询。"},
{"card_snapshot.csv", "old_status", "老卡状态值", "老系统 tbl_card.status 原值。"},
{"card_snapshot.csv", "old_status_name", "老卡状态", "old_status 的中文含义。"},
{"card_snapshot.csv", "old_type", "老卡类型值", "老系统 tbl_card.type 原值。"},
{"card_snapshot.csv", "old_type_name", "老卡类型", "old_type 的中文含义。"},
{"card_snapshot.csv", "old_category", "老运营商分类值", "老系统 tbl_card.category 原值。"},
{"card_snapshot.csv", "old_category_name", "老运营商分类", "优先来自 tbl_vendor_category.category_name。"},
{"card_snapshot.csv", "old_account_id", "老供应账号 ID", "老系统 tbl_card.account_id。"},
{"card_snapshot.csv", "old_account_name", "老供应账号名称", "老系统 tbl_card.account_name。"},
{"card_snapshot.csv", "old_agent_id", "老代理 ID", "老系统 tbl_card.agent_id。"},
{"card_snapshot.csv", "old_agent_name", "老代理名称", "老系统 tbl_card.agent_name。"},
{"card_snapshot.csv", "old_agent_three_id", "三级代理 ID", "老系统 tbl_card.agent_three_id。"},
{"card_snapshot.csv", "old_set_meal_id", "老套餐 ID", "老系统 tbl_card.set_meal_id。"},
{"card_snapshot.csv", "old_card_package", "老套餐名称", "老系统 tbl_card.card_package。"},
{"card_snapshot.csv", "old_expire_time", "老卡到期时间", "老系统 tbl_card.expire_time行业卡可能有值普通设备套餐以 package_usage_snapshot.csv 为准。"},
{"card_snapshot.csv", "old_flow_size_mb", "老套餐总量 MB", "老系统 tbl_card.flow_size。"},
{"card_snapshot.csv", "old_total_bytes_cnt_mb", "老网关累计已用 MB", "老系统 tbl_card.total_bytes_cnt 原值。"},
{"card_snapshot.csv", "current_used_mb_decimal", "当前已用 MB", "迁移时建议保留的小数已用量。"},
{"card_snapshot.csv", "current_used_mb_int", "当前已用 MB 整数", "向下取整后的已用量,便于新系统整数/DECIMAL 字段导入。"},
{"card_snapshot.csv", "old_synchro_time", "老同步时间", "老系统 tbl_card.synchro_time。"},
{"card_snapshot.csv", "old_serv_create_at", "运营商开户时间", "老系统 tbl_card.serv_create_date。"},
{"card_snapshot.csv", "old_balance_money", "老余额字段", "老系统 tbl_card.balance_money。"},
{"card_snapshot.csv", "suggested_tb_iot_card_data_usage_mb", "建议卡累计用量", "建议导入新系统 tb_iot_card.data_usage_mb。"},
{"card_snapshot.csv", "suggested_tb_iot_card_current_month_usage_mb", "建议自然月已用量", "建议导入新系统 tb_iot_card.current_month_usage_mb需结合新系统口径复核。"},
{"card_snapshot.csv", "suggested_tb_iot_card_last_gateway_reading_mb", "建议网关读数基线", "建议导入新系统 tb_iot_card.last_gateway_reading_mb避免首轮轮询重复扣历史流量。"},
{"package_usage_snapshot.csv", "iccid", "ICCID", "套餐所属老卡 ICCID。"},
{"package_usage_snapshot.csv", "input_device_virtual_no", "输入设备号", "该套餐所属卡来自哪些输入设备号;多个值用 | 分隔。"},
{"package_usage_snapshot.csv", "source_table", "来源表", "tbl_card_life 表示当前/历史套餐tbl_next_month_card_life 表示次月待生效套餐。"},
{"package_usage_snapshot.csv", "source_table_name", "来源表名称", "来源表的中文说明。"},
{"package_usage_snapshot.csv", "old_usage_id", "老套餐记录 ID", "老系统套餐使用记录主键。"},
{"package_usage_snapshot.csv", "old_meal_id", "老套餐 ID", "老系统套餐 ID。"},
{"package_usage_snapshot.csv", "old_meal_name", "老套餐名称", "老系统套餐名称。"},
{"package_usage_snapshot.csv", "old_order_id", "老订单 ID", "老系统订单 ID。"},
{"package_usage_snapshot.csv", "start_at", "开始时间", "tbl_card_life.start_date。"},
{"package_usage_snapshot.csv", "expires_at", "到期时间", "tbl_card_life.expire_date。"},
{"package_usage_snapshot.csv", "effective_start_at", "有效开始时间", "迁移参考开始时间;优先使用 start_at老库 start_at 为空时使用记录创建时间兜底。"},
{"package_usage_snapshot.csv", "effective_expires_at", "有效到期时间", "迁移参考到期时间;当前仅使用老库 expire_date待生效套餐老库通常没有到期时间。"},
{"package_usage_snapshot.csv", "date_source_note", "时间来源说明", "说明开始/到期时间来自哪个老库字段,以及是否使用兜底。"},
{"package_usage_snapshot.csv", "effect_begin_time", "待生效开始时间", "tbl_next_month_card_life.effect_begin_time。"},
{"package_usage_snapshot.csv", "effect_complete_time", "待生效完成时间", "tbl_next_month_card_life.effect_complete_time。"},
{"package_usage_snapshot.csv", "record_created_at", "套餐记录创建时间", "tbl_card_life.create_time 或 tbl_next_month_card_life.create_date。"},
{"package_usage_snapshot.csv", "record_updated_at", "套餐记录更新时间", "tbl_card_life.update_time次月待生效表没有对应字段。"},
{"package_usage_snapshot.csv", "old_status", "老套餐状态值", "来源表中的 status 原值。"},
{"package_usage_snapshot.csv", "old_status_name", "老套餐状态", "old_status 的中文含义;不同来源表含义不同。"},
{"package_usage_snapshot.csv", "old_type", "老套餐类型值", "tbl_card_life.type 原值。"},
{"package_usage_snapshot.csv", "old_type_name", "老套餐类型", "old_type 的中文含义。"},
{"package_usage_snapshot.csv", "effect_type", "生效类型值", "tbl_card_life.effect_type 原值。"},
{"package_usage_snapshot.csv", "effect_type_name", "生效类型", "effect_type 的中文含义。"},
{"package_usage_snapshot.csv", "order_type", "订单类型值", "tbl_next_month_card_life.order_type 原值。"},
{"package_usage_snapshot.csv", "order_type_name", "订单类型", "order_type 的中文含义。"},
{"package_usage_snapshot.csv", "flow_limit_mb", "套餐总量 MB", "老套餐记录 flow_size。"},
{"package_usage_snapshot.csv", "card_current_used_mb_decimal", "卡当前已用 MB", "来自 card_snapshot 的当前已用量。"},
{"package_usage_snapshot.csv", "allocated_used_mb", "建议套餐已用 MB", "按当前生效套餐顺序从卡已用量中分摊出来的迁移建议值。"},
{"package_usage_snapshot.csv", "remaining_mb", "建议套餐剩余 MB", "flow_limit_mb - allocated_used_mb。"},
{"package_usage_snapshot.csv", "suggested_new_status", "建议新套餐状态值", "建议导入新系统 tb_package_usage.status。"},
{"package_usage_snapshot.csv", "suggested_new_status_name", "建议新套餐状态", "suggested_new_status 的中文含义。"},
{"package_usage_snapshot.csv", "is_pending_next_month", "是否次月待生效", "true 表示来源于 tbl_next_month_card_life。"},
{"wallet_snapshot.csv", "iccid", "ICCID", "钱包所属老卡 ICCID。"},
{"wallet_snapshot.csv", "money_balance_yuan", "钱包余额元", "老系统 tbl_card_wallet.money_balance。"},
{"wallet_snapshot.csv", "money_balance_fen", "钱包余额分", "钱包余额按分换算后的整数值。"},
{"wallet_snapshot.csv", "tx_version", "钱包版本号", "老系统钱包乐观锁版本。"},
{"wallet_snapshot.csv", "meal_auto_renew_status", "主套餐自动续费状态值", "老系统 tbl_card_wallet.meal_auto_renew_status。"},
{"wallet_snapshot.csv", "meal_auto_renew_status_name", "主套餐自动续费状态", "1 启用2 关闭。"},
{"wallet_snapshot.csv", "auto_renew_meal_id", "主套餐自动续费套餐 ID", "老系统主套餐自动续费绑定的套餐 ID。"},
{"wallet_snapshot.csv", "addon_auto_renew_status", "叠加包自动续费状态值", "老系统 tbl_card_wallet.djb_meal_auto_renew_status。"},
{"wallet_snapshot.csv", "addon_auto_renew_status_name", "叠加包自动续费状态", "1 启用2 关闭。"},
{"wallet_snapshot.csv", "addon_auto_renew_meal_id", "叠加包自动续费套餐 ID", "老系统叠加包自动续费绑定的套餐 ID。"},
{"wallet_snapshot.csv", "meal_auto_renew_updated_at", "自动续费更新时间", "老系统 tbl_card_wallet.meal_auto_renew_udpate_time。"},
{"wallet_snapshot.csv", "created_at", "钱包创建时间", "老系统 tbl_card_wallet.create_date。"},
{"commission_account_snapshot.csv", "old_agent_id", "老代理 ID", "老系统佣金账户 agent_id。"},
{"commission_account_snapshot.csv", "old_agent_name", "老代理名称", "老系统佣金账户 agent_name。"},
{"commission_account_snapshot.csv", "old_agent_login_account", "老代理登录账号", "老系统佣金账户 agent_login_account。"},
{"commission_account_snapshot.csv", "suggested_new_shop_id", "建议新店铺 ID", "预留给后续导入新系统时填写 shop_id。"},
{"commission_account_snapshot.csv", "total_commission_yuan", "累计佣金元", "老佣金账户累计佣金。"},
{"commission_account_snapshot.csv", "total_commission_fen", "累计佣金分", "累计佣金按分换算后的整数值。"},
{"commission_account_snapshot.csv", "total_active_award_commission_yuan", "累计激活奖励元", "老佣金账户累计激活奖励佣金。"},
{"commission_account_snapshot.csv", "total_active_award_commission_fen", "累计激活奖励分", "累计激活奖励按分换算后的整数值。"},
{"commission_account_snapshot.csv", "mf_commission_yuan", "秒返佣金元", "老佣金账户秒返佣金。"},
{"commission_account_snapshot.csv", "mf_commission_fen", "秒返佣金分", "秒返佣金按分换算后的整数值。"},
{"commission_account_snapshot.csv", "nomf_commission_yuan", "非秒返佣金元", "老佣金账户非秒返佣金。"},
{"commission_account_snapshot.csv", "nomf_commission_fen", "非秒返佣金分", "非秒返佣金按分换算后的整数值。"},
{"commission_account_snapshot.csv", "total_draw_yuan", "累计提现元", "老佣金账户累计提现金额。"},
{"commission_account_snapshot.csv", "total_draw_fen", "累计提现分", "累计提现金额按分换算后的整数值。"},
{"commission_account_snapshot.csv", "can_draw_yuan", "可提现金额元", "老佣金账户可提现余额。"},
{"commission_account_snapshot.csv", "can_draw_fen", "可提现金额分", "可提现余额按分换算后的整数值。"},
{"commission_account_snapshot.csv", "applying_draw_yuan", "提现中金额元", "老佣金账户申请提现中金额。"},
{"commission_account_snapshot.csv", "applying_draw_fen", "提现中金额分", "提现中金额按分换算后的整数值。"},
{"commission_account_snapshot.csv", "remark", "备注", "老佣金账户备注。"},
{"commission_account_snapshot.csv", "tx_version", "佣金账户版本号", "老佣金账户乐观锁版本。"},
{"commission_account_snapshot.csv", "created_at", "佣金账户创建时间", "老佣金账户 create_date。"},
{"commission_account_snapshot.csv", "updated_at", "佣金账户更新时间", "老佣金账户 update_date。"},
{"commission_detail_snapshot.csv", "id", "佣金明细 ID", "老系统佣金明细主键。"},
{"commission_detail_snapshot.csv", "commission_account_id", "佣金账户 ID", "老系统佣金账户 ID。"},
{"commission_detail_snapshot.csv", "wx_order_id", "微信订单 ID", "老系统微信订单 ID。"},
{"commission_detail_snapshot.csv", "wx_order_number", "微信订单号", "老系统微信订单号。"},
{"commission_detail_snapshot.csv", "iccid", "ICCID", "佣金明细关联的老卡 ICCID。"},
{"commission_detail_snapshot.csv", "phone", "手机号/卡号", "佣金明细记录的老系统 phone。"},
{"commission_detail_snapshot.csv", "wx_order_at", "微信订单时间", "老系统 wx_order_date。"},
{"commission_detail_snapshot.csv", "created_at", "佣金明细创建时间", "老系统 create_date。"},
{"commission_detail_snapshot.csv", "commission_amount_yuan", "本次佣金元", "本条佣金金额。"},
{"commission_detail_snapshot.csv", "commission_amount_fen", "本次佣金分", "本条佣金金额按分换算后的整数值。"},
{"commission_detail_snapshot.csv", "total_commission_amount_yuan", "累计佣金元", "该明细发生后的累计佣金金额。"},
{"commission_detail_snapshot.csv", "total_commission_amount_fen", "累计佣金分", "累计佣金金额按分换算后的整数值。"},
{"commission_detail_snapshot.csv", "mf_status", "秒返状态值", "老系统 tbl_agent_commission_detail.mf_status。"},
{"commission_detail_snapshot.csv", "mf_status_name", "秒返状态", "0 成功1 失败。"},
{"commission_detail_snapshot.csv", "commission_type", "佣金类型值", "老系统 tbl_agent_commission_detail.commission_type。"},
{"commission_detail_snapshot.csv", "commission_type_name", "佣金类型", "commission_type 的中文含义。"},
{"commission_detail_snapshot.csv", "active_award_commission_amount_yuan", "本次激活奖励元", "本条激活奖励佣金金额。"},
{"commission_detail_snapshot.csv", "active_award_commission_amount_fen", "本次激活奖励分", "本条激活奖励佣金按分换算后的整数值。"},
{"commission_detail_snapshot.csv", "total_active_award_commission_amount_yuan", "累计激活奖励元", "该明细发生后的累计激活奖励佣金。"},
{"commission_detail_snapshot.csv", "total_active_award_commission_amount_fen", "累计激活奖励分", "累计激活奖励佣金按分换算后的整数值。"},
{"missing_cards.csv", "iccid", "缺失 ICCID", "输入中解析到但老系统 tbl_card 未命中的 ICCID。"},
}
return writeCSV(path, []string{"file_name", "column_name", "chinese_name", "description"}, func(w *csv.Writer) error {
for _, item := range fields {
if err := w.Write([]string{item.FileName, item.ColumnName, item.ChineseName, item.Description}); err != nil {
return err
}
}
return nil
})
}
func writeEnumDictionary(path string, data *exportData) error {
enums := baseEnumDefinitions()
enums = append(enums, observedCategoryEnums(data)...)
return writeCSV(path, []string{"file_name", "column_name", "value", "meaning", "source", "note"}, func(w *csv.Writer) error {
for _, item := range enums {
if err := w.Write([]string{item.FileName, item.ColumnName, item.Value, item.Meaning, item.Source, item.Note}); err != nil {
return err
}
}
return nil
})
}
func writeOutputGuide(path string, data *exportData) error {
packageCount := 0
for _, rows := range data.PackagesByICCID {
packageCount += len(rows)
}
content := strings.Builder{}
content.WriteString("# 老系统设备迁移导出说明\n\n")
content.WriteString(fmt.Sprintf("生成时间:%s\n\n", time.Now().Format(timeFormat)))
content.WriteString("## 优先查看\n\n")
content.WriteString("- `card_snapshot.csv`:卡本体、老卡状态、老套餐字段、当前已用量,以及新系统卡流量字段建议值。\n")
content.WriteString("- `package_usage_snapshot.csv`:老套餐记录、已用/剩余分摊建议,以及新系统套餐状态建议值。\n")
content.WriteString("- `field_dictionary.csv`:每个输出字段的中文说明。\n")
content.WriteString("- `enum_dictionary.csv`:状态码、类型码、来源表等枚举值说明。\n\n")
content.WriteString("## 本次摘要\n\n")
content.WriteString(fmt.Sprintf("- 已解析输入关系:%d 行\n", len(data.Inputs)))
content.WriteString(fmt.Sprintf("- 输入 ICCID%d 个\n", len(data.InputByICCID)))
content.WriteString(fmt.Sprintf("- 老库卡本体命中:%d 张\n", len(data.Cards)))
content.WriteString(fmt.Sprintf("- 套餐快照:%d 行\n", packageCount))
content.WriteString(fmt.Sprintf("- 钱包快照:%d 行\n", len(data.Wallets)))
content.WriteString(fmt.Sprintf("- 佣金账户:%d 行\n", len(data.CommissionAccounts)))
content.WriteString(fmt.Sprintf("- 未解析输入:%d 行\n", len(data.UnresolvedInputs)))
content.WriteString(fmt.Sprintf("- 老库缺失 ICCID%d 个\n\n", len(data.MissingICCIDs)))
content.WriteString("## 口径提醒\n\n")
content.WriteString("- 英文字段名保留给后续脚本处理,中文含义请看同表的 `*_name` 列和 `field_dictionary.csv`。\n")
content.WriteString("- 老系统状态值来自老库字段注释;新系统建议状态来自当前项目常量。\n")
content.WriteString("- `suggested_tb_iot_card_last_gateway_reading_mb` 是新系统轮询的基线,导入时要保留,避免首轮轮询重复计算历史流量。\n")
content.WriteString("- `allocated_used_mb` 是迁移建议值,正式生成导入 SQL 前仍要结合新系统供应商、套餐系列、套餐映射复核。\n")
return os.WriteFile(path, []byte(content.String()), 0644)
}
func baseEnumDefinitions() []enumDefinition {
var result []enumDefinition
appendMapEnums(&result, "card_snapshot.csv", "old_status", oldCardStatusNames, "老库 tbl_card.status 字段注释", "")
appendMapEnums(&result, "card_snapshot.csv", "old_type", oldCardTypeNames, "老库 tbl_card.type 字段注释", "")
appendMapEnums(&result, "package_usage_snapshot.csv", "source_table", map[string]string{
sourceCardLife: sourceTableName(sourceCardLife),
sourceNextMonthLife: sourceTableName(sourceNextMonthLife),
}, "脚本来源表约定", "")
appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldCardLifeStatusNames, "老库 tbl_card_life.status 字段注释", "仅 source_table=tbl_card_life 时适用")
appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldNextMonthLifeStatusNames, "老库 tbl_next_month_card_life.status 字段注释", "仅 source_table=tbl_next_month_card_life 时适用")
appendMapEnums(&result, "package_usage_snapshot.csv", "old_type", oldPackageTypeNames, "老库 tbl_card_life.type 字段注释", "")
appendMapEnums(&result, "package_usage_snapshot.csv", "effect_type", oldEffectTypeNames, "老库 tbl_card_life.effect_type 字段注释", "")
appendMapEnums(&result, "package_usage_snapshot.csv", "order_type", oldOrderTypeNames, "老库 tbl_next_month_card_life.order_type 字段注释", "")
appendMapEnums(&result, "package_usage_snapshot.csv", "suggested_new_status", newPackageUsageStatusNames, "新系统 pkg/constants PackageUsageStatus*", "建议导入值,生成 SQL 前需复核")
appendMapEnums(&result, "wallet_snapshot.csv", "meal_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.meal_auto_renew_status 字段注释", "")
appendMapEnums(&result, "wallet_snapshot.csv", "addon_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.djb_meal_auto_renew_status 字段注释", "")
appendMapEnums(&result, "commission_detail_snapshot.csv", "mf_status", mfStatusNames, "老库 tbl_agent_commission_detail.mf_status 字段注释", "")
appendMapEnums(&result, "commission_detail_snapshot.csv", "commission_type", commissionTypeNames, "老库 tbl_agent_commission_detail.commission_type 字段注释", "")
return result
}
func appendMapEnums(result *[]enumDefinition, fileName, columnName string, values map[string]string, source, note string) {
keys := sortedStringMapKeys(values)
for _, key := range keys {
*result = append(*result, enumDefinition{
FileName: fileName,
ColumnName: columnName,
Value: key,
Meaning: values[key],
Source: source,
Note: note,
})
}
}
func observedCategoryEnums(data *exportData) []enumDefinition {
seen := make(map[string]string)
for _, card := range data.Cards {
if card.Category == "" {
continue
}
seen[card.Category] = oldCardCategoryName(card.Category, card.CategoryName)
}
keys := sortedStringMapKeys(seen)
result := make([]enumDefinition, 0, len(keys))
for _, key := range keys {
result = append(result, enumDefinition{
FileName: "card_snapshot.csv",
ColumnName: "old_category",
Value: key,
Meaning: seen[key],
Source: "老库 tbl_vendor_category.category_name",
Note: "仅列出本次导出中实际出现的运营商分类",
})
}
return result
}
func oldCardStatusName(value string) string {
return enumName(oldCardStatusNames, value)
}
func oldCardTypeName(value string) string {
return enumName(oldCardTypeNames, value)
}
func oldCardCategoryName(value, dbName string) string {
if strings.TrimSpace(dbName) != "" {
return dbName
}
return enumName(oldCategoryNames, value)
}
func oldPackageStatusName(sourceTable, value string) string {
if sourceTable == sourceNextMonthLife {
return enumName(oldNextMonthLifeStatusNames, value)
}
return enumName(oldCardLifeStatusNames, value)
}
func oldPackageTypeName(value string) string {
return enumName(oldPackageTypeNames, value)
}
func oldEffectTypeName(value string) string {
return enumName(oldEffectTypeNames, value)
}
func oldOrderTypeName(value string) string {
return enumName(oldOrderTypeNames, value)
}
func autoRenewStatusName(value string) string {
return enumName(autoRenewStatusNames, value)
}
func newPackageUsageStatusName(value string) string {
return enumName(newPackageUsageStatusNames, value)
}
func mfStatusName(value string) string {
return enumName(mfStatusNames, value)
}
func commissionTypeName(value string) string {
return enumName(commissionTypeNames, value)
}
func sourceTableName(value string) string {
switch value {
case sourceCardLife:
return "当前/历史套餐记录"
case sourceNextMonthLife:
return "次月待生效套餐记录"
default:
return ""
}
}
func slotPositionName(slot int) string {
switch slot {
case 1:
return "主卡"
case 2:
return "副卡1"
case 3:
return "副卡2"
case 4:
return "卡槽4"
default:
return ""
}
}
func resolvedByName(value string) string {
switch {
case value == "input_iccid":
return "输入直接提供 ICCID"
case value == "input_slot_iccid":
return "输入按卡槽提供 ICCID"
case strings.Contains(value, "old_tbl_card_vcode") && strings.Contains(value, "old_tbl_card_relate"):
return "按设备码命中后展开关联卡"
case value == "old_tbl_card_vcode":
return "按老库 tbl_card.vcode 反查"
case value == "old_tbl_card_imei":
return "按老库 tbl_card.imei 反查"
case value == "old_tbl_card_relate":
return "按老库 tbl_card_relate 展开"
case value == "pending_device_key":
return "待按设备号/IMEI 解析"
case value == "unresolved":
return "无法解析"
case value == "unresolved_device_key":
return "设备号/IMEI 未命中老库"
default:
return ""
}
}
func enumName(values map[string]string, value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if name, ok := values[value]; ok {
return name
}
return "未知值"
}
func sortedStringMapKeys(values map[string]string) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}