Files
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

182 lines
7.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}