Files
FamilyBook/app/Vehicle.php
T
laoyang 2e51d3f5e4 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 与线上逐一核对一致。
2026-10-05 13:26:49 +08:00

198 lines
8.2 KiB
PHP

<?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;
}
}