2026-09-22 22:03:08 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace App;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 花销与分摊
|
|
|
|
|
|
*
|
2026-09-23 14:59:02 +08:00
|
|
|
|
* 表结构:expenses(挂在 trip_nodes 上)+ expense_splits(这笔钱按人分摊,谁承担多少)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 历史上 expenses 有 is_budget(预算 / 实际)之分,用户明确反馈「预算没有意义」,
|
|
|
|
|
|
* 现在只记真实花销:查询一律 is_budget = 0,表单也不再提供「预算」选项。
|
|
|
|
|
|
* 列本身保留(老数据还在库里,不至于丢),但 UI 与汇总都不再出现预算概念。
|
2026-09-22 22:03:08 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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$'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 14:59:02 +08:00
|
|
|
|
/** 某个节点下的花销(只看真实花销),带付款人和分摊明细 */
|
2026-09-22 22:03:08 +08:00
|
|
|
|
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
|
2026-09-23 14:59:02 +08:00
|
|
|
|
WHERE e.trip_node_id = ? AND e.is_budget = 0
|
|
|
|
|
|
ORDER BY e.created_at ASC, e.id ASC');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
$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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 14:59:02 +08:00
|
|
|
|
/** 活动下的真实花销总计,以及各节点的小计 */
|
2026-09-22 22:03:08 +08:00
|
|
|
|
public static function summaryForActivity(int $activityId): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$db = Database::get();
|
|
|
|
|
|
$stmt = $db->prepare('
|
2026-09-23 14:59:02 +08:00
|
|
|
|
SELECT tn.id AS node_id, SUM(e.total_amount) AS amount, COUNT(*) AS cnt
|
2026-09-22 22:03:08 +08:00
|
|
|
|
FROM expenses e
|
|
|
|
|
|
JOIN trip_nodes tn ON tn.id = e.trip_node_id
|
2026-09-23 14:59:02 +08:00
|
|
|
|
WHERE tn.activity_id = ? AND e.is_budget = 0
|
|
|
|
|
|
GROUP BY tn.id');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
$stmt->execute([$activityId]);
|
|
|
|
|
|
|
|
|
|
|
|
$byNode = [];
|
2026-09-23 14:59:02 +08:00
|
|
|
|
$total = 0.0;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
$count = 0;
|
|
|
|
|
|
foreach ($stmt->fetchAll() as $row) {
|
|
|
|
|
|
$amount = (float)$row['amount'];
|
2026-09-23 14:59:02 +08:00
|
|
|
|
$byNode[(int)$row['node_id']] = $amount;
|
|
|
|
|
|
$total += $amount;
|
2026-09-22 22:03:08 +08:00
|
|
|
|
$count += (int)$row['cnt'];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
2026-09-23 14:59:02 +08:00
|
|
|
|
'total' => $total,
|
|
|
|
|
|
'count' => $count,
|
|
|
|
|
|
'by_node' => $byNode,
|
2026-09-22 22:03:08 +08:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 新增一笔花销,并把金额按人平均分摊(尾差归最后一人,保证分摊之和等于总额)
|
|
|
|
|
|
* @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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-23 14:59:02 +08:00
|
|
|
|
/** 某个节点的真实花销合计 */
|
2026-09-22 22:03:08 +08:00
|
|
|
|
public static function summaryForNode(int $nodeId): array
|
|
|
|
|
|
{
|
2026-09-23 14:59:02 +08:00
|
|
|
|
$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');
|
2026-09-22 22:03:08 +08:00
|
|
|
|
$stmt->execute([$nodeId]);
|
2026-09-23 14:59:02 +08:00
|
|
|
|
$row = $stmt->fetch();
|
|
|
|
|
|
return ['total' => (float)$row['amount'], 'count' => (int)$row['cnt']];
|
2026-09-22 22:03:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|