v24 第 13 批:旅程模块重构(双轨 → 一条记录 + 状态机)

用户原话:「旅程这个模块,我准备大改…如果按照计划行程和实际行程来分别管理,
会变的很复杂…根据我的想法,重构这个模块。」
—— 这不是加需求,是把「双轨」架构换成「一条记录 + 状态机」。

## 用户确认的三个决定
1. 两层:**活动**(顶层容器)→ **行程**(挂其下,字段沿用原节点)
2. **修正不立即转实际**:修正只是记录,行程仍标"计划(已修正 N 次)",
   要再点一次「标记为实际」才转实际
3. 老数据**按现有轨道自动迁移**(category=reality → stage=actual)

## 状态机(本次重构的心脏)
trip_nodes.stage:
  · plan    计划行程 —— 唯一以「计划时间」上日程的状态,三个动作都能用
  · actual  实际行程 —— 上日程(实际时间)+ 进「往年今日」
  · skipped 未成行   —— **不上日程、不进往年今日**,但旅程里仍显示并标注
                          (用户:"还是要显示,只不过要标记上")
旧的 category(plan/reality) 保留作兼容,业务一律看 stage(saveNode 同步维护两者)。

## 三个动作(Trip.php)
· reviseNode —— 记一次修正。🔴 **第一次修正前先把原计划存成 seq=1 快照**,
  否则用户要的"之前的计划行程数据要保留,为了以后做对比"就落空。**不动 stage**。
  可无限次(seq 递增)。
· completeNode —— 实际=计划,直接转 actual,**不产生修正历史**(没有变化就没有历史)。
· markActual —— 把(已修正过的)计划正式转 actual。
· skipNode —— **不是真删**,只置 stage='skipped'(用户明确"不要真的删除,只是做一个标记")。
· restoreNode —— 撤销未成行(点错了能改回来)。
界面上的「彻底删除」与「标记未成行」是两个并列动作,弹窗文案里写明区别。

## 修正历史(Schema v19,新表 trip_node_revisions)
每次修正一行:实际起止 / 实际地点 / 说明 / 参与人快照。
第 1 条 = 原计划快照,第 2..n 条 = 各次修正 —— 详情页用表格展示,可直接对比。

## 日历与往年今日(用户点名的联动)
· 日历:`n.stage IN ('plan','actual')` —— 去掉旧的 `status<>'cancelled'`
  (那是双轨口径;现在"未成行"是 stage)。实际行程标题加 ✓ 前缀便于区分。
· **活动本身绝不上日程**(用户:"活动因为可能跨越的时间比较长")。
· 往年今日:旅程部分从「整趟 events」改成**实际行程**(stage='actual'),
  标题带活动名前缀;未成行的不出现。

