Files
FamilyBook/app/ContactImporter.php
laoyang 8d79857f1a v54:首页任务标题截断(真根因=两列网格被撑破)/ 名册分组→标签且候选读词表 / 旅程地图视图 / 旅程设置按钮 / 显示方式默认列表或地图
用户 2026-10-16 傍晚五条。

① 首页「近期任务」标题太长会挤成两行
   用户原话:「首页**近期任务**那个地方要注意截断:如果任务标题太长,
   会把这个任务**挤成两行**。」
   🔴🔴 **真根因是两层,第一版只修了表面**:
     · 表面:`.dash-what` 只有 `flex:1 1 auto; min-width:0`,**缺** nowrap/hidden/ellipsis
       ⇒ 收缩后文字在框内换行;
     · 真身:`.home-duo` 是**两列 grid**,grid 子项默认 `min-width: auto`
       ("我最小也得装下我的内容")⇒ 一条长标题把那一列顶到 **1763px**
       (父 `home-duo` 才 1032px)⇒ **整个两列布局被撑破**、页面横向溢出到 **2089px**
       (视口 1440)。⇒ 行宽 1721px、`scrollWidth === clientWidth`,
       **`text-overflow: ellipsis` 看起来"没生效"** —— 其实容器自己先爆了。
   修法:`.home-duo > * { min-width: 0; }`,且 **整条 flex 链**都补
     (`.hd-pane` → `.hd-pane-head` → `.hd-pane-body` → `.dash-list/.tk-list` → `.tk-row`)
     —— 链上任何一层漏了,收缩就断在那一层、下面的 nowrap 全白写。
   配套:任务标题加 `title=` 悬停看全。
   ⚠️ 这是本项目**第二次**踩 flex/grid 的 `min-width:auto`(v53 的 `.mv-grid` 是同类)。
      **以后"文字截断不生效"先量容器有没有被撑破**。

② 名册「分组」→「标签」;打标签的候选要**从设置里读**
   用户原话:「现在名册里面给人打标签的时候,**这个标签数据是从哪读出来的**呀?
   在这个名册的设置页面,有很多分组,现在还叫分组。但是打标签的时候,
   **却不是从这些分组里读出来的**。……应该叫「名册标签」,是不是?
   **应该从这里面把所有标签都读出来**吧?……以后就没有分组的概念了,
   **全部都是标签的概念**。」
   🔴 **用户观察得完全正确,这是两套数据源**:
     · 设置页显示的词表来自 `person_groups`(**分组表**);
     · 打标签的候选来自 `PersonTag::cloud()`(**你已经打过的词**,现算的);
     ⇒ 两边根本不是一个数据源 ⇒ "设置里列着一堆、打标签时却只有几个"。
   修法:**`PersonTag::choices()` = 设置页词表 ∪ 我用过的词**(并集,词表在前)。
     · 词表里的词**还没用过**也能一点就打上(这正是词表的意义);
     · 用过的词补在后面(历史不丢);
     · 名册列表 / 新增 / 编辑 / 挑人 / 导入 **五处同一个函数**。
   界面口径全面改"标签"(**表名仍是 `person_groups`**:改名要背着 `PersonGroup` 类、
   `person_roster.group_id`、以及**另一个会话正在写的 `app/ApiHandler.php`**
   (它引用了 `PersonGroup::orderFor`)一起动,风险远大于收益):
     · `_person_groups.php` 标题「名册标签」、按钮「加一个我自己的标签」;
     · 删除文案改成"只是从候选里去掉这个词,已打在别人身上的不受影响"
       (**语义真的变了**:标签是各自身上的字符串,不是引用);
     · `people/_settings_menu.php`、`people/settings.php`、`settings/me.php`、
       `settings.php`(「公共分组」→「公共标签」)全部同步。
   顺带按"全部都是标签"改另两条入口:
     · `people/pick.php`「加进哪一组」下拉 → **打标签**;
     · `people/import.php` 同上;`ContactImporter::run()` 加 `$tags` 参数。
   🔴 新增 `PersonTag::addOne()`(**追加**一个标签)—— 这两处**绝不能用 `setFor()`**
      (它是"先 DELETE 再 INSERT"的整组替换)⇒ 会把人**原有**的标签清光
      (症状是"用一次导入,别人原有的标签全没了",极难联想)。
   ⚠️ `group_id` 在 pick/import 的**服务端仍收着**(老书签/脚本兼容),只是界面不再展示。

