} */ 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 = [ 'name' => $name !== '' ? $name : $phone, 'phone' => $phone, 'email' => (string)($r['email'] ?? ''), 'address' => (string)($r['address'] ?? ''), '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' => '', ]; 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[] 这次补上了哪些(中文标签,用来告诉用户"改了什么") */ private static function fillMissing(array $person, array $r, int $meId): array { $pid = (int)$person['id']; $cols = []; // "`email` = ?" => 值 $labels = []; // 给人看的"补了什么" // ---- ① persons 上的标量列:他空着才补 ---- $scalar = [ 'email' => ['邮箱', (string)($r['email'] ?? ''), 200], 'address' => ['住址', (string)($r['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[] = '职务/职称'; } // 生日:他一个生日都没有时才补。有生日就绝不动 —— 生日是强身份信息,错了很难发现。 $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)); } }