Commit 5671371c by luoqi

feat(patient): 真实号码导入(病历号对照)+ phoneVerified 标记 + 列表"真实号码"筛选

测试库 phone 全为造数假号;业务提供 1500 名患者的真实手机号(按病历号 file_num 对照)。

- schema: Patient 加 phoneVerified(默认 false)+ migration;true = 外部对照表核实替换的真号。
- cli/import-real-phones: 读 CSV(file_num,client_phone)→ 按 medical_record_number 匹配 →
  phone 改真号 + phoneVerified=true;支持 --dry-run;号码做基本卫生(去非数字)。
- 列表: ListPlansQuerySchema 加 phoneVerified(query 串 'true' preprocess 还原布尔);
  plan.service 把 keyword 与 phoneVerified 合并进 patient 子查询;PlanPatientBrief 透出 phoneVerified。
- web: 筛选条加"真实号码"开关(teal 高亮);行内手机号旁加"真"角标(tooltip 注明已核实)。

本地验证:1500 行对照表匹配 12 名(本地数据不全属预期)→ 更新 12;
列表 all=274 → phoneVerified=true 筛出 12,行内标记正确。两端 tsc 0。

注:重新全量摄入会被宿主假号覆盖 phone(phoneVerified 不回退)→ 重摄后需重跑导入脚本。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent be08cd62
-- AlterTable
ALTER TABLE "patients" ADD COLUMN "phone_verified" BOOLEAN NOT NULL DEFAULT false;
...@@ -175,6 +175,9 @@ model Patient { ...@@ -175,6 +175,9 @@ model Patient {
name String? name String?
/// 联系电话(stub 期间可空 同上;phone IS NULL 自动排除召回池) /// 联系电话(stub 期间可空 同上;phone IS NULL 自动排除召回池)
phone String? phone String?
/// 号码真实性标记:true = 已用外部对照表(病历号→真实手机号)核实替换的真号;
/// false = 宿主同步来的号(测试库为造数假号)。导入脚本写 true
phoneVerified Boolean @default(false) @map("phone_verified")
/// 性别(自由文本 各宿主取值不一,不强枚举;业务用到时归一化) /// 性别(自由文本 各宿主取值不一,不强枚举;业务用到时归一化)
gender String? gender String?
/// 出生日期 /// 出生日期
......
/**
* import-real-phones — 用外部对照表(病历号 → 真实手机号)替换库里的造数假号。
*
* 背景:测试库 phone 全是造数假号;业务给了 1500 个患者的真实号码(按病历号对照)。
* 行为:按 medical_record_number 匹配 → phone 改为真号 + phoneVerified=true(列表"真实号码"筛选用)。
* 匹配不到的病历号原样跳过(本地数据不全属预期);号码做基本卫生(去空格/非数字字符)。
*
* 跑:
* pnpm --filter pac-service exec ts-node -r tsconfig-paths/register src/cli/import-real-phones.cli.ts -- --csv=/tmp/phone_match.csv
* # 或编译后: node dist/cli/import-real-phones.cli.js --csv=...
* CSV 格式(带表头): file_num,client_phone
* --dry-run 只统计不写库
*/
import { readFileSync } from 'node:fs';
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
interface Row {
fileNum: string;
phone: string;
}
function parseCsv(path: string): Row[] {
const lines = readFileSync(path, 'utf8').split(/\r?\n/).filter((l) => l.trim());
const out: Row[] = [];
for (const [i, line] of lines.entries()) {
if (i === 0 && /file_num/i.test(line)) continue; // 表头
const [fileNum, phone] = line.split(',').map((s) => s?.trim() ?? '');
if (!fileNum || !phone) continue;
const digits = phone.replace(/\D/g, '');
if (digits.length < 7) continue; // 垃圾行防御
out.push({ fileNum, phone: digits });
}
return out;
}
async function main(): Promise<void> {
const logger = new Logger('ImportRealPhones');
const csvArg = process.argv.find((a) => a.startsWith('--csv='))?.slice('--csv='.length);
const dryRun = process.argv.includes('--dry-run');
if (!csvArg) {
logger.error('用法: --csv=<对照表.csv>(列: file_num,client_phone)[--dry-run]');
process.exit(1);
}
const rows = parseCsv(csvArg);
logger.log(`对照表 ${rows.length} 行(去表头/垃圾后)`);
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
const prisma = app.get(PrismaService);
try {
let matched = 0;
let updated = 0;
const sample: string[] = [];
// 1500 行量级,逐行 updateMany 即可(病历号可能跨患者重复 → updateMany 全更)
for (const r of rows) {
const hit = await prisma.patient.findMany({
where: { medicalRecordNumber: r.fileNum },
select: { id: true },
});
if (hit.length === 0) continue;
matched += 1;
if (!dryRun) {
const res = await prisma.patient.updateMany({
where: { medicalRecordNumber: r.fileNum },
data: { phone: r.phone, phoneVerified: true },
});
updated += res.count;
} else {
updated += hit.length;
}
if (sample.length < 5) sample.push(`${r.fileNum}${r.phone.slice(0, 3)}****${r.phone.slice(-4)}`);
}
logger.log(
`${dryRun ? '[dry-run] ' : ''}匹配病历号 ${matched}/${rows.length},更新患者 ${updated} 名`,
);
if (sample.length) logger.log(`样例: ${sample.join(' | ')}`);
const verifiedTotal = await prisma.patient.count({ where: { phoneVerified: true } });
logger.log(`库内 phoneVerified=true 总数: ${verifiedTotal}`);
} finally {
await app.close();
}
}
void main();
...@@ -102,14 +102,20 @@ export class PlanService { ...@@ -102,14 +102,20 @@ export class PlanService {
// W3 末:服务端 keyword 模糊匹配 — patient.name / phone / externalId 任一命中 // W3 末:服务端 keyword 模糊匹配 — patient.name / phone / externalId 任一命中
// (替代前端本页 .filter,改成跨全表搜索,真实接入万人级数据后才能用) // (替代前端本页 .filter,改成跨全表搜索,真实接入万人级数据后才能用)
const patientWhere: Prisma.PatientWhereInput = {};
if (query.keyword) { if (query.keyword) {
where.patient = { patientWhere.OR = [
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' } }, { name: { contains: query.keyword, mode: 'insensitive' } },
{ phone: { contains: query.keyword } }, { phone: { contains: query.keyword } },
{ externalId: { contains: query.keyword, mode: 'insensitive' } }, { externalId: { contains: query.keyword, mode: 'insensitive' } },
], ];
}; }
// 只看真实号码(外部对照表核实过的;测试库其余为造数假号)
if (query.phoneVerified !== undefined) {
patientWhere.phoneVerified = query.phoneVerified;
}
if (Object.keys(patientWhere).length > 0) {
where.patient = patientWhere;
} }
// W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确 // W3 末:服务端 sort —— 替代前端 .sort,跨页排序正确
...@@ -142,6 +148,7 @@ export class PlanService { ...@@ -142,6 +148,7 @@ export class PlanService {
externalId: true, externalId: true,
name: true, name: true,
phone: true, phone: true,
phoneVerified: true,
gender: true, gender: true,
birthDate: true, birthDate: true,
}, },
...@@ -162,6 +169,7 @@ export class PlanService { ...@@ -162,6 +169,7 @@ export class PlanService {
name: patient?.name ?? null, name: patient?.name ?? null,
nameMasked: maskName(patient?.name ?? null), nameMasked: maskName(patient?.name ?? null),
phoneMasked: maskPhone(patient?.phone ?? null), phoneMasked: maskPhone(patient?.phone ?? null),
phoneVerified: patient?.phoneVerified ?? false,
gender: patient?.gender ?? null, gender: patient?.gender ?? null,
age: patient?.birthDate ? calcAge(patient.birthDate) : null, age: patient?.birthDate ? calcAge(patient.birthDate) : null,
}, },
......
...@@ -21,6 +21,8 @@ export const plansApi = { ...@@ -21,6 +21,8 @@ export const plansApi = {
targetClinicIds: q.targetClinicIds?.length ? q.targetClinicIds.join(',') : undefined, targetClinicIds: q.targetClinicIds?.length ? q.targetClinicIds.join(',') : undefined,
assigneeUserId: q.assigneeUserId, assigneeUserId: q.assigneeUserId,
keyword: q.keyword, keyword: q.keyword,
// 只看真实号码:布尔上线成 'true'(后端 preprocess 还原);undefined 不带
phoneVerified: q.phoneVerified === undefined ? undefined : String(q.phoneVerified),
sort: q.sort, sort: q.sort,
page: q.page ?? 1, page: q.page ?? 1,
pageSize: q.pageSize ?? 20, pageSize: q.pageSize ?? 20,
......
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
Flame, Flame,
ListChecks, ListChecks,
LogOut, LogOut,
PhoneCall,
RefreshCw, RefreshCw,
Search, Search,
Waves, Waves,
...@@ -228,6 +229,12 @@ export function PlansListApp() { ...@@ -228,6 +229,12 @@ export function PlansListApp() {
setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 }); setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
clearSelected(); clearSelected();
}; };
// 只看真实号码(外部对照表核实过;测试库其余为造数假号)
const realPhoneOnly = query.phoneVerified === true;
const toggleRealPhone = () => {
setQuery({ phoneVerified: realPhoneOnly ? undefined : true, page: 1 });
clearSelected();
};
return ( return (
<div className="flex h-full min-h-screen flex-col bg-slate-50"> <div className="flex h-full min-h-screen flex-col bg-slate-50">
...@@ -255,6 +262,8 @@ export function PlansListApp() { ...@@ -255,6 +262,8 @@ export function PlansListApp() {
clinicOptions={clinicOptions} clinicOptions={clinicOptions}
selectedClinics={selectedClinics} selectedClinics={selectedClinics}
setClinics={setClinics} setClinics={setClinics}
realPhoneOnly={realPhoneOnly}
onToggleRealPhone={toggleRealPhone}
density={density} density={density}
setDensity={setDensity} setDensity={setDensity}
allSelected={visible.length > 0 && visible.every((p) => selected.has(p.id))} allSelected={visible.length > 0 && visible.every((p) => selected.has(p.id))}
...@@ -561,6 +570,7 @@ function FilterBar({ ...@@ -561,6 +570,7 @@ function FilterBar({
q, setQ, status, setStatus, q, setQ, status, setStatus,
sort, setSort, density, setDensity, sort, setSort, density, setDensity,
clinicOptions, selectedClinics, setClinics, clinicOptions, selectedClinics, setClinics,
realPhoneOnly, onToggleRealPhone,
allSelected, someSelected, onTogglePage, allSelected, someSelected, onTogglePage,
}: { }: {
view: View; view: View;
...@@ -575,6 +585,8 @@ function FilterBar({ ...@@ -575,6 +585,8 @@ function FilterBar({
clinicOptions: Array<{ id: string; name: string }>; clinicOptions: Array<{ id: string; name: string }>;
selectedClinics: string[]; selectedClinics: string[];
setClinics: (ids: string[]) => void; setClinics: (ids: string[]) => void;
realPhoneOnly: boolean;
onToggleRealPhone: () => void;
allSelected: boolean; allSelected: boolean;
someSelected: boolean; someSelected: boolean;
onTogglePage: () => void; onTogglePage: () => void;
...@@ -633,6 +645,21 @@ function FilterBar({ ...@@ -633,6 +645,21 @@ function FilterBar({
<SelectItem value="priority_asc">优先级 低 → 高</SelectItem> <SelectItem value="priority_asc">优先级 低 → 高</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{/* 只看真实号码(外部对照表核实;测试库其余为造数假号) */}
<button
type="button"
onClick={onToggleRealPhone}
title="只看已核实的真实手机号"
className={cn(
'inline-flex h-8 items-center gap-1 rounded-md border px-2.5 text-xs transition-colors',
realPhoneOnly
? 'border-teal-300 bg-teal-50 font-medium text-teal-700'
: 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50',
)}
>
<PhoneCall className="h-3.5 w-3.5" />
真实号码
</button>
<div className="inline-flex items-center rounded-md bg-slate-100 p-0.5"> <div className="inline-flex items-center rounded-md bg-slate-100 p-0.5">
{(['comfy', 'regular', 'dense'] as const).map((d) => ( {(['comfy', 'regular', 'dense'] as const).map((d) => (
<button <button
...@@ -766,6 +793,14 @@ function PatientPlanCard({ ...@@ -766,6 +793,14 @@ function PatientPlanCard({
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span> <span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span>
{p.patient.phoneVerified && (
<span
title="真实手机号(外部对照表已核实)"
className="inline-flex items-center rounded bg-teal-50 px-1 text-[10px] font-medium leading-4 text-teal-700 ring-1 ring-inset ring-teal-200"
>
</span>
)}
{p.targetClinicId && ( {p.targetClinicId && (
<> <>
<span className="text-slate-300">·</span> <span className="text-slate-300">·</span>
......
...@@ -138,6 +138,11 @@ export const ListPlansQuerySchema = z.object({ ...@@ -138,6 +138,11 @@ export const ListPlansQuerySchema = z.object({
.describe('"pool" = active+unassigned; "mine" = assigned to caller; "all" = no extra filter'), .describe('"pool" = active+unassigned; "mine" = assigned to caller; "all" = no extra filter'),
/// 关键字搜索:服务端按 patient.name / phone / externalId 模糊匹配(W3 末加,替代前端本页 filter) /// 关键字搜索:服务端按 patient.name / phone / externalId 模糊匹配(W3 末加,替代前端本页 filter)
keyword: z.string().trim().min(1).optional(), keyword: z.string().trim().min(1).optional(),
/// 只看真实号码(patient.phoneVerified=true,外部对照表核实过的)。query 串传 'true'。
phoneVerified: z.preprocess(
(v) => (v === 'true' ? true : v === 'false' ? false : v),
z.boolean().optional(),
),
/// 排序:服务端 ORDER BY(W3 末加,替代前端 .sort) /// 排序:服务端 ORDER BY(W3 末加,替代前端 .sort)
sort: z.enum(['priority_desc', 'priority_asc', 'created_desc']).default('priority_desc'), sort: z.enum(['priority_desc', 'priority_asc', 'created_desc']).default('priority_desc'),
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
...@@ -152,6 +157,8 @@ export const PlanPatientBriefSchema = z.object({ ...@@ -152,6 +157,8 @@ export const PlanPatientBriefSchema = z.object({
name: z.string().nullable(), name: z.string().nullable(),
nameMasked: z.string().nullable(), nameMasked: z.string().nullable(),
phoneMasked: z.string().nullable(), phoneMasked: z.string().nullable(),
/// 号码真实性(外部对照表核实过 = true);列表给"真"角标 + 筛选用
phoneVerified: z.boolean().optional(),
gender: z.string().nullable(), gender: z.string().nullable(),
age: z.number().int().nullable(), age: z.number().int().nullable(),
}); });
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment