导出/合并:交通方式编号空间串了,导出会吐出两条同名方式(真机整包拒收)
继上一轮修「活动颜色为 NULL」之后,生产重新导出 `data/20260929.rondbackup` 依旧导不回 rond。 这次拿**最后一次被真机接受的包**(20260920-fixed-A-数据修复)当参照逐项比对:SQLite 头、31 表 / 47 索引 / 建表语句 / 列定义、`Z_METADATA.Z_PLIST` 与 `Z_MODELCACHE`(逐字节)、值类型分布、文本合法 字节、BLOB 长度、UUID 唯一性、关系完整性、`Z_OPT`、行级时间不变量——全部一致;唯一差异是 **`ZTRANSPORT` 里出现了两条同名「公交」(Z_PK 2 与 4),而真机与可导入的 A 包都只有一条**。 根因:`mergeVisits` 把实机的 `ZTRANSPORT` 编号原样写进 `movements.transport_src`。站点没有交通方式表, 方式只活在位移的 transport_* 四列里,两套编号空间同一个数字含义不同(实测 src=4:站点=电🐔、实机=公交, src 3/5/6 同样撞车)。于是同一个 src 带上两种含义:导出按 src 聚合只能取一个名字 → 另一种含义的位移挂错 方式;更糟的是吐出两条同名方式,真机那边是模型唯一约束级别的违规 → 整包拒收。 修复两处: 1. 合并侧 `mergeVisits`:按名称把实机方式归一到站点编号(新增 `(*Server).transportsByName`:同名沿用 站点已有的编号,没有才 `nextSrcPK(transportSeq)` 新发一个)。 2. 导出侧 `queryTransports`:改为按**名称**归组(名称就是身份),用量最多的名称优先认领编号,其余编号的 位移改挂到它上面;并清掉「基线里同名但编号已被归并掉」的旧行——`keepMissing` 会把它原样带回,于是又是 两条同名。这段清理必须排在所有托管表写完之后,否则兜底判据看到的还是基线里未重写的 ZMOVEMENT。 真实数据效果(本地库,与生产同一形状):方式恢复成站点原本那套 1火车/2公交/3高铁/4电🐔/5地铁/6步行、 无重名;位移各挂回自己的方式(电🐔 336、地铁 25、高铁 23、公交 19、火车 9、步行 3——修复前只有 6 条能对上)。 用例:`TestExportMergesDuplicateTransportNames`(同名归并 + 位移挂回自己那条 + 无悬空引用)、 `TestMergeKeepsTransportNumberingConsistent`(合并后同一 transport_src 不许有两种名称,附「位移确实被补 回来了」的哨兵断言避免写成空用例)。三处都做了变异验证:去掉改挂 / 去掉旧行清理 / 合并照抄实机编号 → 对应用例分别变红。 全量验收 295 PASS / 0 FAIL / 0 SKIP。
This commit is contained in:
+172
-25
@@ -12,6 +12,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -254,8 +255,15 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
|
||||
if p.Lat.Valid && p.Lon.Valid && !known["ZLOCATION"][p.SrcPK] {
|
||||
rawLat, rawLon = geo.GCJ02ToWGS84(p.Lat.Float64, p.Lon.Float64)
|
||||
}
|
||||
// 国家码:真机这列从来不写 NULL(未知时写空串,实测 09-28 那份有 19 行是 ''),
|
||||
// 而站点新建的地点没有这一项、导入时 '' 也被折叠成了 NULL —— 写回时按真机的形状补空串,
|
||||
// 别把 NULL 塞回去(那又是它没见过的形状)。
|
||||
country := ns(p.Country)
|
||||
if country == nil {
|
||||
country = ""
|
||||
}
|
||||
if err := loc.write(p.SrcPK, []any{ns(p.Name), ns(p.Category), nf(p.Lat), nf(p.Lon),
|
||||
ns(p.Country), ns(p.Province), ns(p.City), ns(p.Sublocality), ns(p.Thoroughfare), ns(p.Timezone),
|
||||
country, ns(p.Province), ns(p.City), ns(p.Sublocality), ns(p.Thoroughfare), ns(p.Timezone),
|
||||
lastArrival, rawLat, rawLon},
|
||||
raw); err != nil {
|
||||
return fmt.Errorf("写入地点失败: %w", err)
|
||||
@@ -407,7 +415,7 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
|
||||
}
|
||||
|
||||
// 6) 交通方式 ZTRANSPORT(从位移里聚合而来)
|
||||
transports, err := queryTransports(ctx, db, datasetID)
|
||||
transports, transByName, err := queryTransports(ctx, db, datasetID, maxPKOf(known["ZTRANSPORT"], nil)+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -515,7 +523,13 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
|
||||
for _, m := range movements {
|
||||
var trv, fromv, tov any
|
||||
if m.Transport.Valid {
|
||||
trv = m.Transport.Int64
|
||||
// 方式按**这条位移自己的名称**归并(见 queryTransports):同一个 src 在站点里可能
|
||||
// 带着两种含义,只有当条的名称才能认出它到底是哪一种,否则会挂到错的方式上。
|
||||
if x, ok := transByName[m.TransportName]; ok {
|
||||
trv = x
|
||||
} else {
|
||||
trv = m.Transport.Int64
|
||||
}
|
||||
}
|
||||
if m.FromVisit.Valid {
|
||||
fromv = m.FromVisit.Int64
|
||||
@@ -559,6 +573,53 @@ func patchSQLite(ctx context.Context, db *sql.DB, datasetID int64, sqlitePath st
|
||||
return err
|
||||
}
|
||||
|
||||
// 10.5) 清掉被归并掉的交通方式旧行
|
||||
// 基线里「同名但编号已被归并掉」的旧行必须删掉:keepMissing 会把没写到的基线行原样留下,
|
||||
// 于是包里又出现两条同名方式(正是 2026-09-29 生产导出被拒的形状)。名称没有被任何新行认领的
|
||||
// 基线方式仍然保留——站点侧「删掉某方式的最后一条行程」不等于用户想删掉这个方式。
|
||||
canonical := map[string]int{}
|
||||
for _, t := range transports {
|
||||
if t.Name.Valid && t.Name.String != "" {
|
||||
canonical[t.Name.String] = t.SrcPK
|
||||
}
|
||||
}
|
||||
if len(canonical) > 0 {
|
||||
var stale []int
|
||||
rows, err := tx.QueryContext(ctx, `SELECT Z_PK, COALESCE(ZNAME_,'') FROM ZTRANSPORT`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取基线的交通方式: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var pk int
|
||||
var name string
|
||||
if err := rows.Scan(&pk, &name); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if keep, ok := canonical[name]; ok && keep != pk {
|
||||
stale = append(stale, pk)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, pk := range stale {
|
||||
// 兜底:还有位移指着它就不删(宁可有重复,也不要留悬空引用)
|
||||
var refs int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM ZMOVEMENT WHERE ZTRANSPORT_ = ?`, pk).Scan(&refs); err != nil {
|
||||
return err
|
||||
}
|
||||
if refs > 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM ZTRANSPORT WHERE Z_PK = ?`, pk); err != nil {
|
||||
return fmt.Errorf("清理被归并的交通方式 %d: %w", pk, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11) 回写 Z_PRIMARYKEY.Z_MAX:只上调,避免 rond 下次插入撞键
|
||||
bumps := map[string]int{
|
||||
"Location": maxPKOf(known["ZLOCATION"], placesPKs(places)),
|
||||
@@ -1028,37 +1089,121 @@ type pgTransport struct {
|
||||
Name, Color, Icon sql.NullString
|
||||
}
|
||||
|
||||
// queryTransports 把位移里用到的交通方式聚合成 ZTRANSPORT 的行。
|
||||
// queryTransports 把位移里用到的交通方式按**名称**归并成 ZTRANSPORT 的行,并给出
|
||||
// 「方式名 -> 编号」——每条位移照自己的 transport_name 改挂过去。
|
||||
//
|
||||
// 必须按 transport_src 唯一:DISTINCT 在「同一方式的不同行程取名不一致」时会返回
|
||||
// 两行同一个 Z_PK(编辑页允许改方式名,历史数据里也已经有这种脏数据),
|
||||
// 导出时会往 ZTRANSPORT 插两次同一个主键——新建的方式直接撞唯一约束让整次导出失败。
|
||||
// 取名取最近编辑过的那条(id 最大)的非空值。
|
||||
func queryTransports(ctx context.Context, db *sql.DB, datasetID int64) ([]pgTransport, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT transport_src,
|
||||
(array_agg(transport_name ORDER BY id DESC) FILTER (WHERE transport_name IS NOT NULL))[1],
|
||||
// 为什么不按 transport_src 聚合:站点没有交通方式表,方式只活在位移的 transport_* 四列里,
|
||||
// 而 transport_src 是**两套编号空间**——把实机备份合并进来时实机的编号被写进了同名列,于是同一个
|
||||
// src 带着两种含义(实测 src=4 既是「电🐔」又是「公交」)。按 src 聚合只能给一个 src 取一个名字,
|
||||
// 另一种含义的位移就被挂到错的方式上;更糟的是会吐出两条同名方式——同名在真机那边是模型唯一约束
|
||||
// 级别的违规,整包会被拒收(2026-09-29 生产导出踩的第二个坑)。
|
||||
//
|
||||
// 归一规则:**名称就是身份**。按各名称的位移数从多到少依次认领编号——优先用它原本用得最多、
|
||||
// 且还没被别的名称认领的那个;都被占了就新发一个。实测这套规则会把编号恢复成站点原本那一套,
|
||||
// 每条位移也都能挂回自己真正的方式上。
|
||||
//
|
||||
// floor 是「新编号至少要从多少开始」,用来避开基线里已有的编号(避免把新行当成基线行改写)。
|
||||
func queryTransports(ctx context.Context, db *sql.DB, datasetID int64, floor int) ([]pgTransport, map[string]int, error) {
|
||||
type cand struct{ src, n int }
|
||||
byName := map[string][]cand{}
|
||||
total := map[string]int{}
|
||||
rows, err := db.QueryContext(ctx, `SELECT COALESCE(transport_name,''), transport_src, count(*)
|
||||
FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL
|
||||
GROUP BY 1,2`, datasetID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("读取交通方式: %w", err)
|
||||
}
|
||||
maxSrc := 0
|
||||
for rows.Next() {
|
||||
var name string
|
||||
var src, n int
|
||||
if err := rows.Scan(&name, &src, &n); err != nil {
|
||||
rows.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
byName[name] = append(byName[name], cand{src, n})
|
||||
total[name] += n
|
||||
if src > maxSrc {
|
||||
maxSrc = src
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 颜色/图标:每个名称取最近编辑过的那条位移上的非空值(真机这两列从来不空,空值在写回时补占位)
|
||||
colorIcon := map[string][2]sql.NullString{}
|
||||
rows, err = db.QueryContext(ctx, `SELECT COALESCE(transport_name,''),
|
||||
(array_agg(transport_color ORDER BY id DESC) FILTER (WHERE transport_color IS NOT NULL))[1],
|
||||
(array_agg(transport_icon ORDER BY id DESC) FILTER (WHERE transport_icon IS NOT NULL))[1]
|
||||
FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL
|
||||
GROUP BY transport_src ORDER BY transport_src`, datasetID)
|
||||
GROUP BY 1`, datasetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取交通方式: %w", err)
|
||||
return nil, nil, fmt.Errorf("读取交通方式颜色: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []pgTransport
|
||||
for rows.Next() {
|
||||
var t pgTransport
|
||||
var ts sql.NullInt64
|
||||
if err := rows.Scan(&ts, &t.Name, &t.Color, &t.Icon); err != nil {
|
||||
return nil, err
|
||||
var name string
|
||||
var color, icon sql.NullString
|
||||
if err := rows.Scan(&name, &color, &icon); err != nil {
|
||||
rows.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if !ts.Valid {
|
||||
continue
|
||||
colorIcon[name] = [2]sql.NullString{color, icon}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(byName))
|
||||
for name := range byName {
|
||||
names = append(names, name)
|
||||
}
|
||||
// 用量多的先认领编号;数量相同按名称排,保证结果与遍历顺序无关
|
||||
sort.Slice(names, func(i, j int) bool {
|
||||
if total[names[i]] != total[names[j]] {
|
||||
return total[names[i]] > total[names[j]]
|
||||
}
|
||||
return names[i] < names[j]
|
||||
})
|
||||
|
||||
next := maxSrc + 1
|
||||
if next < floor {
|
||||
next = floor
|
||||
}
|
||||
claimed := map[int]bool{}
|
||||
transByName := map[string]int{}
|
||||
out := make([]pgTransport, 0, len(names))
|
||||
for _, name := range names {
|
||||
list := byName[name]
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
if list[i].n != list[j].n {
|
||||
return list[i].n > list[j].n
|
||||
}
|
||||
return list[i].src < list[j].src
|
||||
})
|
||||
src := 0
|
||||
for _, c := range list {
|
||||
if !claimed[c.src] {
|
||||
src = c.src
|
||||
break
|
||||
}
|
||||
}
|
||||
if src == 0 {
|
||||
src = next
|
||||
next++
|
||||
}
|
||||
claimed[src] = true
|
||||
transByName[name] = src
|
||||
t := pgTransport{SrcPK: src, Color: colorIcon[name][0], Icon: colorIcon[name][1]}
|
||||
if name != "" {
|
||||
t.Name = sql.NullString{String: name, Valid: true}
|
||||
}
|
||||
t.SrcPK = int(ts.Int64)
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].SrcPK < out[j].SrcPK })
|
||||
return out, transByName, nil
|
||||
}
|
||||
|
||||
type pgVisit struct {
|
||||
@@ -1096,10 +1241,12 @@ type pgMovement struct {
|
||||
SrcPK int
|
||||
Transport, FromVisit, ToVisit sql.NullInt64
|
||||
Start, End sql.NullTime
|
||||
TransportName string // 该条位移自己的方式名(归并用,见 queryTransports)
|
||||
}
|
||||
|
||||
func queryMovements(ctx context.Context, db *sql.DB, datasetID int64) ([]pgMovement, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT src_pk, transport_src, from_visit_src, to_visit_src, started_at, ended_at
|
||||
rows, err := db.QueryContext(ctx, `SELECT src_pk, transport_src, from_visit_src, to_visit_src, started_at, ended_at,
|
||||
COALESCE(transport_name,'')
|
||||
FROM movements WHERE dataset_id=$1 ORDER BY id`, datasetID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取位移: %w", err)
|
||||
@@ -1108,7 +1255,7 @@ func queryMovements(ctx context.Context, db *sql.DB, datasetID int64) ([]pgMovem
|
||||
var out []pgMovement
|
||||
for rows.Next() {
|
||||
var m pgMovement
|
||||
if err := rows.Scan(&m.SrcPK, &m.Transport, &m.FromVisit, &m.ToVisit, &m.Start, &m.End); err != nil {
|
||||
if err := rows.Scan(&m.SrcPK, &m.Transport, &m.FromVisit, &m.ToVisit, &m.Start, &m.End, &m.TransportName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
|
||||
@@ -516,6 +516,28 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond
|
||||
for _, t := range b.Transports {
|
||||
transName[t.SrcPK], transColor[t.SrcPK], transIcon[t.SrcPK] = t.Name, t.Color, t.Icon
|
||||
}
|
||||
// 交通方式是**两套编号空间**:站点的 movements.transport_src 用自己的编号(站点没有交通方式表,
|
||||
// 方式只活在位移的 transport_* 四列里),而 m.TransportSrc 是实机的编号。同一个数字两边含义
|
||||
// 常常不同(实测实机 4=公交、站点 4=电🐔),照抄会把一个 src 变成两种含义——导出按 src 聚合时
|
||||
// 会吐出两条同名方式,真机那边(以及 Core Data 的唯一约束)会判成损坏数据、整包拒收。
|
||||
// 按**名称**归一:站点已有同名方式就沿用它的编号,没有才新发一个。
|
||||
siteTrans, transNext, err := s.transportsByName(ctx, ex, dsID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
transSrc := map[int]int{}
|
||||
for _, t := range b.Transports {
|
||||
if t.Name == "" {
|
||||
continue
|
||||
}
|
||||
if src, ok := siteTrans[t.Name]; ok {
|
||||
transSrc[t.SrcPK] = src
|
||||
continue
|
||||
}
|
||||
siteTrans[t.Name] = transNext
|
||||
transSrc[t.SrcPK] = transNext
|
||||
transNext++
|
||||
}
|
||||
coordOf := map[int][2]float64{}
|
||||
for _, l := range b.Locations {
|
||||
coordOf[l.SrcPK] = [2]float64{l.Lat, l.Lon}
|
||||
@@ -570,11 +592,21 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// transport_src 写**站点编号空间**里的号(见上面 transSrc 的说明):照抄实机编号会让
|
||||
// 同一个 src 出现两种含义,导出时吐出两条同名方式、整包被真机拒收。
|
||||
var transFK any
|
||||
if m.TransportSrc != nil {
|
||||
if x, ok := transSrc[*m.TransportSrc]; ok {
|
||||
transFK = x
|
||||
} else {
|
||||
transFK = *m.TransportSrc
|
||||
}
|
||||
}
|
||||
if _, err := ex.ExecContext(ctx, `INSERT INTO movements
|
||||
(dataset_id, src_pk, transport_src, transport_name, transport_color, transport_icon,
|
||||
from_visit_src, to_visit_src, from_place_id, to_place_id, started_at, ended_at, duration_min, distance_km)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`,
|
||||
dsID, pk, m.TransportSrc, tname, tcolor, ticon, fsrc, tsrc,
|
||||
dsID, pk, transFK, tname, tcolor, ticon, fsrc, tsrc,
|
||||
fromPID, toPID, m.Start, m.End, dur, dist); err != nil {
|
||||
return fmt.Errorf("新增位移: %w", err)
|
||||
}
|
||||
@@ -627,6 +659,40 @@ func (s *Server) mergeVisits(ctx context.Context, ex execer, dsID int64, b *rond
|
||||
return nil
|
||||
}
|
||||
|
||||
// transportsByName 读站点现有的交通方式,按**名称**归并成一个「名称 -> 编号」表(同名取用得多
|
||||
// 的那个编号,并列取小),并给出下一个可用编号。站点没有交通方式表,方式只活在位移的
|
||||
// transport_src / transport_name 里,所以这里从 movements 里聚合。
|
||||
func (s *Server) transportsByName(ctx context.Context, ex execer, dsID int64) (map[string]int, int, error) {
|
||||
rows, err := ex.QueryContext(ctx, `SELECT transport_src, transport_name, count(*)
|
||||
FROM movements
|
||||
WHERE dataset_id=$1 AND transport_src IS NOT NULL AND COALESCE(transport_name,'') <> ''
|
||||
GROUP BY 1,2`, dsID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
count, best := map[string]int{}, map[string]int{}
|
||||
for rows.Next() {
|
||||
var src, n int
|
||||
var name string
|
||||
if err := rows.Scan(&src, &name, &n); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
cur, seen := count[name]
|
||||
if !seen || n > cur || (n == cur && src < best[name]) {
|
||||
count[name], best[name] = n, src
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
next, err := s.nextSrcPK(ctx, ex, dsID, transportSeq)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return best, next, nil
|
||||
}
|
||||
|
||||
// applyDeletions 处理「仅站内有」里被勾选删除的记录。顺序是到访 → 活动 → 地点:
|
||||
// 删地点会级联删掉它名下的全部到访(外键 ON DELETE CASCADE),所以反过来会误删;
|
||||
// 而且**有到访引用时直接拒绝**,让站长先处理到访,而不是悄悄带走一批。
|
||||
|
||||
@@ -418,3 +418,54 @@ func TestMergePlacesMapsSameNameLocationsOneToOne(t *testing.T) {
|
||||
t.Errorf("备份 #42 应配到最近的 920002,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 交通方式的编号是**两套空间**:站点用自己的 transport_src 编号,而备份里的 m.TransportSrc 是
|
||||
// 实机的编号。合并时照抄实机编号,会让同一个 src 带上两种含义(实测 src=4 既是「电🐔」又是
|
||||
// 「公交」)——导出时按名称归并前会吐出两条同名方式,真机整包拒收。合并必须按名称把实机的方式
|
||||
// 归一到站点的编号上。
|
||||
func TestMergeKeepsTransportNumberingConsistent(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, pg, dsID := f.ctx, f.pg, f.dsID
|
||||
movCount := func() int {
|
||||
return f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, dsID)
|
||||
}
|
||||
before := movCount()
|
||||
|
||||
// 把站点所有方式改名(编辑页允许改方式名,这就是真实操作):于是站点的「1 号」与实机的
|
||||
// 「1 号」含义不同——两套编号空间撞车的形状
|
||||
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_name = transport_name || '(站内)'
|
||||
WHERE dataset_id=$1 AND transport_src IS NOT NULL`, dsID); err != nil {
|
||||
t.Fatalf("预置改名失败: %v", err)
|
||||
}
|
||||
|
||||
// 删掉一条**带交通方式**的位移的端点到访,好让合并把它连同位移一起补回来
|
||||
// (没有新到访就不会插入任何位移,用例会变成空的)
|
||||
var pk int
|
||||
if err := pg.QueryRowContext(ctx, `SELECT from_visit_src FROM movements
|
||||
WHERE dataset_id=$1 AND transport_src IS NOT NULL AND from_visit_src IS NOT NULL
|
||||
ORDER BY id LIMIT 1`, dsID).Scan(&pk); err != nil {
|
||||
t.Fatalf("挑端点到访失败: %v", err)
|
||||
}
|
||||
for _, q := range []string{
|
||||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src=$2 OR to_visit_src=$2)`,
|
||||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src=$2`,
|
||||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
} {
|
||||
if _, err := pg.ExecContext(ctx, q, dsID, pk); err != nil {
|
||||
t.Fatalf("预置删除失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := previewBackupToken(t, f, f.src)
|
||||
mergeBackup(t, f, token, "add_new=1")
|
||||
|
||||
if got := movCount(); got < before {
|
||||
t.Fatalf("合并没有把删掉的位移补回来(%d → %d),这条用例会变成空的", before, got)
|
||||
}
|
||||
n := f.scalar(t, `SELECT count(*) FROM (SELECT transport_src FROM movements
|
||||
WHERE dataset_id=$1 AND transport_src IS NOT NULL
|
||||
GROUP BY transport_src HAVING count(DISTINCT transport_name) > 1) x`, dsID)
|
||||
if n != 0 {
|
||||
t.Fatalf("%d 个交通方式编号带上了两种名称(实机编号被照抄进站点的编号空间了)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"rond-with-you/internal/rond"
|
||||
)
|
||||
|
||||
// 交通方式是**全局表、以名称区分**:同名两条会被真机当成损坏数据,整包拒收
|
||||
// (2026-09-29 生产导出就是这么被拒的:`ZTRANSPORT` 里两条「公交」)。
|
||||
//
|
||||
// 站点的 transport_src 是另一套编号空间——把实机备份合并进来时,同一个 src 会带上两种含义
|
||||
// (实测 src=4 既是「电🐔」又是「公交」)。导出必须按**名称**归并,并且把每条位移挂回
|
||||
// 它自己那个名称对应的方式上;只按 src 取一个名字会让另一种含义的位移挂错方式。
|
||||
func TestExportMergesDuplicateTransportNames(t *testing.T) {
|
||||
f := newMovementFixture(t)
|
||||
ctx, pg, dsID := f.ctx, f.pg, f.dsID
|
||||
|
||||
// 造出「同一个 src、两种名称」的形状:正是合并实机备份留下的样子
|
||||
var k1, k2 int
|
||||
if err := pg.QueryRowContext(ctx, `SELECT
|
||||
(SELECT src_pk FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL ORDER BY id LIMIT 1),
|
||||
(SELECT src_pk FROM movements WHERE dataset_id=$1 AND transport_src IS NOT NULL ORDER BY id OFFSET 1 LIMIT 1)`,
|
||||
dsID).Scan(&k1, &k2); err != nil {
|
||||
t.Fatalf("挑位移失败: %v", err)
|
||||
}
|
||||
for _, v := range []struct {
|
||||
pk int
|
||||
name string
|
||||
color string
|
||||
icon string
|
||||
}{{k1, "电🐔", "purple", "moped.fill"}, {k2, "公交", "red", "bus.fill"}} {
|
||||
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_src=900, transport_name=$3,
|
||||
transport_color=$4, transport_icon=$5 WHERE dataset_id=$1 AND src_pk=$2`,
|
||||
dsID, v.pk, v.name, v.color, v.icon); err != nil {
|
||||
t.Fatalf("预置失败: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pg.ExecContext(ctx, `DELETE FROM movements WHERE dataset_id=$1 AND transport_src=900`, dsID)
|
||||
})
|
||||
// 再把站点原本那条「公交」(src=2)改名,于是「公交」这个名字只剩注入的那条:
|
||||
// 归并后基线里 src=2 那行就成了「同名但编号已被归并掉」的旧行,必须被删掉,
|
||||
// 否则 keepMissing 会把它原样留下 → 包里又是两条同名方式
|
||||
if _, err := pg.ExecContext(ctx, `UPDATE movements SET transport_name='巴士'
|
||||
WHERE dataset_id=$1 AND transport_src=2`, dsID); err != nil {
|
||||
t.Fatalf("预置改名失败: %v", err)
|
||||
}
|
||||
|
||||
out := filepath.Join(t.TempDir(), "export.rondbackup")
|
||||
if err := os.WriteFile(out, f.export(t), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edb, err := rond.Open(out, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("打开导出包失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { edb.Close() })
|
||||
|
||||
// 1) 同名不许出现两条
|
||||
rows, err := edb.Query(`SELECT ZNAME_ FROM ZTRANSPORT WHERE ZNAME_ IS NOT NULL
|
||||
GROUP BY ZNAME_ HAVING count(*) > 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("读 ZTRANSPORT 失败: %v", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Errorf("导出包里有两条同名交通方式 %q:真机会判成损坏数据、整包拒收", name)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 2) 每条位移必须挂回自己名称对应的那条方式
|
||||
for _, want := range []struct {
|
||||
pk int
|
||||
name string
|
||||
}{{k1, "电🐔"}, {k2, "公交"}} {
|
||||
var got string
|
||||
if err := edb.QueryRow(`SELECT t.ZNAME_ FROM ZMOVEMENT m JOIN ZTRANSPORT t ON t.Z_PK = m.ZTRANSPORT_
|
||||
WHERE m.Z_PK = ?`, want.pk).Scan(&got); err != nil {
|
||||
t.Fatalf("位移 #%d 的方式查不到: %v", want.pk, err)
|
||||
}
|
||||
if got != want.name {
|
||||
t.Errorf("位移 #%d 应挂到 %q,实际挂到了 %q(归并后要按位移自己的名称改挂)", want.pk, want.name, got)
|
||||
}
|
||||
}
|
||||
// 3) 归并后不许留下悬空的 ZTRANSPORT_ 引用
|
||||
if n := countNull(t, edb, `SELECT count(*) FROM ZMOVEMENT m WHERE m.ZTRANSPORT_ IS NOT NULL
|
||||
AND m.ZTRANSPORT_ <> 0 AND NOT EXISTS (SELECT 1 FROM ZTRANSPORT t WHERE t.Z_PK = m.ZTRANSPORT_)`); n != 0 {
|
||||
t.Errorf("归并后有 %d 条位移指向不存在的方式", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user