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:
+207
-3
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user