182 lines
7.0 KiB
PHP
182 lines
7.0 KiB
PHP
<?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;
|
|||
|
|
}
|
|||
|
|
}
|