[名字, 是资产还是负债, 图标] */ 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 = self::defaultName($ownerId); } $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(); } /** * 默认账本名:「xxx的账本」(v24 用户 2026-10-03 第 7 条)。 * * 用户原话:「每个人的账本,应该默认的名字是 xxx 的账本,而不是家庭账本, * 否则的话,如果我把账本共享给另外一个人,他将会看到好几个家庭账本, * 这不好区分。」 * * ⚠️ 用**本名**而不是「我」对他的称呼:账本名是给**所有能看到它的人**看的 * (共享出去之后别人也会看到),用我私下的称呼(如「老公」)会让别人莫名其妙。 * 取不到名字就退回「家庭账本」(别造出「的账本」这种残句)。 */ public static function defaultName(int $ownerId): string { if ($ownerId > 0) { $st = Database::get()->prepare('SELECT name FROM persons WHERE id = ?'); $st->execute([$ownerId]); $n = trim((string)($st->fetchColumn() ?: '')); if ($n !== '') { return $n . '的账本'; } } return '家庭账本'; } /** * 这个家庭至少有**一本我能看到的**账本;没有就建一本并给我共享。 * @return int 账本 id */ public static function ensureDefault(int $familyId, int $personId): int { $mine = self::mine($familyId, $personId); if ($mine) { return (int)$mine[0]['id']; } // 建的时候就把名字定成「xxx的账本」(用户第 7 条), // 而不是等下一次有人抱怨"怎么又叫家庭账本"。 $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'; } // ---------------- 盘点:金额的历史(v24 用户 2026-10-03 第 8 条)---------------- /** * 盘点一笔账目:把新金额记下来,**旧值不丢**。 * * 用户原话:「账本要增加盘点功能,因为某一个账目可能会变,要记住每一次变化, * 用盘点来完成。我点击盘点,让我对一个账目的金额进行重新输入, * 但是盘点之前的账目数目不能清空,要记住,这样的话,每个账目的变化, * 就知道了,比如我每年盘点一次,我招商银行每年的金额变化,就看到了。」 * * 所以这是**两条记录**一起写: * ① 账目本身的当前金额更新(列表上显示的还是最新值); * ② 历史表落一行快照(用于画"这条账目随时间怎么变的")。 * * ⚠️ 为什么历史不与"改账目"共用:普通的 `改` 是**修正录错了**(那笔账从来就不是 * 那个数),盘点才是"它真的变了"。两者的语义完全不同 —— * 如果改账目也落历史,图上会出现一堆"记账手滑"造成的假波动, * 真正的资产变化反而淹没在里面。 * * @return bool 成功与否(账目不属于这本账、金额非法 → false) */ public static function reconcile(int $ledgerId, int $entryId, $amount, string $note = '', int $byId = 0): bool { $db = Database::get(); $st = $db->prepare('SELECT id FROM ledger_entries WHERE id = ? AND ledger_id = ?'); $st->execute([$entryId, $ledgerId]); if ($st->fetchColumn() === false) { return false; } $cents = self::toCents($amount); $db->prepare('UPDATE ledger_entries SET amount_cents = ? WHERE id = ? AND ledger_id = ?') ->execute([$cents, $entryId, $ledgerId]); $db->prepare( 'INSERT INTO ledger_entry_snapshots (ledger_id, entry_id, amount_cents, note, by_id) VALUES (?,?,?,?,?)' )->execute([ $ledgerId, $entryId, $cents, trim($note) !== '' ? mb_substr(trim($note), 0, 120, 'UTF-8') : null, $byId ?: null, ]); return true; } /** 一条账目的盘点历史(按时间正序) */ public static function history(int $entryId): array { $st = Database::get()->prepare( 'SELECT s.*, p.name AS by_name FROM ledger_entry_snapshots s LEFT JOIN persons p ON p.id = s.by_id WHERE s.entry_id = ? ORDER BY s.created_at, s.id' ); $st->execute([$entryId]); return $st->fetchAll(); } /** * 这本账的**汇总时间线**:每个盘点日,整本账的净资产是多少。 * * 做法:把每个账目在每个时点上"当时的值"取出来再加总 —— * · 某账目在 t 之前从未盘点过 → 它的值是**它现在的值**(因为一直没变过); * · 盘点过 → 取 t 之前最后一次盘点的值。 * 这样"只盘过招行"也能画出一条合理的总资产曲线, * 而不是"没盘过的账目在图上凭空消失"。 * * @return array 至少一条(今天);空账本返回 [] */ public static function timeline(int $ledgerId): array { $entries = self::entries($ledgerId); if (!$entries) { return []; } // 所有盘点过的日期(同一天多次盘点合成一个时点) $st = Database::get()->prepare( 'SELECT DISTINCT DATE(created_at) AS d FROM ledger_entry_snapshots WHERE ledger_id = ? ORDER BY d' ); $st->execute([$ledgerId]); $dates = []; foreach ($st->fetchAll() as $r) { $dates[] = (string)$r['d']; } $today = date('Y-m-d'); $dates[] = $today; // 永远带上"现在" $dates = array_values(array_unique($dates)); sort($dates); // 每条账目的快照:entry_id => [[date, cents], …] $snaps = []; foreach ($entries as $e) { $snaps[(int)$e['id']] = []; } $st = Database::get()->prepare( 'SELECT entry_id, DATE(created_at) AS d, amount_cents FROM ledger_entry_snapshots WHERE ledger_id = ? ORDER BY created_at, id' ); $st->execute([$ledgerId]); foreach ($st->fetchAll() as $r) { $eid = (int)$r['entry_id']; if (isset($snaps[$eid])) { $snaps[$eid][] = [(string)$r['d'], (int)$r['amount_cents']]; } } $out = []; foreach ($dates as $d) { $asset = 0; $debt = 0; foreach ($entries as $e) { $eid = (int)$e['id']; $val = (int)$e['amount_cents']; // 默认:一直没变过 = 还是现在这个值 foreach ($snaps[$eid] as [$sd, $sv]) { if ($sd <= $d) { $val = $sv; // 取该时点之前最后一次盘点的值 } else { break; } } if (self::isDebt((string)$e['kind'])) { $debt += $val; } else { $asset += $val; } } $out[] = ['date' => $d, 'net' => $asset - $debt, 'asset' => $asset, 'debt' => $debt]; } return $out; } /** * 一条账目自己的金额变化(画它那条线用)。 * 没有任何盘点记录 → 返回单点(就是它现在这个值),图上画一个点而不是空白。 * * @return array */ public static function entrySeries(int $entryId, int $currentCents): array { $rows = self::history($entryId); $out = []; foreach ($rows as $r) { $out[] = ['date' => substr((string)$r['created_at'], 0, 10), 'cents' => (int)$r['amount_cents']]; } if (!$out) { $out[] = ['date' => date('Y-m-d'), 'cents' => $currentCents]; } return $out; } /** * 这本账里**有盘点历史**的账目(列表上方只给这几条画线)。 * 全画会变成一坨面条 —— 用户要的是"招商银行每年的金额变化"这种单条曲线。 * * @return array */ public static function charts(int $ledgerId): array { $st = Database::get()->prepare( 'SELECT entry_id, COUNT(*) AS n FROM ledger_entry_snapshots WHERE ledger_id = ? GROUP BY entry_id HAVING n > 0 ORDER BY entry_id' ); $st->execute([$ledgerId]); $ids = array_map('intval', array_column($st->fetchAll(), 'entry_id')); if (!$ids) { return []; } $byId = []; foreach (self::entries($ledgerId) as $e) { $byId[(int)$e['id']] = $e; } $out = []; foreach ($ids as $eid) { if (!isset($byId[$eid])) { continue; } $out[] = [ 'entry' => $byId[$eid], 'series' => self::entrySeries($eid, (int)$byId[$eid]['amount_cents']), ]; } return $out; } }