Files
FamilyBook/app/Ledger.php
T
laoyang e0562b8627 v24 第 5 轮:十条清单(天气填城市 / 输入即筛选 / 看板列=看板 / 账本盘点折线图 / 纪念日分类)
用户 2026-10-03 的十条,逐条落点:

1. 天气改为**个人设置里填城市**(每人一份):新增 app/Geo.php,内置 197 个
   地级市坐标(离线、逐条核对,生成脚本 _tooling/_fb_geo_gen.py)。填坐标仍
   作为内置表没有时的出口;老的家庭级坐标保留为兜底,升级当天天气不会凭空消失。
2. 所有"找个人"的地方**输入即筛选**:新增 assets/js/filter.js(纯前端显隐,
   不发请求、回车不刷新)+ app/PersonQuery.php 把服务端判据收敛成一份
   (名册 / 挑人 / 高级搜索共用,避免三处实现漂移)。
3. 名册列表:有昵称就不重复显示本名、行变矮、补齐手机/阴历阳历生日(带年份)/
   邮箱/工作单位;「清除」与「搜索」同款同尺寸。
4/5. 日程:撤掉左侧日历本栏改满宽,功能(显隐/颜色/导入/默认视图)全收进
   右侧 ☰;**色块高度改为按起止时长**(旧实现一律 30 分钟,三小时的会跟
   十五分钟的同高);点日历空白处即建日程,开始时间=点的那一格。
6/10. 资源:搜索提到最上;按"最近改动/最新新增"排序,默认只显示最新 10 条并
   给「显示全部」出口。
7. 任务:看板的**列=用户自定义的看板**(不再是写死的待办/进行中),
   拖动/左右键=换看板(改 board_id,不动 status)。
8. 财务:概览在上账目在下、记一笔改右侧按钮、账本设置(共享/改名/新建/删除)
   收进 ☰、默认账本名「xxx的账本」、"挂在谁名下"默认自己且用标准选人框;
   **新增盘点**(新增 ledger_entry_snapshots 只增不改的历史表,旧值不被清空)
   + 上面画金额变化折线图(app/chart.php 服务端画 SVG,零依赖)。
9. 纪念日:手动添加的与名册生日派生的**分两块**、手动的在上;右侧设置可增删
   分类(家庭级,删分类不强改已用的历史)。

本轮另外修掉/加固的四类问题(都由新增的静态或运行时守卫兜住):
- index.php 少写 2 个 use 导入(Pinyin / UserSetting)→ 名册与个人设置
  返回 200 但正文是 Fatal;顺带修了一处早已存在的 Attachment 未导入。
  → 新增 _fb_import_check.py(1f 段)。
- 一次不精确替换弄丢了 app/Calendar.php 开头的 `<?php`,整份源码被当 HTML
  吐给浏览器,而 `php -l` 报"无语法错误"(纯文本输出确实合法)。
  → 新增 _fb_php_open_check.py(1e 段)。
- 模板片段 unset 了宿主模板的变量(账本片段 unset($canWrite))→ 正文里一片
  Undefined variable 而 HTTP 仍 200。同 _datetime.php 早年 $t 的坑。
  → 新增 _fb_fragment_scope_check.py(1g 段)+ _fb_batch_e.py 的 26 页无告警巡检。
- 窄屏下页头动作区不换行 → 周视图横向溢出且按钮被挤成三种高度(用户提过三次
  "按钮要同高")→ .page-head .actions 加 flex-wrap。

验证战绩(全绿):
  e2e 773/0 · 第 5 轮专项 91/0 · 结构升级 126/0 · 几何 428/0 · 移动端 684/0 ·
  交互 97/0 · 家谱 26/0 · 按钮 60/0;三轮反向验证各 3/3 抓到,还原后复绿。
线上:schema v14、指纹一致(9df6c9fccc6796db)、snapshots 表已建、197 城、
  8 个关键文件 md5 与本地逐字节一致、app/ 403 无源码泄漏、错误日志无新增。
2026-10-03 15:01:38 +08:00

525 lines
20 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', '💳'],
'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<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');
$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<int,array{date:string,net: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'];
}
$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<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;
}
/**
* 这本账里**有盘点历史**的账目(列表上方只给这几条画线)。
* 全画会变成一坨面条 —— 用户要的是"招商银行每年的金额变化"这种单条曲线。
*
* @return array<int,array{entry:array,series: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;
}
}