v24 第 17 批:城市聚合 / 高德地址 / 表单精简(用户 10 条)

用户 10 条原话要点与落地:
- ①「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,有多段行程,
  都有青岛作为出发地或者目的地,那么就只记一次」
  → trip_nodes 补 from_city/to_city(Schema v22),Trip::cities() 改成
    「出发地 ∪ 到达地」去重、按最早时间 + 出发地优先排。
    详情页信息区显示的就是这个聚合结果(第 4 条:「图片右侧显示的是聚合的城市,
    而不是终点地址」)。
- ②「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」
  → NodeType::TRIPLOG_TYPES(休息/突发/购物/拜访亲友),在 all() 里读时自愈补齐。
- ③「出行方式,只有选择自驾,才让选车辆;车辆下拉框不需要出现什么财务里登记的车,
  只把车辆列出来就行了」→ DRIVE_MODES 收窄成 ['drive'];
  Vehicle::pickable() 只回车辆库;车辆页删掉「财务里已登记的车」那块。
- ③「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
  → seat_info / gate 从表单与列表**彻底移除**(列保留,老数据不丢;
    saveNode 的 UPDATE 也不再写这两列,否则会抹掉迁来的数据)。
- ③「出发地和到达地,都用高德地图的接口,记录详细地址」+「参与人弹框选择」
  +「给参与人标注角色这个功能去掉」
  → 新增 templates/trips/_loc_field.php(高德联想 + 详细地址 + 坐标 + 城市 +
    导航链接,没配 key 也能手输);参与人改用全局弹框选人组件;
    角色输入框删除,saveNodeMembers 改成差集增删(否则老 role_tag 被静默抹掉)。
- ④「triplog 中的行程花销数据,没有同步过来,需要迁移过来」
  → 新增 _tooling/_fb_b17_backfill.php:迁地址(20 条)、花销(4 笔 ¥8584)、
    车辆(3 台)。线上已执行并核对(笔数与金额一分不差)。
- ④「行程中,时间明明具体到了时分秒,怎么不显示?」
  → Trip::nodeSpan():行程时间精确到时分(同一天只写一次日期、跨天写月日、
    两头都没时分就不写 00:00)。

其他:
- App\Geo 扩展 cityFromPoint / cityFromText / navUrl / jsConfig。
  中文地址提城市修掉四种错法(白名单→黑名单、取最后→取第一个、直辖市最先认、
  离线表只能兜底),并加了「五大连池 ≠ 大连」的位置守卫。
- config.example.php 加 amap 段(key / security_js / nav_src)。

验证:
- e2e 920/0(+29 条本批断言)· 升级 149/0(+15 条 v22 断言)·
  城市解析 32/32(新套件,样例全是 TripLog 线上真实地址)·
  桌面几何 428/0 · 移动端 688/0 · 交互 92/0 · 窄屏溢出 24/0。
- 反向验证 5/5:城市退回读 place / 只算到达地 / 时间退回只到日期 /
  打车也要车 / 类型不补 —— 五种坏法全部被断言抓住。