③ 旅程模块加**地图视图**(读 TripLog 移植)
   用户原话:「你去读一下 TripLog 的代码,在旅程这个模块,**可以在地图上显示旅程数据**。
   1. 先读一下它之前那个代码是怎么实现的,**在我们的旅程模块也实现一下**。」
   读 TripLog(`templates/activities/index.php`)得到的要点:
     · 用**高德 AMap**(合规白名单内,绝不是境外瓦片);
     · `position` 必须是 **[lng,lat] 数组**(传 `{lng,lat}` 裸对象会让 AMap 抛错、
       整个绘制循环当场中断 —— 它注释里写着"批 11 的光秃秃地图就是它");
     · `setFitView()` 后**上限 11 级**(只有一趟时会被放大到街道级,那是导航视角);
     · `.amap-marker` 整层 `pointer-events:none`(否则包装层挡住别家卡片的点击);
     · **容器必须有固定高度**(0 高度 → 空白地图)。
     它那套"同城环形扇开"是**几百行几何**,本仓数据量没压出这个问题 ⇒ **有意不做**。
   本仓数据更规整:`trip_nodes` 早有 `from_lng/from_lat/to_lng/to_lat`(v17 批)
   + `from_city/to_city` ⇒ 新增 `Trip::attachSpots()`:
     ① 有坐标取**重心**(不是端点 —— 端点会让"青岛→伊春"偏到某一头);
     ② 没坐标退到**城市重心**(`Geo::lookup` 离线城市表,**不发网络请求**:
        列表页几十趟若逐条打高德逆地理,一次开页就是几十个外部请求);
     ③ 都没有就不上图(列表里照常有,模板会说清"有几趟没上图")。
     🔴 城市那里**必须也取重心**:`cities()` 的顺序是**按时间**(出发地在最前),
        取"第一个城市"会让**所有旅程都叠在家上**、地图毫无信息量。
   布局:两个视图用**标签页**(`?view=map` 是**链接**不是纯 JS 按钮 ——
     书签/前进后退/无 JS 都正确;也**有意不**把地图藏在 `display:none` 里,
     高德在 0 尺寸容器里会渲染空白)。
   高德 SDK 加载器重构:原来 `fbLoadAddr()` 顺带拉 `addr_suggest.js`,
   而地图只用 SDK 自带的 `AMap.Map/Marker` ⇒ 抽出 `window.fbAmapSdk()`(只拉 SDK)、
   新增 `fb-amap` meta(不带 plugin),`fb-addr` 那份共用同一加载器。

④ 旅程列表页「新建旅程」**后面**加「设置」按钮
   用户原话:「2. 在旅程的「新建旅程」后面也加一个「设置」按钮。」
   ⇒ 补 `_settings_menu.php`(设置恒排最右),菜单**第一条**加「显示方式」。

⑤ 显示方式:默认列表还是地图(用户自配)
   用户原话:「3. 里面的设置让用户自己配置:**到底默认显示旅程列表,还是用地图显示**。」
   ⇒ 新增 `trips/display`(设置页 + `?set=` 快速设置)与 `trips/display_save`,
     存 **`user_settings.trip_default_view`**(每人一份,**不是**全家设置)。
   ⚠️ 访客也能改(只影响自己看到的默认,不是数据变更)。

验证(新增 2 套件 + 更新 e2e/deploy 断言):
 · _fb_v54_ui.py(**33 条**真机断言)
 · _b54_falsify.py(**11 注入 / 11 全抓**,基线 33/0)
 · e2e **1518/0**;familybook_deploy.sh 加「第54批形态」段(**24 条**)
 · 部署 **0 FAIL**;线上 **20 个文件 md5 逐字节一致**;
   线上实测:词表 24 条、`choices()` 24 条、**`cloud` 15 条全部 ⊆ `choices`**、
   4 趟旅程全部可上图(样例落在济南·青岛重心);诊断页已删复验 404

⚠️ 本轮踩坑(已写进 pitfalls.md):
 · 🔴🔴 **"文字截断不生效"要先量容器有没有被撑破**(第一嫌疑是 flex/grid 子项
   `min-width:auto`,不是 `text-overflow` 写错);
 · **反向断言("不该再出现 X")必须用 `_code_has`(剥注释)** ——
   需求原话正当留在注释里,裸 `grep -F` 会命中自己的说明(本轮部署假红一次);
 · **冗余防线会让反向验证打不红**(两处 `min-width:0` 只去掉一条 ⇒ 探针照旧全绿,
   注入必须一起去掉);
 · **界面多加一个字符就可能打歪既有断言**(我在词表管理页给标签名加了 `#`,
   把 e2e 的 `my_group_order()` 读成 `#家人`)。
