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 实测)。
This commit is contained in:
+77
-4
@@ -22,12 +22,31 @@ final class Calendar
|
||||
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
|
||||
public const ATTACH = 'calendar_event';
|
||||
|
||||
/** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */
|
||||
/** 系统预置的默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom')
|
||||
*
|
||||
* v24 第 10 批 #4:加了两个**不可删除的只读本** ——
|
||||
* · lunar 农历(每天的农历日期 + 农历节日),由 Holiday::lunarRange 派生;
|
||||
* · holiday 节假日(法定假日的**休息与调休**),由 Holiday::year 派生,
|
||||
* 并且**自动更新**(官方一公布就换用官方数据,见 Holiday 的类注释)。
|
||||
* 它们和 movie/trip 一样是"从别处派生、不落 calendar_events"的视图本,
|
||||
* 而且 kind ≠ 'custom' → Calendar::delete() 天然删不掉(用户要求"不可删除")。
|
||||
*/
|
||||
public const DEFAULT_KINDS = [
|
||||
'movie' => ['观影', '#7A4E8C'],
|
||||
'trip' => ['旅行', '#3F7D5A'],
|
||||
'movie' => ['观影', '#7A4E8C'],
|
||||
'trip' => ['旅行', '#3F7D5A'],
|
||||
'lunar' => ['农历', '#8C6D3F'],
|
||||
'holiday' => ['节假日', '#B0342C'],
|
||||
];
|
||||
|
||||
/** 派生型只读日历(内容不从 calendar_events 取,也不能往里加日程) */
|
||||
public const READONLY_KINDS = ['movie', 'trip', 'lunar', 'holiday'];
|
||||
|
||||
/** 这本是不是"派生只读"的(农历/节假日/观影/旅行都算) */
|
||||
public static function isReadonlyKind(string $kind): bool
|
||||
{
|
||||
return in_array($kind, self::READONLY_KINDS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建本时的预设色板(与影院管理同一个交互:取色器 + 点一下就换)。
|
||||
*
|
||||
@@ -73,7 +92,10 @@ final class Calendar
|
||||
// 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的,
|
||||
// 大小写混着存会让"颜色一样吗"这种判断时对时错)。
|
||||
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
|
||||
$kind === 'movie' || $kind === 'trip' ? $kind : 'custom',
|
||||
// ⚠️ 别写成 `'kind' => …`:这是个**位置参数**数组(对应 6 个 ? 占位符),
|
||||
// 混一个命名键进去会让 PDO 报 `Invalid parameter number` ——
|
||||
// 而且是**静默的**:整个 seedDefaults 抛异常,日历本一本都建不出来。
|
||||
isset(self::DEFAULT_KINDS[$kind]) || $kind === 'custom' ? $kind : 'custom',
|
||||
self::nextPosition($ownerId),
|
||||
]);
|
||||
return (int)$db->lastInsertId();
|
||||
@@ -762,6 +784,57 @@ final class Calendar
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 农历(v24 第 10 批 #4)----
|
||||
// 用户原话:「日程中,增加两个不可删除的日历,一个是农历…」
|
||||
// 每一天都出一条(标题 = 农历月日),农历节日另标一个名字。
|
||||
// 它是**派生只读**的:不落 calendar_events,删不掉,也不能往里加日程。
|
||||
if (isset($byKind['lunar'])) {
|
||||
$c = $byKind['lunar'][0];
|
||||
foreach (Holiday::lunarRange($from, $to) as $l) {
|
||||
$fest = (string)($l['fest'] ?? '');
|
||||
$title = (string)$l['title'] . ($fest !== '' ? ' · ' . $fest : '');
|
||||
$out[] = [
|
||||
'cal' => (int)$c['id'],
|
||||
'color' => (string)$c['color'],
|
||||
'calname' => (string)$c['name'],
|
||||
'title' => $title,
|
||||
'date' => (string)$l['date'],
|
||||
'time' => '',
|
||||
'all_day' => true,
|
||||
'kind' => 'lunar',
|
||||
'ref_id' => 0,
|
||||
// 农历本没有可点进去的详情;给一个月视图锚点即可(点它=看那天)
|
||||
'href' => url('calendar', ['view' => 'day', 'd' => (string)$l['date']]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 节假日:放假 + 调休(v24 第 10 批 #4)----
|
||||
// 用户原话:「一个是法定假日的休息和调休信息,法定假日这个,需要有自动更新功能…
|
||||
// 不是让你更新,而是让系统,代码自动更新。」
|
||||
// 自动更新在 App\Holiday 里:内置规则保证"永远有",官方数据一到就覆盖成官方口径。
|
||||
if (isset($byKind['holiday'])) {
|
||||
$c = $byKind['holiday'][0];
|
||||
// [from,to] 可能跨年(12 月末的月视图会看到次年 1 月初),两年都要取
|
||||
$years = [(int)substr($from, 0, 4), (int)substr($to, 0, 4)];
|
||||
foreach (array_unique($years) as $y) {
|
||||
foreach (Holiday::range($y, $from, $to) as $h) {
|
||||
$out[] = [
|
||||
'cal' => (int)$c['id'],
|
||||
'color' => (string)$c['color'],
|
||||
'calname' => (string)$c['name'],
|
||||
'title' => (string)$h['title'],
|
||||
'date' => (string)$h['date'],
|
||||
'time' => '',
|
||||
'all_day' => true,
|
||||
'kind' => 'holiday',
|
||||
'ref_id' => 0,
|
||||
'href' => url('calendar', ['view' => 'day', 'd' => (string)$h['date']]),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 有截止时间的任务 ----
|
||||
$st = Database::get()->prepare(
|
||||
"SELECT t.id, t.title, t.due_at, t.status
|
||||
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
<?php
|
||||
/**
|
||||
* 法定节假日 / 调休 + 农历 —— 日程页两个**不可删除的只读日历**的数据源。
|
||||
*
|
||||
* v24 第 10 批 #4(用户原话):
|
||||
* 「日程中,增加两个不可删除的日历,一个是农历,一个是法定假日的休息和调休信息,
|
||||
* 法定假日这个,需要有自动更新功能,例如明年的放假调休情况定下来了,那么就要及时
|
||||
* 更新进去,**不是让你更新,而是让系统,代码自动更新**。」
|
||||
*
|
||||
* 所以这里的核心不是"填一份 2026 年的数据",而是**把"自动更新"做成机制**:
|
||||
*
|
||||
* ┌─ 第一级:内置规则(离线可用、永不失效)
|
||||
* │ 元旦 / 清明 / 劳动节 / 国庆 是**公历固定或节气**,可以直接推导;
|
||||
* │ 春节 / 端午 / 中秋 由农历日期推导(Lunar 类,1900-2100 全覆盖)。
|
||||
* │ → 即使断网、即使没有任何外部数据,每年也会自动"长出"这 7 个节日的放假日期。
|
||||
* │ 只是"调休哪几天"官方没公布之前推不出来(那本来就是行政决定)。
|
||||
* └─ 第二级:官方公布数据(自动补齐调休)
|
||||
* 国务院办公厅每年 10-11 月公布次年安排。数据源用**公开的 JSON 镜像**
|
||||
* (NateScarlet/holiday-cn,按年一个文件),抓到就写进库并缓存。
|
||||
* → 这就是用户要的"明年的放假调休情况定下来了,系统自动更新进去"。
|
||||
*
|
||||
* ⚠️ 两级的关系是**互补而非替代**:拉取失败必须仍然有第一级的结果(只是少调休),
|
||||
* 绝不能因为外部源挂了就让日历空掉——那样用户会以为"功能坏了"。
|
||||
*
|
||||
* ⚠️ 缓存放 settings 表(`holiday_cache_<年>`),而不是每次请求都去抓:
|
||||
* 日历页是高频页面,把外网请求放渲染路径上会让它随外网抖动而变慢。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Holiday
|
||||
{
|
||||
/** 官方数据源(公开镜像,按年一个 JSON:{year, papers, days:[{name,date,isOffDay}]}) */
|
||||
private const SOURCE = 'https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/%d.json';
|
||||
private const SOURCE_ALT = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/%d.json';
|
||||
|
||||
/** 缓存有效期:查到一次就够用很久(官方安排一年只公布一次),失败则短缓存避免反复打)
|
||||
* 单位为秒。写成常量方便测试里改。 */
|
||||
public const CACHE_OK = 604800; // 7 天
|
||||
public const CACHE_FAIL = 21600; // 6 小时
|
||||
|
||||
/**
|
||||
* 公历固定日期的节日(月 => [日, 名字])。
|
||||
* 只放**日期固定**的:清明是节气(4月4/5/6日浮动)要算,不在这里。
|
||||
*/
|
||||
private const FIXED = [
|
||||
1 => [1 => '元旦'],
|
||||
5 => [1 => '劳动节'],
|
||||
10 => [1 => '国庆节'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 农历节日:月 => [日, 名字, 放假天数]。
|
||||
* 放假天数只是"内置规则"下的默认值;官方数据到了会以官方为准。
|
||||
*/
|
||||
private const LUNAR_FEST = [
|
||||
1 => [1 => ['春节', 3]], // 正月初一起(实际从除夕就开始,见下)
|
||||
5 => [5 => ['端午节', 1]],
|
||||
8 => [15 => ['中秋节', 1]],
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 取一年的安排
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 拿某一年的完整安排(放假 + 调休)。
|
||||
*
|
||||
* 🔴 **这个方法不发任何网络请求** —— 只读缓存 + 内置规则。
|
||||
*
|
||||
* 为什么这么设计(这一条是本功能能不能用的分水岭):
|
||||
* 日历是**高频页面**。如果把"抓官方数据"放在渲染路径上,
|
||||
* 那么外网一抖(DNS 慢、GitHub 被墙、jsdelivr 抽风),用户打开日程就要等
|
||||
* 连接超时(4s)+ 传输超时(8s)——**页面本身没坏,但慢得没法用**,
|
||||
* 而且没有任何报错能让人看出是"节假日数据源"的问题。
|
||||
* 所以这里只做本地计算,抓取交给 `refreshInBackground()`:
|
||||
* 在**响应发完之后**(php-fpm 的 fastcgi_finish_request)才去联网,
|
||||
* 成功了写缓存,下次请求自然就换成官方口径。
|
||||
*
|
||||
* @return array{year:int,source:string,days:array<string,array{name:string,off:bool}>}
|
||||
* days 的键是 'Y-m-d',值 = ['name'=>节日名, 'off'=>true 放假 / false 调休上班]
|
||||
*/
|
||||
public static function year(int $year): array
|
||||
{
|
||||
// 只认缓存;没有缓存就是"内置规则 + 排队等后台更新"
|
||||
$cached = self::fromCache($year);
|
||||
if ($cached !== null) {
|
||||
// 缓存里若只有内置规则(还没等到官方),顺手排一次后台更新
|
||||
if (($cached['source'] ?? '') !== 'official') {
|
||||
self::queueBackgroundFetch($year);
|
||||
}
|
||||
return $cached;
|
||||
}
|
||||
$out = ['year' => $year, 'source' => 'builtin', 'days' => self::builtin($year)];
|
||||
self::saveCache($year, $out, false);
|
||||
self::queueBackgroundFetch($year);
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 后台自动更新(用户要的"系统自动更新"就落在这里)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** @var array<int,bool> 本次请求已经排过队的年份(避免重复注册 shutdown 钩子) */
|
||||
private static array $queued = [];
|
||||
|
||||
/**
|
||||
* 排一次"响应之后去抓官方数据"。
|
||||
*
|
||||
* php-fpm 下用 `fastcgi_finish_request()`:它把响应**立刻发给浏览器**,
|
||||
* 之后脚本继续跑(抓取、写缓存)—— 这正是我们要的"用户无感"。
|
||||
* 没有这个函数(php -S、CLI)时不提前结束,抓取仍在脚本末尾发生,
|
||||
* 只是算在请求时长里(仅开发环境如此,可接受)。
|
||||
*/
|
||||
private static function queueBackgroundFetch(int $year): void
|
||||
{
|
||||
if (isset(self::$queued[$year]) || !self::shouldTry($year)) {
|
||||
return;
|
||||
}
|
||||
self::$queued[$year] = true;
|
||||
register_shutdown_function(static function () use ($year): void {
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request();
|
||||
}
|
||||
self::doFetch($year);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 现在该不该去抓(频率闸门)。
|
||||
*
|
||||
* 用一个独立的 `last_try` 时间戳文件:**无论成败**都记一次,
|
||||
* 失败后短时间内不再重试 —— 否则源挂掉的那几天,每个请求都去撞一次超时,
|
||||
* 白白浪费服务器资源(而且 php-fpm 的 worker 会被这些慢请求占住)。
|
||||
*/
|
||||
private static function shouldTry(int $year): bool
|
||||
{
|
||||
$f = self::tryFile($year);
|
||||
$last = is_file($f) ? (int)@file_get_contents($f) : 0;
|
||||
return (time() - $last) > self::CACHE_FAIL;
|
||||
}
|
||||
|
||||
private static function tryFile(int $year): string
|
||||
{
|
||||
return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-try-' . $year . '.txt';
|
||||
}
|
||||
|
||||
private static function markTried(int $year): void
|
||||
{
|
||||
$dir = dirname(self::tryFile($year));
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
@file_put_contents(self::tryFile($year), (string)time(), LOCK_EX);
|
||||
}
|
||||
|
||||
/** 真正去抓 + 落缓存(只在后台跑) */
|
||||
private static function doFetch(int $year): void
|
||||
{
|
||||
self::markTried($year);
|
||||
$official = self::fetch($year);
|
||||
$days = self::builtin($year);
|
||||
$source = 'builtin';
|
||||
if ($official !== null) {
|
||||
foreach ($official as $d => $v) {
|
||||
$days[$d] = $v;
|
||||
}
|
||||
$source = 'official';
|
||||
}
|
||||
self::saveCache($year, ['year' => $year, 'source' => $source, 'days' => $days],
|
||||
$source === 'official');
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯抓取(不落缓存)—— 给**命令行/设置页的"立即更新"**用。
|
||||
*
|
||||
* ⚠️ 与 year() 的区别:这个会**联网**。绝不要放在页面渲染路径上。
|
||||
*/
|
||||
public static function fetchNow(int $year): array
|
||||
{
|
||||
$official = self::fetch($year);
|
||||
$days = self::builtin($year);
|
||||
if ($official !== null) {
|
||||
foreach ($official as $d => $v) {
|
||||
$days[$d] = $v;
|
||||
}
|
||||
}
|
||||
$out = ['year' => $year, 'source' => $official !== null ? 'official' : 'builtin', 'days' => $days];
|
||||
self::saveCache($year, $out, $official !== null);
|
||||
self::markTried($year);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置规则推导:只算**放假**,不算调休。
|
||||
*
|
||||
* 调休(哪个周末上班)是行政决定,任何"推导"都是瞎猜 ——
|
||||
* 猜错的代价是用户按错的日期安排了事(比"没有调休信息"更糟)。
|
||||
* 所以内置规则只给确定的公历/农历节日,调休一律等官方数据。
|
||||
*/
|
||||
private static function builtin(int $year): array
|
||||
{
|
||||
$days = [];
|
||||
|
||||
// ---- 公历固定节日 ----
|
||||
foreach (self::FIXED as $m => $v) {
|
||||
foreach ($v as $d => $name) {
|
||||
$days[sprintf('%04d-%02d-%02d', $year, $m, $d)] = ['name' => $name, 'off' => true];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 清明(节气,4 月 4~6 日之间浮动)----
|
||||
$qingming = self::qingmingDay($year);
|
||||
if ($qingming > 0) {
|
||||
$days[sprintf('%04d-04-%02d', $year, $qingming)] = ['name' => '清明节', 'off' => true];
|
||||
}
|
||||
|
||||
// ---- 农历节日(春节 / 端午 / 中秋)----
|
||||
if (Lunar::inRange($year)) {
|
||||
// 春节:法定假从**除夕**开始(除夕=正月初一前一天,注意可能跨到上一年的腊月)
|
||||
// ⚠️ lunarToSolar 返回的是**关联数组**(['year'=>, 'month'=>, 'day'=>]),
|
||||
// 不是 [0,1,2] —— 用数字下标取会得到 null,然后 sprintf 出 '0000-00-00'。
|
||||
$cny = Lunar::lunarToSolar($year, 1, 1);
|
||||
if ($cny) {
|
||||
$first = sprintf('%04d-%02d-%02d', $cny['year'], $cny['month'], $cny['day']);
|
||||
$days[$first] = ['name' => '春节', 'off' => true];
|
||||
// 正月初二、初三也放假(法定 3 天)
|
||||
for ($i = 1; $i <= 2; $i++) {
|
||||
$t = date('Y-m-d', strtotime($first . ' +' . $i . ' day'));
|
||||
$days[$t] = ['name' => '春节', 'off' => true];
|
||||
}
|
||||
// 除夕(正月初一前一天)—— 法定假从除夕算起
|
||||
$eve = date('Y-m-d', strtotime($first . ' -1 day'));
|
||||
$days[$eve] = ['name' => '除夕', 'off' => true];
|
||||
}
|
||||
foreach ([5 => [5, '端午节'], 8 => [15, '中秋节']] as $m => [$d, $name]) {
|
||||
$s = Lunar::lunarToSolar($year, $m, $d);
|
||||
if ($s) {
|
||||
$days[sprintf('%04d-%02d-%02d', $s['year'], $s['month'], $s['day'])]
|
||||
= ['name' => $name, 'off' => true];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($days);
|
||||
return $days;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清明节的公历日期。
|
||||
*
|
||||
* 清明是**节气**不是农历节日,用通行的近似公式(20 世纪/21 世纪两套系数):
|
||||
* Y = 年 % 100;日 = int(Y × 0.2422 + C) − int(Y / 4)
|
||||
* 21 世纪 C = 4.81,20 世纪 C = 5.59。
|
||||
* ⚠️ 个别年份有 1 天的修正(公式的已知例外),这里只列真正会碰到的:
|
||||
* 2008 年应为 4 日(公式给 5 日)。范围外一律回退到"4 月 5 日"这个最常值。
|
||||
*/
|
||||
private static function qingmingDay(int $year): int
|
||||
{
|
||||
if ($year < 1900 || $year > 2100) {
|
||||
return 5;
|
||||
}
|
||||
$c = $year >= 2000 ? 4.81 : 5.59;
|
||||
$y = $year % 100;
|
||||
$day = (int)($y * 0.2422 + $c) - (int)($y / 4);
|
||||
// 已知例外(来自紫金山天文台的公开修正表,只影响极少数年份)
|
||||
$except = [2008 => 4, 2020 => 4, 1925 => 5, 1942 => 5];
|
||||
if (isset($except[$year])) {
|
||||
$day = $except[$year];
|
||||
}
|
||||
return ($day >= 4 && $day <= 6) ? $day : 5;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 官方数据自动拉取
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 抓官方公布的那个 JSON。**任何失败都返回 null**(调用方回落到内置规则)。
|
||||
*
|
||||
* ⚠️ 这里刻意不抛异常、不写日志刷屏:日历是高频页,
|
||||
* 外网抖动是常态,抛出去只会让页面 500 —— 而我们本来就有兜底。
|
||||
*/
|
||||
private static function fetch(int $year): ?array
|
||||
{
|
||||
foreach ([self::SOURCE, self::SOURCE_ALT] as $tpl) {
|
||||
$json = self::httpGet(sprintf($tpl, $year));
|
||||
if ($json === null) {
|
||||
continue;
|
||||
}
|
||||
$dec = json_decode($json, true);
|
||||
if (!is_array($dec) || empty($dec['days']) || !is_array($dec['days'])) {
|
||||
continue;
|
||||
}
|
||||
$days = [];
|
||||
foreach ($dec['days'] as $d) {
|
||||
if (!is_array($d)) {
|
||||
continue;
|
||||
}
|
||||
$date = trim((string)($d['date'] ?? ''));
|
||||
// 只认本年的、格式合法的(数据源里混进别的年份就直接忽略)
|
||||
if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m) || (int)$m[1] !== $year) {
|
||||
continue;
|
||||
}
|
||||
$days[$date] = [
|
||||
'name' => trim((string)($d['name'] ?? '')),
|
||||
'off' => (bool)($d['isOffDay'] ?? false),
|
||||
];
|
||||
}
|
||||
if ($days) {
|
||||
ksort($days);
|
||||
return $days;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 一次 HTTP GET(curl 优先,退回 file_get_contents);失败/超时返回 null */
|
||||
private static function httpGet(string $url): ?string
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 12,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_USERAGENT => 'FamilyBook/' . \App\Schema::VERSION,
|
||||
// ⚠️ 不能用 CURLOPT_NOBODY(那是 HEAD);这里是 GET
|
||||
CURLOPT_HTTPGET => true,
|
||||
];
|
||||
// ⚠️ CA 证书兜底:很多服务器(含本机开发环境)的 PHP 没配 curl.cainfo,
|
||||
// 表现是 `errno=60 unable to get local issuer certificate` ——
|
||||
// 看起来像"站点不通",其实是**证书链没处可查**。
|
||||
// 这里按常见位置找一个 CA bundle;找不到就退回系统默认(不关校验,
|
||||
// 关掉 VERIFYPEER 等于把 HTTPS 降级成明文,不能为了"能跑"那样做)。
|
||||
$ca = self::findCaBundle();
|
||||
if ($ca !== null) {
|
||||
$opts[CURLOPT_CAINFO] = $ca;
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if (is_string($body) && $code === 200 && $body !== '') {
|
||||
return $body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 没有 curl:file_get_contents 同样需要 CA;失败一律 null(上层回落内置规则)
|
||||
$ctx = stream_context_create([
|
||||
'http' => ['timeout' => 12, 'user_agent' => 'FamilyBook'],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$body = @file_get_contents($url, false, $ctx);
|
||||
return is_string($body) && $body !== '' ? $body : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 找一个可用的 CA bundle。找不到返回 null(保持 PHP 默认行为)。
|
||||
*
|
||||
* 只查固定几个常见位置,不做目录遍历 —— 这是渲染路径之外的后台任务,
|
||||
* 但也不该在磁盘上乱翻。
|
||||
*/
|
||||
private static function findCaBundle(): ?string
|
||||
{
|
||||
$candidates = [
|
||||
ini_get('curl.cainfo') ?: '',
|
||||
ini_get('openssl.cafile') ?: '',
|
||||
'C:/php/extras/ssl/cacert.pem',
|
||||
'C:/php/cacert.pem',
|
||||
'D:/Yongquan/code/_tooling/php/cacert.pem',
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/etc/ssl/cert.pem',
|
||||
];
|
||||
foreach ($candidates as $c) {
|
||||
$c = (string)$c;
|
||||
if ($c !== '' && is_file($c) && is_readable($c)) {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 缓存(放 settings 表:全局数据、与具体人无关)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 缓存落**文件**(storage/cache/holiday-<年>.json),不蹭 settings 表。
|
||||
*
|
||||
* ⚠️ 为什么不用 settings 表:它是**家庭级**的(主键 family_id + k,且 family_id 有
|
||||
* 外键约束指向 families(id))。而节假日安排是**全局数据**(全国一样、与哪个家无关),
|
||||
* 硬塞进去最省事的做法是"借用 0 号家庭",但那会直接违反外键。
|
||||
* 节假日本来就该在"一次拉取、全体共用、随部署走"的共享存储里 —— 就是文件。
|
||||
*/
|
||||
private const CACHE_DIR = 'cache';
|
||||
|
||||
private static function cacheFile(int $year): string
|
||||
{
|
||||
// storage/cache/holiday-<年>.json
|
||||
// ⚠️ 这个目录**不在打包白名单里**(缓存不该随包走),由部署脚本 mkdir 出来;
|
||||
// 代码这边只负责"没有就建",且**任何写失败都不算错**(见 saveCache)。
|
||||
return dirname(__DIR__) . '/storage/' . self::CACHE_DIR . '/holiday-' . $year . '.json';
|
||||
}
|
||||
|
||||
private static function fromCache(int $year): ?array
|
||||
{
|
||||
$f = self::cacheFile($year);
|
||||
if (!is_file($f)) {
|
||||
return null;
|
||||
}
|
||||
$raw = @file_get_contents($f);
|
||||
if (!is_string($raw) || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
$dec = json_decode($raw, true);
|
||||
if (!is_array($dec) || !isset($dec['at'], $dec['data'])) {
|
||||
return null;
|
||||
}
|
||||
// 成功的官方数据缓存久一点;只有内置规则的缓存短一点(好早点换上官方数据)
|
||||
$ttl = ($dec['source'] ?? '') === 'official' ? self::CACHE_OK : self::CACHE_FAIL;
|
||||
if (time() - (int)$dec['at'] > $ttl) {
|
||||
return null;
|
||||
}
|
||||
$data = $dec['data'];
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private static function saveCache(int $year, array $data, bool $official): void
|
||||
{
|
||||
$payload = json_encode([
|
||||
'at' => time(),
|
||||
'source' => $official ? 'official' : 'builtin',
|
||||
'data' => $data,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$dir = dirname(self::cacheFile($year));
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
// 写失败(目录不可写)**不算错**:下次请求重算一遍而已,页面照常出内容
|
||||
@file_put_contents(self::cacheFile($year), $payload, LOCK_EX);
|
||||
}
|
||||
|
||||
/** 强制刷新(设置页/命令行用):删缓存再取一次 */
|
||||
public static function refresh(int $year): array
|
||||
{
|
||||
$f = self::cacheFile($year);
|
||||
if (is_file($f)) {
|
||||
@unlink($f);
|
||||
}
|
||||
return self::year($year);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 供日历渲染用
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 把放假/调休摊成日历条目(只取 [from,to] 落在范围内的)。
|
||||
*
|
||||
* @return array<int,array{date:string,title:string,name:string,off:bool}>
|
||||
*/
|
||||
public static function range(int $year, string $from, string $to): array
|
||||
{
|
||||
$y = self::year($year);
|
||||
$out = [];
|
||||
foreach (($y['days'] ?? []) as $date => $v) {
|
||||
if ($date < $from || $date > $to) {
|
||||
continue;
|
||||
}
|
||||
$off = !empty($v['off']);
|
||||
$name = (string)($v['name'] ?? '');
|
||||
$out[] = [
|
||||
'date' => (string)$date,
|
||||
'name' => $name,
|
||||
'off' => $off,
|
||||
// 调休上班的日子写清楚"补班",否则用户看到工作日里冒出一条日程会莫名
|
||||
'title' => $off ? ('休 ' . ($name !== '' ? $name : '假日')) : ('班 ' . ($name !== '' ? $name : '调休')),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 农历条目([from,to] 每天都出一条,标题是"农历月日 + 节日")。
|
||||
*
|
||||
* ⚠️ 每天的农历都要出,不能只出初一十五 —— 用户要的是"看日历就知道今天农历几号"。
|
||||
*/
|
||||
public static function lunarRange(string $from, string $to): array
|
||||
{
|
||||
$out = [];
|
||||
$t = strtotime($from);
|
||||
$end = strtotime($to);
|
||||
if ($t === false || $end === false || $end < $t) {
|
||||
return [];
|
||||
}
|
||||
// 上限保险:范围异常大时不至于把请求拖死(正常月视图最多 42 天)
|
||||
for ($i = 0; $t <= $end && $i < 400; $i++, $t = strtotime('+1 day', $t)) {
|
||||
$date = date('Y-m-d', $t);
|
||||
$y = (int)date('Y', $t); $m = (int)date('n', $t); $d = (int)date('j', $t);
|
||||
$l = Lunar::solarToLunar($y, $m, $d);
|
||||
if (!$l) {
|
||||
continue;
|
||||
}
|
||||
$lm = (int)$l['month']; $ld = (int)$l['day']; $leap = !empty($l['is_leap']);
|
||||
$out[] = [
|
||||
'date' => $date,
|
||||
'title' => self::lunarTitle($lm, $ld, $leap),
|
||||
'fest' => self::lunarFestival($lm, $ld, $leap),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** 日历条上显示的农历文字:初一显示月名,其余显示日名 */
|
||||
private static function lunarTitle(int $m, int $d, bool $leap): string
|
||||
{
|
||||
if ($d === 1) {
|
||||
return Lunar::monthName($m, $leap);
|
||||
}
|
||||
return Lunar::dayName($d);
|
||||
}
|
||||
|
||||
/**
|
||||
* 那天是不是农历节日(春节/元宵/端午/七夕/中元/中秋/重阳/腊八/小年/除夕)。
|
||||
* 返回节日名,没有则空串。
|
||||
*/
|
||||
private static function lunarFestival(int $m, int $d, bool $leap): string
|
||||
{
|
||||
if ($leap) {
|
||||
return ''; // 闰月不算节日
|
||||
}
|
||||
$map = [
|
||||
'1-1' => '春节', '1-15' => '元宵节',
|
||||
'5-5' => '端午节', '7-7' => '七夕',
|
||||
'7-15' => '中元节', '8-15' => '中秋节',
|
||||
'9-9' => '重阳节', '12-8' => '腊八节',
|
||||
'12-23'=> '小年',
|
||||
];
|
||||
return $map[$m . '-' . $d] ?? '';
|
||||
}
|
||||
|
||||
/** 两个内置日历本的定义(不可删除 → kind 不是 custom,delete() 天然删不掉) */
|
||||
public const BUILTIN_KINDS = [
|
||||
'lunar' => ['农历', '#8C6D3F'],
|
||||
'holiday' => ['节假日', '#B0342C'],
|
||||
];
|
||||
|
||||
/** 某年官方数据是否已到手(设置页显示"已自动更新到哪一年"用) */
|
||||
public static function isOfficial(int $year): bool
|
||||
{
|
||||
$y = self::year($year);
|
||||
return ($y['source'] ?? '') === 'official';
|
||||
}
|
||||
}
|
||||
+105
-43
@@ -38,6 +38,12 @@ final class Ledger
|
||||
'cash' => ['现金', 'asset', '💵'],
|
||||
'loan' => ['贷款', 'debt', '📉'],
|
||||
'credit' => ['信用卡', 'debt', '💳'],
|
||||
// v24 第 10 批 #2(用户原话):「类别,增加一个欠款,用来记录别人的钱,
|
||||
// 属于负债,是负的。」
|
||||
// 与「贷款/信用卡」的区别:那两样是欠**机构**的钱,这个是欠**个人**的
|
||||
// (借了亲戚朋友的钱、还没还的份子钱…)。同属负债,所以一起计入「总负债」,
|
||||
// 净资产照旧 = 资产 − 负债。
|
||||
'owe' => ['欠款', 'debt', '🤝'],
|
||||
'other' => ['其它', 'asset', '📦'],
|
||||
];
|
||||
|
||||
@@ -106,6 +112,14 @@ final class Ledger
|
||||
|
||||
/**
|
||||
* 这本账的汇总。
|
||||
*
|
||||
* ⚠️ 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
|
||||
@@ -116,11 +130,10 @@ final class Ledger
|
||||
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;
|
||||
if (self::isDebt($kind)) {
|
||||
$debt += abs($amt); // 见方法注释:负数输入也要被算成负债
|
||||
} else {
|
||||
$asset += $amt;
|
||||
$asset += $amt; // 资产保持符号原样(透支=负资产是合理的)
|
||||
}
|
||||
$byKind[$kind] = ($byKind[$kind] ?? 0) + $amt;
|
||||
}
|
||||
@@ -133,6 +146,18 @@ final class Ledger
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 负债口径归一化:**负债类别的金额一律取绝对值**,其它类别原样。
|
||||
*
|
||||
* 只在写入口(addEntry / updateEntry / reconcile)调用 ——
|
||||
* 存进库里的数就已经是"负债为正、由 net 去减"的唯一口径,
|
||||
* 下游(汇总/时间线/图表)不必各写一遍 abs,也就不会有人漏写。
|
||||
*/
|
||||
private static function normalizeAmount(string $kind, int $cents): int
|
||||
{
|
||||
return self::isDebt($kind) ? abs($cents) : $cents;
|
||||
}
|
||||
|
||||
// ---------------- 写 ----------------
|
||||
|
||||
/** @return int 新账本 id */
|
||||
@@ -231,15 +256,17 @@ final class Ledger
|
||||
/** @return int 新增账目 id */
|
||||
public static function addEntry(int $ledgerId, array $d, int $byId = 0): int
|
||||
{
|
||||
$db = Database::get();
|
||||
$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,
|
||||
self::kind($d['kind'] ?? ''),
|
||||
$knd,
|
||||
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
|
||||
self::toCents($d['amount'] ?? ''),
|
||||
// 负债归一化成正数(见 normalizeAmount):用户会把欠款填成负数
|
||||
self::normalizeAmount($knd, self::toCents($d['amount'] ?? '')),
|
||||
trim((string)($d['note'] ?? '')) ?: null,
|
||||
(int)($d['person_id'] ?? 0) ?: null,
|
||||
$byId ?: null,
|
||||
@@ -249,13 +276,14 @@ final class Ledger
|
||||
|
||||
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([
|
||||
self::kind($d['kind'] ?? ''),
|
||||
$knd,
|
||||
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
|
||||
self::toCents($d['amount'] ?? ''),
|
||||
self::normalizeAmount($knd, self::toCents($d['amount'] ?? '')),
|
||||
trim((string)($d['note'] ?? '')) ?: null,
|
||||
(int)($d['person_id'] ?? 0) ?: null,
|
||||
$id, $ledgerId,
|
||||
@@ -362,12 +390,14 @@ final class Ledger
|
||||
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 = $db->prepare('SELECT id, kind FROM ledger_entries WHERE id = ? AND ledger_id = ?');
|
||||
$st->execute([$entryId, $ledgerId]);
|
||||
if ($st->fetchColumn() === false) {
|
||||
$row = $st->fetch();
|
||||
if ($row === false) {
|
||||
return false;
|
||||
}
|
||||
$cents = self::toCents($amount);
|
||||
// 盘点也走同一条归一化:负债填 -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(
|
||||
@@ -395,15 +425,19 @@ final class Ledger
|
||||
}
|
||||
|
||||
/**
|
||||
* 这本账的**汇总时间线**:每个盘点日,整本账的净资产是多少。
|
||||
* 这本账的**汇总时间线**:每个时点,整本账的净资产是多少。
|
||||
*
|
||||
* 做法:把每个账目在每个时点上"当时的值"取出来再加总 ——
|
||||
* · 某账目在 t 之前从未盘点过 → 它的值是**它现在的值**(因为一直没变过);
|
||||
* · 盘点过 → 取 t 之前最后一次盘点的值。
|
||||
* 这样"只盘过招行"也能画出一条合理的总资产曲线,
|
||||
* 而不是"没盘过的账目在图上凭空消失"。
|
||||
* v24 第 10 批 #3 修正:时点不再只取"盘点过的日期"。
|
||||
* ⚠️ 只取盘点日期有个致命的冷启动问题:用户**第一次盘点时只有一个时点**,
|
||||
* 模板的 `count($timeline) > 1` 不成立 → 图根本不显示。而那一刻恰恰是他
|
||||
* 最想看到图的时刻("我盘点了,图怎么还没出来")。
|
||||
* 所以时点集合改成三者并集:① 账目**创建日**(那时它开始存在)
|
||||
* ② 所有盘点日 ③ 今天。于是"今天记了三笔账"就能画出一条 0 → 逐步累加的线。
|
||||
*
|
||||
* @return array<int,array{date:string,net:int}> 至少一条(今天);空账本返回 []
|
||||
* 取值规则(每个时点 t):账目在 t 之后才创建 → **不计入**(那时它还不存在);
|
||||
* 在 t 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。
|
||||
*
|
||||
* @return array<int,array{date:string,net:int,asset:int,debt:int}> 空账本返回 []
|
||||
*/
|
||||
public static function timeline(int $ledgerId): array
|
||||
{
|
||||
@@ -421,15 +455,43 @@ final class Ledger
|
||||
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);
|
||||
|
||||
// 每条账目的快照:entry_id => [[date, cents], …]
|
||||
// ⚠️ 只有一个时点时给一个**零点**在它前面,让曲线有形状。
|
||||
// 全是同一天发生的事(今天记账 + 今天盘点)会退化成单点 ——
|
||||
// 单点折线图视觉上就是一个小圆圈,用户会以为"图坏了"。
|
||||
// 补一个"前一天: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) {
|
||||
$snaps[(int)$e['id']] = [];
|
||||
$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
|
||||
@@ -450,6 +512,10 @@ final class Ledger
|
||||
$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) {
|
||||
@@ -459,7 +525,8 @@ final class Ledger
|
||||
}
|
||||
}
|
||||
if (self::isDebt((string)$e['kind'])) {
|
||||
$debt += $val;
|
||||
// ⚠️ 与 summary 同一口径:负债取绝对值(老数据里可能是被判反的负数)
|
||||
$debt += abs($val);
|
||||
} else {
|
||||
$asset += $val;
|
||||
}
|
||||
@@ -490,34 +557,29 @@ final class Ledger
|
||||
}
|
||||
|
||||
/**
|
||||
* 这本账里**有盘点历史**的账目(列表上方只给这几条画线)。
|
||||
* 全画会变成一坨面条 —— 用户要的是"招商银行每年的金额变化"这种单条曲线。
|
||||
* 这本账里**每条账目**的曲线(列表上方逐条画)。
|
||||
*
|
||||
* v24 第 10 批 #3(用户原话):「在账目上面,增加一个图表显示,**显示所有账目和
|
||||
* 总金额的变化曲线**,根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」
|
||||
*
|
||||
* ⚠️ 这里与 v24 第 8 条的旧实现有个**语义修正**:
|
||||
* 旧版只画「有盘点记录的账目」(当时担心"全画会变成一坨面条")。
|
||||
* 但用户这次明确要求「**所有**账目」,所以改成**每条都出线**,
|
||||
* "面条"问题改由**前端折叠 + 按类别筛选**解决(见模板里的曲线选择条),
|
||||
* 而不是靠"少画几条"偷偷减少信息。
|
||||
* 没盘点过的账目会得到一条**单点曲线**(就是它现在的值),
|
||||
* 图上画一个点而不是空白 —— 空白会被误读成"这条账没数据"。
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
foreach (self::entries($ledgerId) as $e) {
|
||||
$eid = (int)$e['id'];
|
||||
$out[] = [
|
||||
'entry' => $byId[$eid],
|
||||
'series' => self::entrySeries($eid, (int)$byId[$eid]['amount_cents']),
|
||||
'entry' => $e,
|
||||
'series' => self::entrySeries($eid, (int)$e['amount_cents']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
|
||||
+45
-2
@@ -71,7 +71,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '16';
|
||||
public const VERSION = '17';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -484,7 +484,7 @@ CREATE TABLE IF NOT EXISTS `calendars` (
|
||||
`owner_id` INT UNSIGNED NOT NULL COMMENT '这本日历是谁的',
|
||||
`name` VARCHAR(50) NOT NULL,
|
||||
`color` CHAR(7) NOT NULL DEFAULT '#4A7C82' COMMENT '日历本的颜色(日程按本子的颜色显示)',
|
||||
`kind` ENUM('custom','movie','trip') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip = 系统给每人预置的默认本,内容从 events 派生',
|
||||
`kind` ENUM('custom','movie','trip','lunar','holiday') NOT NULL DEFAULT 'custom' COMMENT 'movie/trip/lunar/holiday = 系统预置的派生只读本(农历与节假日为 v24 第 10 批新增,不可删除)',
|
||||
`position` INT NOT NULL DEFAULT 100,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
@@ -850,6 +850,49 @@ SQL,
|
||||
self::tryStep('老地址拆成国家/省/市/区', static function () use ($db) {
|
||||
self::backfillAddress($db);
|
||||
});
|
||||
// ---- v24 第 10 批 #4:日历本 kind 枚举加 lunar / holiday ----
|
||||
// ⚠️ 与上面 widenEventKindEnum 同一类坑:建表语句里的 ENUM 对**已存在的表**
|
||||
// 不会更新(CREATE TABLE IF NOT EXISTS 直接跳过),必须显式 MODIFY。
|
||||
// 不改的后果:给老库插 kind='lunar' 会被严格模式整条拒掉 →
|
||||
// "农历日历本建不出来",而新装的库一切正常(最难查的那种)。
|
||||
$db->exec("ALTER TABLE `calendars` MODIFY COLUMN `kind`
|
||||
ENUM('custom','movie','trip','lunar','holiday') NOT NULL DEFAULT 'custom'
|
||||
COMMENT 'movie/trip/lunar/holiday = 系统预置的派生只读本'");
|
||||
// 给每个家庭里的每个人都补上农历/节假日这两本(幂等)
|
||||
self::tryStep('补建每人的农历 / 节假日日历本', static function () use ($db) {
|
||||
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||
\App\Calendar::seedDefaultsForFamily((int)$fam['id']);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- v24 第 10 批 #2:负债类账目一律存正数 ----
|
||||
// 用户原话:「类别,增加一个欠款,用来记录别人的钱,属于负债,是负的。」
|
||||
// 也就是说用户会把欠款填成 **-500**。旧的 summary() 用 `net = 资产 − 负债`,
|
||||
// 负的负债会让净资产**反向变大**(1000 − (−500) = 1500,欠钱反而更有钱)。
|
||||
// 写入口已加 normalizeAmount 归一化;这里把**库里已有的**负数负债也纠正过来,
|
||||
// 否则老数据的净资产会一直错下去。
|
||||
self::tryStep('负债类账目金额取绝对值(纠正负数欠款)', static function () use ($db) {
|
||||
$kinds = [];
|
||||
foreach (\App\Ledger::KINDS as $k => $v) {
|
||||
if (($v[1] ?? 'asset') === 'debt') {
|
||||
$kinds[] = $k;
|
||||
}
|
||||
}
|
||||
if (!$kinds) {
|
||||
return;
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($kinds), '?'));
|
||||
// 账目表自己带 kind,直接筛
|
||||
$db->prepare("UPDATE `ledger_entries` SET amount_cents = -amount_cents
|
||||
WHERE amount_cents < 0 AND kind IN ($in)")->execute($kinds);
|
||||
// ⚠️ 快照表**没有 kind 列**(它只有 entry_id)—— 必须回表 ledger_entries 判类别,
|
||||
// 不能凭"金额为负"就取反:负的**资产**(透支、欠款记账前的误录)是合法值。
|
||||
$db->prepare("UPDATE `ledger_entry_snapshots` s
|
||||
JOIN `ledger_entries` e ON e.id = s.entry_id
|
||||
SET s.amount_cents = -s.amount_cents
|
||||
WHERE s.amount_cents < 0 AND e.kind IN ($in)")->execute($kinds);
|
||||
});
|
||||
|
||||
// 老头像(v11 之前传的)补算一次:回读已存的图算主色。
|
||||
// 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人,
|
||||
// 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。
|
||||
|
||||
Reference in New Issue
Block a user