- 部署:服务器自检 19/19;线上 schema v22;补迁后花销 4 笔 ¥8584 与源库对齐。
This commit is contained in:
2026-10-05 16:45:10 +08:00
parent 2e51d3f5e4
commit c30ca99d22
13 changed files with 1158 additions and 235 deletions
+325
View File
@@ -331,4 +331,329 @@ final class Geo
'海南省', '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省',
'台湾省',
];
// ================================================================
// 以下为 v24 第 17 批新增:**地址 → 城市**(用于旅程的城市聚合统计)
// ================================================================
/**
* 高德 Web 服务的逆地理端点 / 超时。
*
* ⚠️ 与上面天气那套(离线表)是**两条独立路径**,互不影响:
* 天气要"城市 → 坐标"(离线表够用),
* 旅程要"坐标/地址 → 城市"(必须联网问高德,因为地址千奇百怪)。
*/
private const REGEO_URL = 'https://restapi.amap.com/v3/geocode/regeo';
private const HTTP_TIMEOUT = 3;
/** 直辖市(高德对它们的 `city` 字段返回空数组,要认 `province`) */
private const MUNICIPALITIES = ['北京市', '上海市', '天津市', '重庆市'];
/**
* 从「经纬度 + 地址文本」解析**城市名**(如「青岛市」)。
*
* 三级回落,每一级都可用(用户第 1 条要的就是"城市",不能因为接口挂了就统计不出来):
* ① **高德逆地理**(有坐标时):`addressComponent.city` 是权威答案;
* ② 离线城市表 `CITIES` 做**地址包含匹配**(「黑龙江省伊春市…」里
* 能认出已知的城市名;不联网、不受接口抖动影响);
* ③ 从地址里提「XX市」(最朴素的一条,但中文地址里几乎总有)。
*
* ⚠️ 三级都拿不到就返回**空串**,不猜:`place` 里填的是
* 「溪水国家森林公园」「青岛胶东国际机场」这类**地点名**,
* 从里面猜城市只会猜错(用户宁可少一个城市,也不要一个假城市名)。
*
* ⚠️ 绝不抛异常:这是"锦上添花",它挂了不能让保存行程失败。
*/
public static function cityFromPoint($lng, $lat, string $fallback = ''): string
{
if (is_numeric($lng) && is_numeric($lat)) {
$ac = self::regeo((string)$lng, (string)$lat);
if ($ac !== null) {
$c = self::cityOfComponents($ac);
if ($c !== '') {
return $c;
}
}
}
return self::cityFromText($fallback);
}
/**
* 调高德逆地理,返回 `regeocode.addressComponent`(失败返回 null)。
*
* ⚠️ 用 cURL 而不是 `file_get_contents`:后者在 `allow_url_fopen=0`
* 的主机上不可用(宝塔默认可能就是关的),而且**没有超时控制**
* —— 高德慢一点,用户保存行程就会一直转圈。
* ⚠️ 成败看 body 里的 `status`(字符串 "1"),**不是** HTTP 状态码:
* 配额用完时高德返回 200 + status="0",只看 HTTP 会误判成功。
*/
public static function regeo(string $lng, string $lat): ?array
{
$key = self::apiKey();
if ($key === '' || !function_exists('curl_init')) {
return null;
}
$url = self::REGEO_URL . '?' . http_build_query([
'key' => $key,
'location' => $lng . ',' . $lat, // 高德要「经度,纬度」
'extensions' => 'base',
'output' => 'JSON',
]);
$ch = curl_init($url);
if ($ch === false) {
return null;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::HTTP_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => self::HTTP_TIMEOUT,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'FamilyBook/1.0',
]);
$body = curl_exec($ch);
$err = curl_errno($ch);
curl_close($ch);
if ($err !== 0 || !is_string($body) || $body === '') {
return null;
}
$j = json_decode($body, true);
if (!is_array($j) || (string)($j['status'] ?? '') !== '1') {
return null;
}
$ac = $j['regeocode']['addressComponent'] ?? null;
return is_array($ac) ? $ac : null;
}
/**
* 从高德的 addressComponent 里取出"城市"。
*
* ⚠️ 直辖市(北京/上海/天津/重庆)的 `city` 字段是**空数组或空串**,
* 它们本身就是城市一级。只读 `city` 会让这四地的行程一个城市都统计不出来
* (用户会觉得"北京那次怎么不算")。
*/
public static function cityOfComponents(array $ac): string
{
$pick = static function ($v): string {
// ⚠️ 高德在没有值时返回**空数组**(不是空串),is_array 这层不能省
if (is_array($v)) {
$v = $v['name'] ?? '';
}
return trim((string)$v);
};
$city = $pick($ac['city'] ?? '');
$prov = $pick($ac['province'] ?? '');
if ($city !== '') {
return $city;
}
foreach (self::MUNICIPALITIES as $m) {
if ($prov === $m) {
return $m;
}
}
return '';
}
/**
* 从**地址文本**里提城市(不联网;②③ 两级都走这里)。
*
* 顺序(可靠 → 不可靠):
* ① 先提「XX市」:中文地址里城市几乎总以「XX市」出现,
* 而且地址是 **省 → 市 → 区** 的层级顺序,所以取**第一个**「XX市」
* 就是地级市那级。
* ⚠️ 不能取"最后一个":「黑龙江省黑河市五大连池市五大连池镇」取最后
* 会得到县级市「五大连池市」,再往前回溯窗口一截就成「大连市」了
* (实测踩到);而且「山东省青岛市市南区」的"市南区"里还有个"市"字。
* ② 提不出来时才查离线表 `CITIES` 做**包含匹配**(最长优先)。
* ⚠️ 只在 ① 失败时用:「长春市朝阳区」包含「朝阳」(辽宁朝阳市在表里),
* 先走离线表会返回「朝阳市」—— 离了大谱(实测踩到)。
* 「青岛胶东国际机场」这种**没有"市"字的地点名**才是这一级要救的。
* ③ 直辖市(不带"市"字也可能写:「北京朝阳区」)。
*
* ⚠️ 不猜地点名:`place` 字段里全是「溪水国家森林公园」这种,
* 从里面猜城市只会猜错(宁可不计,不要算错)。
* 注意「青岛胶东国际机场」能提出来是因为"青岛"是**已知城市名**(离线表里有),
* 不是靠猜。
*/
public static function cityFromText(string $text): string
{
$t = trim($text);
if ($t === '') {
return '';
}
// ---- ⓪ 直辖市**先认**(必须在剥省份前缀之前)----
// ⚠️ 顺序错了会出大错(实测踩到):「北京市朝阳区建国路」如果先剥掉
// 「北京市」(它在 PROVINCE_WORDS 里),剩下「朝阳区建国路」,
// 接着在离线表里匹配到「朝阳」→ 返回**辽宁朝阳市**。离了大谱。
// 所以直辖市要在最前面单独认掉。
foreach (self::MUNICIPALITIES as $m) {
if (mb_strpos($t, $m, 0, 'UTF-8') !== false) {
return $m;
}
// 也可能是短名(「北京朝阳区」)
if (mb_strpos($t, mb_substr($m, 0, 2, 'UTF-8'), 0, 'UTF-8') !== false) {
return $m;
}
}
// ---- ① 提「XX市」----
// ⚠️ 三步的顺序与量词都很讲究,每一条都是实测踩出来的:
//
// a) **先去省份前缀**。不去的话「内蒙古自治区呼和浩特市」用惰性匹配
// 最多看 7 个字,`{2,7}?` 够不到「呼和浩特市」(省名占了 6 个字)
// → 提不出城市。
// b) **惰性量词 `{2,7}?`**,不是贪婪。贪婪会先试最长的,
// 「山东省青岛市市南区」会匹配到 `青岛市市`(把市南区的"市"当成后缀)
// → 得出「青岛市市」。惰性从最短开始试,先命中「青岛市」。
// c) **取第一个命中的**。地址是"省 → 市 → 区"的层级顺序,
// 第一个「XX市」就是地级市那级。
// ⚠️ 不能取最后一个:「黑龙江省黑河市五大连池市五大连池镇」取最后
// 会拿到县级市「五大连池市」,再回溯窗口一截就成了「大连市」。
$body = $t;
foreach (self::PROVINCE_WORDS as $pw) {
if ($pw !== '' && mb_strpos($body, $pw, 0, 'UTF-8') === 0) {
$body = (string)mb_substr($body, mb_strlen($pw, 'UTF-8'), null, 'UTF-8');
break;
}
}
// 排除"市场 / 市民 / 市区"这类:它们后面那个字会暴露自己
$badNext = ['场', '民', '集', '政', '长', '面', '内', '外', '郊', '容'];
if (preg_match('/([\x{4e00}-\x{9fa5}]{2,7}?)市([\x{4e00}-\x{9fa5}])?/u',
$body, $m)) {
$cand = $m[1];
$nxt = (string)($m[2] ?? '');
if (!($nxt !== '' && in_array($nxt, $badNext, true))) {
// 兜一层:万一身体里还残留「省」(「XX省XX市」连着写、没被上面剥掉)
$sp = mb_strpos($cand, '省', 0, 'UTF-8');
if ($sp !== false) {
$cand = (string)mb_substr($cand, $sp + 1, null, 'UTF-8');
}
if (mb_strlen($cand, 'UTF-8') >= 2) {
return $cand . '市';
}
}
}
// ---- ② 离线表包含匹配(最长优先)----
// 只有 ① 提不出来才走这里(见上面的说明:先走会被「朝阳」这类误命中)。
//
// 🔴 这一级**很危险**(实盘 dry-run 抓到):「五大连池风景区-龙门石寨景区」
// 里包含「大连」二字 → 被判成**辽宁大连市**(离了大谱)。
// 收紧两条:
// a) 匹配到的城市名**前面那一个字不能是汉字**(字符串开头除外)——
// 「五大连池」的"大连"前面是"五",被排除;「青岛胶东国际机场」的
// "青岛"前面是开头,保留。
// b) 匹配位置**后面**那一个字也不能是"字"这类能拼成新词的
// (「张家口」的"张家"后面是"口")—— 其实 a) 已够,b) 作补充。
// 老实说这一级只该救「XX机场」「XX站」这种**没有市字的地点名**,
// 真正的地址(带省市)都会在 ① 就被提出来。
$best = '';
$bestPos = -1;
foreach (self::CITIES as $name => $_xy) {
if ($name === '') {
continue;
}
// 直辖市在表里存的是短名,这一级不处理(交给 ③,且已经在前面的 ⓪ 认过了)
$isMuni = false;
foreach (self::MUNICIPALITIES as $mu) {
if (mb_substr($mu, 0, 2, 'UTF-8') === $name) { $isMuni = true; break; }
}
if ($isMuni) {
continue;
}
$p = mb_strpos($t, $name, 0, 'UTF-8');
if ($p === false) {
continue;
}
// (a) 前面那个字若是汉字 → 说明这个"城市名"是更长地名的一部分,跳过
if ($p > 0) {
$prev = mb_substr($t, $p - 1, 1, 'UTF-8');
if (preg_match('/[\x{4e00}-\x{9fa5}]/u', $prev)) {
continue;
}
}
// 最长优先(并列时取靠前的)
if (mb_strlen($name, 'UTF-8') > mb_strlen($best, 'UTF-8')) {
$best = $name;
$bestPos = $p;
}
}
if ($best !== '') {
return $best . '市';
}
// ---- ③ 直辖市(不带"市")----
foreach (self::MUNICIPALITIES as $m) {
$short = mb_substr($m, 0, 2, 'UTF-8');
if (mb_strpos($t, $short, 0, 'UTF-8') !== false) {
return $m;
}
}
return '';
}
/**
* 一段行程的**城市集合**(出发地 + 到达地,去重)。
*
* 用户第 1 条:「有多段行程,都有青岛作为出发地或者目的地,那么就只记一次」
* —— 正是这里:出发地与到达地**都算**,然后去重。
*
* @param array $n trip_nodes 行(读 from_city / to_city)
* @return string[] 城市名(出发地在前)
*/
public static function nodeCities(array $n): array
{
$out = [];
foreach (['from_city', 'to_city'] as $k) {
$c = trim((string)($n[$k] ?? ''));
if ($c !== '' && !in_array($c, $out, true)) {
$out[] = $c;
}
}
return $out;
}
/**
* 高德导航链接(TripLog 同款;用户第 3 条要"记录详细地址"就是为了能导航过去)。
*
* ⚠️ 有坐标走 `navigation`(精确到点),没坐标只能走 `search`(按名字搜)。
*/
public static function navUrl($lng, $lat, string $name = '', string $mode = 'car'): string
{
$a = fb_config('amap');
$src = (is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '') ?: 'family.example.com';
if (is_numeric($lng) && is_numeric($lat)) {
$to = (string)$lng . ',' . (string)$lat . ($name !== '' ? ',' . rawurlencode($name) : '');
return 'https://uri.amap.com/navigation?to=' . $to
. '&mode=' . rawurlencode($mode)
. '&policy=1&src=' . rawurlencode($src) . '&coordinate=gaode&callnative=1';
}
$kw = trim($name);
if ($kw === '') {
return '';
}
return 'https://uri.amap.com/search?keyword=' . rawurlencode($kw) . '&src=' . rawurlencode($src);
}
/** 高德 **Web 服务** key(服务端逆地理用;没配返回空串 → 自动走离线兜底) */
public static function apiKey(): string
{
$a = fb_config('amap');
return is_array($a) ? trim((string)($a['key'] ?? '')) : '';
}
/**
* 前端地址联想需要的配置(**JS API** key + 安全密钥)。
*
* @return array{key:string, security:string, nav_src:string}
*/
public static function jsConfig(): array
{
$a = fb_config('amap');
return [
'key' => is_array($a) ? trim((string)($a['key'] ?? '')) : '',
'security' => is_array($a) ? trim((string)($a['security_js'] ?? '')) : '',
'nav_src' => is_array($a) ? trim((string)($a['nav_src'] ?? '')) : '',
];
}
}
+79 -2
View File
@@ -31,6 +31,29 @@ final class NodeType
'other' => ['其他', '📌'],
];
/**
* TripLog(trip.yangyq.net)实际用过的类型 —— 用户第 17 批 第 2 条要求
* 「你看看 triplog 里面都有哪些,都拿过来」。
*
* ⚠️ 这份清单是**照着线上库抄的**(7 种,见 Schema 里那句注释)。
* 与 BUILTIN 的差别就两种:
* · 这里**没有「住宿」** —— 本地有,保留(家庭出游住宿是刚需,
* TripLog 当时用「休息」兼职了这件事,不代表我们也要那样);
* · 这里**多「休息/突发/购物/拜访亲友」** —— 本地要补上。
* 图标也照 TripLog 抄(🍽️ 😴 ⚠️ 🛍️ 🏠),保持两边认得出是同一类。
*
* @var array<string,string> 名字 => 图标
*/
public const TRIPLOG_TYPES = [
'赶路' => '🚗',
'游玩' => '🎡',
'用餐' => '🍽️',
'休息' => '😴',
'突发' => '⚠️',
'购物' => '🛍️',
'拜访亲友' => '🏠',
];
/**
* 一个家的全部类型(表里没有就先把内置的预置进去)。
*
@@ -41,6 +64,13 @@ final class NodeType
public static function all(int $familyId): array
{
self::seedDefaults($familyId);
// ⚠️ TripLog 那几种(休息/突发/购物/拜访亲友)也要在**读时**自愈地补。
// 只放在升级步骤里不够:升级是**一次性**的(靠 Schema 指纹触发),
// 而"重装/清库后重建"不会重跑升级 —— 那时表里只有 seedDefaults 的 5 种,
// 用户第 17 批 第 2 条要的那 4 种永远进不来(e2e 实测就是这个症状:
// 库重建后类型只剩「游玩,用餐,赶路,住宿,其他,赶海」)。
// seedFromTripLog 内部是 INSERT IGNORE,重复调用零成本。
self::seedFromTripLog($familyId);
$st = Database::get()->prepare(
'SELECT id, name, icon, is_system, position FROM node_types
WHERE family_id = ? ORDER BY position, id'
@@ -69,8 +99,7 @@ final class NodeType
if ($has >= count(self::BUILTIN)) {
return 0; // 已经有内置那几条了,不必逐个查(热点路径)
}
$hasAny = (int)$db->query('SELECT COUNT(*) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn();
$hasAny = $n; // 上面已经查过一次了,直接用
// ⚠️ 只在**一条都没有**时才预置:用户可能特意删掉了「用餐」(他家不用这个),
// 每次读都补回去会让人永远删不掉系统内置的那几种。
if ($hasAny > 0) {
@@ -89,6 +118,54 @@ final class NodeType
return $n;
}
/**
* 补齐 **TripLog 实际用过的**那一批类型(v24 第 17 批 用户第 2 条)。
*
* 用户原话:「添加行程那里,类型不够丰富,你看看 triplog 里面都有哪些,都拿过来。」
*
* TripLog 线上(trip.yangyq.net)实际有 7 种,其中 5 种是它自己的内置、
* 2 种是本人后来自己加的(`is_system=0`):
* 赶路🚗 / 游玩🎡 / 用餐🍽️ / 休息😴 / 突发⚠️ / 购物🛍️ / 拜访亲友🏠
*
* 与本地原有 5 种(游玩/用餐/赶路/住宿/其他)相比:
* · 差的是 **休息、突发、购物、拜访亲友**(本地没有);
* · 「住宿」是本地有而 TripLog 没有的 —— **保留**(用户没说要删,而且
* 家庭出游住宿是刚需,TripLog 当时是用「休息」兼职了这件事)。
*
* ⚠️ 与 `seedDefaults` 的关键区别:那个只在**表全空**时预置(保护用户的删除),
* 这个要**按名字逐个补**(只补缺的那几个)—— 因为用户明确要求"都拿过来",
* 而线上库已经有 5 条了,不补的话差的 4 种永远进不来。
* ⚠️ 用 `INSERT IGNORE`(靠 uk_nt_family_name 唯一键)而不是先查后插:
* 并发/重跑都安全,少一轮 N+1 查询。
* ⚠️ 标 `is_system=1`:这几个是"系统预置"(与 TripLog 同源),不该被删掉
* —— 它们在 TripLog 那边也是内置的。
*
* @return int 补了几条
*/
public static function seedFromTripLog(int $familyId): int
{
$db = Database::get();
try {
// 表还没建(极老的库,升级跑到这一步之前)→ 静默返回,别把页面炸掉
$db->query('SELECT 1 FROM node_types LIMIT 1');
} catch (\Throwable $e) {
return 0;
}
$st = $db->prepare(
'INSERT IGNORE INTO node_types (family_id, name, icon, is_system, position)
VALUES (?,?,?,1,?)'
);
$pos = (int)($db->query('SELECT COALESCE(MAX(position),0) FROM node_types WHERE family_id = '
. (int)$familyId)->fetchColumn());
$n = 0;
foreach (self::TRIPLOG_TYPES as $name => $icon) {
$pos += 10;
$st->execute([$familyId, $name, $icon, $pos]);
$n += $st->rowCount();
}
return $n;
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
+57 -1
View File
@@ -73,6 +73,15 @@ final class Schema
* (实际起止 / 参与人 / 备注),可无限次,供日后对比。
* 迁移:category='reality' → stage='actual','plan' → stage='plan'
* (按现有轨道自动迁,用户已确认)。
* 22 → **城市的聚合与地址(用户 2026-10-05 第 17 批)**:
* 用户第 1 条:「统计出来的不是目的地,也不是车站,而是城市…城市要聚合,
* 比如说有多段行程,都有青岛作为出发地或者目的地,那么就只记一次。」
* → 只靠 `trip_nodes.place` 一个自由文本字段是算不出"城市"的
* (那里填的是「青岛胶东国际机场」「溪水国家森林公园」这类**地点**)。
* 给 trip_nodes 补两列 `from_city` / `to_city`(出发地/到达地所属城市),
* 在**保存行程时**由 `App\Geo` 解析好存下来,读的时候直接去重。
* 为什么不每次读时现算:解析要调高德接口(有配额、有网络),
* 而城市是"写一次看很多次"的字段 —— 现算会让列表页变慢且可能超配额。
*/
/**
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
@@ -80,7 +89,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '21';
public const VERSION = '22';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -1106,6 +1115,53 @@ SQL,
}
});
// ---- v24 第 17 批:城市的聚合与地址(Schema v22)----
// 用户第 1 条要的是**城市**("经过了几个城市"),而 `place` 里填的是
// 「青岛胶东国际机场」这种**地点名**,直接拿它去重会得到一堆机场/车站名。
// 用户第 4 条又点明「图片右侧显示的是聚合的城市,而不是终点地址」——
// 所以必须在**出发地 / 到达地**上各解析出一个城市,去重后再展示。
//
// ⚠️ 解析结果**落库**而不是每次读时现算:
// · 解析要调高德逆地理接口(有配额、可能失败),
// · 而城市是"写一次、看很多次"的字段(列表页、日历、首页都要用),
// 现算会让每次列表刷新都打好几次接口 —— 既慢又可能把配额烧光。
// 高德拿不到时 `App\Geo` 会从地址文本里提「XX市」兜底,实在提不出就留空
// (留空只是这个城市不计入,不影响页面)。
self::addColumn($db, 'trip_nodes', 'from_city',
"ADD COLUMN `from_city` VARCHAR(60) DEFAULT NULL COMMENT '出发地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `to_place`");
self::addColumn($db, 'trip_nodes', 'to_city',
"ADD COLUMN `to_city` VARCHAR(60) DEFAULT NULL COMMENT '到达地所属城市(聚合统计用;由 App\\\\Geo 解析)' AFTER `from_city`");
// 地址的**详细地址**也一并存下来(TripLog 同款):用户第 3 条要
// 「出发地和到达地,都用高德地图的接口,记录详细地址」——
// 光有城市不够,导航要用的是详细地址(或坐标)。
self::addColumn($db, 'trip_nodes', 'from_addr',
"ADD COLUMN `from_addr` VARCHAR(300) DEFAULT NULL COMMENT '出发地详细地址(高德返回)' AFTER `to_city`");
self::addColumn($db, 'trip_nodes', 'to_addr',
"ADD COLUMN `to_addr` VARCHAR(300) DEFAULT NULL COMMENT '到达地详细地址(高德返回)' AFTER `from_addr`");
self::addColumn($db, 'trip_nodes', 'from_lng',
"ADD COLUMN `from_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地经度(高德返回,用于导航链接)' AFTER `to_addr`");
self::addColumn($db, 'trip_nodes', 'from_lat',
"ADD COLUMN `from_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '出发地纬度' AFTER `from_lng`");
self::addColumn($db, 'trip_nodes', 'to_lng',
"ADD COLUMN `to_lng` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地经度' AFTER `from_lat`");
self::addColumn($db, 'trip_nodes', 'to_lat',
"ADD COLUMN `to_lat` DECIMAL(10,6) DEFAULT NULL COMMENT '到达地纬度' AFTER `to_lng`");
// ⚠️ `seat_info` / `gate` **保留列、不再使用**(用户第 3 条:
// 「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
// 因为可能是多个人同行,座位什么的不记录了」)。
// 不删列的理由:删列不可逆,而 TripLog 迁来的老数据里这两个字段
// 是有真实内容的(本人当时逐条填过);留着它们在库里至少可查。
// 界面上彻底不再出现 —— 见 node_form.php / show.php。
// 类型扩充(用户第 2 条:「类型不够丰富,你看看 triplog 里面都有哪些,都拿过来」)。
// TripLog 线上实际有 7 种(含两个自定义的),本地只有 5 种内置,差的就是这些。
// ⚠️ 只**补**不删:已有的(用户可能改过名)一律保留。
self::tryStep('补齐 TripLog 那几种节点类型', static function () use ($db) {
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
\App\NodeType::seedFromTripLog((int)$fam['id']);
}
});
// ---- v24 第 13 批:旅程模块重构(Schema v19)----
// ① 给 trip_nodes 补三列(老库建表语句不会更新已存在的表,必须显式加)
self::addColumn($db, 'trip_nodes', 'stage',
+233 -41
View File
@@ -64,8 +64,32 @@ final class Trip
'other' => ['其他', '🧭'],
];
/** 自驾类(这两种才需要选车辆) */
public const DRIVE_MODES = ['drive', 'taxi'];
/**
* 自驾类(**只有自驾**才需要选车辆)。
*
* ⚠️ v24 第 17 批 用户第 3 条把范围收窄了:
* 原话「出行方式,只有选择**自驾**,才让选车辆」。
* 上一批是按 TripLog 的 `DRIVE_MODES` 把「打车」也算进来
* (打车时"坐的是哪辆车"确实可填),但用户明确不要 ——
* 打车是"叫一辆车",车牌没有记忆价值,选了反而逼人去车库里挑一台不相干的车。
*/
public const DRIVE_MODES = ['drive'];
/**
* 只需要填「车次 / 航班号」的出行方式(用户第 17 批 第 3 条)。
*
* 用户原话:「飞机、铁路,只记录车次、航班号就可以,其他的没法记录,
* 因为可能是多个人同行,座位什么的不记录了。」
*
* ⚠️ 于是这两个方式下**不再出现**「座位 / 车厢」「登机口 / 站台」
* —— 这两个字段对"一家人出行"毫无意义(一次订五六张票,座位号不在一起,
* 而登机口在出发当天才知道)。表单与列表都彻底不显示它们。
* `trip_nodes.seat_info` / `gate` 两列**保留不删**(老数据不丢,见 Schema)。
*
* ⚠️ 名单外的出行方式(公交/地铁/轮船等)连车次都不用填:
* 地铁没有"车次"可言,轮船的船名填在标题里就够。
*/
public const TICKET_MODES = ['train', 'plane'];
/**
* 行程的**状态机**(v24 第 13 批,Schema v19 —— 本次旅程重构的核心)。
@@ -155,31 +179,54 @@ final class Trip
}
/**
* 一条活动去过的城市(各段旅程 place 去重,按**最早出现的时间**排)。
* 一条旅程去过的**城市**(用户第 17 批 第 1/4 条)。
*
* ⚠️ 顺序按时间排而不是按字母/插入顺序:
* "青岛 · 威海" 让人一眼看出先后,排成字母序就完全没有这层信息了。
* ⚠️ skipped(未成行)的**不算去过** —— 计划去但没去成的地方不该出现在
* "去过哪几个城市"里(这与"不上日程、不进往年今日"是同一条口径)。
* 用户原话:
* · 第 1 条:「统计出来的不是目的地,也不是车站,而是**城市**…
* 城市要聚合,比如说有多段行程,都有青岛作为出发地或者目的地,
* 那么就只记一次。」
* · 第 4 条:「旅程详情页面,图片右侧显示的是**聚合的城市**,而不是终点地址。」
*
* 做法:把每条行程的 `from_city`(出发地城市)与 `to_city`(到达地城市)
* 都算进来,**去重** —— 出发地与到达地各算一票,同一城市只记一次。
*
* ⚠️ 这两个列是**保存行程时**由 `App\Geo` 解析好写进去的,不是读时现算:
* 解析要打高德接口(有配额、可能超时),而城市是"写一次看很多次"的字段。
* 老数据由补迁脚本回填(见 `_fb_b17_migrate.php`)。
* ⚠️ 两个列都空时**不回落**去读 `place`:`place` 里是「溪水国家森林公园」
* 这种**地点名**(用户明确说"不是目的地,也不是车站"),
* 拿它当城市正是这一批要改掉的东西。宁可暂时少一个城市,也不要显示错的。
* ⚠️ 排序按**最早出现的时间**:先后顺序本身是信息
* ("青岛 → 伊春 → 黑河"能看出路线,字母序看不出任何东西)。
* 同一时刻(老数据没时间的)用城市名兜底,保证顺序稳定可复现。
* ⚠️ skipped(未成行)**不算去过**:计划去但没去成的地方不该出现在
* "去过哪几个城市"里(与"不上日程、不进往年今日"是同一条口径)。
*/
public static function cities(int $eventId): array
{
// ⚠️ ORDER BY 里**不能**写聚合别名(first_at):MySQL/MariaDB 会报
// `Reference 'first_at' not supported (reference to group function)` ——
// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。
// 写成 `ORDER BY MIN(n.starts_at), n.place` 就没问题(聚合函数本身可用)。
$st = Database::get()->prepare(
"SELECT n.place, MIN(n.starts_at) AS first_at
FROM trip_nodes n
WHERE n.event_id = ? AND n.stage IN ('plan','actual')
AND n.place IS NOT NULL AND n.place <> ''
GROUP BY n.place
ORDER BY MIN(n.starts_at), n.place"
);
$st->execute([$eventId]);
// `Reference 'first_at' not supported (reference to group function)`,
// 而且这是**运行时**才炸(php -l 看不出),整个旅程模块直接 Fatal。
// 写成 `ORDER BY MIN(t), city` 就没问题(聚合函数本身可用)。
$sql = "SELECT c.city AS city, MIN(c.t) AS first_at, MIN(c.role) AS first_role
FROM (
SELECT from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role
FROM trip_nodes
WHERE event_id = ? AND stage IN ('plan','actual')
AND from_city IS NOT NULL AND from_city <> ''
UNION ALL
SELECT to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role
FROM trip_nodes
WHERE event_id = ? AND stage IN ('plan','actual')
AND to_city IS NOT NULL AND to_city <> ''
) c
GROUP BY c.city
ORDER BY MIN(c.t), MIN(c.role), c.city";
$st = Database::get()->prepare($sql);
$st->execute([$eventId, $eventId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$p = trim((string)$r['place']);
$p = trim((string)$r['city']);
if ($p !== '' && !in_array($p, $out, true)) {
$out[] = mb_substr($p, 0, 60, 'UTF-8');
}
@@ -235,19 +282,29 @@ final class Trip
'ends_at' => self::dtOrNull((string)($r['end_at'] ?? '')),
];
}
// ⚠️ 同 cities():ORDER BY 只写聚合函数,不写别名
// ⚠️ 同 cities():只认 from_city / to_city(第 17 批第 1 条的城市聚合),
// ORDER BY 只写聚合函数、不写别名。原来读的是 place —— 那是**地点名**
// (「溪水国家森林公园」),拿它当城市正是这批要改掉的。
$st2 = Database::get()->prepare(
"SELECT event_id, place, MIN(starts_at) AS first_at
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
AND place IS NOT NULL AND place <> ''
GROUP BY event_id, place
ORDER BY event_id, MIN(starts_at), place"
"SELECT c.event_id, c.city, MIN(c.t) AS first_at, MIN(c.role) AS first_role
FROM (
SELECT event_id, from_city AS city, COALESCE(starts_at, created_at) AS t, 0 AS role
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
AND from_city IS NOT NULL AND from_city <> ''
UNION ALL
SELECT event_id, to_city AS city, COALESCE(starts_at, created_at) AS t, 1 AS role
FROM trip_nodes
WHERE event_id IN ($in) AND stage IN ('plan','actual')
AND to_city IS NOT NULL AND to_city <> ''
) c
GROUP BY c.event_id, c.city
ORDER BY c.event_id, MIN(c.t), MIN(c.role), c.city"
);
$st2->execute($ids);
$st2->execute(array_merge($ids, $ids));
foreach ($st2->fetchAll() as $r) {
$id = (int)$r['event_id'];
$p = mb_substr(trim((string)$r['place']), 0, 60, 'UTF-8');
$p = mb_substr(trim((string)$r['city']), 0, 60, 'UTF-8');
if ($id > 0 && $p !== '' && !in_array($p, $city[$id], true)) {
$city[$id][] = $p;
}
@@ -641,6 +698,31 @@ final class Trip
if ($vid > 0 && !\App\Vehicle::find($familyId, $vid)) {
$vid = 0;
}
// ---- v24 第 17 批:出发地 / 到达地的**详细地址 + 坐标 + 城市** ----
// 用户第 3 条要「用高德地图的接口,记录详细地址」,第 1 条要按**城市**聚合统计。
// 用户第 3 条又明确「飞机、铁路,只记录车次、航班号就可以…座位什么的不记录了」
// → `seat_info` / `gate` 不再从表单读(列还留着,老数据不丢)。
$fromPlace = (string)($d['from_place'] ?? '');
$toPlace = (string)($d['to_place'] ?? '');
$fromAddr = trim((string)($d['from_addr'] ?? ''));
$toAddr = trim((string)($d['to_addr'] ?? ''));
$fromLng = self::coordOrNull($d['from_lng'] ?? null);
$fromLat = self::coordOrNull($d['from_lat'] ?? null);
$toLng = self::coordOrNull($d['to_lng'] ?? null);
$toLat = self::coordOrNull($d['to_lat'] ?? null);
// 城市:**优先用表单传来的**(前端联想选中时高德已经把城市带回来了,零成本),
// 没有才去解析。解析绝不抛异常(Geo 内部全兜底)。
$fromCity = mb_substr(trim((string)($d['from_city'] ?? '')), 0, 60, 'UTF-8');
$toCity = mb_substr(trim((string)($d['to_city'] ?? '')), 0, 60, 'UTF-8');
if ($fromCity === '') {
$fromCity = \App\Geo::cityFromPoint($fromLng, $fromLat, $fromPlace !== '' ? $fromPlace : $fromAddr);
}
if ($toCity === '') {
$toCity = \App\Geo::cityFromPoint($toLng, $toLat, $toPlace !== '' ? $toPlace : $toAddr);
}
// 详细地址为空时用地点名兜底(用户看到的是"出发地"那行字,不能空着)
if ($fromAddr === '') { $fromAddr = $fromPlace; }
if ($toAddr === '') { $toAddr = $toPlace; }
$fields = [
self::textOrNull2($d['title'] ?? ''),
self::textOrNull($d['detail'] ?? ''),
@@ -658,31 +740,70 @@ final class Trip
self::textOrNull2($d['line_name'] ?? ''),
self::textOrNull2($d['carrier'] ?? ''),
self::textOrNull($d['trip_no'] ?? ''),
// ⚠️ 第 17 批:这两个字段**不再从表单接收**(用户第 3 条要求去掉)。
// 但编辑老行程时如果把它们写成 null,TripLog 迁来的座位信息就永远消失了
// —— 所以这里"保持原值不变"(下面对 UPDATE 单独处理这两列)。
self::textOrNull($d['seat_info'] ?? ''),
self::textOrNull($d['gate'] ?? ''),
self::textOrNull2($d['from_place'] ?? ''),
self::textOrNull2($d['to_place'] ?? ''),
self::textOrNull2($fromPlace),
self::textOrNull2($toPlace),
// ---- v22 新增:城市 / 详细地址 / 坐标 ----
self::textOrNull($fromCity),
self::textOrNull($toCity),
self::textOrNull($fromAddr),
self::textOrNull($toAddr),
$fromLng, $fromLat, $toLng, $toLat,
];
$cols = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?';
carrier=?, trip_no=?, seat_info=?, gate=?, from_place=?, to_place=?,
from_city=?, to_city=?, from_addr=?, to_addr=?,
from_lng=?, from_lat=?, to_lng=?, to_lat=?';
if ($id !== null && $id > 0) {
$db->prepare('UPDATE trip_nodes SET ' . $cols . ' WHERE id=?')
->execute([...$fields, $id]);
// ⚠️ `seat_info` / `gate` 在 UPDATE 里**不参与**(表单已不提交它们):
// 写成 null 会把 TripLog 迁来的老数据抹掉。这两列从此只读不写。
$colsKeep = 'title=?, detail=?, place=?, starts_at=?, ends_at=?, node_type=?, status=?,
category=?, position=?, stage=?, travel_mode=?, vehicle_id=?, line_name=?,
carrier=?, trip_no=?, from_place=?, to_place=?,
from_city=?, to_city=?, from_addr=?, to_addr=?,
from_lng=?, from_lat=?, to_lng=?, to_lat=?';
$keep = $fields;
// 去掉 seat_info / gate 两项(它们在 $fields 里的下标是 15、16)
array_splice($keep, 15, 2);
$db->prepare('UPDATE trip_nodes SET ' . $colsKeep . ' WHERE id=?')
->execute([...$keep, $id]);
$nid = $id;
} else {
$db->prepare(
'INSERT INTO trip_nodes (family_id, event_id, title, detail, place, starts_at, ends_at,
node_type, status, category, position, stage, travel_mode, vehicle_id, line_name,
carrier, trip_no, seat_info, gate, from_place, to_place)
carrier, trip_no, seat_info, gate, from_place, to_place,
from_city, to_city, from_addr, to_addr, from_lng, from_lat, to_lng, to_lat)
VALUES (?,?,' . implode(',', array_fill(0, count($fields), '?')) . ')'
)->execute([$familyId, $eventId, ...$fields]);
$nid = (int)$db->lastInsertId();
}
self::saveNodeMembers($nid, $d['members'] ?? [], $d['roles'] ?? []);
// ⚠️ 第 17 批:**角色标注功能已去掉**(用户第 3 条:「给参与人标注角色这个功能
// 去掉,不要了」)→ 不再传 $d['roles']。saveNodeMembers 的第三个参数
// 保留(老调用点/修正流程还在用),传空数组即可。
self::saveNodeMembers($nid, $d['members'] ?? []);
return $nid;
}
/** 经纬度:只接受合理的数值范围,其余一律 null(脏数据不进库) */
private static function coordOrNull($v): ?float
{
if ($v === null || $v === '' || !is_numeric($v)) {
return null;
}
$f = (float)$v;
// 经度 ±180、纬度 ±90;超出范围的显然是解析错了,宁可不存
if (abs($f) > 180.0) {
return null;
}
return $f;
}
/**
* 行程类型:存**名字**(可自定义的类型表里那个 name),认不出退回 'other'。
*
@@ -897,14 +1018,29 @@ final class Trip
public static function saveNodeMembers(int $nodeId, array $personIds, array $roles = []): void
{
$db = Database::get();
$db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]);
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag)
VALUES (?,?,?)');
// ⚠️ v24 第 17 批:**不再整表 DELETE 再插**,改成"差集增删"。
// 原因:用户第 3 条把角色标注功能去掉了(表单不再提交 roles),
// 如果这里照旧先删光再插,`role_tag` 就会被写成 null ——
// TripLog 迁来的角色数据(虽然界面不再显示)会被**静默抹掉**。
// 现在只做两件事:删掉"本次没选的人",插入"新选的人";
// 已存在且仍被选中的,**原样不动**(role_tag 保住)。
$keep = [];
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
if ($pid > 0) {
$keep[$pid] = true;
}
}
if ($keep) {
$in = implode(',', array_fill(0, count($keep), '?'));
$db->prepare("DELETE FROM trip_node_members WHERE node_id = ? AND person_id NOT IN ($in)")
->execute([$nodeId, ...array_keys($keep)]);
} else {
$db->prepare('DELETE FROM trip_node_members WHERE node_id = ?')->execute([$nodeId]);
}
$ins = $db->prepare('INSERT IGNORE INTO trip_node_members (node_id, person_id, role_tag)
VALUES (?,?,?)');
foreach (array_keys($keep) as $pid) {
$role = mb_substr(trim((string)($roles[$pid] ?? $roles[(string)$pid] ?? '')), 0, 30, 'UTF-8');
$ins->execute([$nodeId, $pid, $role !== '' ? $role : null]);
}
@@ -1005,6 +1141,62 @@ final class Trip
return $s . ' – ' . date('Y年n月j日', $te);
}
/**
* 一段行程的时间,**精确到时分**(用户第 17 批 第 4 条末)。
*
* 用户原话:「行程中,时间明明具体到了时分秒,怎么不显示?」
*
* ⚠️ 原来用的是 `formatSpan()` —— 它只到**日**(`Y年n月j日`)。
* 那个精度是给"旅程"(容器)用的:一次出行说"7月10日到7月12日"就够。
* 但**一段行程**(比如"10:30 出发去机场")只显示日期等于没说 ——
* 用户填进去的时分秒被吞掉了,看起来像"我填的时间没保存"。
*
* 展示规则(同一天时只写一次日期,避免"2026年7月10日 10:00 – 2026年7月10日 12:00"
* 这种把同一天写两遍的啰嗦写法):
* · 同一天:`2026年7月10日 10:00 – 12:00`
* · 跨天: `2026年7月10日 10:00 – 7月12日 17:00`
* · 只有开始:`2026年7月10日 10:00`
* · 时分是 00:00(老数据只存了日期):**不写 00:00** —— 那会让人以为
* "这个人专门挑午夜开始",其实只是没填时间。
*/
public static function nodeSpan(?string $start, ?string $end): string
{
if ($start === null || $start === '') {
return '日期待补';
}
$ts = strtotime($start);
if ($ts === false) {
return '日期待补';
}
$sDate = date('Y年n月j日', $ts);
$sTime = date('H:i', $ts);
// 午夜 00:00 视为"没填具体时间"(日期控件的默认值),不显示它
$sHasTime = $sTime !== '00:00';
$head = $sDate . ($sHasTime ? ' ' . $sTime : '');
if ($end === null || $end === '') {
return $head;
}
$te = strtotime($end);
if ($te === false) {
return $head;
}
$eTime = date('H:i', $te);
$eHasTime = $eTime !== '00:00';
// 同一天:后半段只写时分
if (date('Y-m-d', $ts) === date('Y-m-d', $te)) {
if (!$eHasTime && !$sHasTime) {
return $sDate; // 两头都没时分 → 就是一天
}
if (!$eHasTime) {
return $head . ' 起'; // 结束没填时分:说"从几点开始"更准
}
return $sDate . ($sHasTime ? ' ' . $sTime : '') . ' – ' . $eTime;
}
// 跨天:结束那天写月日(不重复年份)
return $head . ' – ' . date('n月j日', $te) . ($eHasTime ? ' ' . $eTime : '');
}
/**
* 行程类型的**名字**。
*
+21 -8
View File
@@ -48,9 +48,13 @@ final class Vehicle
/**
* 财务里已登记的车子(**只读引用**,不复制进 vehicles)。
*
* 取的是所有账本里 kind='car' 的账目 —— 用户说"直接读取就可以"。
* 返回结构与自有车辆**同形**(id 用负数区分:`-账目id`,`from` 标来源),
* 这样模板里画下拉、画配置列表都能用同一套代码。
* 🔴 **自 v24 第 17 批(用户第 3 条)起,界面上已不再使用** ——
* 用户原话「车辆下拉框,不需要出现什么财务里登记的车,只把车辆列出来就行了」。
* 方法本身保留的两个理由:
* ① `vehicles.ledger_id` 是**软关联**(某台出行车在财务里对应哪条资产账目),
* 将来"按车统计油费/保养"要用它把两边的数字对起来;
* ② 删掉方法会让那层对应关系在代码里失去唯一的说明处(注释也跟着没了)。
* **不要再把它接回选车下拉** —— 那正是这一批要改掉的东西。
*/
public static function fromLedger(int $familyId): array
{
@@ -86,16 +90,25 @@ final class Vehicle
}
/**
* 选车下拉用的合并列表:财务的车子在前(用户已经录好了),自家车辆在后。
* 选车下拉用的列表 —— **只有 vehicles 表里的车**。
*
* @return array 每项都带 `from` = 'ledger' | 'local'
* ⚠️ 用户 2026-10-05 第 17 批 第 3 条把上一批的做法**推翻**了:
* 「车辆下拉框,不需要出现什么财务里登记的车,只把车辆列出来就行了。」
* 第 16 批第 8 条当时说的是"财务里的车直接读取就可以",这一批明确不要了。
* 理由(用户的思路):**出行的车**与**财务里的车子账目**是两回事 ——
* 后者记的是"这台车值多少钱"(资产),没有车牌车型,混进选车列表里
* 只会让人在"大众朗逸"和"家里的老车"之间发懵。
* 所以现在选车只认 vehicles 表(自家 / 租的 / 借的都录在这里)。
*
* ⚠️ `fromLedger()` 方法**暂时保留但不再被任何界面调用**(见下方注释):
* 财务里那些车是真实存在的资产,将来若要"按车统计油费/保养"还会用到;
* 删掉它反而会丢掉那层对应关系(`vehicles.ledger_id` 软关联还指着它)。
*
* @return array 每项都带 `from` = 'local'
*/
public static function pickable(int $familyId): array
{
$out = [];
foreach (self::fromLedger($familyId) as $v) {
$out[] = $v;
}
foreach (self::all($familyId) as $v) {
$v['from'] = 'local';
$out[] = $v;