v24 第 16 批:旅程 / 行程两级(术语固定 · 题图16:9 · 五项统计 · 独立行程页 · 设置三页)

用户十条,逐条落地:

1. 「题图应该按照 16:9 显示,因为添加的时候是按这个比例裁剪的,现在的显示比例不对。」
   → 详情页题图容器从 4:3 改成 16:9,与 ImageSlot 的 cover 槽位同一个数字
     (部署断言比对这两处,避免哪天改一处漏一处)。

2. 「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、一共几段行程,
     一共花了多少钱,这些信息,都从该活动下的所有行程统计总结,简介那一段话保留。」
   → 新增 Trip::summary():参与人(各段并集)/ 城市(去重)/ 天数 / 段数 / 总花销,
     全部读时算(与"时间城市派生"同一条原则,不存冗余)。
     天数按**日历天**含首尾(8/1→8/3 = 3 天,不是 24 小时整除的 2 天)。
     城市与时间从标题旁挪进信息区,简介保留。

3. 「活动页面,不需要上传图片,图片视频,都是针对一次具体的行程的。」
   → 删掉旅程页的照片上传板块与照片墙;照片一律挂在**行程**上
     (行程表单里有上传,附件 target_type='trip_node')。

4. 「计划行程、未成行的行程,都不要显示……只要是没有就是 0 条,那个类型就不显示了,
     现在显示的是 0 条,这样没意义。」
   → 空分组直接跳过不渲染;全空时给一句引导(否则新建旅程页面一片空白)。
     有内容的分组照常显示(计划 / 实际 / 未成行三种)。

5. 「行程的编辑功能,点击后会把页面撑破。编辑行程,打开新的页面进行编辑。」
   → 行程的新增与编辑改成**独立页面**(trips/node_new / trips/node_edit)。
     原来内联在详情页:N 份表单互相撞元素 id、密码管理器逐个扫描变卡,
     正是"把页面撑破"的成因。

6. 「添加计划行程和实际行程的按钮,都放在右上角。点击后打开新的页面进行添加。」
   → 详情页右上角两个按钮(+ 计划行程 / + 实际行程),指向独立新增页。
     顺带:标题旁边不再放时间(时间移到题图右侧信息区)。

7. 「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
   → 字段全量照搬 TripLog 的 nodes/form.php:
     类型 / 标题 / 起止时间 / 出行方式 + 车辆 / 线路名 / 承运方 / 车次航班号 /
     座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(含角色)/ 照片 / 花销。
     交通字段不做条件列(先填高铁车次、后来改成自驾,填过的东西不该丢)。

8. 「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
     注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加那里面的车辆,
     直接读取就可以,但是可以添加其他的车辆,比如租的车,借别人的车。」
   → 新增旅程设置菜单(三页):车辆管理 / 出行方式 / 节点类型。
     车辆**两个来源刻意不合并**:财务里 kind='car' 的账目(那是资产,没有车牌车型)
     由 Vehicle::fromLedger() 只读列出,选车下拉里标「来自财务」;
     自家 / 租的 / 借的车录进 vehicles 表。不复制财务的车(避免同一台车两处维护)。
     节点类型做成**可自定义**的表(TripLog 同款),内置 5 种不可删;
     行程里存类型**名字**,所以改类型名会级联更新已用它的行程。

9. 「活动都改名吧,改成旅程,新建旅程,旅程包含行程,这个名字就固定下来。」
   → 术语固定:**旅程**(容器)包含**行程**(单元)。
     顶层按钮「新建旅程」、详情页三组叫「计划的行程 / 实际行程 / 未成行」、
     列表「N 段行程」、设置菜单「旅程设置」。上一批的"活动"叫法全部换掉。

10. 「新建旅程里面,怎么还有备注啊,同行的人啊,这些都不要了,这些都是从行程里面
      总结出来的。在新建旅程和编辑旅程的时候,都不要了。」
   → 旅程表单只剩:标题 → 题图 → 简介 + 公开开关。
     备注与同行的人全部移除(参与人 = 各段行程参与人的合集)。
     ⚠️ 但**老数据里活动级那份参与人名单不能被清空**(那时还没有行程级参与人),
     所以路由"没传这个键就不动",并加了断言钉住这一点。

数据结构(Schema v21,老库自动升级):
· 新表 vehicles(自家/租的/借的)、node_types(可自定义)、trip_expenses(行程级花销)
· trip_nodes 补 9 列:出行方式 / 车辆 / 线路名 / 承运方 / 车次 / 座位 / 登机口 / 起终点
· trip_node_members 补 role_tag(司机 / 摄影担当这类角色)
· 花销金额存**分**,解析走字符串拆小数点(128.55*100 在浮点下会差一分)

