导出/合并:交通方式编号空间串了,导出会吐出两条同名方式(真机整包拒收)
继上一轮修「活动颜色为 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:
@@ -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),所以反过来会误删;
|
||||
// 而且**有到访引用时直接拒绝**,让站长先处理到访,而不是悄悄带走一批。
|
||||
|
||||
Reference in New Issue
Block a user