[名字, 是资产还是负债, 图标] */ public const KINDS = [ 'deposit' => ['存款', 'asset', '🏦'], 'fund' => ['基金', 'asset', '📈'], 'stock' => ['股票', 'asset', '📊'], 'insurance'=> ['保险', 'asset', '🛡'], 'house' => ['房子', 'asset', '🏠'], 'car' => ['车子', 'asset', '🚗'], 'cash' => ['现金', 'asset', '💵'], 'loan' => ['贷款', 'debt', '📉'], 'credit' => ['信用卡', 'debt', '💳'], 'other' => ['其它', 'asset', '📦'], ]; public const PERM = ['read' => '只读', 'write' => '可写']; /** * 我能看到的账本 = 我建的 + 共享给我的。 * 全都带上 my_perm(我在这本账上能做什么),上层不必再查一次。 */ public static function mine(int $familyId, int $personId): array { $st = Database::get()->prepare( 'SELECT l.*, s.perm AS share_perm, (SELECT COUNT(*) FROM ledger_entries e WHERE e.ledger_id = l.id) AS entry_count FROM ledgers l LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ? WHERE l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL) ORDER BY l.id' ); $st->execute([$personId, $familyId, $personId]); $out = []; foreach ($st->fetchAll() as $r) { $r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read'); $out[] = $r; } return $out; } public static function find(int $familyId, int $id, int $personId): ?array { $st = Database::get()->prepare( 'SELECT l.*, s.perm AS share_perm FROM ledgers l LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ? WHERE l.id = ? AND l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL)' ); $st->execute([$personId, $id, $familyId, $personId]); $r = $st->fetch(); if (!$r) { return null; } $r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read'); return $r; } public static function canWrite(array $ledger): bool { return ($ledger['my_perm'] ?? '') === 'write'; } /** 账本里的全部账目 */ public static function entries(int $ledgerId): array { $st = Database::get()->prepare( 'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color, n.nickname AS person_nick FROM ledger_entries e LEFT JOIN persons p ON p.id = e.person_id LEFT JOIN nicknames n ON n.target_id = e.person_id AND n.viewer_id = ? WHERE e.ledger_id = ? ORDER BY e.id' ); $st->execute([0, $ledgerId]); return $st->fetchAll(); } /** * 这本账的汇总。 * @return array{asset:int,debt:int,net:int,by_kind:array,count:int} */ public static function summary(array $entries): array { $asset = 0; $debt = 0; $byKind = []; foreach ($entries as $e) { $amt = (int)($e['amount_cents'] ?? 0); $kind = (string)($e['kind'] ?? 'other'); $isDebt = (self::KINDS[$kind][1] ?? 'asset') === 'debt'; if ($isDebt) { $debt += $amt; } else { $asset += $amt; } $byKind[$kind] = ($byKind[$kind] ?? 0) + $amt; } return [ 'asset' => $asset, 'debt' => $debt, 'net' => $asset - $debt, 'by_kind' => $byKind, 'count' => count($entries), ]; } // ---------------- 写 ---------------- /** @return int 新账本 id */ public static function create(int $familyId, int $ownerId, string $name): int { $db = Database::get(); $name = trim($name); if ($name === '') { $name = '家庭账本'; } $db->prepare('INSERT INTO ledgers (family_id, owner_id, name) VALUES (?,?,?)') ->execute([$familyId, $ownerId ?: null, mb_substr($name, 0, 60, 'UTF-8')]); return (int)$db->lastInsertId(); } /** * 这个家庭至少有**一本我能看到的**账本;没有就建一本并给我共享。 * @return int 账本 id */ public static function ensureDefault(int $familyId, int $personId): int { $mine = self::mine($familyId, $personId); if ($mine) { return (int)$mine[0]['id']; } $id = self::create($familyId, $personId, '家庭账本'); return $id; // 建的人就是 owner,天然能写 } public static function rename(int $familyId, int $id, string $name): void { Database::get()->prepare('UPDATE ledgers SET name = ? WHERE id = ? AND family_id = ?') ->execute([mb_substr(trim($name), 0, 60, 'UTF-8'), $id, $familyId]); } public static function remove(int $familyId, int $id): void { // 账目与共享行靠外键 ON DELETE CASCADE 一起走 Database::get()->prepare('DELETE FROM ledgers WHERE id = ? AND family_id = ?') ->execute([$id, $familyId]); } /** 共享给某人(重复共享 = 改权限) */ public static function setShare(int $ledgerId, int $personId, string $perm = 'read'): void { $perm = isset(self::PERM[$perm]) ? $perm : 'read'; Database::get()->prepare( 'INSERT INTO ledger_shares (ledger_id, person_id, perm) VALUES (?,?,?) ON DUPLICATE KEY UPDATE perm = VALUES(perm)' )->execute([$ledgerId, $personId, $perm]); } public static function unshare(int $ledgerId, int $personId): void { Database::get()->prepare('DELETE FROM ledger_shares WHERE ledger_id = ? AND person_id = ?') ->execute([$ledgerId, $personId]); } public static function shares(int $ledgerId): array { $st = Database::get()->prepare( 'SELECT s.*, p.name, p.avatar, p.avatar_color FROM ledger_shares s JOIN persons p ON p.id = s.person_id WHERE s.ledger_id = ? ORDER BY s.id' ); $st->execute([$ledgerId]); return $st->fetchAll(); } /** @return int 新增账目 id */ public static function addEntry(int $ledgerId, array $d, int $byId = 0): int { $db = Database::get(); $db->prepare( 'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by) VALUES (?,?,?,?,?,?,?)' )->execute([ $ledgerId, self::kind($d['kind'] ?? ''), mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), self::toCents($d['amount'] ?? ''), trim((string)($d['note'] ?? '')) ?: null, (int)($d['person_id'] ?? 0) ?: null, $byId ?: null, ]); return (int)$db->lastInsertId(); } public static function updateEntry(int $ledgerId, int $id, array $d): void { Database::get()->prepare( 'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=? WHERE id=? AND ledger_id=?' )->execute([ self::kind($d['kind'] ?? ''), mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), self::toCents($d['amount'] ?? ''), trim((string)($d['note'] ?? '')) ?: null, (int)($d['person_id'] ?? 0) ?: null, $id, $ledgerId, ]); } public static function removeEntry(int $ledgerId, int $id): void { Database::get()->prepare('DELETE FROM ledger_entries WHERE id = ? AND ledger_id = ?') ->execute([$id, $ledgerId]); } // ---------------- 钱 ---------------- /** * 「元」→「分」。 * * ⚠️ 不能用 (int)($yuan * 100):浮点乘法会掉一分(19.99 * 100 = 1998.9999…)。 * 一律走字符串:拆出整数部与小数部,各自转整数再拼。 */ public static function toCents($yuan): int { $s = trim((string)$yuan); $s = str_replace([',', ',', ' '], '', $s); if ($s === '') { return 0; } $neg = false; if ($s[0] === '-') { $neg = true; $s = substr($s, 1); } if (!preg_match('/^(\d*)(?:\.(\d*))?$/', $s, $m)) { return 0; } $intPart = $m[1] !== '' ? $m[1] : '0'; $dec = $m[2] ?? ''; $dec = str_pad(substr($dec, 0, 2), 2, '0'); // 超过两位小数直接截断(不做四舍五入到厘) $cents = (int)$intPart * 100 + (int)$dec; return $neg ? -$cents : $cents; } /** 「分」→ 显示用的「元」字符串(总是两位小数) */ public static function amountYuan($cents): string { $c = (int)$cents; $neg = $c < 0; $c = abs($c); $s = sprintf('%d.%02d', intdiv($c, 100), $c % 100); return ($neg ? '-' : '') . $s; } /** 带千分位、带 ¥ 的显示(列表/汇总用) */ public static function money($cents): string { $c = (int)$cents; $neg = $c < 0; $c = abs($c); $s = number_format(intdiv($c, 100)) . '.' . sprintf('%02d', $c % 100); return ($neg ? '-' : '') . '¥' . $s; } public static function kind(string $k): string { return isset(self::KINDS[$k]) ? $k : 'other'; } public static function kindWord(string $k): string { return self::KINDS[$k][0] ?? '其它'; } public static function kindIcon(string $k): string { return self::KINDS[$k][2] ?? '📦'; } public static function isDebt(string $k): bool { return (self::KINDS[$k][1] ?? 'asset') === 'debt'; } }