验证:e2e 891/0(+46 条本批断言)· 升级 134/0 · 桌面几何 428/0 · 交互 92/0 ·
窄屏溢出 30/0(扩到行程表单 + 设置三页 + 详情页)· 题图点击 4/0 · 静态体检全绿。
**反向验证 6/6**:题图退回 4:3、标题旁加回时间、空分组照旧渲染、编辑退回非独立页、
车辆不读财务、总花销不汇总 —— 六种坏法全部被断言抓住。
部署静态断言新增第 16 批一组(含"空分组不许渲染""行程页不许有照片上传"
"旅程表单不许有备注与同行的人""题图必须 16:9"等反退化防线)。
部署后服务器自检 19/19,六个关键文件 md5 与线上逐一核对一致。
This commit is contained in:
2026-10-05 13:26:49 +08:00
parent 7138314dc7
commit 2e51d3f5e4
16 changed files with 2265 additions and 456 deletions
+182
View File
@@ -0,0 +1,182 @@
<?php
/**
* 行程花销(v24 第 16 批 用户第 2 / 7 条)。
*
* 用户第 2 条要旅程页显示"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
* TripLog 的花销就是**挂在节点上**的一笔一笔。本次确认:「行程级花销记录」。
*
* 设计取舍:
* · 一行 = 一段行程里的一笔花销(谁付的 / 多少 / 什么类别 / 说明);
* · **旅程的总花销 = 它下面所有行程的花销之和** —— 读时算,不存冗余字段。
* 存了就要在每次增删改花销后同步维护,"漏同步"是迟早的事(这个项目里
* 同一类错误已经犯过好几次:参与人、时间、城市都是这么改成派生值的)。
* · **不做分摊**(TripLog 有 expense_splits):家庭出行记"一共花了多少"就够,
* 谁欠谁那是财务模块的事,塞进旅行里会让记账变成负担。
* · 金额存**分**(与财务 ledger_entries 同一口径):浮点数做加法迟早差一分钱。
*/
declare(strict_types=1);
namespace App;
final class Expense
{
/** 花销类别(家庭出游够用;TripLog 的类别也差不多是这几种) */
public const CATS = [
'meal' => '餐饮',
'ticket' => '门票',
'traffic' => '交通',
'stay' => '住宿',
'shop' => '购物',
'other' => '其他',
];
/** 一段行程的花销(按时间正序) */
public static function ofNode(int $nodeId): array
{
$st = Database::get()->prepare(
'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color
FROM trip_expenses e
LEFT JOIN persons p ON p.id = e.person_id
WHERE e.node_id = ? ORDER BY e.created_at, e.id'
);
$st->execute([$nodeId]);
return $st->fetchAll();
}
/**
* 一段行程的花销合计(单位:分)。
*
* ⚠️ COALESCE 不能省:没有任何花销时 SUM 返回 NULL,直接拿去 number_format
* 会在 PHP 8 上报"传 null 给 float 参数"的弃用告警(页面上会冒一行 Warning)。
*/
public static function sumOfNode(int $nodeId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(SUM(amount_cents), 0) FROM trip_expenses WHERE node_id = ?'
);
$st->execute([$nodeId]);
return (int)$st->fetchColumn();
}
/** 一段旅程(活动)的总花销:它下面**所有**行程之和(用户第 2 条) */
public static function sumOfEvent(int $eventId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(SUM(e.amount_cents), 0)
FROM trip_expenses e
JOIN trip_nodes n ON n.id = e.node_id
WHERE n.event_id = ?'
);
$st->execute([$eventId]);
return (int)$st->fetchColumn();
}
/**
* 给一批行程批量补"这一段花了多少"(列表/详情用;N+1 在几十段时很明显)。
*
* @param array $rows 行程数组(会被就地补上 `spent_cents`)
*/
public static function attachSums(array &$rows): void
{
$ids = [];
foreach ($rows as $r) {
$id = (int)($r['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
$sum = [];
foreach ($ids as $id) {
$sum[$id] = 0;
}
if ($ids) {
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT node_id, COALESCE(SUM(amount_cents),0) AS s
FROM trip_expenses WHERE node_id IN ($in) GROUP BY node_id"
);
$st->execute($ids);
foreach ($st->fetchAll() as $r) {
$sum[(int)$r['node_id']] = (int)$r['s'];
}
}
foreach ($rows as &$r) {
$r['spent_cents'] = $sum[(int)$r['id']] ?? 0;
}
unset($r);
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function add(int $familyId, int $nodeId, array $d): array
{
$cents = self::parseAmount((string)($d['amount'] ?? ''));
if ($cents === null) {
return ['ok' => false, 'error' => '金额填得不对(例如 128 或 128.50)'];
}
$title = mb_substr(trim((string)($d['title'] ?? '')), 0, 200, 'UTF-8');
$cat = isset(self::CATS[(string)($d['category'] ?? '')]) ? (string)$d['category'] : 'other';
$pid = (int)($d['person_id'] ?? 0);
if ($pid > 0) {
// 只认本家的人(表单可以改,不能凭它把别人家的人在页面上引出来)
$st = Database::get()->prepare('SELECT id FROM persons WHERE id = ? AND family_id = ?');
$st->execute([$pid, $familyId]);
if ($st->fetchColumn() === false) {
$pid = 0;
}
}
$db = Database::get();
$db->prepare(
'INSERT INTO trip_expenses (family_id, node_id, person_id, amount_cents, category, title)
VALUES (?,?,?,?,?,?)'
)->execute([$familyId, $nodeId, $pid > 0 ? $pid : null, $cents, $cat,
$title !== '' ? $title : null]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/** 删一笔(校验它确实属于这个家的某段行程) */
public static function destroy(int $familyId, int $id): bool
{
$st = Database::get()->prepare('SELECT id FROM trip_expenses WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
if ($st->fetchColumn() === false) {
return false;
}
Database::get()->prepare('DELETE FROM trip_expenses WHERE id = ?')->execute([$id]);
return true;
}
// ---------------- 展示 ----------------
/** 分 → 人话(¥1,280 / ¥12.50;金额为零显示 ¥0) */
public static function money(int $cents): string
{
$neg = $cents < 0;
$v = abs($cents) / 100;
// 整数金额不带小数尾巴(¥1280 比 ¥1,280.00 好读);有角分才显示两位
$s = (fmod($v, 1.0) === 0.0) ? number_format($v) : number_format($v, 2);
return ($neg ? '-¥' : '¥') . $s;
}
/**
* 表单里那个金额字符串 → 分。
*
* ⚠️ 不用 `(float)` 直接乘 100:`128.55 * 100` 在浮点下是 12854.999…,
* `(int)` 一截就变成 12854(差一分钱)。这里走**字符串**拆小数点,
* 整数部分与小数部分分开算,没有浮点参与。
*/
public static function parseAmount(string $raw): ?int
{
$raw = trim(str_replace([',', ',', '¥', '¥', ' '], '', $raw));
if ($raw === '') {
return null;
}
if (!preg_match('/^(-?)(\d+)(?:\.(\d{1,2}))?$/', $raw, $m)) {
return null;
}
$yuan = (int)$m[2];
$cent = isset($m[3]) ? (int)str_pad($m[3], 2, '0') : 0;
$total = $yuan * 100 + $cent;
return $m[1] === '-' ? -$total : $total;
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
/**
* 行程节点类型(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「右上角的按钮,需要增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。」
*
* 为什么从"写死的常量"改成"可自定义的表":
* 用户第 7 条要求照 TripLog 的添加节点功能做,TripLog 的节点类型本来就是
* 一张用户可增删的表(系统内置几种 + 自己加)。家庭出游的场景差异很大:
* 有人要「赶海」有人要「探亲」,写死的五种永远差一点。
*
* ⚠️ 与 `Trip::NODE_TYPES` 常量的关系:
* 常量是**初始值与兜底**(表还没建、或某个 type 值已从表里删掉但老数据还在用它),
* 表是"用户改过的真相"。读取一律走 `all()`,它保证表里至少有内置那几种。
* 老数据里的 `trip_nodes.node_type` 存的是 **key**('play'/'meal'…),
* 用户自定义的类型 key 就用 `c<id>` 这种形态,与内置的英文 key 不会撞。
*/
declare(strict_types=1);
namespace App;
final class NodeType
{
/** 内置类型:key => [名字, 图标](与 Trip::NODE_TYPES 保持一致,是初始值) */
public const BUILTIN = [
'play' => ['游玩', '🎡'],
'meal' => ['用餐', '🍜'],
'move' => ['赶路', '🚗'],
'stay' => ['住宿', '🏨'],
'other' => ['其他', '📌'],
];
/**
* 一个家的全部类型(表里没有就先把内置的预置进去)。
*
* ⚠️ 预置放在**读**里而不是只放在安装/升级里:升级步骤是容错的
* (`tryStep`),万一那一步在某个老库上失败了,这里还能自愈 ——
* 否则"类型"是新建行程的必填项,缺了它整个"添加行程"都用不了。
*/
public static function all(int $familyId): array
{
self::seedDefaults($familyId);
$st = Database::get()->prepare(
'SELECT id, name, icon, is_system, position FROM node_types
WHERE family_id = ? ORDER BY position, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 把内置类型补进表里(幂等:按 (family_id, name) 唯一键查过再插)。
*
* @return int 补了几条
*/
public static function seedDefaults(int $familyId): int
{
$db = Database::get();
// 表可能还不存在(老库升级跑到这一步之前 / 极少数失败情形)→ 直接返回,
// 别让"读列表"这个动作本身抛异常把页面炸掉。
try {
$n = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
$has = $n;
} catch (\Throwable $e) {
return 0;
}
if ($has >= count(self::BUILTIN)) {
return 0; // 已经有内置那几条了,不必逐个查(热点路径)
}
$hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
// ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个),
// 每次读都补回去会让人永远删不掉系统内置的那几种。
if ($hasAny > 0) {
return 0;
}
$st = $db->prepare(
'INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,1,?)'
);
$pos = 10;
$n = 0;
foreach (self::BUILTIN as $k => $v) {
$st->execute([$familyId, $v[0], $v[1], $pos]);
$pos += 10;
$n++;
}
return $n;
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/** 同名判重(返回已存在的 id,没有则 0) */
public static function findByName(int $familyId, string $name): int
{
$st = Database::get()->prepare(
'SELECT id FROM node_types WHERE family_id = ? AND name = ? LIMIT 1'
);
$st->execute([$familyId, trim($name)]);
return (int)($st->fetchColumn() ?: 0);
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function save(int $familyId, array $d, ?int $id = null): array
{
$name = mb_substr(trim((string)($d['name'] ?? '')), 0, 30, 'UTF-8');
if ($name === '') {
return ['ok' => false, 'error' => '类型名字不能为空'];
}
$icon = mb_substr(trim((string)($d['icon'] ?? '')), 0, 20, 'UTF-8');
$db = Database::get();
$dup = self::findByName($familyId, $name);
if ($dup > 0 && $dup !== $id) {
return ['ok' => false, 'error' => '已经有一个叫「' . $name . '」的类型了'];
}
if ($id !== null && $id > 0) {
$old = self::find($familyId, $id);
if (!$old) {
return ['ok' => false, 'error' => '找不到这个类型'];
}
$oldName = (string)$old['name'];
$db->prepare('UPDATE node_types SET name=?, icon=? WHERE id=? AND family_id=?')
->execute([$name, $icon !== '' ? $icon : null, $id, $familyId]);
// ⚠️ 改名必须**级联**到已经在用它的行程:`trip_nodes.node_type` 存的是
// **名字**(不是 id)。只改类型表的话,所有老行程都会变成
// "一个类型表里不存在的值" → 图标与名称显示双双落回"其他",
// 用户看到的是"我一改名,历史行程的类型全没了"。
if ($oldName !== $name) {
$db->prepare('UPDATE trip_nodes SET node_type = ? WHERE family_id = ? AND node_type = ?')
->execute([$name, $familyId, $oldName]);
}
return ['ok' => true, 'id' => $id];
}
$pos = (int)($db->query('SELECT COALESCE(MAX(position),0)+10 FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn());
$db->prepare('INSERT INTO node_types (family_id, name, icon, is_system, position) VALUES (?,?,?,0,?)')
->execute([$familyId, $name, $icon !== '' ? $icon : null, $pos]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 删一个类型。
*
* ⚠️ 内置的**不许删**:它们被 `node_type` 的兜底逻辑引用(表空时会重新预置),
* 删了会立刻又冒出来,看起来像"删除没生效"。要停用请改名或忽略。
* ⚠️ 还在被行程使用的也不许删:行程里存的是**名字**(见 usedCount),
* 删了类型定义,那些行程的显示就会落回"其他"—— 数据没丢,但用户会以为丢了。
* 所以这里给出"还有 N 段行程在用"的明确理由,而不是静默失败。
*/
public static function destroy(int $familyId, int $id): array
{
$t = self::find($familyId, $id);
if (!$t) {
return ['ok' => false, 'error' => '找不到这个类型'];
}
if ((int)$t['is_system'] === 1) {
return ['ok' => false, 'error' => '内置类型不能删除(可以改名)'];
}
$n = self::usedCount($familyId, (string)$t['name']);
if ($n > 0) {
return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这个类型,先改掉它们再删'];
}
Database::get()->prepare('DELETE FROM node_types WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
return ['ok' => true];
}
/** 有多少段行程在用这个类型名 */
public static function usedCount(int $familyId, string $name): int
{
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM trip_nodes WHERE family_id = ? AND node_type = ?'
);
$st->execute([$familyId, $name]);
return (int)$st->fetchColumn();
}
/**
* 类型名 → 图标。行程里存的是名字,图标随类型定义走。
* 认不出(类型被删/老数据是英文 key)时返回内置兜底。
*/
public static function iconOf(int $familyId, string $name): string
{
static $cache = [];
if (!isset($cache[$familyId])) {
$map = [];
foreach (self::all($familyId) as $t) {
$map[(string)$t['name']] = (string)($t['icon'] ?? '');
}
$cache[$familyId] = $map;
}
$icon = $cache[$familyId][$name] ?? '';
if ($icon !== '') {
return $icon;
}
// 老数据用的是英文 key(play/move/…)→ 回落到内置那张表
return self::BUILTIN[$name][1] ?? '📌';
}
}
+126 -1
View File
@@ -80,7 +80,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '20';
public const VERSION = '21';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -730,6 +730,92 @@ CREATE TABLE IF NOT EXISTS `book_notes` (
CONSTRAINT `fk_bn_book` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bn_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='读书笔记 / 划线 / 书评(按人记)'
SQL,
// ---- 车辆库(版本 21,用户 2026-10-05 第 16 批第 8 条)----
// 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
// 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复
// 添加那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
// 比如租的车,借别人的车。」
//
// ⚠️ 车辆有**两个来源**,这里刻意**不合并**:
// ① **财务里的车子**(ledger_entries.kind='car' 的账目)—— 那是"资产",
// 记的是这台车值多少钱,不是"出行时可挑的一辆车"(没有车牌、没有车型名)。
// 这里**读它**、在选车时列出来(只读引用),但绝不复制一份到本表 ——
// 复制就变成同一台车两处维护,改了财务这边不同步。
// ② **本表** —— "出行时会用到的车",重点是车牌 + 车型名 + 是租的还是借的。
// 自家车在这里也**可以**录一份(干净、可挑),但不强制:
// 不录也能出行(只是选车时没得挑而已)。
// 所以"租的车 / 借别人的车"是本表的常客,`vehicle_type` 才需要 rental/borrowed
// 这两种;自家车与财务的对齐靠**车型名 + 车牌**人工对上,不做外键硬绑
// (账目随时可能被删/改名,硬绑会让车辆跟着消失)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `vehicles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`plate` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '车牌号(借的车/租的车可能没记全,允许空)',
`name` VARCHAR(100) NOT NULL COMMENT '车型名,如「大众朗逸」「丰田卡罗拉」',
`vtype` ENUM('own','rental','borrowed') NOT NULL DEFAULT 'own'
COMMENT 'own=自家的 / rental=租的 / borrowed=借别人的',
`vendor` VARCHAR(100) DEFAULT NULL COMMENT '租车公司(vtype=rental 时)或车主(borrowed 时)',
`order_no` VARCHAR(50) DEFAULT NULL COMMENT '租车订单号',
`ledger_id` INT UNSIGNED DEFAULT NULL COMMENT '关联财务里那条 kind=car 的账目(软关联,可空;账目删了这里只是记不住,不影响车辆)',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '默认车(新建行程选车时默认选中它)',
`note` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_veh_family` (`family_id`, `vtype`),
CONSTRAINT `fk_veh_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出行用的车辆(自家 / 租的 / 借的);财务里的车子是资产,不复制进来'
SQL,
// ---- 行程节点类型(版本 21,第 16 批第 8 条 + TripLog 同款)----
// 用户第 7 条要求「照 TripLog 的添加节点功能」,TripLog 的节点类型
// 就是一张**可自定义**的表(游玩/用餐/赶路/休息/突发…),不是写死的枚举。
// ⚠️ 与旧代码里那个 `Trip::NODE_TYPES` 常量并存一段:常量当**兜底**
// (老库/新装库都要有初始值),表是"用户改过的真相"。
// 读取一律 `NodeType::all()`(表空时自动回落到常量并预置进表)。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `node_types` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(30) NOT NULL COMMENT '游玩 / 用餐 / 赶路 / 住宿 / 休息 / 突发…',
`icon` VARCHAR(20) DEFAULT NULL COMMENT '一个表情符号',
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '内置类型(预置的那几种),不允许删',
`position` INT NOT NULL DEFAULT 100,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_nt_family_name` (`family_id`, `name`),
CONSTRAINT `fk_nt_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点类型库(可自定义)'
SQL,
// ---- 行程花销(版本 21,第 16 批第 2/7 条)----
// 用户第 2 条要"一共花了多少钱",第 7 条要求照 TripLog 的节点功能,
// 而 TripLog 的花销是**挂在节点上**的一笔一笔(谁付的 / 多少 / 什么类别)。
// ⚠️ 用户明确"行程级花销记录"(本次确认),所以:
// · 一行 = 一段行程里的一笔花销;
// · 旅程的总花销 = 它下面所有行程的花销之和(读时算,不存冗余)。
// ⚠️ 不做 `expense_splits`(分摊):家庭出行记"一共花了多少"就够,
// 谁欠谁那是财务模块的事,硬塞进旅行里会让记账变成负担。
<<<'SQL'
CREATE TABLE IF NOT EXISTS `trip_expenses` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`family_id` INT UNSIGNED NOT NULL,
`node_id` INT UNSIGNED NOT NULL COMMENT '属于哪一段行程',
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '谁付的钱(可空)',
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额,单位**分**(与财务同一口径,避免浮点误差)',
`currency` VARCHAR(10) NOT NULL DEFAULT 'CNY',
`category` VARCHAR(30) DEFAULT NULL COMMENT '餐饮 / 门票 / 交通 / 购物 / 其他',
`title` VARCHAR(200) DEFAULT NULL COMMENT '消费说明',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_te_node` (`node_id`),
KEY `idx_te_family` (`family_id`),
CONSTRAINT `fk_te_node` FOREIGN KEY (`node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)'
SQL,
];
}
@@ -981,6 +1067,45 @@ SQL,
}
});
// ---- v24 第 16 批:行程对齐 TripLog(Schema v21)----
// 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的添加节点功能,跟那个一样。」
// TripLog 的节点比 FamilyBook 现有的"行程"多这些:
// 出行方式(走路/开车/高铁/飞机…)+ 关联车辆、车次/航班号、座位/车厢、
// 登机口/站台、起终点位置(带坐标)、线路名/承运公司。
// 这些一律**补列**到 trip_nodes 上(不是另建卫星表):
// ① 它们与行程是 1:1(一段行程一种出行方式),卫星表只是多一次 JOIN;
// ② 旅行模块里"这一段怎么到的"是**必看信息**,不是可有可无的附加物。
self::addColumn($db, 'trip_nodes', 'travel_mode',
"ADD COLUMN `travel_mode` VARCHAR(20) DEFAULT NULL COMMENT '出行方式:walk/drive/bus/subway/train/plane/ship/taxi/bike/other' AFTER `place`");
self::addColumn($db, 'trip_nodes', 'vehicle_id',
"ADD COLUMN `vehicle_id` INT UNSIGNED DEFAULT NULL COMMENT '关联车辆(仅自驾:自家/租的/借的)' AFTER `travel_mode`");
self::addColumn($db, 'trip_nodes', 'line_name',
"ADD COLUMN `line_name` VARCHAR(100) DEFAULT NULL COMMENT '线路名,如「京沪高铁」「地铁 3 号线」' AFTER `vehicle_id`");
self::addColumn($db, 'trip_nodes', 'carrier',
"ADD COLUMN `carrier` VARCHAR(100) DEFAULT NULL COMMENT '承运方,如「中国铁路」「滴滴」' AFTER `line_name`");
self::addColumn($db, 'trip_nodes', 'trip_no',
"ADD COLUMN `trip_no` VARCHAR(50) DEFAULT NULL COMMENT '车次 / 航班号 / 线路号' AFTER `carrier`");
self::addColumn($db, 'trip_nodes', 'seat_info',
"ADD COLUMN `seat_info` VARCHAR(100) DEFAULT NULL COMMENT '座位号 / 车厢' AFTER `trip_no`");
self::addColumn($db, 'trip_nodes', 'gate',
"ADD COLUMN `gate` VARCHAR(20) DEFAULT NULL COMMENT '登机口 / 站台' AFTER `seat_info`");
self::addColumn($db, 'trip_nodes', 'from_place',
"ADD COLUMN `from_place` VARCHAR(200) DEFAULT NULL COMMENT '出发地(赶路段的起点)' AFTER `gate`");
self::addColumn($db, 'trip_nodes', 'to_place',
"ADD COLUMN `to_place` VARCHAR(200) DEFAULT NULL COMMENT '到达地(赶路段的终点)' AFTER `from_place`");
// 车型/车次这类字段只在"赶路"时有意义,但**不做条件列**(列固定、界面按类型显隐),
// 免得"换个出行方式就把填过的车次弄丢"。
self::addColumn($db, 'trip_node_members', 'role_tag',
"ADD COLUMN `role_tag` VARCHAR(30) DEFAULT NULL COMMENT '角色标签,如「司机」「摄影担当」' AFTER `person_id`");
// 车辆库三张表在 statements() 里(IF NOT EXISTS 幂等),老库由那条统一建。
// 但**预置节点类型**要显式跑一次:老库建完表是空的,界面上会一个类型都没有
// ("类型"是新建行程的必填项 → 直接卡死,人以为功能坏了)。
self::tryStep('预置行程节点类型', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\NodeType::seedDefaults((int)$fam['id']);
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',
+219 -17
View File
@@ -41,6 +41,32 @@ final class Trip
'cancelled' => '已取消',
];
/**
* 出行方式(v24 第 16 批 第 7 条,照 TripLog 的 travel_modes)。
*
* ⚠️ 这里存的是**枚举 key**(不是 TripLog 那种另一张可配置的表):
* 出行方式是**固定的物理事实**(走路/开车/高铁/飞机…),
* 不像"节点类型"那样因人而异 —— 没人需要自定义一种"新的交通方式"。
* 用户第 8 条要的"出行方式设置",实际要设的是**每种方式下的常用项**
* (比如自己常坐的车次/常用航空公司),那是"记忆上次填的"而不是"新增方式",
* 见 Setting 里那几条 last_* 记忆。所以这里用常量,界面那页做的是"提示 + 记忆"。
*/
public const TRAVEL_MODES = [
'walk' => ['步行', '🚶'],
'bike' => ['骑行', '🚲'],
'drive' => ['自驾', '🚗'],
'taxi' => ['打车', '🚕'],
'bus' => ['公交', '🚌'],
'subway' => ['地铁', '🚇'],
'train' => ['火车', '🚄'],
'plane' => ['飞机', '✈️'],
'ship' => ['轮船', '🚢'],
'other' => ['其他', '🧭'],
];
/** 自驾类(这两种才需要选车辆) */
public const DRIVE_MODES = ['drive', 'taxi'];
/**
* 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。
*
@@ -452,7 +478,15 @@ final class Trip
]);
$id = (int)$db->lastInsertId();
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
// ⚠️ 只有**真的传了** attendees 才动这份名单。
// v24 第 16 批 用户第 10 条之后,旅程表单里已经没有"同行的人"字段了 ——
// 如果这里写成 `$d['attendees'] ?? []`,每次编辑旅程都会把
// **活动级**的老名单整个清空(老数据里很多人的参与人只登记在这一层,
// 那时还没有行程级参与人)→ 用户看到的是"我一编辑,参与人全没了"。
// 新数据的参与人从行程里长出来(tripPeople 的合集口径),不受影响。
if (array_key_exists('attendees', $d)) {
self::saveAttendees($id, (array)$d['attendees']);
}
return $id;
}
@@ -471,7 +505,10 @@ final class Trip
$id, $familyId,
]);
self::saveDetails($id, $d);
self::saveAttendees($id, $d['attendees'] ?? []);
// ⚠️ 同 create:没传就不动(见上面那段说明)
if (array_key_exists('attendees', $d)) {
self::saveAttendees($id, (array)$d['attendees']);
}
}
private static function saveDetails(int $eventId, array $d): void
@@ -561,6 +598,8 @@ final class Trip
$n['attachs'] = $attach[(int)$n['id']] ?? [];
}
unset($n);
// 每段花了多少(用户第 2 条:总花销 = 各段之和;这里是各段的那个"段值")
\App\Expense::attachSums($rows);
return $rows;
}
@@ -572,6 +611,21 @@ final class Trip
return $r ?: null;
}
/**
* 保存一段行程(新增 / 编辑)。
*
* v24 第 16 批 用户第 7 条:「添加和编辑行程的内容,你去找 triplog 里面的
* 添加节点功能,跟那个一样」→ 字段**全量照搬**:
* 类型 / 标题 / 起止时间 / 地点 / 出行方式 + 车辆 / 线路名 / 承运方 /
* 车次航班号 / 座位 / 登机口 / 出发地 / 到达地 / 内容 / 参与人(+角色)/ 照片。
* (花销走 Expense 单独增删,不在这张表单里一并提交:一笔一笔加更符合实际,
* 也避免"提交一次行程就要把整串花销重算一遍"。)
*
* ⚠️ 交通类字段(车次/座位/登机口…)**不做条件列**:用户可能先填了高铁车次,
* 后来把出行方式改成自驾 —— 那时按逻辑该清空车次,但"清空"意味着丢数据。
* 这里一律**存下来**,界面按出行方式决定显示哪几个(详情页只显示有值的 +
* 该出行方式相关的),这样换个方式再换回来,填过的东西还在。
*/
public static function saveNode(int $familyId, int $eventId, array $d, ?int $id = null): int
{
$db = Database::get();
@@ -582,35 +636,79 @@ final class Trip
// ⚠️ category 只作**兼容保留**(老代码/导出还会读它),业务一律看 stage。
// 这里同步维护,保证两边不打架:stage=actual → reality,其余 → plan。
$category = $stage === 'actual' ? 'reality' : 'plan';
// 车辆:只认本家的(负数/0 是"来自财务的引用",那不是本表的行,存不进去)
$vid = (int)($d['vehicle_id'] ?? 0);
if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) {
$vid = 0;
}
$fields = [
self::textOrNull2($d['title'] ?? ''),
self::textOrNull($d['detail'] ?? ''),
self::textOrNull($d['place'] ?? ''),
self::dtOrNull($d['starts_at'] ?? ''),
self::dtOrNull($d['ends_at'] ?? ''),
in_array($d['node_type'] ?? '', array_keys(self::NODE_TYPES), true) ? $d['node_type'] : 'other',
self::nodeType($familyId, $d['node_type'] ?? ''),
in_array($d['status'] ?? '', array_keys(self::NODE_STATUS), true) ? $d['status'] : 'planned',
$category,
(int)($d['position'] ?? 100),
$stage,
// ---- v21 新增:交通与起终点(TripLog 同款)----
isset(self::TRAVEL_MODES[(string)($d['travel_mode'] ?? '')]) ? (string)$d['travel_mode'] : null,
$vid > 0 ? $vid : null,
self::textOrNull2($d['line_name'] ?? ''),
self::textOrNull2($d['carrier'] ?? ''),
self::textOrNull($d['trip_no'] ?? ''),
self::textOrNull($d['seat_info'] ?? ''),
self::textOrNull($d['gate'] ?? ''),
self::textOrNull2($d['from_place'] ?? ''),
self::textOrNull2($d['to_place'] ?? ''),
];
$cols = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?';
if ($id !== null && $id > 0) {
$db->prepare(
'UPDATE trip_nodes SET title=?, detail=?, place=?, starts_at=?, ends_at=?,
node_type=?, status=?, category=?, position=?, stage=? WHERE id=?'
)->execute([...$fields, $id]);
$db->prepare('UPDATE trip_nodes SET ' . $cols . ' 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, stage) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
node_type, status, category, position, stage, travel_mode, vehicle_id, line_name,
carrier, trip_no, seat_info, gate, from_place, to_place)
VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')'
)->execute([$familyId, $eventId, ...$fields]);
$nid = (int)$db->lastInsertId();
}
self::saveNodeMembers($nid, $d['members'] ?? []);
self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []);
return $nid;
}
/**
* 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。
*
* ⚠️ 为什么存名字而不是 id:用户把类型改名时要级联到所有行程
* (NodeType::save 里做了),存名字让"改名"是一句 UPDATE;
* 存 id 则要在每次显示时 JOIN,而且删除类型会让老行程显示成"未知"。
* 兼容老数据:英文 key(play/move/…)原样保留 —— NodeType::iconOf 认得它。
*/
private static function nodeType(int $familyId, string $v): string
{
$v = trim($v);
if ($v === '') {
return 'other';
}
if (isset(self::NODE_TYPES[$v])) {
return $v; // 老数据的英文 key
}
// 是否是本家定义过的类型名(含刚预置的那几种)
foreach (\App\NodeType::all($familyId) as $t) {
if ((string)$t['name'] === $v) {
return $v;
}
}
return 'other';
}
// ---------------- 计划行程的三个动作(v24 第 13 批) ----------------
//
// 用户原话:「如果是计划行程,则这个行程下面有几个按钮,用来进一步操作」
@@ -780,7 +878,8 @@ final class Trip
public static function nodeMembersFor(int $nodeId, int $viewerId): array
{
$st = Database::get()->prepare(
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname
'SELECT p.id, p.name, p.avatar, p.avatar_color, n.nickname AS my_nickname,
m.role_tag
FROM trip_node_members m JOIN persons p ON p.id = m.person_id
LEFT JOIN nicknames n ON n.target_id = p.id AND n.viewer_id = ?
WHERE m.node_id = ? ORDER BY p.id'
@@ -789,17 +888,79 @@ final class Trip
return $st->fetchAll();
}
public static function saveNodeMembers(int $nodeId, array $personIds): void
/**
* 存一段行程的参与人(整组替换)。
*
* @param array $roles 可选:person_id => 角色标签(TripLog 的"司机/摄影担当");
* 不传就是没有角色,与老调用完全兼容。
*/
public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void
{
$db = Database::get();
$db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]);
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id) VALUES (?,?)');
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag)
VALUES (?,?,?)');
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid > 0) {
$ins->execute([$nodeId, $pid]);
if ($pid <= 0) {
continue;
}
$role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8');
$ins->execute([$nodeId, $pid, $role !== '' ? $role : null]);
}
}
/**
* 一段旅程(活动)的**汇总信息**(v24 第 16 批 用户第 2 条)。
*
* 用户原话:「题图的右侧,显示的是参与人都有谁、去了几个城市、一共去了几天、
* 一共几段行程,一共花了多少钱,这些信息,都从该活动下的所有行程
* 统计总结。」
*
* 五项全是从行程**算出来**的(与"时间/城市派生"同一条原则,不存冗余):
* · people —— 各段行程参与人的并集(就是 tripPeople,已有口径)
* · cities —— 各段行程去过的地点去重(未成行段不算)
* · days —— 从最早一段的开始 到 最晚一段的结束,共几天(含首尾;
* 跨天算天,同一天往返算 1 天)
* · nodes —— 行程段数(**只数会显示的**:计划 + 实际;未成行的单独给一个数)
* · cents —— 各段行程花销之和
*
* ⚠️ days 的算法要说清楚,否则"3 天"到底是几天会各人一套算法:
* 按**日历天**算(8/1 14:00 出发、8/3 10:00 回来 = 3 天),
* 不是按 24 小时整除(那样会算成 2 天,与人的直觉不符)。
*/
public static function summary(int $eventId, int $viewerId = 0): array
{
$st = Database::get()->prepare(
"SELECT MIN(starts_at) AS start_at,
MAX(COALESCE(ends_at, starts_at)) AS end_at,
SUM(CASE WHEN stage IN ('plan','actual') THEN 1 ELSE 0 END) AS shown_n,
SUM(CASE WHEN stage = 'skipped' THEN 1 ELSE 0 END) AS skipped_n
FROM trip_nodes WHERE event_id = ?"
);
$st->execute([$eventId]);
$r = $st->fetch() ?: [];
$days = 0;
$start = (string)($r['start_at'] ?? '');
$end = (string)($r['end_at'] ?? '');
if ($start !== '') {
$a = strtotime(substr($start, 0, 10));
$b = strtotime(substr($end !== '' ? $end : $start, 0, 10));
if ($a !== false && $b !== false && $b >= $a) {
// 含首尾:8/1 → 8/3 是 3 天(floor(差/86400) + 1)
$days = (int)floor(($b - $a) / 86400) + 1;
}
}
return [
'people' => self::tripPeople($eventId, $viewerId),
'cities' => self::cities($eventId),
'days' => $days,
'nodes' => (int)($r['shown_n'] ?? 0),
'skipped' => (int)($r['skipped_n'] ?? 0),
'cents' => \App\Expense::sumOfEvent($eventId),
'spent' => \App\Expense::money(\App\Expense::sumOfEvent($eventId)),
];
}
// ---------------- 展示 ----------------
@@ -844,16 +1005,57 @@ final class Trip
return $s . ' – ' . date('Y年n月j日', $te);
}
public static function typeWord(string $key): string
/**
* 行程类型的**名字**。
*
* ⚠️ 两种取值来源,顺序不能反:
* ① 用户在设置里定义的类型表(`NodeType::all()`,按名字存进 trip_nodes)——
* 这是"用户改过的真相",优先级最高;
* ② 老数据的英文 key(play/move/…)→ 内置常量表。
* 少了 ① 就会把用户自定义的类型名显示成"其他"(看起来像类型丢了)。
*
* @param int $familyId 传 0 表示"只按内置表解析"(没有家庭上下文时用)
*/
public static function typeWord(string $key, int $familyId = 0): string
{
return self::NODE_TYPES[$key][0] ?? '其他';
if (isset(self::NODE_TYPES[$key])) {
return self::NODE_TYPES[$key][0];
}
if ($familyId > 0) {
foreach (\App\NodeType::all($familyId) as $t) {
if ((string)$t['name'] === $key) {
return $key;
}
}
}
return $key !== '' ? $key : '其他';
}
public static function typeIcon(string $key): string
public static function typeIcon(string $key, int $familyId = 0): string
{
if ($familyId > 0) {
return \App\NodeType::iconOf($familyId, $key);
}
return self::NODE_TYPES[$key][1] ?? '📌';
}
/** 出行方式的名字 / 图标 */
public static function modeWord(string $key): string
{
return self::TRAVEL_MODES[$key][0] ?? '';
}
public static function modeIcon(string $key): string
{
return self::TRAVEL_MODES[$key][1] ?? '🧭';
}
/** 这个出行方式要不要选车辆 */
public static function modeNeedsVehicle(string $key): bool
{
return in_array($key, self::DRIVE_MODES, true);
}
public static function statusWord(string $key): string
{
return self::NODE_STATUS[$key] ?? '计划中';
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* 车辆库(v24 第 16 批 用户第 8 条)。
*
* 用户原话:「增加一个设置按钮,用来设置车辆管理、出行方式、节点类型。
* 注意,我们在财务中,已经把车辆添加进去了,所以这里不用重复添加
* 那里面的车辆,直接读取就可以,但是可以添加其他的车辆,
* 比如租的车,借别人的车。」
*
* ⚠️ 车辆有**两个来源**,这里刻意**不合并**(合并 = 同一台车两处维护):
* ① 财务里的车子 —— `ledger_entries.kind='car'` 的那些**账目**。
* 它记的是"这台车值多少钱"(一台车的资产),**没有车牌、没有车型名**,
* 严格说不是"一辆能挑来出行的车"。用户说"直接读取就可以",
* 所以这里把它作为**只读候选**列出来(标成「来自财务」),
* 选中时只记一个名字,不往 vehicles 表里复制。
* ② vehicles 表 —— 真正"出行用的车":有车牌、车型名、是自家/租的/借的。
* 自家车在这里**可以**录(录了就干净、好挑),但**不强制**。
*
* 为什么不给两者加外键:账目随时可能被改名或删除(财务那边是它自己的节奏),
* 硬绑会让车辆记录跟着消失 —— 而出行的车不该因为"这台车在财务里被删了"就没了。
* 真要对上就靠车型名(人工),这是有意的松耦合。
*/
declare(strict_types=1);
namespace App;
final class Vehicle
{
/** 车辆来源:自家 / 租的 / 借别人的 */
public const TYPES = [
'own' => '自家的',
'rental' => '租的',
'borrowed' => '借的',
];
/** 一个家的车(vehicles 表里的) */
public static function all(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM vehicles WHERE family_id = ?
ORDER BY is_default DESC, vtype, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 财务里已登记的车子(**只读引用**,不复制进 vehicles)。
*
* 取的是所有账本里 kind='car' 的账目 —— 用户说"直接读取就可以"。
* 返回结构与自有车辆**同形**(id 用负数区分:`-账目id`,`from` 标来源),
* 这样模板里画下拉、画配置列表都能用同一套代码。
*/
public static function fromLedger(int $familyId): array
{
// ⚠️ 表可能还不存在(极老库 / 升级中途)→ 宽容返回空,别把"选车"整块炸掉
try {
$st = Database::get()->prepare(
"SELECT e.id, e.title, e.note FROM ledger_entries e
JOIN ledgers l ON l.id = e.ledger_id
WHERE l.family_id = ? AND e.kind = 'car'
ORDER BY e.id"
);
$st->execute([$familyId]);
} catch (\Throwable $ex) {
return [];
}
$out = [];
foreach ($st->fetchAll() as $r) {
$out[] = [
// 负数 id:与 vehicles 表的正数 id 天然不撞;选车表单提交负数即"来自财务"
'id' => -((int)$r['id']),
'ledger_id' => (int)$r['id'],
'name' => (string)$r['title'],
'plate' => '',
'vtype' => 'own',
'vendor' => null,
'order_no' => null,
'is_default'=> 0,
'note' => (string)($r['note'] ?? ''),
'from' => 'ledger', // 模板据此标「来自财务」并且不给改/删
];
}
return $out;
}
/**
* 选车下拉用的合并列表:财务的车子在前(用户已经录好了),自家车辆在后。
*
* @return array 每项都带 `from` = 'ledger' | 'local'
*/
public static function pickable(int $familyId): array
{
$out = [];
foreach (self::fromLedger($familyId) as $v) {
$out[] = $v;
}
foreach (self::all($familyId) as $v) {
$v['from'] = 'local';
$out[] = $v;
}
return $out;
}
public static function find(int $familyId, int $id): ?array
{
if ($id <= 0) {
return null; // 负数/0 是"来自财务的引用",不是本表的行
}
$st = Database::get()->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/** 「走到哪都显示得出来」的一行字:车牌 + 车型 */
public static function label(array $v): string
{
$plate = trim((string)($v['plate'] ?? ''));
$name = trim((string)($v['name'] ?? ''));
if ($plate !== '' && $name !== '') {
return $plate . ' ' . $name;
}
return $plate !== '' ? $plate : $name;
}
/** @return array{ok:bool,id?:int,error?:string} */
public static function save(int $familyId, array $d, ?int $id = null): array
{
$name = mb_substr(trim((string)($d['name'] ?? '')), 0, 100, 'UTF-8');
if ($name === '') {
return ['ok' => false, 'error' => '车型名不能为空'];
}
$vtype = isset(self::TYPES[(string)($d['vtype'] ?? '')]) ? (string)$d['vtype'] : 'own';
$row = [
mb_substr(trim((string)($d['plate'] ?? '')), 0, 20, 'UTF-8'),
$name,
$vtype,
self::txt($d['vendor'] ?? '', 100),
self::txt($d['order_no'] ?? '', 50),
(int)($d['is_default'] ?? 0) > 0 ? 1 : 0,
self::txt($d['note'] ?? '', 500),
];
$db = Database::get();
// ⚠️ 默认车**只能有一台**:把别的都清掉再设,否则"默认"就失去意义
// (新建行程时默认选中的是哪一台,取决于 SQL 的排序运气)。
if ($row[5] === 1) {
$db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ?')->execute([$familyId]);
}
if ($id !== null && $id > 0) {
if (!self::find($familyId, $id)) {
return ['ok' => false, 'error' => '找不到这辆车'];
}
$db->prepare('UPDATE vehicles SET plate=?, name=?, vtype=?, vendor=?, order_no=?,
is_default=?, note=? WHERE id=? AND family_id=?')
->execute([...$row, $id, $familyId]);
return ['ok' => true, 'id' => $id];
}
$db->prepare('INSERT INTO vehicles (family_id, plate, name, vtype, vendor, order_no,
is_default, note) VALUES (?,?,?,?,?,?,?,?)')
->execute([$familyId, ...$row]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/**
* 删一辆车。
*
* ⚠️ 只用**本表**的行能被删(负数 id 是财务的引用,删它等于动财务数据 —— 不干)。
* ⚠️ 已被行程引用的车不许删:删了行程上的 `vehicle_id` 就成了悬空值
* (外键是 SET NULL,数据不丢,但用户会看到"我明明选过车,现在没了")。
* 给一句"还有 N 段行程在用",比静默清空好。
*/
public static function destroy(int $familyId, int $id): array
{
if ($id <= 0) {
return ['ok' => false, 'error' => '来自财务的车子请到财务里改'];
}
if (!self::find($familyId, $id)) {
return ['ok' => false, 'error' => '找不到这辆车'];
}
$st = Database::get()->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?');
$st->execute([$id]);
$n = (int)$st->fetchColumn();
if ($n > 0) {
return ['ok' => false, 'error' => '还有 ' . $n . ' 段行程在用这辆车,先改掉它们再删'];
}
Database::get()->prepare('DELETE FROM vehicles WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
return ['ok' => true];
}
private static function txt($v, int $max): ?string
{
$v = mb_substr(trim((string)$v), 0, $max, 'UTF-8');
return $v !== '' ? $v : null;
}
}