Files
FamilyBook/app/Ledger.php
T
laoyang ecf121772f v24 第 12 批:5 条(删除按钮位置/日期控件三修/日历跳转/节气节日/财务图)
用户 2026-10-05 的 5 条。第 11 批交付后用户追问"确定这些需求都做完了?"—— 没有,
这 5 条是进一步要求。实现过程中查出 4 个真 bug。

#1 详情页删除按钮紧贴「开通账号」
   上一版放在页面最下方独立区块(danger-zone),用户要「就放在开通账号按钮下面,
   紧挨着,长度大小和开通账号一样,颜色改成红色」。→ 搬进「登录账号」卡片内
   (.del-inline + 细分隔线 + .btn-block-lg)。
   线上几何实测:同一张卡片、左边缘对齐 255==255、宽度一致 990==990、红色 rgb(192,83,63)。

#2 日期控件三修(两个真 bug + 一次闪烁)
   · 灰底:Semi **外层** .semi-datepicker-input 自带 rgba(46,50,56,.05)、高 32px;
     内层 wrapper 白底 40px → 灰的露在外面。实测「灰底元素」= 1 个 → 修后 0 个。
   · 不垂直居中:外层 32px < 内层 40px,实测 gapTop=1 / **gapBottom=17**(文字贴顶)
     → 修后 gapTop=1 / gapBottom=1。
   · 闪烁:实测首 **200ms** 原生输入框可见(要等 3.4MB React 解析)。
     用 no-js/js 二态:<html class="no-js"> + head 内联脚本立刻换 js +
     CSS `html.js .dt-input:not(.is-semi) .dt-native { visibility:hidden }`
     (配同高骨架 ::after 防布局跳动)。实测「出现过原生可见」= False。

#3 日历跳转保留视图 + 下拉日历
   原来写死 view=day(在月视图点跳转会被踢进日视图)。→ 表单带当前 $view(月还带 ym);
   选择器从"居中弹窗"改成**按钮下方下拉** .cal-jump-drop。
   实测:月视图跳 8/15 仍在月视图且是 8 月;周视图跳 8/15 → 落 8/10 那一周;
   日视图跳 8/15 → 显示 8/15。

#4 传统节日 + 二十四节气两个系统本(Schema v18)
   Holiday::SOLAR_TERMS(24 项 C 常数近似公式 + 例外表)+ traditionalRange
   (农历推导;除夕用"次日是否正月初一"反推,写死腊月廿九会有一半年份错)。
   实测 2026:节气 24 条全对(清明 4/5、冬至 12/22);春节 2/17、除夕 2/16、中秋 9/25。
   ⚠️ ENUM 必须显式 ALTER(对已存在的表不生效)——同一坑第二次踩,VERSION 17→18。

#5 财务(一个真 bug + 一个假起点 + 一个真 bug)
   · chart_line 读 `cents` 而 timeline() 返回 `net` → 键名不匹配,**总资产图每点读成 0**
     (正是用户说的"图上是 0")。修:兼容 cents ?? net ?? asset。
     实测 timeline net=800000(8000 元),线上 polyline 有真实起伏。
   · 去掉 timeline 人为插的"前一天=0"假起点(用户:"总资产不应该是 0,
     应该是所有账目创建时的金额的总数")。
   · ppSetSelected 只用 document.querySelector 按名字找,而财务页有**两个** person_id
     组件(「记一笔」+ 修改弹框)→ 永远灌到第一个。加 root 参数、调用方传 panel。

验证:e2e 786/0、交互 92/0、升级 129/0(+3 条 v18 断言)、batch_e 91/0、batch10 46/0、
batch9 38/0、picker_unify 28/0、picker_lazy 11/0、crop 17/0、formbloat 9/0、semi 27/0、
mobile 688/0、drag 5/0、geom 5/5;第 12 批专项:财务 7/0、日历 19/0。
部署静态断言新增 5 组(含反向验证:注入回退 → FAIL=1)。
线上自检 19/19;线上真实账号验收 **30/0**(含真实浏览器几何量删除按钮位置)。
2026-10-04 19:43:00 +08:00

627 lines
27 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;
}
/**
* 图例色板(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 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。
*
* @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);
// ⚠️ 只有一个时点时的处理(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<int,array{date:string,cents:int}>
*/
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<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,
// 图例用的颜色:同一账目**每次都一样**(按 id 取模取色板),
// 否则刷新一次就换色,用户没法靠颜色认账目。
'color' => self::legendColor($eid),
'series' => self::entrySeries(
$eid, (int)$e['amount_cents'],
substr((string)($e['created_at'] ?? ''), 0, 10)),
];
}
return $out;
}
}