Files
TripLog/src/Expenses.php
T
laoyang 48237c8947 节点详情:照片灯箱浏览、参与人点击编辑、花销只记总计
1) 照片与附件不再新开窗口看
   之前点缩略图直接 target=_blank 打开原始文件,看完还得切回来。
   现在统一走当前页灯箱(templates/_lightbox.php,详情页照片墙与
   照片管理页共用):左右箭头 / 键盘 ←→ / 手机滑动切上一张下一张,
   Esc 或点背景关闭,带「第几张 / 共几张」计数并预加载相邻两张。
   非图片附件(PDF 等)仍保留直接打开与下载。

2) 参与人可以直接改了
   之前加完只能移出重加。现在每个参与人旁有「编辑」,点开预填弹窗
   (角色标签 / 心情 / 签到时间),保存仍走 members/store ——
   Members::save() 本来就是 upsert(同节点同人一条),服务端零改动。
   失败回填按 user_id 精确匹配,只回填刚才没保存成的那个人。

3) 花销去掉预算,只记真实花费
   明细上移,底部一行「总计(N 笔)¥X」。表单不再有「预算」选项,
   查询一律 is_budget=0(历史预算行不再出现在列表与合计里);
   活动页统计同步改成「共 N 笔 · 总计」。列保留在库里不删数据。

已验证:本地全链 11 步全绿(php -l / 渲染 286 项 / SQL / PDO / CSS /
Mime / amap / 内联 JS×11 / 布局几何 / 表单交互 73 项);远端 e2e
296 项 0 失败(含灯箱、编辑弹窗、历史预算行排除的专项断言);
部署后双 PHP 渲染逐字节一致、60 文件哈希一致,线上 style.css 含新规则。
2026-09-23 14:59:02 +08:00

