Files
FamilyBook/app/Ledger.php
T
laoyang b2ca4f336e v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
2026-10-04 10:46:50 +08:00

587 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 财务 / 账本(v24 用户第 15 条)
*
* 用户原话:「增加财务模块,用户可以添加自己的财务状况,例如存款、基金、保险、房子、车子等,
* 输入金额,最后给计算出所有的资金总数。这个模块,要先创建一个家庭,或者说,
* 创建一个账本,这个账本,可以共享给某个人,共享后,这个人就可以往这个账本加入账目,
* 这样,双方都可以看到所有的账目,最后计算出总数,这就是一个家庭的总账目。」
*
* 所以模型是两层:
* **账本(ledger)** —— 一个家庭的总账;可以共享给某个人(可读 / 可写)。
* **账目(ledger_entries)** —— 一笔资产/负债:存款、基金、保险、房子、车子…
*
* 三个设计决定:
* ① 金额一律用**分**存(BIGINT),不用浮点。
* 钱是最不能容忍 0.1+0.2=0.30000000000000004 的地方。
* 对外一律走 amountYuan() 转成元来显示,四舍五入到分。
* ② 分清「资产 / 负债」:房子车子是资产,房贷车贷是负债。
* 只把资产加总会把"欠着银行两百万"这件事藏起来。净资产 = 资产 − 负债。
* ③ 账本为空时**自动建一个默认账本**并把它共享给建号的人,
* 否则新用户进财务页一片空白、还不知道要先"创建家庭"。
*/
declare(strict_types=1);
namespace App;
final class Ledger
{
/** 账目分类:key => [名字, 是资产还是负债, 图标] */
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<string,int>,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');
if (self::isDebt($kind)) {
$debt += abs($amt); // 见方法注释:负数输入也要被算成负债
} else {
$asset += $amt; // 资产保持符号原样(透支=负资产是合理的)
}
$byKind[$kind] = ($byKind[$kind] ?? 0) + $amt;
}
return [
'asset' => $asset,
'debt' => $debt,
'net' => $asset - $debt,
'by_kind' => $byKind,
'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;
}
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 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。
*
* @return array<int,array{date:string,net:int,asset:int,debt:int}> 空账本返回 []
*/
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'];
}
// ① 账目创建日:让"刚记完账"就有曲线可看(见方法注释的冷启动问题)
$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);
// ⚠️ 只有一个时点时给一个**零点**在它前面,让曲线有形状。
// 全是同一天发生的事(今天记账 + 今天盘点)会退化成单点 ——
// 单点折线图视觉上就是一个小圆圈,用户会以为"图坏了"。
// 补一个"前一天:0"作为基线,读起来就是"从 0 涨到现在",
// 与直觉一致(账本是新起的,之前确实没有资产)。
// 只补在**最早那个时点的前一天**,不动中间的真实数据。
if (count($dates) === 1) {
array_unshift($dates, date('Y-m-d', strtotime($dates[0] . ' -1 day')));
}
// 每条账目的快照: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<int,array{date:string,cents:int}>
*/
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;
}
/**
* 这本账里**每条账目**的曲线(列表上方逐条画)。
*
* v24 第 10 批 #3(用户原话):「在账目上面,增加一个图表显示,**显示所有账目和
* 总金额的变化曲线**,根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」
*
* ⚠️ 这里与 v24 第 8 条的旧实现有个**语义修正**:
* 旧版只画「有盘点记录的账目」(当时担心"全画会变成一坨面条")。
* 但用户这次明确要求「**所有**账目」,所以改成**每条都出线**,
* "面条"问题改由**前端折叠 + 按类别筛选**解决(见模板里的曲线选择条),
* 而不是靠"少画几条"偷偷减少信息。
* 没盘点过的账目会得到一条**单点曲线**(就是它现在的值),
* 图上画一个点而不是空白 —— 空白会被误读成"这条账没数据"。
*
* @return array<int,array{entry:array,series:array}>
*/
public static function charts(int $ledgerId): array
{
$out = [];
foreach (self::entries($ledgerId) as $e) {
$eid = (int)$e['id'];
$out[] = [
'entry' => $e,
'series' => self::entrySeries($eid, (int)$e['amount_cents']),
];
}
return $out;
}
}