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
+13 -3
View File
@@ -744,15 +744,21 @@ final class Calendar
// ⚠️ 别在 SQL 字符串里写 `--` 行内注释:PHP 里那段是普通字符串,
// 但 `--` 之后的内容仍是字符串的一部分,SQL 侧被注释掉,
// 而 PHP 侧语法检查看不出问题 —— 会变成"语句少半截"的静默错误。
// v24 第 13 批(用户 2026-10-05):旅程重构后,**按 stage 过滤**。
// · plan → 上日程(用**计划**时间)
// · actual → 上日程(时间已经是实际时间,因为"修正"会把实际值写回行程)
// · skipped → **不上日程**(用户:"就权当没有这个日程")
// ⚠️ 不能再按 status<>'cancelled' 过滤 —— 那是旧双轨的口径;
// 现在"未成行"是 stage='skipped',而 status 可能仍是 planned。
$st = Database::get()->prepare(
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
n.node_type, n.status AS node_status,
n.node_type, n.status AS node_status, n.stage AS node_stage,
e.id AS event_id, e.title AS trip_title
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
AND n.status <> 'cancelled'
AND n.stage IN ('plan','actual')
AND COALESCE(d.is_public, 1) = 1
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
ORDER BY n.starts_at"
@@ -772,7 +778,11 @@ final class Calendar
'cal' => (int)$c['id'],
'color' => (string)$c['color'],
'calname' => (string)$c['name'],
'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'],
// 用户要"一眼看出这是计划还是实际":实际行程加个 ✓ 前缀。
// 计划行程保持原样(日历里大多数是它,加前缀反而吵)。
'title' => Trip::typeIcon((string)$n['node_type'])
. ((string)($n['node_stage'] ?? 'plan') === 'actual' ? ' ✓ ' : ' ')
. (string)$n['node_title'],
'date' => $day,
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
// 结束时刻只落在节点跨天的最后一天(与自定义日程同一口径)
+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 = 资产 − 负债`,
+207 -3
View File
@@ -41,6 +41,50 @@ final class Trip
'cancelled' => '已取消',
];
/**
* 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。
*
* 用户原话(2026-10-05):
* 「刚开始添加的时候,可以选择是计划行程还是实际行程。」
* 「如果是计划行程,则这个行程下面有几个按钮…一个是删除,如果是标记为删除,
* 不要真的删除,只是做一个标记,意味着这次计划没有成行…」
* 「另外一个按钮,用来记录实际的情况,按钮名字就叫修正,但是记住,这个实际的
* 行程和计划行程,是一条记录,不是两条…」「还有一个按钮,就是完成…
* 这个行程直接就变成了实际行程。」
* 「实际行程,都会出现在日历和首页的往年今日中。计划行程,只有在未执行上面
* 三个操作的时候,才出现在日程中。一旦标记为删除,或转变为实际行程,
* 则计划行程就不再出现。」
*
* ┌─ plan 计划行程:**唯一会以"计划时间"上日程**的状态。
* │ 三个动作:标记未成行 / 修正 / 完成。
* ├─ actual 实际行程:上日程(按**实际时间**)、也进「往年今日」。
* └─ skipped 标记未成行:**不上日程、不进往年今日**;但在旅程模块里**仍然显示**
* 并标注"未成行"(用户明确要求"还是要显示,只不过要标记上")。
*
* ⚠️ 旧的 `category(plan/reality)` 是**两条独立记录**,
* 而用户要的是"一条记录 + 反复修正",所以业务一律看 stage。
*/
public const NODE_STAGES = [
'plan' => '计划行程',
'actual' => '实际行程',
'skipped' => '未成行',
];
/** 会出现在日程/往年今日里的状态(计划用计划时间、实际用实际时间) */
public const STAGE_ON_CALENDAR = ['plan', 'actual'];
/** 状态 → 徽标样式(模板用) */
public static function stageWord(string $key): string
{
return self::NODE_STAGES[$key] ?? '计划行程';
}
/** 这条行程该不该上日程/往年今日 */
public static function stageOnCalendar(string $key): bool
{
return in_array($key, self::STAGE_ON_CALENDAR, true);
}
/** 全部旅程(按出发时间倒序) */
public static function all(int $familyId, string $keyword = ''): array
{
@@ -278,6 +322,13 @@ final class Trip
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
{
$db = Database::get();
// v24 第 13 批:新加时由用户选「计划行程 / 实际行程」(用户原话第 2 条)。
// 编辑时若表单带了 stage 就一并改(修订面板里"标记为实际"走的是另一个方法)。
$stage = in_array($d['stage'] ?? '', array_keys(self::NODE_STAGES), true)
? (string)$d['stage'] : 'plan';
// ⚠️ category 只作**兼容保留**(老代码/导出还会读它),业务一律看 stage。
// 这里同步维护,保证两边不打架:stage=actual → reality,其余 → plan。
$category = $stage === 'actual' ? 'reality' : 'plan';
$fields = [
self::textOrNull2($d['title'] ?? ''),
self::textOrNull($d['detail'] ?? ''),
@@ -286,19 +337,20 @@ final class Trip
self::dtOrNull($d['ends_at'] ?? ''),
in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other',
in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned',
($d['category'] ?? '') === 'reality' ? 'reality' : 'plan',
$category,
(int)($d['position'] ?? 100),
$stage,
];
if ($id !== null && $id > 0) {
$db->prepare(
'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?,
node_type=?, status=?, category=?, position=? WHERE id=?'
node_type=?, status=?, category=?, position=?, stage=? WHERE id=?'
)->execute([...$fields, $id]);
$nid = $id;
} else {
$db->prepare(
'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at,
node_type, status, category, position) VALUES (?,?,?,?,?,?,?,?,?,?,?)'
node_type, status, category, position, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([$familyId, $eventId, ...$fields]);
$nid = (int)$db->lastInsertId();
}
@@ -306,6 +358,158 @@ final class Trip
return $nid;
}
// ---------------- 计划行程的三个动作(v24 第 13 批) ----------------
//
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」
// · 标记未成行(用户原话叫"删除",但明确"不要真的删除,只是做一个标记")
// · 修正(记录实际执行情况,与计划是**同一条记录**,可无限次)
// · 完成(实际与计划完全相同,直接变实际行程)
/**
* 「修正」:记下一次实际执行情况,**行程本身仍是计划**(用户确认的语义:
* 修正后要再点一次「标记为实际」才正式转为实际行程)。
*
* 🔴 关键:这是**同一条记录**,不是新建第二条。所以做法是
* ① 把这次填的实际值**快照**进 trip_node_revisions(历史,可无限次);
* ② 同时把 trip_nodes 上的实际值写进去(这样"计划"旁边就能看到"实际打算怎么走"),
* 但**不动 stage** —— 它还是 plan,用户点「标记为实际」才会变 actual。
* 这样"改动前的计划数据"永远留着(历史表第一份快照就是原计划),日后可对比。
* ⚠️ 原计划数据**不能丢**:第一次修正前先把当前值存成 seq=1 的历史快照。
*
* @return int 这次修正的序号(seq)
*/
public static function reviseNode(int $nodeId, array $d, ?int $byPerson = null): int
{
$db = Database::get();
$cur = self::node($nodeId);
if (!$cur) {
return 0;
}
// 第一次修正前,先把**原计划**存成 seq=1 —— 否则"之前的计划数据要保留"
// 这条就落空了(用户明确要求保留,为了以后做对比)。
$n = (int)($cur['revision_n'] ?? 0);
if ($n === 0) {
$db->prepare(
'INSERT INTO trip_node_revisions
(node_id, seq, starts_at, ends_at, place, detail, members, created_by)
VALUES (?,?,?,?,?,?,?,?)'
)->execute([$nodeId, 1,
$cur['starts_at'], $cur['ends_at'], $cur['place'], $cur['detail'],
self::membersCsv($nodeId), $byPerson]);
$n = 1;
}
$seq = $n + 1;
$members = isset($d['members']) ? implode(',', array_map('intval', (array)$d['members'])) : null;
$db->prepare(
'INSERT INTO trip_node_revisions
(node_id, seq, starts_at, ends_at, place, detail, members, created_by)
VALUES (?,?,?,?,?,?,?,?)'
)->execute([$nodeId, $seq,
self::dtOrNull($d['starts_at'] ?? ''),
self::dtOrNull($d['ends_at'] ?? ''),
self::textOrNull($d['place'] ?? ''),
self::textOrNull($d['detail'] ?? ''),
$members, $byPerson]);
// 行程上同步"实际打算"的起止与地点(stage 不动,仍是计划)
$db->prepare(
'UPDATE trip_nodes SET starts_at=COALESCE(?, starts_at), ends_at=COALESCE(?, ends_at),
place=COALESCE(?, place), revised_at=NOW(), revision_n=? WHERE id=?'
)->execute([self::dtOrNull($d['starts_at'] ?? ''),
self::dtOrNull($d['ends_at'] ?? ''),
self::textOrNull($d['place'] ?? ''),
$seq, $nodeId]);
if (isset($d['members'])) {
self::saveNodeMembers($nodeId, (array)$d['members']);
}
return $seq;
}
/**
* 「标记为实际」:把这条**计划行程**正式转成实际行程。
*
* 用户确认的流程是"先修正(可多次)→ 再点标记为实际"。
* 转到 actual 之后:它会以**实际时间**出现在日历,并进「往年今日」。
*/
public static function markActual(int $nodeId): bool
{
$cur = self::node($nodeId);
if (!$cur) {
return false;
}
Database::get()->prepare(
"UPDATE trip_nodes SET stage='actual', category='reality',
status='done', updated_at=NOW() WHERE id=?"
)->execute([$nodeId]);
return true;
}
/**
* 「完成」:实际执行与计划**完全相同**,无需任何修改,直接变成实际行程。
*
* 与「修正 + 标记为实际」的区别:**不产生修正历史**(没有变化就不该有历史),
* 只是把 stage 推到 actual、status 推到 done。
*/
public static function completeNode(int $nodeId): bool
{
$cur = self::node($nodeId);
if (!$cur) {
return false;
}
Database::get()->prepare(
"UPDATE trip_nodes SET stage='actual', category='reality',
status='done', updated_at=NOW() WHERE id=?"
)->execute([$nodeId]);
return true;
}
/**
* 「标记未成行」:**不是真删**,只打标记(用户明确要求)。
*
* 后果:① 不再出现在日程;② 不进「往年今日」("就权当没有这个日程");
* ③ 但在旅程模块里**仍然显示**并标注"未成行"(用户要求保留可查)。
*/
public static function skipNode(int $nodeId): bool
{
$cur = self::node($nodeId);
if (!$cur) {
return false;
}
Database::get()->prepare(
"UPDATE trip_nodes SET stage='skipped', status='cancelled', updated_at=NOW() WHERE id=?"
)->execute([$nodeId]);
return true;
}
/** 撤销「未成行」,退回计划行程(点错了要能改回来) */
public static function restoreNode(int $nodeId): bool
{
Database::get()->prepare(
"UPDATE trip_nodes SET stage='plan', category='plan',
status='planned', updated_at=NOW() WHERE id=? AND stage='skipped'"
)->execute([$nodeId]);
return true;
}
/** 一条行程的修正历史(按 seq 升序:第 1 条 = 原计划,后面是每次修正) */
public static function revisions(int $nodeId): array
{
$st = Database::get()->prepare(
'SELECT * FROM trip_node_revisions WHERE node_id = ? ORDER BY seq'
);
$st->execute([$nodeId]);
return $st->fetchAll();
}
/** 把某行程的参与人取成 "1,2,3"(存进修正快照用 —— 快照要留当时的名单) */
private static function membersCsv(int $nodeId): string
{
$st = Database::get()->prepare(
'SELECT person_id FROM trip_node_members WHERE node_id = ? ORDER BY person_id'
);
$st->execute([$nodeId]);
return implode(',', array_map('intval', array_column($st->fetchAll(), 'person_id')));
}
public static function deleteNode(int $id): void
{
foreach (\App\Attachment::list('trip_node', $id) as $a) {