198 lines
7.6 KiB
PHP
Raw 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
declare(strict_types=1);
namespace App;
/**
* 花销与分摊
*
* 表结构:expenses(挂在 trip_nodes 上)+ expense_splits(这笔钱按人分摊,谁承担多少)
*
* ⚠️ 历史上 expenses 有 is_budget(预算 / 实际)之分,用户明确反馈「预算没有意义」,
* 现在只记真实花销:查询一律 is_budget = 0,表单也不再提供「预算」选项。
* 列本身保留(老数据还在库里,不至于丢),但 UI 与汇总都不再出现预算概念。
*/
class Expenses
{
public const CATEGORIES = ['餐饮', '门票', '交通', '住宿', '购物', '娱乐', '其他'];
/** 展示用的货币符号(库里只存三字母代码) */
public const SYMBOLS = ['CNY' => '¥', 'JPY' => '¥', 'USD' => '$', 'EUR' => '€', 'HKD' => 'HK$'];
public const MAX_AMOUNT = 99999999.99;
public static function currencyLabels(): array
{
return ['CNY' => '人民币 ¥', 'JPY' => '日元 ¥', 'USD' => '美元 $', 'EUR' => '欧元 €', 'HKD' => '港币 HK$'];
}
/** 某个节点下的花销(只看真实花销),带付款人和分摊明细 */
public static function forNode(int $nodeId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT e.*, u.name AS payer_name, u.avatar AS payer_avatar
FROM expenses e
LEFT JOIN users u ON u.id = e.payer_user_id
WHERE e.trip_node_id = ? AND e.is_budget = 0
ORDER BY e.created_at ASC, e.id ASC');
$stmt->execute([$nodeId]);
$rows = $stmt->fetchAll();
if (empty($rows)) return [];
$ids = array_column($rows, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$s = $db->prepare("SELECT es.*, u.name AS user_name
FROM expense_splits es
LEFT JOIN users u ON u.id = es.user_id
WHERE es.expense_id IN ($ph)
ORDER BY es.id ASC");
$s->execute($ids);
$splits = [];
foreach ($s->fetchAll() as $row) {
$splits[(int)$row['expense_id']][] = $row;
}
foreach ($rows as &$row) {
$row['splits'] = $splits[(int)$row['id']] ?? [];
}
unset($row);
return $rows;
}
/** 活动下的真实花销总计,以及各节点的小计 */
public static function summaryForActivity(int $activityId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT tn.id AS node_id, SUM(e.total_amount) AS amount, COUNT(*) AS cnt
FROM expenses e
JOIN trip_nodes tn ON tn.id = e.trip_node_id
WHERE tn.activity_id = ? AND e.is_budget = 0
GROUP BY tn.id');
$stmt->execute([$activityId]);
$byNode = [];
$total = 0.0;
$count = 0;
foreach ($stmt->fetchAll() as $row) {
$amount = (float)$row['amount'];
$byNode[(int)$row['node_id']] = $amount;
$total += $amount;
$count += (int)$row['cnt'];
}
return [
'total' => $total,
'count' => $count,
'by_node' => $byNode,
];
}
/**
* 新增一笔花销,并把金额按人平均分摊(尾差归最后一人,保证分摊之和等于总额)
* @return array{ok: bool, error?: string, id?: int}
*/
public static function create(int $nodeId, int $payerId, string $amountRaw, string $currency, string $category, string $description, bool $isBudget, array $splitUserIds): array
{
$db = Database::get();
$amountRaw = str_replace([',', ',', ' '], '', trim($amountRaw));
if ($amountRaw === '' || !is_numeric($amountRaw)) {
return ['ok' => false, 'error' => '金额必须是数字'];
}
$amount = round((float)$amountRaw, 2);
if ($amount <= 0) {
return ['ok' => false, 'error' => '金额必须大于 0'];
}
if ($amount > self::MAX_AMOUNT) {
return ['ok' => false, 'error' => '金额过大'];
}
if (!isset(self::currencyLabels()[$currency])) {
$currency = 'CNY';
}
if ($category === '' || !in_array($category, self::CATEGORIES, true)) {
$category = '其他';
}
$description = trim($description);
if (mb_strlen($description) > 200) {
return ['ok' => false, 'error' => '描述不能超过 200 个字'];
}
// 付款人和分摊人都必须是这个节点所属家庭的成员
$stmt = $db->prepare('SELECT a.family_id FROM trip_nodes tn JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
$stmt->execute([$nodeId]);
$familyId = $stmt->fetchColumn();
if ($familyId === false) {
return ['ok' => false, 'error' => '节点不存在'];
}
$familyId = (int)$familyId;
$memberIds = array_column(Members::familyUsers($familyId), 'id');
$memberIds = array_map('intval', $memberIds);
if (!in_array($payerId, $memberIds, true)) {
return ['ok' => false, 'error' => '付款人必须是本家庭成员'];
}
$splitUserIds = array_values(array_unique(array_intersect(array_map('intval', $splitUserIds), $memberIds)));
if (empty($splitUserIds)) {
// 没选分摊人时默认由付款人自己承担
$splitUserIds = [$payerId];
}
$db->beginTransaction();
try {
$stmt = $db->prepare('INSERT INTO expenses
(trip_node_id, payer_user_id, currency, total_amount, category, description, is_budget)
VALUES (?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$nodeId, $payerId, $currency, number_format($amount, 2, '.', ''),
$category, $description !== '' ? $description : null, $isBudget ? 1 : 0,
]);
$expenseId = (int)$db->lastInsertId();
// 以「分」为单位平均分,把除不尽的尾差加到最后一个人身上
$totalCents = (int)round($amount * 100);
$n = count($splitUserIds);
$base = intdiv($totalCents, $n);
$rest = $totalCents - $base * $n;
$stmt = $db->prepare('INSERT INTO expense_splits (expense_id, user_id, share_amount) VALUES (?, ?, ?)');
foreach ($splitUserIds as $i => $uid) {
$cents = $base + ($i === $n - 1 ? $rest : 0);
$stmt->execute([$expenseId, $uid, number_format($cents / 100, 2, '.', '')]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['ok' => false, 'error' => '保存失败:' . $e->getMessage()];
}
return ['ok' => true, 'id' => $expenseId];
}
public static function delete(int $id): bool
{
$db = Database::get();
$stmt = $db->prepare('SELECT id FROM expenses WHERE id = ?');
$stmt->execute([$id]);
if (!$stmt->fetch()) return false;
// expense_splits 有 ON DELETE CASCADE,这里显式删一遍更直观
$db->prepare('DELETE FROM expense_splits WHERE expense_id = ?')->execute([$id]);
$db->prepare('DELETE FROM expenses WHERE id = ?')->execute([$id]);
return true;
}
/** 某个节点的真实花销合计 */
public static function summaryForNode(int $nodeId): array
{
$stmt = Database::get()->prepare('SELECT COALESCE(SUM(total_amount), 0) AS amount, COUNT(*) AS cnt
FROM expenses WHERE trip_node_id = ? AND is_budget = 0');
$stmt->execute([$nodeId]);
$row = $stmt->fetch();
return ['total' => (float)$row['amount'], 'count' => (int)$row['cnt']];
}
}