'¥', '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']]; } }