Files
FamilyBook/app/ContactImporter.php
T
laoyang 52cc1ecccb v24 第 9 批:名册九条(分组按钮/加入名册/生日月日/地址四级/千人性能/拖动修复/删除人员/返回名册/Semi DatePicker)
- #131 名册卡片加「分组」按钮(details+radio,显示当前分组,选中即改)
- #132 修复「从已有人里加入名册」无反应
- #133 生日只显示月和日(阳历 MM-DD / 农历中文名),PersonQuery::birthdaysOf 取代 birthdayOf
- #134 地址结构化为 国家/省/市/区+详细:regions 表(3429 行全国区划)+ Region.php +
  ?r=regions 级联接口 + 表单 .addr-grid + 老地址回填(Schema v16)
- #135 千人名册性能:.person content-visibility:auto + 名册页 ob_gzhandler
  (实测 服务端 1.15s/3.8MB → 0.17s/87KB;客户端导航→load 1.35s → 0.37s)
- #136 拖动排序改 Pointer Events(Chrome 下 HTML5 DnD 在 DOM 变动时断)
- #137 个人详情页底部「删除人员」
- #138 编辑资料页加「返回名册」
- #139 所有日期选择换 Semi Design DatePicker(渐进增强:原生四字段保 POST 契约,
  懒加载 vendor semi UMD,值回写原字段;工作经历 type=month 一并升级;宣纸主题对齐)

验证:e2e 785/0、batch9 37/0、upgrade 126/0、picker 28/0、mobile 688/0、interact 97/0、
batch_e 91/0、import 21/0、douban 22/0、drag 5/0、semi-integrate 28/0、semi-probe 17/0、
geom 5/5;部署后线上自检 16/16(含 4 个 semi 资源 200 + regions 路由)。
2026-10-04 08:56:40 +08:00

342 lines
16 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 用户 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
{
$added = 0;
$updated = 0;
$skipped = 0;
$log = [];
// 现有的人一次取出来做索引(几十~几百人,比逐条查库快得多,也不会漏判)
// 索引键: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);
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');
// 建完把索引补上,避免同一个文件里出现两条同名时建出两个人
$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));
}
}