[名字, 是资产还是负债, 图标] */ public const KINDS = [ 'deposit' => ['存款', 'asset', '🏦'], 'fund' => ['基金', 'asset', '📈'], 'stock' => ['股票', 'asset', '📊'], 'insurance'=> ['保险', 'asset', '🛡'], 'house' => ['房子', 'asset', '🏠'], 'car' => ['车子', 'asset', '🚗'], 'cash' => ['现金', 'asset', '💵'], 'loan' => ['贷款', 'debt', '📉'], 'credit' => ['信用卡', 'debt', '💳'], // v24 第 10 批 #2(用户原话):「类别,增加一个欠款,用来记录别人的钱, // 属于负债,是负的。」 // 与「贷款/信用卡」的区别:那两样是欠**机构**的钱,这个是欠**个人**的 // (借了亲戚朋友的钱、还没还的份子钱…)。同属负债,所以一起计入「总负债」, // 净资产照旧 = 资产 − 负债。 'owe' => ['欠款', '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(); } /** * 这本账的汇总。 * * ⚠️ v24 第 10 批 #2:负债一律按**绝对值**计入(abs)。 * 用户原话说「欠款…属于负债,**是负的**」—— 也就是说他会把欠款填成 -500。 * 如果不取绝对值,`net = 资产 − 负债 = 1000 − (−500) = 1500`, * **欠了钱净资产反而变多**,这是最容易被忽略又最误导人的一类错。 * 负债的"负"是**方向语义**(我欠别人),金额本身按正数参与"总负债", * 再由 net = 资产 − 负债 落到负号上 —— 全站只有这一个口径。 * * @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 = []; $byKindN = []; foreach ($entries as $e) { $amt = (int)($e['amount_cents'] ?? 0); $kind = (string)($e['kind'] ?? 'other'); if (self::isDebt($kind)) { $debt += abs($amt); // 见方法注释:负数输入也要被算成负债 } else { $asset += $amt; // 资产保持符号原样(透支=负资产是合理的) } $byKind[$kind] = ($byKind[$kind] ?? 0) + $amt; $byKindN[$kind] = ($byKindN[$kind] ?? 0) + 1; } return [ 'asset' => $asset, 'debt' => $debt, 'net' => $asset - $debt, 'by_kind' => $byKind, // v24 第 23 批 用户第 2 条:概览上的类别标签要变成**可点**的筛选入口, // 点一下就得说出"这一类有多少笔"。条数在这里一并算掉 —— // 模板里再遍历一遍 entries 去数,等于把同一件事写第二遍。 'by_kind_n' => $byKindN, 'count' => count($entries), ]; } /** * 负债口径归一化:**负债类别的金额一律取绝对值**,其它类别原样。 * * 只在写入口(addEntry / updateEntry / reconcile)调用 —— * 存进库里的数就已经是"负债为正、由 net 去减"的唯一口径, * 下游(汇总/时间线/图表)不必各写一遍 abs,也就不会有人漏写。 */ private static function normalizeAmount(string $kind, int $cents): int { return self::isDebt($kind) ? abs($cents) : $cents; } // ---------------- 写 ---------------- /** @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(); $knd = self::kind($d['kind'] ?? ''); $db->prepare( 'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by) VALUES (?,?,?,?,?,?,?)' )->execute([ $ledgerId, $knd, mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), // 负债归一化成正数(见 normalizeAmount):用户会把欠款填成负数 self::normalizeAmount($knd, 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 { $knd = self::kind($d['kind'] ?? ''); Database::get()->prepare( 'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=? WHERE id=? AND ledger_id=?' )->execute([ $knd, mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'), self::normalizeAmount($knd, 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; } /** * 图例色板(v24 第 11 批 #7)。 * * 用户原话:「图下面的条目不用显示详情,只需要显示每个账目的颜色, * 就是说哪个颜色代表哪个账目,就可以了。」 * * 所以每条账目要有一个**稳定**的颜色 —— 按 id 取模落在色板上, * 不随排序/刷新变化(否则用户没法靠颜色认账目,图例就白做了)。 * 色板挑中低饱和的,压在宣纸底色上不刺眼,且与 .led-kind-chip 的观感一致。 */ public const LEGEND_COLORS = [ '#B0342C', '#3F7D5A', '#7A4E8C', '#A65C30', '#3078A6', '#8C6D3F', '#A6305C', '#4A7C82', '#6BA630', '#9730A6', '#A68930', '#5B6B8C', ]; public static function legendColor(int $entryId): string { $n = count(self::LEGEND_COLORS); return self::LEGEND_COLORS[(($entryId % $n) + $n) % $n]; } 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, kind FROM ledger_entries WHERE id = ? AND ledger_id = ?'); $st->execute([$entryId, $ledgerId]); $row = $st->fetch(); if ($row === false) { return false; } // 盘点也走同一条归一化:负债填 -500 也存成 500(见 normalizeAmount) $cents = self::normalizeAmount(self::kind((string)$row['kind']), 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(); } /** * 这本账的**汇总时间线**:每个时点,整本账的净资产是多少。 * * v24 第 10 批 #3 修正:时点不再只取"盘点过的日期"。 * ⚠️ 只取盘点日期有个致命的冷启动问题:用户**第一次盘点时只有一个时点**, * 模板的 `count($timeline) > 1` 不成立 → 图根本不显示。而那一刻恰恰是他 * 最想看到图的时刻("我盘点了,图怎么还没出来")。 * 所以时点集合改成三者并集:① 账目**创建日**(那时它开始存在) * ② 所有盘点日 ③ 今天。于是"今天记了三笔账"就能画出一条 0 → 逐步累加的线。 * * 取值规则(每个时点 t):账目在 t 之后才创建 → **不计入**(那时它还不存在); * 在 t 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。 * * @param string $kind v24 第 23 批 用户第 2 条:**只看某一类别**时传它的 key * ('' = 整本账)。只影响"哪些账目参与累加",口径一字不改 —— * 所以"存款"那条线与"只记了存款这一笔的账本"完全等价。 * @return array 空账本返回 [] */ public static function timeline(int $ledgerId, string $kind = ''): array { $entries = self::entries($ledgerId); if ($kind !== '') { // ⚠️ 必须在**取时点之前**过滤(而不是算完再筛): // 时点集合里有"每条账目的创建日",包含被筛掉那些账目的创建日 → // 图右端就会凭空多出几个"其实什么都没变"的时点。 $entries = array_values(array_filter( $entries, static fn($e): bool => (string)$e['kind'] === $kind)); } 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']; } // ① 账目创建日:让"刚记完账"就有曲线可看(见方法注释的冷启动问题) $st = Database::get()->prepare( 'SELECT DISTINCT DATE(created_at) AS d FROM ledger_entries WHERE ledger_id = ? AND created_at IS NOT NULL ORDER BY d' ); $st->execute([$ledgerId]); foreach ($st->fetchAll() as $r) { $d = (string)$r['d']; if ($d !== '' && $d !== '0000-00-00') { $dates[] = $d; } } $today = date('Y-m-d'); $dates[] = $today; // 永远带上"现在" $dates = array_values(array_unique($dates)); sort($dates); // ⚠️ 只有一个时点时的处理(v24 第 12 批 #5 用户反馈后重写): // 旧版**人为插了一个"前一天 = 0"**当基线,理由是"让曲线有形状"。 // 但用户看到的是一条从 0 拉到当前值的斜线,误以为"总资产从 0 开始"—— // 而他说得对:「总资产不应该是 0,应该是所有账目创建时的金额的总数」。 // 所以改成:**去掉那个假零点**,时点就用真实数据(账目创建日 + 盘点日 + 今天)。 // 单点时 chart_line 会把线画在中间(已有 flat 分支),不会贴底装成 0。 // 真要"从 0 涨起来"的形状,那也该由**账目创建日**天然提供 —— 账目在 // 创建日之前不存在(下面 $born 判断会跳过),所以曲线本就该从创建日开始。 // (保留:若连一个真实时点都没有,补今天,避免空图。) // 每条账目的快照:entry_id => [[date, cents], …];born = 它的创建日期 $snaps = []; $born = []; $today10 = date('Y-m-d'); foreach ($entries as $e) { $eid = (int)$e['id']; $snaps[$eid] = []; $c = substr((string)($e['created_at'] ?? ''), 0, 10); // 取不到创建日就当"今天出生":宁可不画前段,也不要凭空多出历史 $born[$eid] = ($c !== '' && $c !== '0000-00-00') ? $c : $today10; } $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']; // 这个时点上它还不存在(还没记进来)→ 不计入,否则等于凭空多钱 if ($d < $born[$eid]) { continue; } $val = (int)$e['amount_cents']; // 默认:一直没变过 = 还是现在这个值 foreach ($snaps[$eid] as [$sd, $sv]) { if ($sd <= $d) { $val = $sv; // 取该时点之前最后一次盘点的值 } else { break; } } if (self::isDebt((string)$e['kind'])) { // ⚠️ 与 summary 同一口径:负债取绝对值(老数据里可能是被判反的负数) $debt += abs($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, string $born = ''): array { $rows = self::history($entryId); $out = []; foreach ($rows as $r) { $out[] = ['date' => substr((string)$r['created_at'], 0, 10), 'cents' => (int)$r['amount_cents']]; } // ⚠️ v24 第 11 批 #7(用户报「那个图,怎么数据都是 0 啊,没有盘点数据, // 就用账目的初始数据就可以了,不一定非要有盘点数据」): // 旧版"没有任何盘点"时只回**一个点**(今天=当前值)—— // 单点折线图在页面上就是一根贴边的平线,两端标注还是同一个数, // 看起来就像"数据都是 0"。用户要的是"用账目的初始数据画出来"。 // 所以:没盘点过 → 从**账目创建日(那时是 0)**画到今天(当前值), // 这条线表达的正是"这笔钱从无到有"。 if (!$out) { $start = $born !== '' ? $born : date('Y-m-d'); if ($start === date('Y-m-d')) { // 今天建、今天看 → 给一个**前一天**当起点,否则还是单点 $start = date('Y-m-d', strtotime('-1 day')); } $out[] = ['date' => $start, 'cents' => 0]; $out[] = ['date' => date('Y-m-d'), 'cents' => $currentCents]; } return $out; } /** * 这本账里**每条账目**的曲线(列表上方逐条画)。 * * v24 第 10 批 #3(用户原话):「在账目上面,增加一个图表显示,**显示所有账目和 * 总金额的变化曲线**,根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」 * * ⚠️ 这里与 v24 第 8 条的旧实现有个**语义修正**: * 旧版只画「有盘点记录的账目」(当时担心"全画会变成一坨面条")。 * 但用户这次明确要求「**所有**账目」,所以改成**每条都出线**, * "面条"问题改由**前端折叠 + 按类别筛选**解决(见模板里的曲线选择条), * 而不是靠"少画几条"偷偷减少信息。 * 没盘点过的账目会得到一条**单点曲线**(就是它现在的值), * 图上画一个点而不是空白 —— 空白会被误读成"这条账没数据"。 * * @return array */ public static function charts(int $ledgerId): array { $out = []; foreach (self::entries($ledgerId) as $e) { $eid = (int)$e['id']; $out[] = [ 'entry' => $e, // 图例用的颜色:同一账目**每次都一样**(按 id 取模取色板), // 否则刷新一次就换色,用户没法靠颜色认账目。 'color' => self::legendColor($eid), 'series' => self::entrySeries( $eid, (int)$e['amount_cents'], substr((string)($e['created_at'] ?? ''), 0, 10)), ]; } return $out; } }