## 验证
新增 _trip_e2e12.py **30/0**:建活动 → 加行程(可选计划/实际)→ 修正(仍计划 /
历史保留原计划 09:00 / 可无限次)→ 标记为实际 → 完成(无历史)→ 标记未成行(未真删)
→ 日历四种情形(已转实际✓ 新建实际✓ 完成✓ 未成行✗ 活动✗)→ 往年今日只看实际。
回归:e2e 787/0、升级 129/0(含 v19 路径)、batch_e 91/0、batch10 46/0、batch9 38/0、
picker 28/0 + 11/0、crop 17/0、formbloat 9/0、semi 27/0、geom 5/5、mobile 688/0、
interact 92/0、drag 5/0。
e2e 两条旧断言("双轨两块/两轨各一条")按重构语义更新为"三组渲染 / stage 各一条
+ category 与 stage 同步"。
部署断言新增 1 大组,含**反退化防线**:skipNode 若被改成 DELETE 立即变红。
线上自检 19/19;线上真实账号验收 16/0。
线上老数据迁移实测:#222 计划3+实际1、#221 实际4、#220 实际13 —— 一条没丢。
This commit is contained in:
2026-10-04 20:57:40 +08:00
parent ecf121772f
commit 89b956205d
8 changed files with 708 additions and 40 deletions
+70 -2
View File
@@ -64,6 +64,15 @@ final class Schema
* + task_recur(灵活循环);
* ⑤ 统一附件 attachments(旅程/节点/资源/任务/评论共用一张表);
* ⑥ 模块导航与主题色走 user_settings / settings,不新建表。
* 19 → **旅程模块重构(用户 2026-10-05)**:活动/行程两层语义收敛。
* `trip_nodes` 的**双轨 category(plan/reality) 改为状态机 stage**:
* plan(计划)/ actual(实际)/ skipped(标记未成行,不真删)。
* 理由:用户明确「实际的行程和计划行程,是一条记录,不是两条」,
* 而旧的双轨是**两条独立记录**。
* 新增 `trip_node_revisions`:每次「修正」存一份快照
* (实际起止 / 参与人 / 备注),可无限次,供日后对比。
* 迁移:category='reality' → stage='actual','plan' → stage='plan'
* (按现有轨道自动迁,用户已确认)。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
@@ -71,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '18';
public const VERSION = '19';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -447,7 +456,10 @@ CREATE TABLE IF NOT EXISTS `trip_nodes` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`event_id` INT UNSIGNED NOT NULL COMMENT '属于哪趟旅程',
`category` ENUM('plan','reality') NOT NULL DEFAULT 'plan' COMMENT 'plan=计划 / reality=实际走过的',
`category` ENUM('plan','reality') NOT NULL DEFAULT 'plan' COMMENT '【v19 起仅作兼容保留,业务改用 stage】plan=计划 / reality=实际走过的',
`stage` ENUM('plan','actual','skipped') NOT NULL DEFAULT 'plan' COMMENT 'v19 行程状态机:plan=计划 / actual=实际行程 / skipped=标记未成行(不真删)',
`revised_at` DATETIME DEFAULT NULL COMMENT '最后一次「修正」的时间(NULL=从未修正)',
`revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数(可用于显示"已修正 N 次")',
`node_type` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 其他',
`title` VARCHAR(200) NOT NULL,
`detail` TEXT DEFAULT NULL COMMENT '这一段的文字记录',
@@ -464,6 +476,23 @@ CREATE TABLE IF NOT EXISTS `trip_nodes` (
CONSTRAINT `fk_tn_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tn_event` FOREIGN KEY (`event_id`) REFERENCES `events` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点:一趟旅程里的每一段(计划/执行双轨,TripLog 同款)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_revisions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL COMMENT '被修正的行程(trip_nodes.id)',
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几次修正(从 1 开始)',
`starts_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**开始',
`ends_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**结束',
`place` VARCHAR(200) DEFAULT NULL COMMENT '实际地点',
`detail` TEXT DEFAULT NULL COMMENT '这次修正的说明(为什么改了)',
`members` TEXT DEFAULT NULL COMMENT '实际参与人 id 列表(逗号分隔的快照)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tnr_node` (`node_id`, `seq`),
CONSTRAINT `fk_tnr_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程的修正历史(每次「修正」一条,可无限次,供日后对比)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_members` (
@@ -868,6 +897,45 @@ SQL,
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',
"ADD COLUMN `stage` ENUM('plan','actual','skipped') NOT NULL DEFAULT 'plan' "
. "COMMENT 'v19 行程状态机:plan/actual/skipped' AFTER `category`");
self::addColumn($db, 'trip_nodes', 'revised_at',
"ADD COLUMN `revised_at` DATETIME DEFAULT NULL COMMENT '最后一次修正时间' AFTER `stage`");
self::addColumn($db, 'trip_nodes', 'revision_n',
"ADD COLUMN `revision_n` INT NOT NULL DEFAULT 0 COMMENT '修正次数' AFTER `revised_at`");
// ② 建修正历史表(老库没有)
self::tryStep('建行程修正历史表', static function () use ($db) {
$db->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_node_revisions` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`node_id` INT UNSIGNED NOT NULL COMMENT '被修正的行程(trip_nodes.id)',
`seq` INT NOT NULL DEFAULT 1 COMMENT '第几次修正(从 1 开始)',
`starts_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**开始',
`ends_at` DATETIME DEFAULT NULL COMMENT '这次修正填的**实际**结束',
`place` VARCHAR(200) DEFAULT NULL COMMENT '实际地点',
`detail` TEXT DEFAULT NULL COMMENT '这次修正的说明(为什么改了)',
`members` TEXT DEFAULT NULL COMMENT '实际参与人 id 列表(逗号分隔的快照)',
`created_by` INT UNSIGNED DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_tnr_node` (`node_id`, `seq`),
CONSTRAINT `fk_tnr_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='行程的修正历史(每次「修正」一条,可无限次,供日后对比)'
SQL);
});
// ③ 按现有轨道迁移 stage(用户 2026-10-05 确认:"按现有轨道自动迁移")
// category='reality' 的那条 → 已经是实际行程;'plan' → 计划行程。
// ⚠️ 只迁**还是默认值**的行(stage='plan' 且 revision_n=0),
// 避免把用户在新版里已经改过的状态又刷回去(重复跑升级是常态)。
self::tryStep('按现有轨道迁移行程状态(plan/reality → stage)', static function () use ($db) {
$db->exec("UPDATE `trip_nodes` SET `stage` = 'actual'
WHERE `category` = 'reality' AND `revision_n` = 0 AND `stage` = 'plan'");
});
// ---- v24 第 10 批 #2:负债类账目一律存正数 ----
// 用户原话:「类别,增加一个欠款,用来记录别人的钱,属于负债,是负的。」
// 也就是说用户会把欠款填成 **-500**。旧的 summary() 用 `net = 资产 − 负债`,