2026-10-17 00:15:06 +08:00

371 lines
19 KiB
PHP
Raw Permalink 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 用户 2026-10-03 第 1 条)。
*
* 与 App\ContactImport 的分工:
* · ContactImport —— 只管"文件 → 记录数组"(纯解析,不碰库);
* · 本类 —— 只管"记录数组 → 库里"(判重、补缺、建号、挂名册)。
* 分开的好处:解析器可以单独测(给一段 vCard 看输出),不需要库。
*
* 用户要求的两条规则(原话):
* 「导入时,如果名字或手机号在已有的名册中存在,则导入其他信息,
* 如果这些信息在系统中已经存在,则只导入其他信息。」
* 落成三条:
* ① **判重** 按 手机号(哈希,精确)→ 姓名(大小写无关,精确)依次找;
* ② 命中已有的人 → **只补空字段**,绝不覆盖(批量覆盖没法回退,见 ContactImport 的说明);
* ③ 没命中 → 新建一条,并加进导入者的名册。
*
* ⚠️ 判重范围是**整个家庭**(不只是我的名册):同一个家里"洪勇"只能有一条记录 ——
* 否则导入一次会造出一个重复的人,之后生日、关系、观影记录全都分成两半。
*/
declare(strict_types=1);
namespace App;
final class ContactImporter
{
/**
* @return array{added:int,updated:int,skipped:int,people:array<int,array{name:string,action:string,added:array}>}
*/
public static function run(int $familyId, int $meId, array $records, int $groupId = 0, array $tags = []): array
{
$added = 0;
$updated = 0;
$skipped = 0;
$log = [];
/* 🔴 v54(用户 2026-10-16 第 2 条):导入时可以**顺手打一串标签**。
用户原话:「现在已经没有分组的概念了,**全部都是标签的概念**。」
⚠️ 用 `PersonTag::addOne()`(**追加**)而不是 `setFor()`(整组替换)——
命中已有的人时,`setFor` 会把他身上**原有**的标签一起清掉
("用一次导入,别人原有的标签全没了")。语义上这里就是"多加一个"。
⚠️ 老参数 `$groupId` 保留:不为别的,只为**不改既有调用契约**;
新界面不再传它(`templates/people/import.php` 已换成标签输入)。 */
$tags = PersonTag::clean($tags);
// 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判)
// 索引键:phone_hash(精确)+ 姓名(小写)
$byPhone = [];
$byName = [];
foreach (Person::all($familyId, '', 'member', $meId) as $p) {
if (!empty($p['phone_hash'])) {
$byPhone[(string)$p['phone_hash']] = $p;
}
$n = mb_strtolower(trim((string)$p['name']), 'UTF-8');
if ($n !== '' && !isset($byName[$n])) {
$byName[$n] = $p;
}
}
foreach ($records as $r) {
$name = trim((string)($r['name'] ?? ''));
$phone = trim((string)($r['phone'] ?? ''));
if ($name === '' && $phone === '') {
$skipped++;
continue;
}
// ---- ① 判重:手机号优先(最精确),其次姓名 ----
$hit = null;
if ($phone !== '') {
$h = Crypto::hash($phone);
if (isset($byPhone[$h])) {
$hit = $byPhone[$h];
}
}
if ($hit === null && $name !== '') {
$k = mb_strtolower($name, 'UTF-8');
if (isset($byName[$k])) {
$hit = $byName[$k];
}
}
if ($hit !== null) {
// ---- ② 命中:只补空字段 ----
$filled = self::fillMissing($hit, $r, $meId);
/* 🔴🔴 v52(用户 2026-10-15 第 5 条):「而用户导入数据时,**也是一样的**,
如果名册中有这个人,就不用再新增了,而是根据名字、手机号等信息
**自动匹配后添加到这个用户的名册中**。」
⇒ 命中已有的人时,**必须**把他也挂进**导入者的名册** ——
否则会出现最费解的一种结果:导入文件里有这个人、系统里也有这个人,
但导入完**我的名册里还是看不到他**(他只在别人名册里),
用户只会觉得"导入漏了人"。
⚠️ 这一段原先只在"没命中 → 新建"那条路上挂名册(见下面 ③ 的 ensure),
命中那条路**没有** —— 正是本批要补的缺口。
⚠️ `ensure` 是幂等的(已在名册里就返回 false),所以重复导入不会出问题。 */
PersonRoster::ensure($familyId, $meId, (int)$hit['id'],
$groupId > 0 ? $groupId : null, 'self');
// 🔴 v54:命中已有的人时**追加**标签(不动他原有的 —— 见方法头的说明)
foreach ($tags as $tg) {
PersonTag::addOne($familyId, $meId, (int)$hit['id'], $tg);
}
if ($filled) {
$updated++;
$log[] = ['name' => (string)$hit['name'], 'action' => 'updated', 'added' => $filled];
} else {
$skipped++;
$log[] = ['name' => (string)$hit['name'], 'action' => 'same', 'added' => []];
}
continue;
}
// ---- ③ 没命中:新建 ----
$d = array_merge([
'name' => $name !== '' ? $name : $phone,
'phone' => $phone,
'email' => (string)($r['email'] ?? ''),
'org' => (string)($r['org'] ?? ''),
// 职务 + 职称合成一列(与 PersonJob::joinTitle 同一口径:
// 两个都有时用 " · " 分开,只有一个就只用那个)
'title' => PersonJob::joinTitle(
(string)($r['title'] ?? ''),
(string)($r['jobrank'] ?? '')
) ?? '',
'note' => (string)($r['note'] ?? ''),
'kind' => 'member',
'avatar' => '',
'birth_rank' => '',
'is_placeholder' => 0,
// 生日:解析器只给"能确定"的日期,给了就当阳历
'birthday_source' => 'solar',
'birthday_solar' => (string)($r['birthday'] ?? ''),
'birthday_lunar' => '',
'birthday_lunar_leap' => 0,
'birthday_id_card' => '',
], self::addrFields($r));
try {
$pid = Person::create($familyId, $d);
} catch (\Throwable $ex) {
$skipped++;
continue;
}
// 补充联系方式(除主手机号之外的那些)
$extra = [];
$extraPhones = array_values(array_filter((array)($r['phones'] ?? []),
static fn($v) => trim((string)$v) !== '' && (string)$v !== $phone));
foreach ($extraPhones as $i => $v) {
$extra['phone'][] = ['label' => $i === 0 ? '其他' : '', 'value' => (string)$v];
}
$extraEmails = array_values(array_filter((array)($r['emails'] ?? []),
static fn($v) => trim((string)$v) !== '' && (string)$v !== (string)($r['email'] ?? '')));
foreach ($extraEmails as $i => $v) {
$extra['email'][] = ['label' => $i === 0 ? '其他' : '', 'value' => (string)$v];
}
if (($r['url'] ?? '') !== '') {
$extra['url'][] = ['label' => '', 'value' => (string)$r['url']];
}
if ($extra) {
Person::replaceContacts($pid, $extra);
}
// 工作经历(第 4 条那张表):导入来的单位/职务就是一段经历。
// ⚠️ 必须**同时**建这一段,否则详情页的"工作经历"永远是空的,
// 而 persons.org 里却有值 —— 两处对不上,用户会以为数据坏了。
if (trim((string)($r['org'] ?? '')) !== '' || trim((string)($r['title'] ?? '')) !== ''
|| trim((string)($r['jobrank'] ?? '')) !== '') {
PersonJob::replace($pid, [[
'org' => (string)($r['org'] ?? ''), 'title' => (string)($r['title'] ?? ''),
'rank_name' => (string)($r['jobrank'] ?? ''),
'start_ym' => '', 'end_ym' => '', 'note' => '',
]]);
}
// 进导入者的名册(谁导入的,就先出现在谁的名册里)
PersonRoster::ensure($familyId, $meId, $pid, $groupId > 0 ? $groupId : null, 'self');
// 🔴 v54:新建的人也打上这串标签
foreach ($tags as $tg) {
PersonTag::addOne($familyId, $meId, $pid, $tg);
}
// 建完把索引补上,避免同一个文件里出现两条同名时建出两个人
$byName[mb_strtolower($d['name'], 'UTF-8')] = ['id' => $pid, 'name' => $d['name']];
if ($phone !== '') {
$byPhone[Crypto::hash($phone)] = ['id' => $pid, 'name' => $d['name']];
}
$added++;
$log[] = ['name' => (string)$d['name'], 'action' => 'added', 'added' => ['新建']];
}
return ['added' => $added, 'updated' => $updated, 'skipped' => $skipped, 'people' => $log];
}
/**
* 把记录里**他没有的值**补上去;已有非空值一律不动。
*
* ⚠️ 写库只分两处:
* ① `persons` 上的标量列(邮箱/住址/单位/职务/生日)→ 一条 UPDATE;
* ② 多值(电话/邮箱/网址)→ 走 person_contacts 的整组替换(要把现有的并进去)。
* 第一版把"SQL 片段"与"中文标签"交替塞进同一个数组,读起来极易出错;
* 这里改成两个独立容器,各司其职。
*
* @return string[] 这次补上了哪些(中文标签,用来告诉用户"改了什么")
*/
/**
* 把一条导入记录里的地址整理成 persons 的五个字段。
*
* 分两种来源:
* · vCard 的 ADR **本来就是分段的**(街道/城市/省/国家各占一段)→ 直接用,
* 再用 Region::parse() 从街道那一段里认出「区 / 县」;
* · CSV 那种一整串自由文本 → 整串交给 Region::parse() 逐级前缀匹配拆开。
*
* ⚠️ 认不出来的部分**一律留在 address(详细地址)里**,绝不丢:
* 结构化是为了"以后能按省找人",不是为了让地址变短。
*
* @return array{address:string,addr_country:string,addr_province:string,addr_city:string,addr_district:string}
*/
private static function addrFields(array $r): array
{
$country = trim((string)($r['addr_country'] ?? ''));
$province = trim((string)($r['addr_province'] ?? ''));
$city = trim((string)($r['addr_city'] ?? ''));
$district = trim((string)($r['addr_district'] ?? ''));
$detail = trim((string)($r['address'] ?? ''));
if ($province === '' && $city === '' && $detail !== '') {
// 一整串自由文本 → 逐级前缀匹配
$p = Region::parse($detail);
$country = $country !== '' ? $country : (string)($p['country'] ?? '');
$province = (string)($p['province'] ?? '');
$city = (string)($p['city'] ?? '');
$district = (string)($p['district'] ?? '');
$detail = (string)($p['detail'] ?? '');
} elseif ($detail !== '' && $district === '') {
// 已经有省/市(vCard 给的),从剩下的街道里再认一次区县
$p = Region::parse($detail);
if (($p['district'] ?? null) !== null) {
$district = (string)$p['district'];
$detail = (string)($p['detail'] ?? '');
}
}
return [
'address' => $detail,
'addr_country' => $country,
'addr_province' => $province,
'addr_city' => $city,
'addr_district' => $district,
];
}
private static function fillMissing(array $person, array $r, int $meId): array
{
$pid = (int)$person['id'];
$cols = []; // "`email` = ?" => 值
$labels = []; // 给人看的"补了什么"
// ---- ① persons 上的标量列:他空着才补 ----
$addr = self::addrFields($r);
$scalar = [
'email' => ['邮箱', (string)($r['email'] ?? ''), 200],
// ⚠️ 地址要走 addrFields() 拆过的那份:直接拿 $r['address'] 会把
// vCard 里"省+市+街道"拼成的一整串原样塞进详细地址,等于没结构化。
'address' => ['住址', $addr['address'], 200],
'org' => ['工作单位', (string)($r['org'] ?? ''), 120],
'note' => ['备注', (string)($r['note'] ?? ''), 2000],
];
foreach ($scalar as $col => $def) {
$label = $def[0];
$new = trim($def[1]);
$max = (int)$def[2];
if (trim((string)($person[$col] ?? '')) === '' && $new !== '') {
$cols["`$col` = ?"] = mb_substr($new, 0, $max, 'UTF-8');
$labels[] = $label;
}
}
// 职务与职称在库里是同一列 title(他那一列空着才补)
$newTitle = trim((string)($r['title'] ?? '') . ' ' . (string)($r['jobrank'] ?? ''));
if (trim((string)($person['title'] ?? '')) === '' && $newTitle !== '') {
$cols['`title` = ?'] = mb_substr($newTitle, 0, 120, 'UTF-8');
$labels[] = '职务/职称';
}
// 地址的四级结构:同样**只补空着的**(他已经在表单里选过省市区就不动)
foreach (['addr_country' => '国家', 'addr_province' => '省',
'addr_city' => '市', 'addr_district' => '区/县'] as $col => $label) {
if (trim((string)($person[$col] ?? '')) === '' && trim($addr[$col]) !== '') {
$cols["`$col` = ?"] = mb_substr(trim($addr[$col]), 0, 60, 'UTF-8');
$labels[] = $label;
}
}
// 生日:他一个生日都没有时才补。有生日就绝不动 —— 生日是强身份信息,错了很难发现。
$hasBd = trim((string)Birthday::solarOf($person)) !== ''
|| trim((string)($person['birthday_lunar'] ?? '')) !== '';
if (!$hasBd && trim((string)($r['birthday'] ?? '')) !== '') {
$cols['`birthday` = ?'] = (string)$r['birthday'] . ' 00:00:00';
$labels[] = '生日';
}
if ($cols) {
$sql = [];
$args = [];
foreach ($cols as $frag => $val) {
$sql[] = $frag;
$args[] = $val;
}
$args[] = $pid;
Database::get()->prepare('UPDATE persons SET ' . implode(', ', $sql) . ' WHERE id = ?')
->execute($args);
}
// ---- ② 多值(电话/邮箱/网址):只在"这一类他一个都没有"时补 ----
// ⚠️ 判据是"这一类为空",不是逐条去重:person_contacts 里的手机号是**密文**,
// 没法拿明文比;而"他这一类本来就空着"是能确定的(也符合"只补空"的要求)。
$cur = Person::contactsOf($pid);
$add = [];
$mainPhone = trim((string)($r['phone'] ?? ''));
$mainMail = trim((string)($r['email'] ?? ''));
$extraPhones = array_values(array_filter((array)($r['phones'] ?? []),
static fn($v) => trim((string)$v) !== '' && trim((string)$v) !== $mainPhone));
$extraEmails = array_values(array_filter((array)($r['emails'] ?? []),
static fn($v) => trim((string)$v) !== '' && trim((string)$v) !== $mainMail));
if (!$cur['phone'] && $extraPhones) {
foreach ($extraPhones as $v) {
$add['phone'][] = ['label' => '', 'value' => (string)$v];
}
$labels[] = '电话';
}
if (!$cur['email'] && $extraEmails) {
foreach ($extraEmails as $v) {
$add['email'][] = ['label' => '', 'value' => (string)$v];
}
$labels[] = '邮箱';
}
if (!$cur['url'] && trim((string)($r['url'] ?? '')) !== '') {
$add['url'][] = ['label' => '', 'value' => (string)$r['url']];
$labels[] = '网址';
}
if ($add) {
// replaceContacts 是**整组替换** → 必须把现有的并进去,否则会把已有联系方式清掉
$merged = $cur;
foreach ($add as $k => $rows) {
foreach ($rows as $row) {
$merged[$k][] = $row;
}
}
Person::replaceContacts($pid, $merged);
}
// ---- ③ "工作经历"里空着、而这次**刚补上**了单位/职务 → 补一段经历 ----
// ⚠️⚠️ 两个硬条件,缺一不可:
// ① 这次确实补了(`$cols` 里刚写过 org 或 title)—— 他已经有的值不能碰;
// ② 经历表里本来就没有 —— 有经历时以经历表为准,不重复插。
// 为什么条件①这么关键:`PersonJob::replace()` 末尾会调
// `syncCurrent()` 把"最新那段"回写到 persons.org/title。若在这里对
// "已有单位的人"也插一段导入来的单位,syncCurrent 就会把
// **他原来的单位覆盖掉** —— 正好违反"只补空、不覆盖"。
// (这个 bug 是被 _fb_import_test.py 的 ⑩ 抓出来的。)
$justFilledOrg = isset($cols['`org` = ?']);
$justFilledTitle = isset($cols['`title` = ?']);
if (($justFilledOrg || $justFilledTitle) && !PersonJob::list($pid)) {
PersonJob::replace($pid, [[
// 用**刚存进去的值**(不是导入文件里的原值)——
// 这样 syncCurrent 回写时与库里一致,不会造成二次变动。
'org' => (string)($cols['`org` = ?'] ?? ''),
'title' => (string)($r['title'] ?? ''),
'rank_name' => (string)($r['jobrank'] ?? ''),
'start_ym' => '', 'end_ym' => '', 'note' => '',
]]);
}
return array_values(array_unique($labels));
}
}