Commit 9920577a by luoqi

feat: W3 末部署准备包 — deploy 基建 + 增量同步 + 前端 polish

- deploy/: deploy.sh + systemd units + README(staging/prod 通用)
- 增量同步: sync-incremental scheduler + CLI + dw-lag-monitor 告警
- assemblers: jvs-dw 全套 yaml 字段对齐 + tooth-position 解析
- plan-detail: chain-viz / facts-timeline / drawer / outcome-form 改版
- auth: mock-login dialog(staging 用) + auth-gate 调整
- parsers: diagnosis/treatment/image/recommendation 解析增强
- CLI: verify-chain 调试工具
- docs: deployment-data-ingest + w3-report
parent 910ebbc5
# ═══════════════════════════════════════════════════════════════════════
# PAC Service — Environment Variables(对照模板)
#
# 三套环境:
# local 本地开发(你 Mac + docker compose)
# staging 试部署 / 演示(云服务器,客服试用)
# production 正式生产(云服务器,真客服上线)
#
# 部署:
# - 复制本文件 → .env(本地)/ /opt/pac/.env(服务器)
# - 服务器侧 chmod 600 .env
# - .env 永远不进 git(.gitignore 已守)
#
# 上线前必检:JWT_SECRET / JWT_REFRESH_SECRET / DEEPSEEK_API_KEY / DW_CLICKHOUSE_PASSWORD
# 必须 per-env 不同(staging / prod 各一份强随机)
# ═══════════════════════════════════════════════════════════════════════
# ─── 基础 ────────────────────────────────────────────────────────────
# local: development
# staging: staging
# production: production ⚠️ 必须设 production,关掉一些开发兜底
NODE_ENV=development NODE_ENV=development
PORT=3001 PORT=3001
# local: debug | staging+prod: info
LOG_LEVEL=info LOG_LEVEL=info
# ─── 数据库 / 缓存 ────────────────────────────────────────────────────
# local: postgresql://pac:pac@localhost:5432/pac?schema=public
# staging: postgresql://pac:<staging-pwd>@<staging-pg-host>:5432/pac?schema=public
# production: postgresql://pac:<prod-pwd>@<prod-pg-host>:5432/pac?schema=public
DATABASE_URL=postgresql://pac:pac@localhost:5432/pac?schema=public DATABASE_URL=postgresql://pac:pac@localhost:5432/pac?schema=public
# local: redis://localhost:6379
# staging+prod: redis://<host>:6379 (BullMQ 队列用,丢了会丢未消费的 plan-asset-generate 任务)
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
# ─── JWT(per-env 必须不同强随机)────────────────────────────────────
# 生成命令:openssl rand -hex 32
# ⚠️ staging / production 必须分别生成 — 同一份 secret 跨环境会让 staging token 在 prod 通过验证
JWT_SECRET=replace-with-strong-random-secret-min-32-chars JWT_SECRET=replace-with-strong-random-secret-min-32-chars
JWT_REFRESH_SECRET=replace-with-another-strong-secret-min-32-chars JWT_REFRESH_SECRET=replace-with-another-strong-secret-min-32-chars
JWT_EXPIRES_IN=2h JWT_EXPIRES_IN=2h
JWT_REFRESH_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=7d
# AI — DeepSeek 国内 endpoint 直连(合规);key 从 https://platform.deepseek.com/api_keys 取
# ─── AI(DeepSeek 国内直连)─────────────────────────────────────────
# Key 从 https://platform.deepseek.com/api_keys 取
# local + staging: 团队共享 key(开发额度)
# production: 独立 prod key(独立配额 / 账单)
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com DEEPSEEK_BASE_URL=https://api.deepseek.com
AI_DEFAULT_MODEL=deepseek-v4-pro
# 默认主力模型
# local: deepseek-v4-flash (省钱开发)
# staging: deepseek-v4-flash
# production: deepseek-v4-pro (质量优先)
AI_DEFAULT_MODEL=deepseek-v4-flash
AI_REQUEST_TIMEOUT_SEC=60 AI_REQUEST_TIMEOUT_SEC=60
# 价格表(¥/M tokens)— vendor 调价时改这里重启即可;不配则用默认值。
# inHit=cache 命中价 / inMiss=未命中(全价) / out=输出价(含 thinking) # 价格表(¥/M tokens)— 调价时改这里重启;不配走默认
# AI_PRICE_TABLE_JSON={"deepseek-v4-pro":{"inHit":0.5,"inMiss":3.6,"out":25},"deepseek-v4-flash":{"inHit":0.07,"inMiss":0.5,"out":2}} # AI_PRICE_TABLE_JSON={"deepseek-v4-pro":{"inHit":0.5,"inMiss":3.6,"out":25},"deepseek-v4-flash":{"inHit":0.07,"inMiss":0.5,"out":2}}
# ─── CORS / iframe ───────────────────────────────────────────────────
# local: http://localhost:3000
# staging: https://pac-staging.<your-domain>
# production: https://pac.<your-domain> (多个用逗号)
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
# iframe 一次性 code TTL(默认 60s)
EXCHANGE_CODE_TTL_SECONDS=60 EXCHANGE_CODE_TTL_SECONDS=60
# ─── 远程 DW(瑞尔 jvs-dw)ClickHouse 直连 ──────────────────────────
# manifest.yaml 里 sql_source.connection 是 dev 默认(localhost:8123);
# 这几个 env 覆盖之,无需改 manifest
#
# local: 不设 → 走 manifest 默认本地 docker CH
# staging+prod: 必须设 → 远程瑞尔 ADS
#
# 生产示例:
# DW_CLICKHOUSE_URL=http://cc-2zen8w29e49076o83.public.clickhouse.ads.aliyuncs.com:8123
# DW_CLICKHOUSE_DATABASE=dw_group
# DW_CLICKHOUSE_USERNAME=jvs_pac
# DW_CLICKHOUSE_PASSWORD=<vendor 给的密码>
DW_CLICKHOUSE_URL=
DW_CLICKHOUSE_DATABASE=
DW_CLICKHOUSE_USERNAME=
DW_CLICKHOUSE_PASSWORD=
# ─── 数据接入 cron / 监控 ────────────────────────────────────────────
# 不设 → 该 cron 不跑(本地默认不跑,避免开发时自动消耗 DW 流量)
# 设了 → 走 standard cron 5 字段(分 时 日 月 周)
#
# local: 不设(留空)
# staging: 30 3 * * * (03:30 错峰,避免跟生产撞)
# production: 30 2 * * * (02:30 — DW 02:00 完成全量后 30 分钟)
PAC_INCREMENTAL_CRON=
# 多 host 用逗号分隔(目前只一个 jvs-dw)
PAC_INCREMENTAL_HOSTS=jvs-dw
# 其他 cron(同步策略:env 不设 → 不跑)
# local: 全部不设
# staging/prod:
# PAC_LAG_MONITOR_CRON=0 * * * * (每小时检查 DW 是否滞后)
# PAC_STALE_SCAN_CRON=0 2 * * * (凌晨 02:00 扫 stale persona 补算)
# PAC_SYNC_HOURLY_CRON= (W5+ host pull 通道接通后启用)
PAC_LAG_MONITOR_CRON=
PAC_STALE_SCAN_CRON=
PAC_SYNC_HOURLY_CRON=
# 数据目录(manifest.yaml 所在位置)
# local: 默认(代码相对路径)
# staging: /opt/pac/apps/pac-service/data
# production: 同
# PAC_INCREMENTAL_DATA_DIR=/opt/pac/apps/pac-service/data
# DW 数据滞后告警阈值(小时)
# local: 999 (不告警)
# staging+prod: 24 warn / 48 error
PAC_LAG_WARN_HOURS=999
PAC_LAG_ERROR_HOURS=999
# ─── 告警通道(W5+ 接钉钉 / 飞书 webhook)──────────────────────────
# local: 空
# staging: 测试群 webhook
# production: 真值班群 webhook
ALERT_WEBHOOK_URL=
# ─── Mock Login(开发 / 试部署快速登录)──────────────────────────────
# 控制 /pac/v1/auth/mock-login endpoint:
# true 强制启用
# false 强制禁用
# (空) 按 NODE_ENV 走(prod 默认禁用,非 prod 默认启用)
#
# local: 不设(自动启用)
# staging: true (演示 / 客服试用必须开)
# production: false ⭐ (生产关掉,走 host SSO)
PAC_ENABLE_MOCK_LOGIN=
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# PAC Service Dockerfile — workspace monorepo build
ARG NODE_VERSION=22-alpine ARG NODE_VERSION=22-alpine
# === base: workspace layout + pnpm === # === base: workspace layout + pnpm ===
...@@ -9,17 +10,17 @@ WORKDIR /app ...@@ -9,17 +10,17 @@ WORKDIR /app
# === deps: install everything once, share across stages === # === deps: install everything once, share across stages ===
FROM base AS deps FROM base AS deps
COPY pnpm-workspace.yaml pnpm-lock.yaml* package.json .npmrc ./ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc* ./
COPY turbo.json tsconfig.base.json ./ COPY turbo.json tsconfig.base.json ./
COPY apps/recall-service/package.json apps/recall-service/ COPY apps/pac-service/package.json apps/pac-service/
COPY packages/shared-types/package.json packages/shared-types/ COPY packages/types/package.json packages/types/
COPY packages/shared-utils/package.json packages/shared-utils/ COPY packages/utils/package.json packages/utils/
RUN pnpm install --frozen-lockfile=false RUN pnpm install --frozen-lockfile
# === dev: hot reload via nest start --watch === # === dev: hot reload via nest start --watch ===
FROM deps AS dev FROM deps AS dev
COPY . . COPY . .
WORKDIR /app/apps/recall-service WORKDIR /app/apps/pac-service
RUN pnpm prisma generate RUN pnpm prisma generate
EXPOSE 3001 EXPOSE 3001
CMD ["pnpm", "dev"] CMD ["pnpm", "dev"]
...@@ -27,7 +28,8 @@ CMD ["pnpm", "dev"] ...@@ -27,7 +28,8 @@ CMD ["pnpm", "dev"]
# === build === # === build ===
FROM deps AS build FROM deps AS build
COPY . . COPY . .
WORKDIR /app/apps/recall-service RUN pnpm --filter @pac/types build
WORKDIR /app/apps/pac-service
RUN pnpm prisma generate RUN pnpm prisma generate
RUN pnpm build RUN pnpm build
...@@ -39,10 +41,11 @@ WORKDIR /app ...@@ -39,10 +41,11 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages ./packages COPY --from=build /app/packages ./packages
COPY --from=build /app/apps/recall-service/node_modules ./apps/recall-service/node_modules COPY --from=build /app/apps/pac-service/node_modules ./apps/pac-service/node_modules
COPY --from=build /app/apps/recall-service/dist ./apps/recall-service/dist COPY --from=build /app/apps/pac-service/dist ./apps/pac-service/dist
COPY --from=build /app/apps/recall-service/prisma ./apps/recall-service/prisma COPY --from=build /app/apps/pac-service/prisma ./apps/pac-service/prisma
COPY --from=build /app/apps/recall-service/package.json ./apps/recall-service/ COPY --from=build /app/apps/pac-service/data ./apps/pac-service/data
WORKDIR /app/apps/recall-service COPY --from=build /app/apps/pac-service/package.json ./apps/pac-service/
WORKDIR /app/apps/pac-service
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dist/main.js"] CMD ["node", "--enable-source-maps", "dist/main.js"]
...@@ -13,6 +13,9 @@ primary: ...@@ -13,6 +13,9 @@ primary:
field_mapping: field_mapping:
externalId: id externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
scheduledAt: scheduled_at_ts # transforms 拼好的精确时刻(日期 + 起始时分) scheduledAt: scheduled_at_ts # transforms 拼好的精确时刻(日期 + 起始时分)
......
...@@ -16,6 +16,9 @@ primary: ...@@ -16,6 +16,9 @@ primary:
field_mapping: field_mapping:
externalId: diag_external_id externalId: diag_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq occurredAt: rq
......
...@@ -14,6 +14,9 @@ primary: ...@@ -14,6 +14,9 @@ primary:
field_mapping: field_mapping:
externalId: id externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
submittedAt: rq submittedAt: rq
......
...@@ -13,6 +13,9 @@ primary: ...@@ -13,6 +13,9 @@ primary:
field_mapping: field_mapping:
externalId: id externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
startedAt: in_time startedAt: in_time
......
...@@ -14,6 +14,9 @@ primary: ...@@ -14,6 +14,9 @@ primary:
field_mapping: field_mapping:
externalId: image_external_id externalId: image_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
uploadedAt: captured_at uploadedAt: captured_at
......
...@@ -8,6 +8,9 @@ primary: ...@@ -8,6 +8,9 @@ primary:
field_mapping: field_mapping:
externalId: patient_id # Int64 → ColdImportService 入库时 String 化 externalId: patient_id # Int64 → ColdImportService 入库时 String 化
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: last_visit_time
createdAt: first_visit_time
name: client_name name: client_name
gender: client_gender gender: client_gender
birthDate: birthday birthDate: birthday
......
...@@ -13,6 +13,9 @@ primary: ...@@ -13,6 +13,9 @@ primary:
field_mapping: field_mapping:
externalId: id externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
paidAt: created_date # W3 末从 billing_date 升级 — 跟 treatment_actual 口径一致。 paidAt: created_date # W3 末从 billing_date 升级 — 跟 treatment_actual 口径一致。
......
...@@ -14,6 +14,9 @@ primary: ...@@ -14,6 +14,9 @@ primary:
field_mapping: field_mapping:
externalId: rec_external_id externalId: rec_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq occurredAt: rq
......
...@@ -15,6 +15,9 @@ primary: ...@@ -15,6 +15,9 @@ primary:
field_mapping: field_mapping:
externalId: referral_external_id externalId: referral_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: last_visit_time
createdAt: first_visit_time
patientExternalId: patient_id # 被推荐人(referee)= 该行的 patient_id patientExternalId: patient_id # 被推荐人(referee)= 该行的 patient_id
# clinicId 不映射:fact_client_out(ADS 聚合)无 organization_id 列,referral 来自主档无明确 clinic # clinicId 不映射:fact_client_out(ADS 聚合)无 organization_id 列,referral 来自主档无明确 clinic
recordedAt: first_visit_time # 近似:推荐发生时间 ≈ 被推荐人首次到诊 recordedAt: first_visit_time # 近似:推荐发生时间 ≈ 被推荐人首次到诊
......
...@@ -25,6 +25,9 @@ primary: ...@@ -25,6 +25,9 @@ primary:
field_mapping: field_mapping:
externalId: id externalId: id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
# 退费时间 = created_date(结算记录入库时间)— 跟 payment / treatment 口径一致 # 退费时间 = created_date(结算记录入库时间)— 跟 payment / treatment 口径一致
......
...@@ -25,6 +25,9 @@ primary: ...@@ -25,6 +25,9 @@ primary:
field_mapping: field_mapping:
externalId: treat_external_id externalId: treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq # treat_plan 已发生,rq=就诊日,actual 时点 occurredAt: rq # treat_plan 已发生,rq=就诊日,actual 时点
...@@ -57,14 +60,35 @@ enum_mapping: ...@@ -57,14 +60,35 @@ enum_mapping:
根管预备: canal_preparation 根管预备: canal_preparation
根充: canal_filling 根充: canal_filling
根管充填: canal_filling 根管充填: canal_filling
# 牙髓切断术 / 活髓保存(pulpotomy 类 — VPT 终末术式)
# 临床等价:活髓切断 / 部分活髓切断 / 部分牙髓切断 / 冠髓切断 / 干髓 / 盖髓 / 牙髓血运重建
活髓切断: pulpotomy
部分活髓切断: pulpotomy
部分牙髓切断: pulpotomy
冠髓切断: pulpotomy
干髓术: pulpotomy
干髓治疗: pulpotomy
盖髓术: pulpotomy
间接盖髓: pulpotomy
直接盖髓: pulpotomy
牙髓血运重建: pulpotomy
活髓保存: pulpotomy
# 种植 # 种植
植入: implant_placement 植入: implant_placement
种植体植入: implant_placement 种植体植入: implant_placement
种植一期: implant_placement 种植一期: implant_placement
种植I期: implant_placement # 罗马数字版(host 实测)
简单种植: implant_placement
复杂种植: implant_placement
即刻种植: implant_placement
延期种植: implant_placement
基台: abutment_placement 基台: abutment_placement
种植二期: abutment_placement
种植II期: abutment_placement # 罗马数字版(host 实测)
上部修复: crown_placement 上部修复: crown_placement
种植冠修复: crown_placement 种植冠修复: crown_placement
种植戴牙: crown_placement 种植戴牙: crown_placement
种植三期: crown_placement
# 牙周 # 牙周
龈上洁治: supragingival_scaling 龈上洁治: supragingival_scaling
龈下刮治: subgingival_scaling 龈下刮治: subgingival_scaling
...@@ -161,6 +185,22 @@ enum_mapping: ...@@ -161,6 +185,22 @@ enum_mapping:
嵌体: restorative 嵌体: restorative
戴嵌体: restorative 戴嵌体: restorative
龋齿充填: restorative 龋齿充填: restorative
# W4 末补 — 玻璃离子(GIC)类充填(临床: 等价于树脂充填,主要用于乳牙/年轻恒牙/暂封)
玻璃离子充填: restorative
玻璃离子充填术: restorative
"玻璃离子充填术。": restorative
"玻璃离子充填。": restorative
进口玻璃离子充填: restorative
"进口玻璃离子充填术。": restorative
"充填治疗(玻璃离子)": restorative
玻璃离子暂封: restorative
玻璃离子预充填: restorative
# 其他高频补全
充填或嵌体修复: restorative
"充填治疗(乳牙复面洞)": restorative
"恒牙充填治疗(儿科)": restorative
高嵌体修复: restorative
预防性树脂充填: restorative
# ── 牙髓 / 根管(endodontic) # ── 牙髓 / 根管(endodontic)
根管治疗: endodontic 根管治疗: endodontic
...@@ -186,6 +226,64 @@ enum_mapping: ...@@ -186,6 +226,64 @@ enum_mapping:
# W4 末补 # W4 末补
根管治疗后冠修复: endodontic 根管治疗后冠修复: endodontic
"RCT+冠修复": endodontic "RCT+冠修复": endodontic
# W4 末补 — pulpotomy 类(VPT 终末术式,等价于完整 RCT;chain-composer 走 terminalSteps 走闭环)
# 已在前面列出的 干髓术 / 盖髓术 / 直接盖髓 / 间接盖髓 / MTA盖髓 / 拔髓 / 开髓 / 牙髓治疗 不再重复
活髓切断术: endodontic
部分活髓切断术: endodontic
部分牙髓切断术: endodontic
"活髓切断术+预成冠修复": endodontic
"活髓切断术+预成冠": endodontic
"活髓切断+预成冠修复": endodontic
"活髓切断+预成冠": endodontic
"活髓切断术+全瓷冠冠修复": endodontic
"活髓切断术+全瓷预成冠修复": endodontic
"活髓,活髓切断术": endodontic
活髓切断: endodontic
乳牙活髓切断术: endodontic
"乳牙活髓切断术+冠修复": endodontic
"乳牙活髓保存+冠修复": endodontic
"活髓保存+冠修复": endodontic
乳牙活髓保存术: endodontic
"乳牙活髓保存术+冠修复": endodontic
冠髓切断术: endodontic
"冠髓切断术(乳牙)": endodontic
"冠髓切断术(乳牙)+预成冠修复": endodontic
"冠髓切断术+预成冠修复": endodontic
"恒牙冠髓切断术(儿科)": endodontic
干髓治疗: endodontic
直接盖髓术: endodontic
间接盖髓术: endodontic
"间接盖髓术+预成冠修复": endodontic
牙髓血运重建: endodontic
# 牙髓相关其他术式(中间步骤,subtype 词根 fallback,无终末效力)
开髓引流: endodontic
开髓失活: endodontic
开髓开放: endodontic
开髓封药: endodontic
"开髓,封药": endodontic
开髓封失活剂: endodontic
"开髓,封失活剂": endodontic
牙髓失活: endodontic
封失活剂: endodontic
放失活剂: endodontic
放置失活剂: endodontic
失活: endodontic
乳牙牙髓治疗: endodontic
"乳牙牙髓治疗+冠修复": endodontic
髓腔消毒: endodontic
髓腔封药: endodontic
髓腔预备: endodontic
髓腔换药: endodontic
保髓治疗: endodontic
牙髓保留: endodontic
根管治疗术: endodontic
"根管治疗术(乳磨牙封失活剂法)": endodontic
"根管治疗术(乳磨牙直接拔髓多步法)": endodontic
"根管治疗术(复杂乳牙封失活剂法)": endodontic
"根管治疗术(复杂乳牙直接拔髓多次法)": endodontic
"根管治疗术(乳前牙直接拔髓多步法)": endodontic
"根管治疗(封失活剂)": endodontic
复杂根管治疗: endodontic
# ── 种植(implant) # ── 种植(implant)
种植: implant 种植: implant
......
...@@ -24,6 +24,9 @@ primary: ...@@ -24,6 +24,9 @@ primary:
field_mapping: field_mapping:
externalId: treat_external_id externalId: treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq # rq=就诊日(医生当天写的计划);plannedFor 由 parser 用此填 occurredAt: rq # rq=就诊日(医生当天写的计划);plannedFor 由 parser 用此填
...@@ -49,13 +52,33 @@ enum_mapping: ...@@ -49,13 +52,33 @@ enum_mapping:
根管预备: canal_preparation 根管预备: canal_preparation
根充: canal_filling 根充: canal_filling
根管充填: canal_filling 根管充填: canal_filling
# 牙髓切断术 / 活髓保存(pulpotomy 类 — VPT 终末术式)
活髓切断: pulpotomy
部分活髓切断: pulpotomy
部分牙髓切断: pulpotomy
冠髓切断: pulpotomy
干髓术: pulpotomy
干髓治疗: pulpotomy
盖髓术: pulpotomy
间接盖髓: pulpotomy
直接盖髓: pulpotomy
牙髓血运重建: pulpotomy
活髓保存: pulpotomy
植入: implant_placement 植入: implant_placement
种植体植入: implant_placement 种植体植入: implant_placement
种植一期: implant_placement 种植一期: implant_placement
种植I期: implant_placement # 罗马数字版(host 实测)
简单种植: implant_placement
复杂种植: implant_placement
即刻种植: implant_placement
延期种植: implant_placement
基台: abutment_placement 基台: abutment_placement
种植二期: abutment_placement
种植II期: abutment_placement # 罗马数字版(host 实测)
上部修复: crown_placement 上部修复: crown_placement
种植冠修复: crown_placement 种植冠修复: crown_placement
种植戴牙: crown_placement 种植戴牙: crown_placement
种植三期: crown_placement
龈上洁治: supragingival_scaling 龈上洁治: supragingival_scaling
龈下刮治: subgingival_scaling 龈下刮治: subgingival_scaling
刮治: subgingival_scaling 刮治: subgingival_scaling
...@@ -147,6 +170,22 @@ enum_mapping: ...@@ -147,6 +170,22 @@ enum_mapping:
嵌体: restorative 嵌体: restorative
戴嵌体: restorative 戴嵌体: restorative
龋齿充填: restorative 龋齿充填: restorative
# W4 末补 — 玻璃离子(GIC)类充填(临床: 等价于树脂充填,主要用于乳牙/年轻恒牙/暂封)
玻璃离子充填: restorative
玻璃离子充填术: restorative
"玻璃离子充填术。": restorative
"玻璃离子充填。": restorative
进口玻璃离子充填: restorative
"进口玻璃离子充填术。": restorative
"充填治疗(玻璃离子)": restorative
玻璃离子暂封: restorative
玻璃离子预充填: restorative
# 其他高频补全
充填或嵌体修复: restorative
"充填治疗(乳牙复面洞)": restorative
"恒牙充填治疗(儿科)": restorative
高嵌体修复: restorative
预防性树脂充填: restorative
# ── 牙髓 / 根管(endodontic) # ── 牙髓 / 根管(endodontic)
根管治疗: endodontic 根管治疗: endodontic
...@@ -172,6 +211,61 @@ enum_mapping: ...@@ -172,6 +211,61 @@ enum_mapping:
# W4 末补 # W4 末补
根管治疗后冠修复: endodontic 根管治疗后冠修复: endodontic
"RCT+冠修复": endodontic "RCT+冠修复": endodontic
# W4 末补 — pulpotomy 类 + 牙髓相关术式(跟 treatment_actual.yaml 同源)
活髓切断术: endodontic
部分活髓切断术: endodontic
部分牙髓切断术: endodontic
"活髓切断术+预成冠修复": endodontic
"活髓切断术+预成冠": endodontic
"活髓切断+预成冠修复": endodontic
"活髓切断+预成冠": endodontic
"活髓切断术+全瓷冠冠修复": endodontic
"活髓切断术+全瓷预成冠修复": endodontic
"活髓,活髓切断术": endodontic
活髓切断: endodontic
乳牙活髓切断术: endodontic
"乳牙活髓切断术+冠修复": endodontic
"乳牙活髓保存+冠修复": endodontic
"活髓保存+冠修复": endodontic
乳牙活髓保存术: endodontic
"乳牙活髓保存术+冠修复": endodontic
冠髓切断术: endodontic
"冠髓切断术(乳牙)": endodontic
"冠髓切断术(乳牙)+预成冠修复": endodontic
"冠髓切断术+预成冠修复": endodontic
"恒牙冠髓切断术(儿科)": endodontic
干髓治疗: endodontic
直接盖髓术: endodontic
间接盖髓术: endodontic
"间接盖髓术+预成冠修复": endodontic
开髓引流: endodontic
开髓失活: endodontic
开髓开放: endodontic
开髓封药: endodontic
"开髓,封药": endodontic
开髓封失活剂: endodontic
"开髓,封失活剂": endodontic
牙髓失活: endodontic
封失活剂: endodontic
放失活剂: endodontic
放置失活剂: endodontic
失活: endodontic
乳牙牙髓治疗: endodontic
"乳牙牙髓治疗+冠修复": endodontic
髓腔消毒: endodontic
髓腔封药: endodontic
髓腔预备: endodontic
髓腔换药: endodontic
保髓治疗: endodontic
牙髓保留: endodontic
根管治疗术: endodontic
"根管治疗术(乳磨牙封失活剂法)": endodontic
"根管治疗术(乳磨牙直接拔髓多步法)": endodontic
"根管治疗术(复杂乳牙封失活剂法)": endodontic
"根管治疗术(复杂乳牙直接拔髓多次法)": endodontic
"根管治疗术(乳前牙直接拔髓多步法)": endodontic
"根管治疗(封失活剂)": endodontic
复杂根管治疗: endodontic
# ── 种植(implant)— 注意"种植上部修复"算 prostho;"种植修复"在 host 文本是合并语,归 implant # ── 种植(implant)— 注意"种植上部修复"算 prostho;"种植修复"在 host 文本是合并语,归 implant
种植: implant 种植: implant
......
...@@ -16,6 +16,9 @@ primary: ...@@ -16,6 +16,9 @@ primary:
field_mapping: field_mapping:
externalId: treat_external_id externalId: treat_external_id
# W4 末 DW upsert:updatedAt 进 source_event_id 幂等键,host UPDATE → 新 transaction(否则永久 dedup 漏 update)
updatedAt: updated_date
createdAt: created_date
patientExternalId: patient_id patientExternalId: patient_id
clinicId: organization_id clinicId: organization_id
occurredAt: rq occurredAt: rq
......
...@@ -74,6 +74,19 @@ sql_source: ...@@ -74,6 +74,19 @@ sql_source:
# W3 末 demo 调到 500k,长期(生产)走增量 pull(cursor)替代一次性拉 # W3 末 demo 调到 500k,长期(生产)走增量 pull(cursor)替代一次性拉
default_limit: 500000 default_limit: 500000
# W4 末:DW 增量配置(sync-incremental CLI 用)
# 跑全量(cold-import)时本段忽略;跑增量时按 per_query 注入 WHERE
# 首次增量(无 cursor)= 等价全量(跟 cold-import 不同点 = 增量模式去 dev cohort LIMIT,不限量)
# fact_client_out 没 updated_date,用 last_visit_time(主档变化 = 患者来诊新事件)
incremental:
per_query:
fact_client_out: { cursor_column: last_visit_time }
fact_appointment_out: { cursor_column: updated_date }
fact_emr_treatment_out: { cursor_column: updated_date }
fact_settlement_out: { cursor_column: updated_date }
fact_settlement_out_refund: { cursor_column: updated_date }
fact_settlement_mode_out: { cursor_column: updated_date }
# SQL 最朴素化 — host(DW)给的数据范围就是 PAC 该消化的范围。 # SQL 最朴素化 — host(DW)给的数据范围就是 PAC 该消化的范围。
# PAC 这边不预设诊所 / brand / 时间过滤,数据来什么就是什么。 # PAC 这边不预设诊所 / brand / 时间过滤,数据来什么就是什么。
# 仅保留 cohort LIMIT(测试期控量,真上量去掉)+ 必要业务过滤(退费状态)。 # 仅保留 cohort LIMIT(测试期控量,真上量去掉)+ 必要业务过滤(退费状态)。
...@@ -85,7 +98,7 @@ sql_source: ...@@ -85,7 +98,7 @@ sql_source:
FROM dw_group.fact_client_out FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC ORDER BY last_visit_time DESC
LIMIT 100 LIMIT 100 OFFSET 100
# ── 预约(全部状态;in_time NULL 的在 transforms.route 时分流跳过 encounter) ── # ── 预约(全部状态;in_time NULL 的在 transforms.route 时分流跳过 encounter) ──
fact_appointment_out: | fact_appointment_out: |
...@@ -94,7 +107,7 @@ sql_source: ...@@ -94,7 +107,7 @@ sql_source:
WHERE (patient_id, brand) IN ( WHERE (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 100 ORDER BY last_visit_time DESC LIMIT 100 OFFSET 100
) )
# ── EMR 全字段(自由文本 + diag/treat_plan JSON,后续 transforms 拆) ── # ── EMR 全字段(自由文本 + diag/treat_plan JSON,后续 transforms 拆) ──
...@@ -102,12 +115,13 @@ sql_source: ...@@ -102,12 +115,13 @@ sql_source:
SELECT SELECT
id, patient_id, organization_id, brand, rq, registration_id, user_id, doctor_name, id, patient_id, organization_id, brand, rq, registration_id, user_id, doctor_name,
illness_desc, pre_illness, past_hist, gen_cond, examine, dispose, doc_order, illness_desc, pre_illness, past_hist, gen_cond, examine, dispose, doc_order,
diag, treat_plan, plan, file_url diag, treat_plan, plan, file_url,
created_date, updated_date
FROM dw_group.fact_emr_treatment_out FROM dw_group.fact_emr_treatment_out
WHERE (patient_id, brand) IN ( WHERE (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 100 ORDER BY last_visit_time DESC LIMIT 100 OFFSET 100
) )
# ── 结算明细(已做治疗反推;退费/未完成结算行 PAC 不消费) ── # ── 结算明细(已做治疗反推;退费/未完成结算行 PAC 不消费) ──
...@@ -116,7 +130,7 @@ sql_source: ...@@ -116,7 +130,7 @@ sql_source:
# - 23% case 团购券/储值/挂号预付,billing_date 在治疗前几小时,created_date 才是医生录治疗的时点 # - 23% case 团购券/储值/挂号预付,billing_date 在治疗前几小时,created_date 才是医生录治疗的时点
# - 全量 244 万对验证:settlement.created vs EMR.created p50 差 5min,51% 在 5min 内 # - 全量 244 万对验证:settlement.created vs EMR.created p50 差 5min,51% 在 5min 内
fact_settlement_out: | fact_settlement_out: |
SELECT id, organization_id, brand, patient_id, rq, billing_date, created_date, SELECT id, organization_id, brand, patient_id, rq, billing_date, created_date, updated_date,
settlement_type, doctor_id, settlement_type, doctor_id,
settlement_project_name, settlement_money, settlement_num, settlement_unit_name, settlement_project_name, settlement_money, settlement_num, settlement_unit_name,
registration_id, is_refund, settlement_status registration_id, is_refund, settlement_status
...@@ -126,7 +140,7 @@ sql_source: ...@@ -126,7 +140,7 @@ sql_source:
AND (patient_id, brand) IN ( AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 100 ORDER BY last_visit_time DESC LIMIT 100 OFFSET 100
) )
# ── 退费明细(独立 fact_type refund_record;LTV 算法 - refund;S5 闭环风控)── # ── 退费明细(独立 fact_type refund_record;LTV 算法 - refund;S5 闭环风控)──
...@@ -135,7 +149,7 @@ sql_source: ...@@ -135,7 +149,7 @@ sql_source:
# ② settlement_status=4 (反向结算单,settlement_money 通常 < 0 表示"金额冲减") # ② settlement_status=4 (反向结算单,settlement_money 通常 < 0 表示"金额冲减")
# parser 侧 Math.abs(amount) 统一成正 cents(语义 = 患者拿回的钱) # parser 侧 Math.abs(amount) 统一成正 cents(语义 = 患者拿回的钱)
fact_settlement_out_refund: | fact_settlement_out_refund: |
SELECT id, organization_id, brand, patient_id, rq, billing_date, created_date, SELECT id, organization_id, brand, patient_id, rq, billing_date, created_date, updated_date,
settlement_type, doctor_id, settlement_type, doctor_id,
settlement_project_name, settlement_money, settlement_project_name, settlement_money,
registration_id, is_refund, settlement_status registration_id, is_refund, settlement_status
...@@ -144,7 +158,7 @@ sql_source: ...@@ -144,7 +158,7 @@ sql_source:
AND (patient_id, brand) IN ( AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 100 ORDER BY last_visit_time DESC LIMIT 100 OFFSET 100
) )
# ── 支付通道明细(17 列 pay_*,后续 transforms.pick_first_nonzero 推) ── # ── 支付通道明细(17 列 pay_*,后续 transforms.pick_first_nonzero 推) ──
...@@ -156,7 +170,7 @@ sql_source: ...@@ -156,7 +170,7 @@ sql_source:
AND (patient_id, brand) IN ( AND (patient_id, brand) IN (
SELECT patient_id, brand FROM dw_group.fact_client_out SELECT patient_id, brand FROM dw_group.fact_client_out
WHERE last_visit_time IS NOT NULL WHERE last_visit_time IS NOT NULL
ORDER BY last_visit_time DESC LIMIT 100 ORDER BY last_visit_time DESC LIMIT 100 OFFSET 100
) )
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
...@@ -183,6 +197,8 @@ transforms: ...@@ -183,6 +197,8 @@ transforms:
- examine - examine
- dispose - dispose
- doc_order - doc_order
- created_date # W4 末:DW upsert,emr.yaml field_mapping 拿 updatedAt → source_event_id 幂等键
- updated_date
# ── B. EMR.diag JSON 拆行 → 诊断 fact 候选 ── # ── B. EMR.diag JSON 拆行 → 诊断 fact 候选 ──
# W3 末改:不再丢 stdCode 缺失行(实测 98% 诊断没填 ICD 码,但 message 是标准中文术语)。 # W3 末改:不再丢 stdCode 缺失行(实测 98% 诊断没填 ICD 码,但 message 是标准中文术语)。
...@@ -200,6 +216,8 @@ transforms: ...@@ -200,6 +216,8 @@ transforms:
rq: rq rq: rq
user_id: user_id # 诊断医生(继承 emr 父级,医患关系信号) user_id: user_id # 诊断医生(继承 emr 父级,医患关系信号)
doctor_name: doctor_name # 诊断医生名(快照) doctor_name: doctor_name # 诊断医生名(快照)
created_date: created_date # W4 末:DW upsert,传父级 created/updated_date 给拆出子行
updated_date: updated_date
element_fields: element_fields:
std_code: stdCode std_code: stdCode
message: message message: message
...@@ -273,6 +291,8 @@ transforms: ...@@ -273,6 +291,8 @@ transforms:
rq: rq rq: rq
user_id: user_id # 治疗医生(继承 emr 父级) user_id: user_id # 治疗医生(继承 emr 父级)
doctor_name: doctor_name # 治疗医生名 doctor_name: doctor_name # 治疗医生名
created_date: created_date # W4 末:DW upsert,传父级 created/updated_date 给拆出子行
updated_date: updated_date
element_fields: element_fields:
treat_name: treatName treat_name: treatName
tooth_position: toothPosition tooth_position: toothPosition
...@@ -294,6 +314,8 @@ transforms: ...@@ -294,6 +314,8 @@ transforms:
rq: rq rq: rq
user_id: user_id # 制定计划的医生 user_id: user_id # 制定计划的医生
doctor_name: doctor_name doctor_name: doctor_name
created_date: created_date # W4 末:DW upsert,传父级 created/updated_date
updated_date: updated_date
element_fields: element_fields:
treat_name: treatName treat_name: treatName
tooth_position: toothPosition tooth_position: toothPosition
...@@ -498,6 +520,8 @@ transforms: ...@@ -498,6 +520,8 @@ transforms:
registration_id: registration_id registration_id: registration_id
user_id: user_id # 接诊医生(影像通常在接诊时拍) user_id: user_id # 接诊医生(影像通常在接诊时拍)
doctor_name: doctor_name doctor_name: doctor_name
created_date: created_date # W4 末:DW upsert,传父级 created/updated_date
updated_date: updated_date
element_fields: element_fields:
check_name: check_name check_name: check_name
file_path: file_url file_path: file_url
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
"prisma:studio": "prisma studio", "prisma:studio": "prisma studio",
"prisma:seed": "ts-node --transpile-only prisma/seed.ts", "prisma:seed": "ts-node --transpile-only prisma/seed.ts",
"cold-import": "ts-node --transpile-only src/cli/cold-import.cli.ts", "cold-import": "ts-node --transpile-only src/cli/cold-import.cli.ts",
"sync-incremental": "ts-node --transpile-only src/cli/sync-incremental.cli.ts",
"recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts", "recompute-persona": "ts-node --transpile-only src/cli/recompute-persona.cli.ts",
"recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts", "recompute-plans": "ts-node --transpile-only src/cli/recompute-plans.cli.ts",
"timeline": "ts-node --transpile-only src/cli/timeline.cli.ts", "timeline": "ts-node --transpile-only src/cli/timeline.cli.ts",
......
...@@ -1192,6 +1192,14 @@ model AgentInvocation { ...@@ -1192,6 +1192,14 @@ model AgentInvocation {
/// judge 细项打分 {relevance:4, compliance:5, tone:4, actionability:3} /// judge 细项打分 {relevance:4, compliance:5, tone:4, actionability:3}
judgeRubric Json? @map("judge_rubric") judgeRubric Json? @map("judge_rubric")
/// W4 :客服 UI thumbs up/down 反馈("本段是否好用") 'up' / 'down' / null
/// judgeScore 平行:judge LLM 自动评,userFeedback 是真人客服评
/// 同一 invocation 只保留一次反馈(再点覆盖);用于话术 / 摘要 prompt 迭代依据
userFeedback String? @map("user_feedback")
userFeedbackAt DateTime? @map("user_feedback_at") @db.Timestamptz(3)
/// 反馈提交人(host userId, JWT.sub );便于审计 / 聚合分析
userFeedbackBy String? @map("user_feedback_by")
/// 输入快照(瘦身版,只存"agent 独占内容",不重复 fact / persona 主体数据): /// 输入快照(瘦身版,只存"agent 独占内容",不重复 fact / persona 主体数据):
/// { /// {
/// factIdsAtCall: ["fact-uuid", ...], // 引用 replay 时按 id fact 表的 T0 版本还原 /// factIdsAtCall: ["fact-uuid", ...], // 引用 replay 时按 id fact 表的 T0 版本还原
......
/**
* Sync Incremental CLI(W4 末)
*
* 从上次 sync_logs.cursor_after 增量拉 DW 数据,跑完触发 persona + plan recompute。
*
* 链路:
* 1. ColdImportService.importDirectory({ incremental: true })
* - 读 sync_logs[host=jvs-dw, resource=incremental_bundle].cursor_after 拿上次 cursor
* - SQL 注入 WHERE updated_date > '${cursor}'(per-query)
* - 跑完写新 sync_log + cursor_after JSON
* 2. 收集本批 affected patient_ids
* 3. 对每个 affected patient 跑 PersonaService.recompute(等价 BullMQ 触发,本地同步跑)
* 4. 跑 PlanEngineService.runHost(SQL 召回 + 6 因子打分,生成/更新 followup_plans)
*
* 用法:
* pnpm sync-incremental -- --dir=./data/jvs-dw # 增量 + 联动 persona/plan
* pnpm sync-incremental -- --dir=./data/jvs-dw --no-recompute # 只拉数据不联动重算(debug)
* pnpm sync-incremental -- --dir=./data/jvs-dw --dry-run # 不写库(测试 cursor 注入是否正确)
*
* 部署:BullMQ cron 每天 02:00 触发(DW 那边每日刷新后)
*/
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from '../app.module';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
import { PrismaService } from '../prisma/prisma.service';
interface CliArgs {
dir?: string;
dryRun: boolean;
noRecompute: boolean;
help: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const out: CliArgs = { dryRun: false, noRecompute: false, help: false };
for (const a of argv) {
if (a === '--help' || a === '-h') out.help = true;
else if (a === '--dry-run') out.dryRun = true;
else if (a === '--no-recompute') out.noRecompute = true;
else if (a.startsWith('--dir=')) out.dir = a.slice('--dir='.length);
}
return out;
}
function printHelp() {
console.log(`
Sync Incremental CLI
DW 直连增量摄入 + 自动 persona/plan 联动重算
Usage:
pnpm sync-incremental -- --dir=<manifest_dir> [--dry-run] [--no-recompute]
Options:
--dir=<path> 必填,manifest.yaml 所在目录(增量 cursor 配置也在 manifest.sql_source.incremental)
--dry-run 只读 cursor 注入预览,不写库
--no-recompute 只拉数据,不触发 persona/plan 重算
--help, -h 显示本帮助
首跑(无 cursor):等价全量(去 dev cohort LIMIT,按 yaml 完整拉)
后续:WHERE updated_date > '\${cursor}',只拉新/改的行
`);
}
async function bootstrap() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.dir) {
printHelp();
process.exit(args.help ? 0 : 1);
}
const logger = new Logger('sync-incremental:cli');
logger.log(`Starting incremental sync(dir=${args.dir}, dryRun=${args.dryRun}, noRecompute=${args.noRecompute})`);
const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['log', 'warn', 'error'],
});
try {
const importSvc = app.get(ColdImportService);
const result = await importSvc.importDirectory(args.dir!, {
dryRun: args.dryRun,
incremental: true,
});
logger.log('─────────────────────────────────────────');
logger.log(`Result:`);
logger.log(` runId: ${result.runId}`);
logger.log(` host: ${result.hostName} (${result.hostId})`);
logger.log(` tenants: [${result.tenantIds.join(',')}]`);
logger.log(` status: ${result.status}`);
logger.log(` patients upserted: ${result.totals.patientsUpserted}`);
logger.log(` transactions written: ${result.totals.transactionsWritten}`);
logger.log(` duplicates (idem): ${result.totals.duplicates}`);
logger.log(` failed: ${result.totals.failed}`);
logger.log(
` facts: created=${result.totals.factsCreated} superseded=${result.totals.factsSuperseded} unchanged=${result.totals.factsUnchanged} failed=${result.totals.factsFailed}`,
);
if (args.dryRun || args.noRecompute) {
logger.log('Skipping persona/plan recompute (dry-run 或 --no-recompute)');
await app.close();
process.exit(0);
}
// ─────────────────────────────────────────
// 联动 persona 重算 — 仅受影响的 patient
// ─────────────────────────────────────────
const prisma = app.get(PrismaService);
const personaSvc = app.get(PersonaService);
const planEngine = app.get(PlanEngineService);
// 找本次 syncLog 之后 written 的 transactions 涉及的 patient
if (!result.syncLogId) {
logger.warn('No syncLog id — 跳过 persona/plan(可能 dry-run)');
await app.close();
process.exit(0);
}
const syncLog = await prisma.syncLog.findUnique({ where: { id: result.syncLogId } });
if (!syncLog) {
logger.warn('syncLog 找不到 — 跳过');
await app.close();
process.exit(0);
}
const affectedPatients = await prisma.patientTransaction.findMany({
where: {
hostId: result.hostId,
createdAt: { gte: syncLog.startedAt },
},
select: { patientId: true },
distinct: ['patientId'],
});
const patientIds = affectedPatients.map((r) => r.patientId).filter((id): id is string => !!id);
logger.log('─────────────────────────────────────────');
logger.log(`Persona recompute:${patientIds.length} patients affected`);
let pSuccess = 0;
let pNoop = 0;
let pFailed = 0;
for (const patientId of patientIds) {
try {
const r = await personaSvc.recompute({
patientId,
source: `incremental-sync:${result.runId}`,
});
if (r.status === 'success' || r.status === 'partial') pSuccess++;
else if (r.status === 'noop') pNoop++;
else pFailed++;
} catch (e) {
pFailed++;
logger.warn(`persona patient=${patientId} failed: ${(e as Error).message}`);
}
}
logger.log(` success=${pSuccess} noop=${pNoop} failed=${pFailed}`);
// ─────────────────────────────────────────
// 联动 plan 重算 — 整 host(scenario SQL 跑一次刷新所有 patient 池)
// ─────────────────────────────────────────
logger.log('─────────────────────────────────────────');
logger.log(`Plan recompute:host=${result.hostName}`);
const tenants = await prisma.patient.findMany({
where: { hostId: result.hostId },
distinct: ['tenantId'],
select: { tenantId: true },
});
let totalPlansCreated = 0;
let totalHits = 0;
for (const t of tenants) {
const r = await planEngine.runAllForHost({
hostId: result.hostId,
tenantId: t.tenantId,
now: new Date(),
});
totalPlansCreated += r.plansCreated;
totalHits += r.patientsHit;
logger.log(` tenant=${t.tenantId}: patientsHit=${r.patientsHit} plansCreated=${r.plansCreated} skipped=${r.plansSkippedAssigned}`);
}
logger.log(` Total: patientsHit=${totalHits} plansCreated=${totalPlansCreated}`);
logger.log('─────────────────────────────────────────');
} catch (e) {
logger.error(`Incremental sync failed: ${(e as Error).message}`);
if ((e as Error).stack) logger.error((e as Error).stack);
await app.close();
process.exit(1);
}
await app.close();
process.exit(0);
}
bootstrap();
/**
* One-shot verify: 跑 chain-composer 对指定 patient, 打印各 chain 状态。
* 用法:pnpm tsx src/cli/verify-chain.cli.ts --id=<patientId>
*/
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../app.module';
import { PrismaService } from '../prisma/prisma.service';
import { ChainComposerService } from '../modules/plan/engine/chain-composer.service';
async function main() {
const id = process.argv.find((a) => a.startsWith('--id='))?.slice('--id='.length);
if (!id) {
console.error('Usage: --id=<patientId>');
process.exit(1);
}
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error'] });
const prisma = app.get(PrismaService);
const composer = app.get(ChainComposerService);
const facts = await prisma.patientFact.findMany({
where: { patientId: id, status: { in: ['active', 'fulfilled'] } },
orderBy: { occurredAt: 'asc' },
});
const chains = composer.compose(facts as any);
console.log(`\n=== Patient ${id}${chains.length} chains ===\n`);
for (const c of chains) {
const target = c.target ? ' ★' : '';
console.log(
`[${c.status.padEnd(11)}] stage=${c.currentStage} ${(c.name ?? '?').padEnd(18)} tooth=${c.toothPosition ?? '-'} code=${c.code ?? '-'} cat=${c.category}${target}`,
);
const lc = (c as { lifecycleNoteZh?: string }).lifecycleNoteZh;
if (lc) console.log(` lifecycle: ${lc}`);
const nodes = (c as unknown as { nodes?: Array<{ stage?: number; title?: string; detail?: string; hint?: string; done?: boolean }> }).nodes;
if (nodes && nodes.length) {
for (const n of nodes) console.log(` S${n.stage ?? '?'} ${n.done ? '✓' : '○'} ${n.title ?? ''} ${n.detail ? '· ' + n.detail : ''}${n.hint ? ' [' + n.hint + ']' : ''}`);
}
const altClosedBy = (c as { alternativeClosedBy?: string }).alternativeClosedBy;
if (altClosedBy) console.log(` alt-closed-by: ${altClosedBy}`);
}
await app.close();
}
main().catch((e) => { console.error(e); process.exit(1); });
...@@ -14,9 +14,13 @@ import { ...@@ -14,9 +14,13 @@ import {
* LLM 偶尔会越过 schema 约束塞禁词,这里再补一道(防御性)。 * LLM 偶尔会越过 schema 约束塞禁词,这里再补一道(防御性)。
* B 方案重写后,output 4 段都是 markdown 字符串,直接全文 join 扫禁词。 * B 方案重写后,output 4 段都是 markdown 字符串,直接全文 join 扫禁词。
*/ */
// ⚠️ 单字符禁词务必避免(会误伤合法词):
// '亲' → 误命中 亲切 / 亲自 / 亲人 / 母亲 / 父亲 等
// '宝' → 误命中 宝贝 / 宝藏 / 宝石 等
// 只保留"销售化"的明确组合形式
const FORBIDDEN_PHRASES = [ const FORBIDDEN_PHRASES = [
'一定能', '保证', '绝对', '百分百', '100%', '一定能', '保证', '绝对', '百分百', '100%',
'亲爱的', '亲', '宝', '亲爱的', // 淘宝式称呼,误伤面比单字符 '亲' 小很多
'便宜', '促销', '折扣', '免费送', '便宜', '促销', '折扣', '免费送',
]; ];
......
...@@ -6,6 +6,8 @@ import { AuthService } from './auth.service'; ...@@ -6,6 +6,8 @@ import { AuthService } from './auth.service';
import { import {
ExchangeCodeRequestDto, ExchangeCodeRequestDto,
ExchangeCodeResponseDto, ExchangeCodeResponseDto,
MockLoginRequestDto,
MockLoginResponseDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
RefreshTokenResponseDto, RefreshTokenResponseDto,
TokenExchangeRequestDto, TokenExchangeRequestDto,
...@@ -43,4 +45,18 @@ export class AuthController { ...@@ -43,4 +45,18 @@ export class AuthController {
refresh(@Body() dto: RefreshTokenRequestDto) { refresh(@Body() dto: RefreshTokenRequestDto) {
return this.auth.refresh(dto.refreshToken); return this.auth.refresh(dto.refreshToken);
} }
@Public()
@Post('mock-login')
@ZodResponse({ status: 200, type: MockLoginResponseDto })
@ApiOperation({
summary:
'Mock login(开发 / 试部署用,env 门控)— 按 { tenant, role } 预制 user payload 直产 JWT',
description:
'env: PAC_ENABLE_MOCK_LOGIN=true 强开 / =false 强关;不设按 NODE_ENV(prod 默认关)。' +
'生产真 host SSO 接入后,把 env 设 false 或删该 endpoint。',
})
mockLogin(@Body() dto: MockLoginRequestDto) {
return this.auth.mockLogin(dto);
}
} }
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
ROLE_PERMISSIONS, ROLE_PERMISSIONS,
type AccessTokenPayload, type AccessTokenPayload,
type ExchangeCodeResponse, type ExchangeCodeResponse,
type MockLoginRequest,
type Permission, type Permission,
type RefreshTokenResponse, type RefreshTokenResponse,
type TokenDictionary, type TokenDictionary,
...@@ -207,4 +208,99 @@ export class AuthService { ...@@ -207,4 +208,99 @@ export class AuthService {
resolvePermissions(role: UserRole): Permission[] { resolvePermissions(role: UserRole): Permission[] {
return ROLE_PERMISSIONS[role]; return ROLE_PERMISSIONS[role];
} }
// ─────────────────────────────────────────────────────────────
// Mock Login(开发 / 试部署用 — env 门控,生产关闭)
// ─────────────────────────────────────────────────────────────
//
// 用途:试部署期间客服 / 演示用户没真 host SSO 也能登录工作台。
// 安全:env 门控(NODE_ENV != production 或 PAC_ENABLE_MOCK_LOGIN=true);
// 生产真 host 接入后,删 endpoint 或把 env 设 false。
/// jvs-dw 两个 brand 的预制 user payload(对齐 manifest.yaml tenant_map)
/// 来源:`apps/pac-service/data/jvs-dw/manifest.yaml` §"tenant 路由"
/// `docs/deployment-data-ingest.md` §clinic UUID 列表
private readonly MOCK_PRESETS = {
ruier: {
tenantId: 'ba67e6cf30dc4f9c9c46adef188bbd04',
tenantNameZh: '瑞尔',
clinics: {
'7d49539c7573490387c03e6496ff1a6c': '杭州大厦诊所',
dad2f04a120947e2b82b41cbd108f3f4: '杭州高德诊所',
'66701845dd2342e19f9e9f576c4ffe9c': '北京朝阳公园诊所',
},
},
ruitai: {
tenantId: '77057aed269f4a14957ae0ad0eff359a',
tenantNameZh: '瑞泰',
clinics: {
c18cadf2d3cd4adda5527debd41356eb: '通善口腔学前街医院',
e83d432a38bb4f6284713b36db4e7497: '上海世纪公园',
},
},
} as const;
isMockLoginEnabled(): boolean {
if (process.env.PAC_ENABLE_MOCK_LOGIN === 'true') return true;
if (process.env.PAC_ENABLE_MOCK_LOGIN === 'false') return false;
return (this.config.get<string>('nodeEnv') ?? process.env.NODE_ENV) !== 'production';
}
async mockLogin(req: MockLoginRequest): Promise<ExchangeCodeResponse> {
if (!this.isMockLoginEnabled()) {
throw new BizError(ApiCode.AUTH_PERMISSION_DENIED, '模拟登录已在当前环境禁用');
}
const preset = this.MOCK_PRESETS[req.tenant];
if (!preset) {
throw new BizError(ApiCode.CLIENT_VALIDATION_FAILED, `unknown tenant: ${req.tenant}`);
}
// 找 jvs-dw host(seed 时已建)
const host = await this.prisma.host.findFirst({ where: { name: 'jvs-dw' } });
if (!host) {
throw new BizError(
ApiCode.HOST_NOT_FOUND,
'请先 seed jvs-dw host(pnpm --filter @pac/service exec prisma db seed)',
);
}
const clinicIds = Object.keys(preset.clinics);
const dictionary: TokenDictionary = {
clinics: preset.clinics as Record<string, string>,
users: {
// 给 sub 一个可读名,UI 头像右侧显示
[`mock-${req.tenant}-${req.role}`]: `${preset.tenantNameZh}·${roleNameZh(req.role)}(模拟)`,
},
};
const permissions = this.resolvePermissions(req.role);
const accessToken = await this.signAccessToken({
sub: `mock-${req.tenant}-${req.role}`,
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
role: req.role,
permissions,
dictionary,
});
const refreshToken = await this.signRefreshToken({
sub: `mock-${req.tenant}-${req.role}`,
hostId: host.id,
tenantId: preset.tenantId,
clinicIds,
role: req.role,
dictionary,
});
const expiresIn = parseDurationToSeconds(this.config.getOrThrow<string>('jwt.expiresIn'));
this.logger.log(
`mock-login: tenant=${req.tenant}(${preset.tenantNameZh}) role=${req.role} ` +
`clinics=${clinicIds.length} hostId=${host.id}`,
);
return { accessToken, refreshToken, expiresIn };
}
}
function roleNameZh(role: UserRole): string {
return ({ staff: '员工', leader: '主管', admin: '管理员' } as const)[role] ?? role;
} }
...@@ -2,6 +2,8 @@ import { createZodDto } from 'nestjs-zod'; ...@@ -2,6 +2,8 @@ import { createZodDto } from 'nestjs-zod';
import { import {
ExchangeCodeRequestSchema, ExchangeCodeRequestSchema,
ExchangeCodeResponseSchema, ExchangeCodeResponseSchema,
MockLoginRequestSchema,
MockLoginResponseSchema,
RefreshTokenRequestSchema, RefreshTokenRequestSchema,
RefreshTokenResponseSchema, RefreshTokenResponseSchema,
TokenExchangeRequestSchema, TokenExchangeRequestSchema,
...@@ -14,3 +16,5 @@ export class ExchangeCodeRequestDto extends createZodDto(ExchangeCodeRequestSche ...@@ -14,3 +16,5 @@ export class ExchangeCodeRequestDto extends createZodDto(ExchangeCodeRequestSche
export class ExchangeCodeResponseDto extends createZodDto(ExchangeCodeResponseSchema) {} export class ExchangeCodeResponseDto extends createZodDto(ExchangeCodeResponseSchema) {}
export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSchema) {} export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSchema) {}
export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {} export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {}
export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {}
...@@ -4,6 +4,7 @@ import { maskName, maskPhone } from '@pac/utils'; ...@@ -4,6 +4,7 @@ import { maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service'; import { ChainComposerService } from '../plan/engine/chain-composer.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { toothSet } from '../sync/pipeline/parsers/tooth-position.util';
/** /**
* PlanAggregateService — Plan 详情聚合服务。 * PlanAggregateService — Plan 详情聚合服务。
...@@ -77,11 +78,18 @@ export class PlanAggregateService { ...@@ -77,11 +78,18 @@ export class PlanAggregateService {
reasonKeys.add(`${t.code}|${tooth || '*'}`); reasonKeys.add(`${t.code}|${tooth || '*'}`);
} }
} }
// W4 末:牙位精确匹配(Palmer 乳牙 + FDI 恒牙都支持)
// 旧版 strip 所有字母 → "1D OD" → "1" → Palmer 乳牙 1A/1B/1C/1D/1E 全部误匹配
// 新版用 toothSet:剥面后缀后保留牙位 base("1D OD" → "1D"; "11 M" → "11")
// 精确匹配避免 Palmer 5 颗乳牙在儿童 case 被误判
const toothOverlap = (a: string, b: string): boolean => { const toothOverlap = (a: string, b: string): boolean => {
if (!a || !b) return false; if (!a || !b) return false;
const A = new Set(a.split(';').map((s) => s.replace(/[^0-9]/g, '').trim()).filter(Boolean)); const A = toothSet(a);
const B = b.split(';').map((s) => s.replace(/[^0-9]/g, '').trim()).filter(Boolean); if (A.size === 0) return false;
return B.some((t) => A.has(t)); for (const t of toothSet(b)) {
if (A.has(t)) return true;
}
return false;
}; };
for (const c of chains) { for (const c of chains) {
if (!c.code) { c.target = false; continue; } if (!c.code) { c.target = false; continue; }
...@@ -335,7 +343,7 @@ function serializeScript(s: { ...@@ -335,7 +343,7 @@ function serializeScript(s: {
content: string | null; content: string | null;
source: string | null; source: string | null;
status: string; status: string;
generatedAt: Date | null; generatedAt?: Date | null;
updatedAt: Date; updatedAt: Date;
}) { }) {
const sections = parseScriptMarkdownToSections(s.content ?? ''); const sections = parseScriptMarkdownToSections(s.content ?? '');
......
...@@ -10,15 +10,25 @@ import { ...@@ -10,15 +10,25 @@ import {
import type { Response } from 'express'; import type { Response } from 'express';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { z } from 'zod';
import { RequirePermission } from '../../common/decorators/permissions.decorator'; import { RequirePermission } from '../../common/decorators/permissions.decorator';
import { import {
TenantScope, TenantScope,
TenantScopeContext, TenantScopeContext,
} from '../../common/decorators/tenant-scope.decorator'; } from '../../common/decorators/tenant-scope.decorator';
import {
CurrentUser,
AuthenticatedUser,
} from '../../common/decorators/current-user.decorator';
import { PrismaService } from '../../prisma/prisma.service';
import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator'; import { PlanScriptOrchestrator } from '../ai/orchestrators/plan-script.orchestrator';
import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator'; import { PlanSummaryOrchestrator } from '../ai/orchestrators/plan-summary.orchestrator';
import { PlanAggregateService } from './plan-aggregate.service'; import { PlanAggregateService } from './plan-aggregate.service';
const ScriptFeedbackSchema = z.object({
feedback: z.enum(['up', 'down']),
});
/** /**
* PlansAggregateController — 生产路径的 plan 详情聚合 + AI 资产再生成端点。 * PlansAggregateController — 生产路径的 plan 详情聚合 + AI 资产再生成端点。
* *
...@@ -42,6 +52,7 @@ export class PlansAggregateController { ...@@ -42,6 +52,7 @@ export class PlansAggregateController {
private readonly demo: PlanAggregateService, private readonly demo: PlanAggregateService,
private readonly planScript: PlanScriptOrchestrator, private readonly planScript: PlanScriptOrchestrator,
private readonly planSummary: PlanSummaryOrchestrator, private readonly planSummary: PlanSummaryOrchestrator,
private readonly prisma: PrismaService,
) {} ) {}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -114,6 +125,44 @@ export class PlansAggregateController { ...@@ -114,6 +125,44 @@ export class PlansAggregateController {
// Summary — 流式重生成(1 次产 3 段:onePage / medicalRecord / treatmentChain) // Summary — 流式重生成(1 次产 3 段:onePage / medicalRecord / treatmentChain)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// 话术 / 摘要 thumbs up/down 反馈("本段是否好用")
// ─────────────────────────────────────────────
/**
* 反馈某 plan 当前话术(对应 plan_scripts.agent_invocation_id)up/down。
* 同一 invocation 反复点会覆盖(再点 down 覆盖 up,以最新为准)。
* 没找到 planScript / agentInvocationId → 404(避免无 invocation 的 plan 接受反馈)。
*
* 后续可加:summary feedback(对称端点)/ 聚合 dashboard(up/down 比 + 命中 fallback 时禁止评)。
*/
@Post(':id/script-feedback')
@RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '对当前话术 thumbs up/down 反馈("本段是否好用")' })
async submitScriptFeedback(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
@Body() body: unknown,
) {
const { feedback } = ScriptFeedbackSchema.parse(body);
const script = await this.prisma.planScript.findFirst({
where: { planId, hostId: scope.hostId, tenantId: scope.tenantId },
});
if (!script?.agentInvocationId) {
return { ok: false as const, reason: 'no_invocation' };
}
await this.prisma.agentInvocation.update({
where: { id: script.agentInvocationId },
data: {
userFeedback: feedback,
userFeedbackAt: new Date(),
userFeedbackBy: user.sub,
},
});
return { ok: true as const, invocationId: script.agentInvocationId, feedback };
}
@Get(':id/summary:stream') @Get(':id/summary:stream')
@RequirePermission(Permission.PLAN_VIEW_OWN) @RequirePermission(Permission.PLAN_VIEW_OWN)
@ApiOperation({ summary: '流式重新生成 plan 摘要(SSE,1 次产 3 段)' }) @ApiOperation({ summary: '流式重新生成 plan 摘要(SSE,1 次产 3 段)' })
......
...@@ -79,7 +79,7 @@ export class ChainComposerService { ...@@ -79,7 +79,7 @@ export class ChainComposerService {
// 替代闭环 pass:同 patient 同 tooth overlap 后续有"已治"链(closed/ongoing s>=3) → // 替代闭环 pass:同 patient 同 tooth overlap 后续有"已治"链(closed/ongoing s>=3) →
// 把当前 discovered/entered chain 标记为 closed(by alternative) // 把当前 discovered/entered chain 标记为 closed(by alternative)
// 例:罗国标 K04 14;15 根管诊断 → 后来 K08 14-17;47 + implant actual → K04 chain 视为替代闭环 // 例:罗国标 K04 14;15 根管诊断 → 后来 K08 14-17;47 + implant actual → K04 chain 视为替代闭环
markAlternativeClosed(deduped); markAlternativeClosed(deduped, byType);
// ⭐ chain ★ 标记不在这里做 patient 级对齐(撤回 filter) // ⭐ chain ★ 标记不在这里做 patient 级对齐(撤回 filter)
// 理由:chain-composer 只产 chain 客观状态(discovered/entered/ongoing/closed) // 理由:chain-composer 只产 chain 客观状态(discovered/entered/ongoing/closed)
...@@ -148,8 +148,19 @@ function filterDiscoveredByPatientLevel(chains: ComposedChain[]): ComposedChain[ ...@@ -148,8 +148,19 @@ function filterDiscoveredByPatientLevel(chains: ComposedChain[]): ComposedChain[
}); });
} }
function markAlternativeClosed(chains: ComposedChain[]): void { function markAlternativeClosed(chains: ComposedChain[], byType: FactsByType): void {
const ALT_CATS = new Set(['implant', 'surgical', 'prosthodontic']); const ALT_CATS = new Set(['implant', 'surgical', 'prosthodontic']);
// ⭐ W4 末改造:扫 fact 而非 chain
// 原因:surgical(拔除)往往是"actual-only"链 — 无对应 dx sig,bucket.tooth='',
// 被原版 markAlternativeClosed(看 chain.toothPosition)跳过。
// 例:季根财 K04@23 + surgical actual @ 23(松动恒牙拔除术)→ 23 已拔不需 RCT,
// 但 surgical chain 是 actual-only(tooth='') → 老版 K04 chain 一直 ★ discovered。
// 改成扫 byType.treatment 的 actual,直接用 actual.content.tooth_position 跟 c.toothPosition 比。
const altActuals = byType.treatment.filter((tx) => {
if (tx.kind !== FactKind.ACTUAL) return false;
const cat = String((tx.content as Record<string, unknown>).category ?? '');
return ALT_CATS.has(cat);
});
for (const c of chains) { for (const c of chains) {
if (c.status !== 'discovered' && c.status !== 'entered') continue; if (c.status !== 'discovered' && c.status !== 'entered') continue;
if (!c.toothPosition || !c.diagnosedAt) continue; if (!c.toothPosition || !c.diagnosedAt) continue;
...@@ -160,22 +171,39 @@ function markAlternativeClosed(chains: ComposedChain[]): void { ...@@ -160,22 +171,39 @@ function markAlternativeClosed(chains: ComposedChain[]): void {
// ⭐ W4 末新加豁免 #2:wholeMouth 链(全口治疗,牙位 ≥ 20 颗)不被 per-tooth 链替代 // ⭐ W4 末新加豁免 #2:wholeMouth 链(全口治疗,牙位 ≥ 20 颗)不被 per-tooth 链替代
// 全口洁治覆盖 26 颗牙 vs 种植 21;41 这 2 颗 — 临床上是"维护 + 局部治疗",不是替代 // 全口洁治覆盖 26 颗牙 vs 种植 21;41 这 2 颗 — 临床上是"维护 + 局部治疗",不是替代
if (isWholeMouthTooth(c.toothPosition)) continue; if (isWholeMouthTooth(c.toothPosition)) continue;
for (const other of chains) { // ⭐ 全牙位覆盖才闭环 — 收集所有适用的 alt actuals 求并集
if (other === c) continue; // 季根财 K08{16,17,25,26,27,44,45} 只被 surgical@44 覆盖 1 颗 → 不闭环(其他 6 颗仍缺口)
if (!ALT_CATS.has(other.category)) continue; // 季根财 K04@23 被 surgical@23 完全覆盖 → 闭环 ✓
if (!other.toothPosition || !other.diagnosedAt) continue; // 注:范萍莉 K08{41,47} 由同 cat implant actual 覆盖,走的是 s5Eligible 闸(同 cat 算正治不算替代)
// ⭐ 同 category 不交叉关闭(W3 末)— alt-close 语义是 K04 根管→K08 种植 这种"不同方案"替代, const cToothSet = new Set(c.toothPosition.split(';').map((t) => t.trim()).filter(Boolean));
// 同病种(都 implant / 都 surgical)多次诊断不是"替代"是"重复",该在桶分阶段合并而非这里互判 if (cToothSet.size === 0) continue;
// 例:王辉 K08 多次诊断 tooth 顺序差异 → 旧版同 implant 互判 alt-closed → 误标 const altCovered = new Set<string>();
if (other.category === c.category) continue; const altSources: string[] = [];
if (other.diagnosedAt < c.diagnosedAt) continue; const cDx = new Date(c.diagnosedAt);
if (!toothOverlap(c.toothPosition, other.toothPosition)) continue; if (isNaN(cDx.getTime())) continue;
// 命中 — 标记替代闭环 for (const tx of altActuals) {
const txCat = String((tx.content as Record<string, unknown>).category ?? '');
if (txCat === c.category) continue;
if (!tx.occurredAt) continue;
if (tx.occurredAt.getTime() < cDx.getTime()) continue;
const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '');
if (!txTooth) continue;
let hit = false;
for (const t of txTooth.split(';')) {
const trimmed = t.trim();
if (trimmed && cToothSet.has(trimmed)) {
altCovered.add(trimmed);
hit = true;
}
}
if (hit) altSources.push(`${CATEGORY_LABEL[txCat] ?? txCat}·${txTooth}`);
}
if (altCovered.size === cToothSet.size && altCovered.size > 0) {
// 全覆盖 → alt-closed
c.status = 'closed'; c.status = 'closed';
c.target = false; c.target = false;
c.alternativeClosedBy = other.name; c.alternativeClosedBy = altSources.join(' / ');
c.currentStage = 5; c.currentStage = 5;
break;
} }
} }
} }
...@@ -234,12 +262,29 @@ function inferChainStage( ...@@ -234,12 +262,29 @@ function inferChainStage(
// ─ 先算 S3 actual(后面 S2 需要 s3FirstActual 作 actual-only 桶 anchor) // ─ 先算 S3 actual(后面 S2 需要 s3FirstActual 作 actual-only 桶 anchor)
// **时间方向** — 只算 s1 之后的 actual(跟 scenario SQL `tx.occurred_at >= sig.occurred_at` 同口径) // **时间方向** — 只算 s1 之后的 actual(跟 scenario SQL `tx.occurred_at >= sig.occurred_at` 同口径)
// s1Earliest null(actual-only 桶)→ 全 actual 都算(无 sig 锚点) // s1Earliest null(actual-only 桶)→ 全 actual 都算(无 sig 锚点)
const allActuals = getActualTreatments(category, byType); // ⭐ W4 末:**牙位过滤** — bucket.tooth 非空时只算同牙位 actual
// 蒋卓易 Joey K03@12 bug:原版无牙位过滤,把所有 restorative actuals(16/26/46/...)
// 都算到 K03@12 桶里 → 误判 s3Reached=true → stage=4 ongoing
// 实际上 12 一次治疗没做,SQL 召回是对的,chain 状态也该 stage=1 discovered
// wholeMouth 桶('*whole')+ actual-only 桶('')不过滤(全口治疗按牙位无意义)
const allActualsByCategory = getActualTreatments(category, byType);
const allActuals = bucket.tooth && bucket.tooth !== '*whole'
? allActualsByCategory.filter((tx) => {
const txTooth = String((tx.content as Record<string, unknown>).tooth_position ?? '');
return toothOverlap(bucket.tooth, txTooth);
})
: allActualsByCategory;
const actuals = s1Earliest?.occurredAt const actuals = s1Earliest?.occurredAt
? allActuals.filter((tx) => tx.occurredAt && tx.occurredAt.getTime() >= s1Earliest.occurredAt!.getTime()) ? allActuals.filter((tx) => tx.occurredAt && tx.occurredAt.getTime() >= s1Earliest.occurredAt!.getTime())
: allActuals; : allActuals;
const matchedSteps = matchMilestoneSteps(actuals, milestone); const matchedSteps = matchMilestoneSteps(actuals, milestone);
const s3Reached = milestone ? matchedSteps.matched.length >= milestone.minSteps : actuals.length > 0; // W4 末:s3Reached 双口径
// ① terminalHit 命中(任一终末术式)→ 即使 minSteps 未满,治疗已收尾
// 例 endodontic:pulpotomy 单步 = 完整 VPT,跟完整 RCT(canal_filling)等价
// ② matched.length >= minSteps(老逻辑,阶梯类)
const s3Reached = milestone
? matchedSteps.terminalHit !== null || matchedSteps.matched.length >= milestone.minSteps
: actuals.length > 0;
const s3FirstActual = earliest(actuals); const s3FirstActual = earliest(actuals);
const s3LastActual = latest(actuals); const s3LastActual = latest(actuals);
...@@ -266,13 +311,28 @@ function inferChainStage( ...@@ -266,13 +311,28 @@ function inferChainStage(
// 6. ⭐ W4 末:lifecycle.requiresCrownProtection → 同牙位有 prosthodontic actual(冠/桩核) // 6. ⭐ W4 末:lifecycle.requiresCrownProtection → 同牙位有 prosthodontic actual(冠/桩核)
// 临床:根管后牙变脆 ~30% 折裂率,没戴冠不算真闭环 // 临床:根管后牙变脆 ~30% 折裂率,没戴冠不算真闭环
// 杨光宗 27 K04 根管完成但没冠 → 应该 ongoing 提示客服回访做冠 // 杨光宗 27 K04 根管完成但没冠 → 应该 ongoing 提示客服回访做冠
// W4 末:requiresCrownProtection 局部 override —
// 命中 pulpotomy(VPT 终末)= 根髓活,牙不变脆,临床不需要冠保护
// 命中 canal_filling(完整 RCT)= 根管去髓,牙变脆,~30% 折裂,需冠保护
// 两者都未命中(仅开髓/根备等)= 治疗进行中,crownOk 判定不影响(s3Reached=false)
const pulpotomyTerminal = matchedSteps.terminalHit === 'pulpotomy';
const crownOk = !lifecycle.requiresCrownProtection const crownOk = !lifecycle.requiresCrownProtection
|| pulpotomyTerminal
|| hasCrownProtection(bucket.tooth, byType, s3AnchorTime); || hasCrownProtection(bucket.tooth, byType, s3AnchorTime);
// ⭐ W4 末:多牙位桶的"覆盖闸"——
// union-find 把同 (code, category) 的多 sig 合并到一桶,bucket.tooth 是并集
// (例:范萍莉 K08 sig {41,47} + implant actual {47} 合一桶,但 41 实际未处置)
// 如果实际 actual 没覆盖到桶里所有牙位,**不能算 closed**
// ⚠️ 召回算法侧暂不动(治疗链内召回属 W5+,先只确保展示层正确)
// wholeMouth(全口 K05 等)桶不参与本闸——全口治疗本来就不按颗牙判
const uncoveredTeeth = computeUncoveredTeeth(bucket.tooth, actuals);
const fullyCovered = uncoveredTeeth.length === 0;
const s5Eligible = const s5Eligible =
s3Reached && s3Reached &&
s4Hits.length > 0 && s4Hits.length > 0 &&
maxAllowedStage === 5 && maxAllowedStage === 5 &&
crownOk && crownOk &&
fullyCovered &&
!hasPostS3Refund(byType, s3AnchorTime) && !hasPostS3Refund(byType, s3AnchorTime) &&
!hasPostS3Relapse(category, byType, s3AnchorTime); !hasPostS3Relapse(category, byType, s3AnchorTime);
...@@ -309,9 +369,15 @@ function inferChainStage( ...@@ -309,9 +369,15 @@ function inferChainStage(
// 显示并集让客服一眼看到"全部受影响牙位",不是只看到首诊那 1-3 颗 // 显示并集让客服一眼看到"全部受影响牙位",不是只看到首诊那 1-3 颗
// 例:张超 K08 合并 9 次诊断 → bucket.tooth="11;15;16;17;22;26;27;37" (8 颗) vs 旧版只显示 s1 的 "15;16;17" // 例:张超 K08 合并 9 次诊断 → bucket.tooth="11;15;16;17;22;26;27;37" (8 颗) vs 旧版只显示 s1 的 "15;16;17"
// wholeMouth(K05/SRP_RECOMMENDED)桶 tooth='*whole' 统一显示"全口" // wholeMouth(K05/SRP_RECOMMENDED)桶 tooth='*whole' 统一显示"全口"
// W4 末:actual-only 桶(无 dx sig,bucket.tooth='')回退到 actuals 牙位并集
// 例 季根财 surgical 链:无 K01/K07 dx,actuals 各拔不同牙位(23;24/15;44/37/22)→
// 汇总并集"15;22;23;24;37;44" 显示给客服,不再"未标注牙位"
const actualToothUnion = actualsToothUnion(actuals);
const tooth = dxRule?.wholeMouth const tooth = dxRule?.wholeMouth
? '全口' ? '全口'
: (bucket.tooth && bucket.tooth !== '*whole' ? bucket.tooth : rawTooth); : (bucket.tooth && bucket.tooth !== '*whole'
? bucket.tooth
: (rawTooth || actualToothUnion));
const chainName = `${chainLabel} · ${tooth || fallback}`; const chainName = `${chainLabel} · ${tooth || fallback}`;
const diagnosedAt = s1Earliest?.occurredAt ? fmtYearMonthDay(s1Earliest.occurredAt) : '—'; const diagnosedAt = s1Earliest?.occurredAt ? fmtYearMonthDay(s1Earliest.occurredAt) : '—';
const gapDays = s1Earliest?.occurredAt const gapDays = s1Earliest?.occurredAt
...@@ -348,7 +414,10 @@ function inferChainStage( ...@@ -348,7 +414,10 @@ function inferChainStage(
lifecycleNoteZh: lifecycle.noteZh, lifecycleNoteZh: lifecycle.noteZh,
// 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key), // 给 cross-chain alternative-closed pass 用;wholeMouth 桶 bucket.tooth='*whole'(内部 key),
// 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后) // 那种情况回填 s1Earliest 真实牙位串;其他用 bucket.tooth 并集(union-find 后)
toothPosition: bucket.tooth && bucket.tooth !== '*whole' ? bucket.tooth : rawTooth, // W4 末:actual-only 桶用 actuals 牙位并集
toothPosition: bucket.tooth && bucket.tooth !== '*whole'
? bucket.tooth
: (rawTooth || actualToothUnion),
nodes: buildStageNodes({ nodes: buildStageNodes({
currentStage, currentStage,
status, status,
...@@ -366,6 +435,7 @@ function inferChainStage( ...@@ -366,6 +435,7 @@ function inferChainStage(
byType, byType,
doctorMap, doctorMap,
crownOk, crownOk,
uncoveredTeeth,
}), }),
}; };
} }
...@@ -664,6 +734,50 @@ function normalizeTooth(s: string): string { ...@@ -664,6 +734,50 @@ function normalizeTooth(s: string): string {
return list.join(';'); return list.join(';');
} }
/// actuals 牙位并集(去重 + 字典序)— actual-only 桶兜底显示用
/// 例:季根财 surgical 4 次 actual 拔 {23;24, 15;44, 37, 22} → "15;22;23;24;37;44"
function actualsToothUnion(actuals: ChainComposeInputFact[]): string {
const set = new Set<string>();
for (const tx of actuals) {
const tp = String((tx.content as Record<string, unknown>).tooth_position ?? '');
for (const t of tp.split(';')) {
const trimmed = t.trim();
if (trimmed) set.add(trimmed);
}
}
return Array.from(set).sort().join(';');
}
/// 桶 sig 牙位中 actual 没覆盖到的部分(差集)— W4 末加,给"覆盖闸"用
///
/// 例:范萍莉 K08·implant 桶 tooth='41;47',6 次 actual 全在 47 → 返回 ['41']
/// 跳过条件:
/// - 桶 tooth 空 / wholeMouth → 返回 []
/// - sig 单颗牙时,只要任一 actual 牙位包含它就算覆盖
///
/// **注**:本判定 only for chain 展示层 closed 闸,不影响 scenario SQL 排除粒度。
function computeUncoveredTeeth(
bucketTooth: string,
actuals: ChainComposeInputFact[],
): string[] {
if (!bucketTooth || bucketTooth === '*whole') return [];
const bucketSet = new Set(bucketTooth.split(';').map((t) => t.trim()).filter(Boolean));
if (bucketSet.size === 0) return [];
const covered = new Set<string>();
for (const tx of actuals) {
const tp = String((tx.content as Record<string, unknown>).tooth_position ?? '');
for (const t of tp.split(';')) {
const trimmed = t.trim();
if (trimmed) covered.add(trimmed);
}
}
const uncovered: string[] = [];
for (const t of bucketSet) {
if (!covered.has(t)) uncovered.push(t);
}
return uncovered.sort();
}
/// 牙位是否有交集(用 ; 分隔,trim 后比较) /// 牙位是否有交集(用 ; 分隔,trim 后比较)
function toothOverlap(a: string, b: string): boolean { function toothOverlap(a: string, b: string): boolean {
const setA = new Set( const setA = new Set(
...@@ -709,6 +823,10 @@ interface MilestoneMatch { ...@@ -709,6 +823,10 @@ interface MilestoneMatch {
allSatisfied: boolean; allSatisfied: boolean;
/// 每个 step → 命中的 actual treatment fact(用于 timeline label) /// 每个 step → 命中的 actual treatment fact(用于 timeline label)
stepToFact: Map<string, ChainComposeInputFact>; stepToFact: Map<string, ChainComposeInputFact>;
/// W4 末:命中的"终末 step"(milestone.terminalSteps 列表中,任一命中即视为治疗收尾)
/// 例 endodontic:canal_filling(完整 RCT)或 pulpotomy(VPT 单步终末)
/// inferChainStage 用它判 s3Reached:命中 terminal 即满足,跳过 minSteps
terminalHit: PACTreatmentStepKey | null;
} }
/// actual treatments 按 milestone.steps 匹配 — W4 末改进: /// actual treatments 按 milestone.steps 匹配 — W4 末改进:
...@@ -727,6 +845,7 @@ function matchMilestoneSteps( ...@@ -727,6 +845,7 @@ function matchMilestoneSteps(
matched: actuals.length > 0 ? ['treatment'] : [], matched: actuals.length > 0 ? ['treatment'] : [],
allSatisfied: actuals.length > 0, allSatisfied: actuals.length > 0,
stepToFact, stepToFact,
terminalHit: null,
}; };
} }
const matched: string[] = []; const matched: string[] = [];
...@@ -752,10 +871,15 @@ function matchMilestoneSteps( ...@@ -752,10 +871,15 @@ function matchMilestoneSteps(
stepToFact.set(step, hit); stepToFact.set(step, hit);
} }
} }
// W4 末:terminalHit — milestone.terminalSteps 中任一命中即视为治疗收尾
// 例 endodontic.terminalSteps=['canal_filling','pulpotomy'] — 任一命中 s3Reached=true
const terminal = milestone.terminalSteps ?? [];
const terminalHit = (terminal.find((s) => matched.includes(s)) as PACTreatmentStepKey | undefined) ?? null;
return { return {
matched, matched,
allSatisfied: matched.length === milestone.steps.length, allSatisfied: matched.length === milestone.steps.length,
stepToFact, stepToFact,
terminalHit,
}; };
} }
...@@ -864,6 +988,7 @@ function buildStageNodes(opts: { ...@@ -864,6 +988,7 @@ function buildStageNodes(opts: {
byType: FactsByType; byType: FactsByType;
doctorMap: Map<string, string>; doctorMap: Map<string, string>;
crownOk: boolean; // W4 末:lifecycle.requiresCrownProtection 满足与否(inferChainStage 算过) crownOk: boolean; // W4 末:lifecycle.requiresCrownProtection 满足与否(inferChainStage 算过)
uncoveredTeeth: string[]; // W4 末:多牙位桶里 actual 没覆盖到的牙位(用于 S5 hint)
}): ChainNode[] { }): ChainNode[] {
const { const {
currentStage, currentStage,
...@@ -882,6 +1007,7 @@ function buildStageNodes(opts: { ...@@ -882,6 +1007,7 @@ function buildStageNodes(opts: {
byType, byType,
doctorMap, doctorMap,
crownOk, crownOk,
uncoveredTeeth,
} = opts; } = opts;
const reached = (s: number) => currentStage >= s; const reached = (s: number) => currentStage >= s;
const cur = (s: number) => currentStage === s; const cur = (s: number) => currentStage === s;
...@@ -927,7 +1053,11 @@ function buildStageNodes(opts: { ...@@ -927,7 +1053,11 @@ function buildStageNodes(opts: {
// ④ S3 reached 无任何上面信号 → "直接执行 · 未经预约"(急诊场景) // ④ S3 reached 无任何上面信号 → "直接执行 · 未经预约"(急诊场景)
// ⑤ status=discovered 无任何信号 → "尚未启动" + hint // ⑤ status=discovered 无任何信号 → "尚未启动" + hint
const plannedHint = findPlannedTreatmentHint(category, byType, s1Earliest?.occurredAt ?? null); const plannedHint = findPlannedTreatmentHint(category, byType, s1Earliest?.occurredAt ?? null);
const s3Reached = matchedSteps.matched.length >= (milestone?.minSteps ?? 1) || actuals.length > 0; // 跟 inferChainStage 同口径:terminalHit 命中即 s3Reached(无字典则 actual 即可)
const s3Reached =
matchedSteps.terminalHit !== null ||
matchedSteps.matched.length >= (milestone?.minSteps ?? 1) ||
(!milestone && actuals.length > 0);
const s2Node: ChainNode = (() => { const s2Node: ChainNode = (() => {
// S2 done:① 有 s2Earliest 强信号 OR ② S3 已到达(治疗都做了一定经过 S2) // S2 done:① 有 s2Earliest 强信号 OR ② S3 已到达(治疗都做了一定经过 S2)
const s2Done = !!s2Earliest || s3Reached; const s2Done = !!s2Earliest || s3Reached;
...@@ -1085,11 +1215,26 @@ function buildStageNodes(opts: { ...@@ -1085,11 +1215,26 @@ function buildStageNodes(opts: {
// 未闭环:提示还差什么 // 未闭环:提示还差什么
n.title = '未闭环'; n.title = '未闭环';
// W4 末:requiresCrownProtection 但无冠 — 显式提示客服(根管后建议戴冠) // W4 末:覆盖闸 — bucket 多牙位但 actual 没覆盖到全部 → 优先提示
if (lifecycle.requiresCrownProtection && s3Reached && !crownOk) { // 例:K08·implant 桶 {41,47},6 次种植全在 47 → 提示"47 已闭环, 41 待处置"
// 说明患者已在治疗链内但仍有未处置牙位(可视为治疗链内召回的暗示)
if (uncoveredTeeth.length > 0) {
const covered = s3LastActual ? '已处置部分牙位' : '';
n.hint = covered
? `${uncoveredTeeth.join(',')} 待处置(其余已处置)`
: `${uncoveredTeeth.join(',')} 待处置`;
} else if (lifecycle.requiresCrownProtection && s3Reached && !crownOk) {
n.hint = '待冠保护(根管后建议戴冠,防牙冠折裂)'; n.hint = '待冠保护(根管后建议戴冠,防牙冠折裂)';
} else if (matchedSteps.terminalHit) {
// W4 末:命中了 terminal step(如 pulpotomy / canal_filling)
// 治疗已收尾,缺的只是 S4 复查 / 时间观察 — 不算"待 missing step"
n.hint = '待复查 / 观察期(治疗已收尾)';
} else if (milestone && matchedSteps.matched.length < milestone.steps.length) { } else if (milestone && matchedSteps.matched.length < milestone.steps.length) {
const missing = milestone.steps.filter((s) => !matchedSteps.matched.includes(s)); // 排除 terminalSteps:replacement 路径不算"缺步"(避免提示"待 pulpotomy"等)
const terminal = new Set<string>(milestone.terminalSteps ?? []);
const missing = milestone.steps.filter(
(s) => !matchedSteps.matched.includes(s) && !terminal.has(s),
);
if (missing.length > 0) { if (missing.length > 0) {
const stepLabelFn = (step: string): string => const stepLabelFn = (step: string): string =>
PACTreatmentStep[step as PACTreatmentStepKey] ?? step; PACTreatmentStep[step as PACTreatmentStepKey] ?? step;
...@@ -1245,7 +1390,7 @@ const STATUS_PRIORITY: Record<ChainStatus, number> = { ...@@ -1245,7 +1390,7 @@ const STATUS_PRIORITY: Record<ChainStatus, number> = {
}; };
/// 没字典的 category 默认 lifecycle(one_shot 等价) /// 没字典的 category 默认 lifecycle(one_shot 等价)
const DEFAULT_LIFECYCLE = { maxStage: 5 as const, noteZh: '默认一次性' }; const DEFAULT_LIFECYCLE = { maxStage: 5 as const, noteZh: '默认一次性', requiresCrownProtection: false };
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// Types(对外接口) // Types(对外接口)
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { import {
PlanScenario, PlanScenario,
DiagnosisTreatmentMap, DiagnosisTreatmentMap,
APPT_COMPLAINT_TO_CATEGORY,
lookupDxTreatment, lookupDxTreatment,
diagnosisCodeNameZh, diagnosisCodeNameZh,
treatmentCategoryNameZh, treatmentCategoryNameZh,
...@@ -212,8 +213,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -212,8 +213,14 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
const allCodes = [...dxCodes, ...recCodes]; const allCodes = [...dxCodes, ...recCodes];
const excludeCats = rule.categories as readonly string[]; const excludeCats = rule.categories as readonly string[];
// (W3 末)SQL 预约排除已放宽 — 不再按 complaint_category 匹配,只看 sig 后有任何预约即排 // W4 末:按 excludeCats 算出对应的预约 complaint 文本(host appointment.complaint_category 字段值)
// 理由:召回目的就是建预约,患者已经有未来预约 → 不需要再 push // APPT_COMPLAINT_TO_CATEGORY 反查:filter complaint where category ∈ excludeCats
// 例 K07 excludeCats=['orthodontic'] → complaintTexts=['正畸','早矫']
// K08 excludeCats=['implant','prosthodontic'] → ['种植','修复']
// 用于 ⑤d:sig 之后有 complaint 匹配的 appointment → 患者已 entered 治疗链,不召回
const complaintTexts = Object.entries(APPT_COMPLAINT_TO_CATEGORY)
.filter(([, cat]) => excludeCats.includes(cat))
.map(([text]) => text);
// ╔═════════════════════════════════════════════════════════════════════╗ // ╔═════════════════════════════════════════════════════════════════════╗
// ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║ // ║ 召回 SQL 完整解读(initiation = 潜在治疗新链召回) ║
...@@ -352,17 +359,32 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -352,17 +359,32 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
'' ''
) )
) )
AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约(W3 末放宽) AND NOT EXISTS ( -- ⑤b 排除:患者已有未来预约
-- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可 -- 召回目的 = 让客服建预约。患者已经有未来预约 → 客服不需要再 push,医生到诊现场处理即可
-- 不按 complaint_category 匹配(陆伟根典型:K07 诊断 + 修复预约 → 不召也对,反正会来诊) -- 不按 complaint_category 匹配(陆伟根:K07 诊断 + 修复预约 → 反正会来诊)
-- **只看 status='active'**(scheduled 未到诊)— fulfilled(已到诊)是历史,⑤a actual treatment 排除已覆盖 -- 只看 status='active' + future
-- 时间锚点:future 预约(planned_for > now)— "已经有要来的事"
SELECT 1 FROM patient_facts appt SELECT 1 FROM patient_facts appt
WHERE appt.patient_id = p.id WHERE appt.patient_id = p.id
AND appt.type = 'appointment_record' AND appt.type = 'appointment_record'
AND appt.status = 'active' AND appt.status = 'active'
AND COALESCE(appt.planned_for, appt.occurred_at) > ${scope.now}::timestamptz AND COALESCE(appt.planned_for, appt.occurred_at) > ${scope.now}::timestamptz
) )
AND NOT EXISTS ( -- ⑤d 排除:sig 之后 complaint 匹配的预约(W4 末加)
-- 跟 chain-composer.collectS2Facts 同口径:complaint_category 命中 expectedCategories → 患者已 entered
-- 例 林菲菲:K07@2026-04-14 诊断 + appointment(complaint=正畸)@2026-04-26 → 已进入正畸链 → 排除
-- "现在召回只做新链" 原则:entered/ongoing 患者不属于新链召回,等 W5+ 治疗链内召回 scenario
-- active 未到诊 + fulfilled 已到诊都算(只要有 complaint 匹配)
SELECT 1 FROM patient_facts appt
WHERE appt.patient_id = p.id
AND appt.type = 'appointment_record'
AND appt.status IN ('active', 'fulfilled')
AND COALESCE(appt.planned_for, appt.occurred_at) >= COALESCE(sig.occurred_at, sig.planned_for)
AND EXISTS (
SELECT 1
FROM unnest(string_to_array(COALESCE(appt.content->>'complaint_category', ''), ',')) AS c
WHERE trim(c) = ANY(${complaintTexts}::text[])
)
)
`; `;
// 同 patient 多个命中信号 → 取最早(daysSince 最大)作为主 hit // 同 patient 多个命中信号 → 取最早(daysSince 最大)作为主 hit
......
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { ExecutionOutcome } from '@pac/types'; import { EXECUTION_OUTCOME_META, ExecutionOutcome } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
...@@ -30,24 +30,16 @@ const MAX_CONTACT_ATTEMPTS_DEFAULT = 4; // 暂常量,后续接 host/scenario 配 ...@@ -30,24 +30,16 @@ const MAX_CONTACT_ATTEMPTS_DEFAULT = 4; // 暂常量,后续接 host/scenario 配
*/ */
type StatusTransition = 'completed' | 'abandoned' | 'keep'; type StatusTransition = 'completed' | 'abandoned' | 'keep';
const OUTCOME_TO_STATUS: Record<string, StatusTransition> = { /**
// ── 4 起步 ── * outcome → 状态机映射 — **派生自 @pac/types 单一真理源**
abandoned: 'abandoned', *
no_answer: 'keep', * 老版本(W4 早期)在这里 hardcode 一份 OUTCOME_TO_STATUS,前端 mock-data
scheduled_next: 'keep', * 也单独维护一份 `drives` 字段,3 处口径漂移已踩过坑。
success_appointed: 'completed', * 现在统一从 EXECUTION_OUTCOME_META.drivesStatus 派生,改一处全生效。
// ── 产品收集 ── */
rescheduled: 'keep', const OUTCOME_TO_STATUS: Record<string, StatusTransition> = Object.fromEntries(
considering: 'keep', Object.entries(EXECUTION_OUTCOME_META).map(([k, v]) => [k, v.drivesStatus]),
declined_recent: 'keep', );
needs_doctor: 'keep',
external_treatment: 'completed', // 已在外院 → 关闭召回(spec 说 "应关闭")
refused: 'completed', // 明确拒绝不需治疗 → 关闭召回(不算放弃,是患者决策)
pending_info: 'keep',
sms_sent: 'keep',
proactive_sms: 'keep',
marked_invalid: 'completed', // 无效记录 → 闭环(配 invalidReason 留底)
};
export interface SubmitExecutionInput { export interface SubmitExecutionInput {
channel: string; channel: string;
......
...@@ -13,11 +13,39 @@ import type { ClickHouseSource } from './manifest.schema'; ...@@ -13,11 +13,39 @@ import type { ClickHouseSource } from './manifest.schema';
* *
* 安全:密码从环境变量读取(password_env),plaintext 永不进 yaml。 * 安全:密码从环境变量读取(password_env),plaintext 永不进 yaml。
*/ */
/// W4 末:DW 增量配置(per table cursor)
export interface IncrementalConfig {
/// per query 配置:cursor 列名(host 表那边的字段)+ 上次 cursor 值(从 sync_logs 读)
perQuery: Record<string, { cursorColumn: string; cursorValue: string | null }>;
/// 跑完后返回新的 cursor 值(每张表的 max)
cursorAdvances?: Record<string, string>;
}
/// W4 末:单患者刷新 scope(详情页"刷新"按钮)
/// 把 manifest queries 的 cohort 子查询替换为 `WHERE patient_id='X' AND brand='Y'`
/// 不跑 cursor、不写 cursor、不反向拉主档(本身就是单患者全量)
export interface PatientScope {
/// host 侧 patient_id(字符串,DW patient_id 是 String 类型)
patientExternalId: string;
/// host 侧 brand(中文 "瑞尔" / "瑞泰" — DW 真实字段值)
brand: string;
}
@Injectable() @Injectable()
export class ClickHouseSourceService { export class ClickHouseSourceService {
private readonly logger = new Logger(ClickHouseSourceService.name); private readonly logger = new Logger(ClickHouseSourceService.name);
async loadAllTables(source: ClickHouseSource): Promise<Record<string, unknown[]>> { /// W4 末:env 覆盖 manifest connection(便于多环境部署 不必改 yaml)
/// 优先级:env → manifest → 抛错
private resolveConnection(source: ClickHouseSource): {
url: string;
database: string;
username: string;
password: string;
} {
const url = process.env.DW_CLICKHOUSE_URL?.trim() || source.connection.url;
const database = process.env.DW_CLICKHOUSE_DATABASE?.trim() || source.connection.database;
const username = process.env.DW_CLICKHOUSE_USERNAME?.trim() || source.connection.username;
const password = process.env[source.connection.password_env]; const password = process.env[source.connection.password_env];
if (!password) { if (!password) {
throw new Error( throw new Error(
...@@ -25,12 +53,20 @@ export class ClickHouseSourceService { ...@@ -25,12 +53,20 @@ export class ClickHouseSourceService {
`请检查 .env 或 dotenv 加载`, `请检查 .env 或 dotenv 加载`,
); );
} }
return { url, database, username, password };
}
async loadAllTables(
source: ClickHouseSource,
incremental?: IncrementalConfig,
): Promise<Record<string, unknown[]>> {
const conn = this.resolveConnection(source);
const client: ClickHouseClient = createClient({ const client: ClickHouseClient = createClient({
url: source.connection.url, url: conn.url,
database: source.connection.database, database: conn.database,
username: source.connection.username, username: conn.username,
password, password: conn.password,
// Aliyun ADS 公网偶尔抽风,加大重试 // Aliyun ADS 公网偶尔抽风,加大重试
request_timeout: 60_000, request_timeout: 60_000,
compression: { response: true, request: false }, compression: { response: true, request: false },
...@@ -41,7 +77,12 @@ export class ClickHouseSourceService { ...@@ -41,7 +77,12 @@ export class ClickHouseSourceService {
try { try {
for (const [tableName, sql] of Object.entries(source.queries)) { for (const [tableName, sql] of Object.entries(source.queries)) {
const sqlWithLimit = this.applyDefaultLimit(sql, defaultLimit); // W4 末:incremental 模式注入 cursor 条件 + cohort LIMIT 移除(增量不需要 dev cohort)
const incCfg = incremental?.perQuery[tableName];
const sqlWithIncremental = incCfg
? this.injectIncrementalCursor(sql, incCfg.cursorColumn, incCfg.cursorValue)
: sql;
const sqlWithLimit = this.applyDefaultLimit(sqlWithIncremental, defaultLimit);
this.logger.log( this.logger.log(
`[clickhouse] query "${tableName}" — ${sqlWithLimit.slice(0, 120).replace(/\s+/g, ' ')}...`, `[clickhouse] query "${tableName}" — ${sqlWithLimit.slice(0, 120).replace(/\s+/g, ' ')}...`,
); );
...@@ -56,6 +97,42 @@ export class ClickHouseSourceService { ...@@ -56,6 +97,42 @@ export class ClickHouseSourceService {
this.logger.log( this.logger.log(
`[clickhouse] "${tableName}" → ${rows.length} 行,${elapsed} ms`, `[clickhouse] "${tableName}" → ${rows.length} 行,${elapsed} ms`,
); );
// W4 末:incremental 跑完算新 cursor(= max(cursor_column))
if (incremental && incCfg && rows.length > 0) {
const maxVal = this.computeMax(rows as Record<string, unknown>[], incCfg.cursorColumn);
if (maxVal) {
incremental.cursorAdvances = incremental.cursorAdvances ?? {};
incremental.cursorAdvances[tableName] = maxVal;
}
}
}
// ⭐ W4 末 反向拉主档(方案 C):增量模式下,fact 表拉来的 patient_id 不一定在 fact_client_out 里
// (例:患者 EMR 编辑了 last_visit_time 不变,主档 cursor 拉不到他)
// → 收集所有 fact 表中出现的 patient_id,追加一次 fact_client_out WHERE patient_id IN (...)
// 合并入已有 fact_client_out tables(set 去重)→ 数据完整性 100%
// stub 兜底(方案 A,parser 侧)+ 主档反向拉(方案 C,sync 侧)= 完整双保险
if (incremental && tables['fact_client_out']) {
const reverseRows = await this.reversePullPatientMaster(client, tables);
if (reverseRows.length > 0) {
const before = tables['fact_client_out'].length;
// 去重 by patient_id+brand:已有 cursor 拉到的不重复 push
const existing = new Set(
(tables['fact_client_out'] as Record<string, unknown>[]).map(
(r) => `${r.patient_id}|${r.brand}`,
),
);
for (const r of reverseRows) {
const key = `${(r as Record<string, unknown>).patient_id}|${(r as Record<string, unknown>).brand}`;
if (!existing.has(key)) {
(tables['fact_client_out'] as Record<string, unknown>[]).push(r as Record<string, unknown>);
existing.add(key);
}
}
this.logger.log(
`[clickhouse] 反向拉主档:+${tables['fact_client_out'].length - before} 行(原 ${before} → 现 ${tables['fact_client_out'].length})`,
);
}
} }
} finally { } finally {
await client.close(); await client.close();
...@@ -64,13 +141,212 @@ export class ClickHouseSourceService { ...@@ -64,13 +141,212 @@ export class ClickHouseSourceService {
return tables; return tables;
} }
/// W4 末:从已拉的 fact 表里收集 patient_id 集合,反向拉对应主档
/// 必要因为 fact_client_out 用 last_visit_time cursor — patient 主档 PII 没变但 fact 变了(EMR 编辑)
/// → fact 表带的 patient_id 不在 cursor 拉的主档里 → stub 派生,但 PII 缺失
/// 反向拉补齐:确保所有 fact 引用的 patient 都有真实主档(姓名/电话/性别等)
private async reversePullPatientMaster(
client: ClickHouseClient,
tables: Record<string, unknown[]>,
): Promise<unknown[]> {
// 收集所有 fact 表的 patient_id + brand 集合(去重)
const factTables = ['fact_appointment_out', 'fact_emr_treatment_out', 'fact_settlement_out', 'fact_settlement_mode_out'];
const pairs = new Set<string>();
for (const t of factTables) {
for (const row of (tables[t] ?? []) as Record<string, unknown>[]) {
const pid = row.patient_id;
const brand = row.brand;
if (pid !== null && pid !== undefined && brand) {
pairs.add(`${pid}|||${brand}`);
}
}
}
if (pairs.size === 0) return [];
// 构造 IN ((pid1,brand1),(pid2,brand2),...) tuple list
// CH 支持 `(patient_id, brand) IN ((1,'瑞尔'),(2,'瑞泰'))` tuple in 语法
const tuples = [...pairs]
.map((p) => {
const [pid, brand] = p.split('|||');
return `('${(pid ?? '').replace(/'/g, "''")}', '${(brand ?? '').replace(/'/g, "''")}')`;
})
.join(',');
const sql = `SELECT * FROM dw_group.fact_client_out WHERE (patient_id, brand) IN (${tuples})`;
this.logger.log(`[clickhouse] 反向拉主档 query — ${pairs.size} pairs`);
const started = Date.now();
const result = await client.query({ query: sql, format: 'JSONEachRow' });
const rows = (await result.json()) as unknown[];
this.logger.log(`[clickhouse] 反向 fact_client_out → ${rows.length} 行,${Date.now() - started} ms`);
return rows;
}
/// W4 末:单患者刷新 — 把 manifest queries 全部改写为 `WHERE patient_id='X' AND brand='Y'` 范围拉
///
/// 跟 loadAllTables 的差别:
/// - 不读 / 不写 sync_logs cursor(daily incremental cron 完全不受影响)
/// - 不跑反向拉主档(本身已 scope 到该 patient,主档 fact_client_out 同样 WHERE 拉到)
/// - 不应用 default_limit(单患者数据量极小,几十~几百行)
///
/// SQL 改写策略(等价 injectIncrementalCursor 套路):
/// 1. parse SELECT cols + FROM table
/// 2. extract 业务过滤(去掉 cohort 子查询、cursor、ORDER BY、LIMIT)
/// 3. rebuild: `SELECT cols FROM table WHERE patient_id='X' AND brand='Y' [AND <business>]`
/// 4. fact_client_out 没 patient_id?它**自己就是 patient 主档表**,patient_id 字段就是主键 →
/// WHERE 同样过滤即可
async loadTablesForPatient(
source: ClickHouseSource,
scope: PatientScope,
): Promise<Record<string, unknown[]>> {
const conn = this.resolveConnection(source);
const client: ClickHouseClient = createClient({
url: conn.url,
database: conn.database,
username: conn.username,
password: conn.password,
request_timeout: 60_000,
compression: { response: true, request: false },
});
const tables: Record<string, unknown[]> = {};
const pidEsc = scope.patientExternalId.replace(/'/g, "''");
const brandEsc = scope.brand.replace(/'/g, "''");
try {
for (const [tableName, sql] of Object.entries(source.queries)) {
const patientSql = this.injectPatientFilter(sql, pidEsc, brandEsc);
this.logger.log(
`[clickhouse·patient] query "${tableName}" — ${patientSql.slice(0, 140).replace(/\s+/g, ' ')}...`,
);
const started = Date.now();
const result = await client.query({
query: patientSql,
format: 'JSONEachRow',
});
const rows = (await result.json()) as unknown[];
tables[tableName] = rows;
this.logger.log(
`[clickhouse·patient] "${tableName}" → ${rows.length} 行,${Date.now() - started} ms`,
);
}
} finally {
await client.close();
}
return tables;
}
/// 把原 SQL 的 cohort/cursor/ORDER/LIMIT 全部剥离,改写为 patient_id+brand 精确过滤
private injectPatientFilter(
originalSql: string,
patientIdEsc: string,
brandEsc: string,
): string {
const m = originalSql.match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\s+([\w.]+)/i);
if (!m) {
throw new Error(
`[patient-refresh] cannot parse SQL: ${originalSql.slice(0, 80)}...`,
);
}
const selectCols = m[1]!.trim();
const fromTable = m[2]!;
const clauses: string[] = [
`patient_id = '${patientIdEsc}'`,
`brand = '${brandEsc}'`,
];
// 业务过滤(如 settlement_status=1 / is_refund=0 / appo_status IN ... )保留
clauses.push(...this.extractBusinessFilters(originalSql));
return `SELECT ${selectCols} FROM ${fromTable} WHERE ${clauses.join(' AND ')}`;
}
/// W4 末:重写 SQL,把 cursor 条件注入到原 SQL 的 cohort 子查询和外层 WHERE
/// 策略:增量模式去掉 dev cohort LIMIT,只按 cursor 过滤
/// - 找最外层 FROM <table>,在它之后 inject WHERE cursor_column > 'cursor_value'
/// - 同时去掉 cohort 子查询(WHERE (patient_id, brand) IN (...))跟 ORDER BY/LIMIT
/// 简化版:直接重写为 `SELECT <orig_columns> FROM dw_group.<table> WHERE cursor_column > '...'`
/// 假设:cold-import 的 query 已 SELECT 出 cursor_column(本任务 manifest 已加)
private injectIncrementalCursor(
originalSql: string,
cursorColumn: string,
cursorValue: string | null,
): string {
// 提取 SELECT 子句 (FROM 之前) 跟 FROM <table>
const m = originalSql.match(/^\s*SELECT\s+([\s\S]+?)\s+FROM\s+([\w.]+)/i);
if (!m) {
throw new Error(
`[incremental] cannot parse SQL for cursor injection: ${originalSql.slice(0, 80)}...`,
);
}
const selectCols = m[1]!.trim();
const fromTable = m[2]!;
// 收集所有 WHERE 子句(cursor + 业务过滤),最后统一 'WHERE ... AND ...' 拼装
const clauses: string[] = [];
if (cursorValue) {
clauses.push(`${cursorColumn} > '${cursorValue.replace(/'/g, "''")}'`);
}
// 业务过滤(如 settlement_status=1)— 去掉 cohort 子查询(patient_id,brand IN ...)
clauses.push(...this.extractBusinessFilters(originalSql));
const whereSql = clauses.length > 0 ? ` WHERE ${clauses.join(' AND ')}` : '';
return `SELECT ${selectCols} FROM ${fromTable}${whereSql} ORDER BY ${cursorColumn}`;
}
/// 从原 SQL 抽出 WHERE 子句里**非 cohort** 的业务过滤条件
/// 返回 clause 数组(每个 clause 不带 WHERE/AND 前缀,纯条件)
private extractBusinessFilters(sql: string): string[] {
// 抓 WHERE ... 到 ORDER BY/LIMIT/end 之间
const m = sql.match(/\bWHERE\s+([\s\S]+?)(?:\bORDER\s+BY\b|\bLIMIT\b|$)/i);
if (!m) return [];
const whereBody = m[1]!.trim();
// 拆 AND,去掉:① cohort 子查询 ② 老的 last_visit_time IS NOT NULL(增量不需要)
return this.splitAndClauses(whereBody)
.map((c) => c.trim())
.filter(
(c) =>
c &&
!/\(patient_id\s*,\s*brand\)\s+IN/i.test(c) &&
!/^last_visit_time\s+IS\s+NOT\s+NULL$/i.test(c),
);
}
private splitAndClauses(whereBody: string): string[] {
// 按 ` AND `(大小写)拆分,但跳过括号内的 AND
const out: string[] = [];
let depth = 0;
let buf = '';
const tokens = whereBody.split(/(\bAND\b)/i);
for (const tok of tokens) {
for (const ch of tok) {
if (ch === '(') depth++;
else if (ch === ')') depth--;
}
if (/^\s*AND\s*$/i.test(tok) && depth === 0) {
out.push(buf);
buf = '';
} else {
buf += tok;
}
}
if (buf.trim()) out.push(buf);
return out;
}
/// 算行集合中 cursor_column 的字符串 max(host updated_date 是 String 类型)
private computeMax(rows: Record<string, unknown>[], column: string): string | null {
let max: string | null = null;
for (const r of rows) {
const v = r[column];
if (v === null || v === undefined) continue;
const s = String(v);
if (!max || s > max) max = s;
}
return max;
}
/** /**
* 给没显式带 LIMIT 的 SQL 自动追加 LIMIT,防内存炸。 * 给没显式带 LIMIT 的 SQL 自动追加 LIMIT,防内存炸。
* 简单字符串嗅探,匹配 LIMIT N(忽略大小写,允许末尾分号)。 * 简单字符串嗅探,匹配 LIMIT N(忽略大小写,允许末尾分号)。
*/ */
private applyDefaultLimit(sql: string, defaultLimit: number): string { private applyDefaultLimit(sql: string, defaultLimit: number): string {
const trimmed = sql.trim().replace(/;\s*$/, ''); const trimmed = sql.trim().replace(/;\s*$/, '');
if (/\bLIMIT\s+\d+(\s*,\s*\d+)?\s*$/i.test(trimmed)) return trimmed; // 已有 LIMIT N / LIMIT N, M / LIMIT N OFFSET M 在末尾 → 不再追加
if (/\bLIMIT\s+\d+(\s*,\s*\d+|\s+OFFSET\s+\d+)?\s*$/i.test(trimmed)) return trimmed;
return `${trimmed} LIMIT ${defaultLimit}`; return `${trimmed} LIMIT ${defaultLimit}`;
} }
} }
...@@ -20,7 +20,11 @@ import { ...@@ -20,7 +20,11 @@ import {
ColdImportManifestSchema, ColdImportManifestSchema,
inferFileFormat, inferFileFormat,
} from './manifest.schema'; } from './manifest.schema';
import { ClickHouseSourceService } from './clickhouse-source.service'; import {
ClickHouseSourceService,
type IncrementalConfig,
type PatientScope,
} from './clickhouse-source.service';
import { TransformEngine } from '../transforms/transform-engine'; import { TransformEngine } from '../transforms/transform-engine';
import { buildTenantResolver, type TenantResolver } from './tenant-resolver'; import { buildTenantResolver, type TenantResolver } from './tenant-resolver';
...@@ -54,7 +58,7 @@ export class ColdImportService { ...@@ -54,7 +58,7 @@ export class ColdImportService {
async importDirectory( async importDirectory(
dir: string, dir: string,
options: { dryRun?: boolean } = {}, options: { dryRun?: boolean; incremental?: boolean } = {},
): Promise<ImportRunResult> { ): Promise<ImportRunResult> {
const absDir = path.resolve(dir); const absDir = path.resolve(dir);
const runId = randomUUID(); const runId = randomUUID();
...@@ -86,7 +90,29 @@ export class ColdImportService { ...@@ -86,7 +90,29 @@ export class ColdImportService {
); );
// 3. 一次性加载所有 raw tables(文件 / ClickHouse 二选一) // 3. 一次性加载所有 raw tables(文件 / ClickHouse 二选一)
let tables = await this.loadAllTables(absDir, manifest); // W4 末:incremental 模式构建 cursor map(从最近一次成功 sync_log 的 cursor_after JSON 读)
let incrementalConfig: IncrementalConfig | undefined;
if (options.incremental) {
const perQueryCfg = manifest.sql_source?.incremental?.per_query;
if (!perQueryCfg) {
throw new Error(
`--incremental 模式但 manifest.sql_source.incremental.per_query 未配置;请在 manifest.yaml 补 cursor 配置`,
);
}
const lastCursor = await this.readLastIncrementalCursor(host.id);
this.logger.log(
`Incremental cursor 读:${Object.keys(lastCursor).length === 0 ? '(首跑,全量)' : JSON.stringify(lastCursor)}`,
);
incrementalConfig = {
perQuery: Object.fromEntries(
Object.entries(perQueryCfg).map(([table, cfg]) => [
table,
{ cursorColumn: cfg.cursor_column, cursorValue: lastCursor[table] ?? null },
]),
),
};
}
let tables = await this.loadAllTables(absDir, manifest, incrementalConfig);
// 3.5 Layer A.5 transforms — yaml 声明式形态改造(JSON 拆行 / 派生 / 路由等) // 3.5 Layer A.5 transforms — yaml 声明式形态改造(JSON 拆行 / 派生 / 路由等)
if (manifest.transforms && manifest.transforms.length > 0) { if (manifest.transforms && manifest.transforms.length > 0) {
...@@ -113,6 +139,15 @@ export class ColdImportService { ...@@ -113,6 +139,15 @@ export class ColdImportService {
// 5. SyncLog start // 5. SyncLog start
// SyncLog 自身的 tenantId 列只能填一个;多 tenant 跑用 '_multi' sentinel // SyncLog 自身的 tenantId 列只能填一个;多 tenant 跑用 '_multi' sentinel
const syncLogTenant = knownTenants.length === 1 ? knownTenants[0]! : '_multi'; const syncLogTenant = knownTenants.length === 1 ? knownTenants[0]! : '_multi';
// W4 末:incremental 模式 → resource='incremental_bundle' + cursor_before 写入老 cursor
const syncResource = options.incremental ? 'incremental_bundle' : 'cold_import_bundle';
const cursorBeforeJson = options.incremental && incrementalConfig
? JSON.stringify(
Object.fromEntries(
Object.entries(incrementalConfig.perQuery).map(([k, v]) => [k, v.cursorValue ?? '']),
),
)
: null;
const syncLog = options.dryRun const syncLog = options.dryRun
? null ? null
: await this.prisma.syncLog.create({ : await this.prisma.syncLog.create({
...@@ -120,9 +155,10 @@ export class ColdImportService { ...@@ -120,9 +155,10 @@ export class ColdImportService {
hostId: host.id, hostId: host.id,
tenantId: syncLogTenant, tenantId: syncLogTenant,
direction: SyncDirection.PULL, // 冷启复用 pull 语义(同套 pipeline) direction: SyncDirection.PULL, // 冷启复用 pull 语义(同套 pipeline)
resource: 'cold_import_bundle', resource: syncResource,
triggeredBy: `cold_import:${path.basename(absDir)}:${runId}`, triggeredBy: `${options.incremental ? 'incremental' : 'cold_import'}:${path.basename(absDir)}:${runId}`,
status: SyncStatus.RUNNING, status: SyncStatus.RUNNING,
cursorBefore: cursorBeforeJson,
}, },
}); });
...@@ -197,6 +233,21 @@ export class ColdImportService { ...@@ -197,6 +233,21 @@ export class ColdImportService {
? SyncStatus.PARTIAL ? SyncStatus.PARTIAL
: SyncStatus.FAILED; : SyncStatus.FAILED;
// W4 末:incremental cursor_after — 合并老 cursor + 本批新增 max
// 本批某表 0 行(无新数据)→ 保留老 cursor 不变(下次同样 WHERE > old_cursor)
// 本批某表有行 → cursor 推进到本批 max(updated_date)
let cursorAfterJson: string | null = null;
if (options.incremental && incrementalConfig) {
const oldCursors: Record<string, string> = JSON.parse(cursorBeforeJson ?? '{}');
const advances = incrementalConfig.cursorAdvances ?? {};
const merged = { ...oldCursors };
for (const [tbl, newVal] of Object.entries(advances)) {
const old = merged[tbl];
if (!old || newVal > old) merged[tbl] = newVal;
}
cursorAfterJson = JSON.stringify(merged);
this.logger.log(`Incremental cursor 写:${cursorAfterJson}`);
}
if (syncLog) { if (syncLog) {
await this.prisma.syncLog.update({ await this.prisma.syncLog.update({
where: { id: syncLog.id }, where: { id: syncLog.id },
...@@ -209,6 +260,7 @@ export class ColdImportService { ...@@ -209,6 +260,7 @@ export class ColdImportService {
failed: totals.failed, failed: totals.failed,
errorMessage: firstError, errorMessage: firstError,
endedAt: new Date(), endedAt: new Date(),
cursorAfter: cursorAfterJson,
}, },
}); });
} }
...@@ -234,6 +286,164 @@ export class ColdImportService { ...@@ -234,6 +286,164 @@ export class ColdImportService {
} }
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// W4 末:单患者按需刷新(详情页"刷新"按钮 / SyncService.runPullForPatient)
//
// 关键纪律(cursor 隔离):
// - resource='patient_refresh',跟 incremental_bundle / cold_import_bundle **分开 sync_logs**
// - 不读 sync_logs cursor(不被增量 cron 影响),**不写** cursor_after(不污染增量 cron)
// - daily cron 完全不受单患者刷新影响 ✓
//
// 等价 importDirectory({incremental:false}) 但 SQL 注入 patient_id+brand 精确范围:
// - chSource.loadTablesForPatient(scope) 改写 manifest queries 拉单患者全表
// - 同 transforms / assemblers / parser 链路(下游无感知)
// - source_event_id partial UNIQUE 自动幂等(多次刷新不重复)
// ─────────────────────────────────────────────────────────
async importPatient(
dir: string,
scope: PatientScope & { triggeredBy?: string },
): Promise<ImportRunResult> {
const absDir = path.resolve(dir);
const runId = randomUUID();
this.logger.log(
`Patient refresh starting: ${absDir} patient=${scope.patientExternalId} brand=${scope.brand} runId=${runId}`,
);
// 1. Manifest
const manifest = this.readManifest(absDir);
if (!manifest.sql_source) {
throw new Error(
`Patient refresh 需要 sql_source(ClickHouse 直连);manifest 当前是 tables[] 文件源,不支持`,
);
}
// 2. Host
const host = await this.prisma.host.findUnique({
where: { name: manifest.host_name },
});
if (!host) {
throw new Error(`Host not found by name=${manifest.host_name}`);
}
const tenantResolver = buildTenantResolver(manifest);
const seenTenants = new Set<string>();
// 3. 单患者 SQL — 改写所有 queries 加 WHERE patient_id+brand
let tables = await this.chSource.loadTablesForPatient(manifest.sql_source, {
patientExternalId: scope.patientExternalId,
brand: scope.brand,
});
// 3.5 Layer A.5 transforms — 同 importDirectory(单患者数据量小,几十~几百行,毫秒级)
if (manifest.transforms && manifest.transforms.length > 0) {
const transformInputs = tables as Record<string, Record<string, unknown>[]>;
tables = this.transformEngine.run({
tables: transformInputs,
transforms: manifest.transforms,
});
}
// 4. Assemblers
const assemblerConfigs = this.loadAllAssemblers(absDir, manifest);
const patientCfg = assemblerConfigs.find((c) => c.canonical === 'patient');
const subjectCfgs = assemblerConfigs.filter((c) => c.canonical !== 'patient');
// 5. SyncLog — resource='patient_refresh' ⭐ 跟增量隔离 ⭐
// tenantId 用 resolver 已知的(单 brand → 唯一 tenant);若动态 pass-through 用 sentinel
const knownTenants = tenantResolver.knownTenants();
const syncLogTenant = knownTenants.length === 1 ? knownTenants[0]! : '_multi';
const syncLog = await this.prisma.syncLog.create({
data: {
hostId: host.id,
tenantId: syncLogTenant,
direction: SyncDirection.PULL,
resource: 'patient_refresh', // ⭐ 跟 incremental_bundle / cold_import_bundle 区分
triggeredBy: scope.triggeredBy ?? `patient_refresh:${scope.patientExternalId}:${runId}`,
status: SyncStatus.RUNNING,
// cursorBefore / cursorAfter 都不写(本身不参与增量)
},
});
// 6. 跑 patient 主档 + subjects(同 importDirectory 路径)
const perResource: PerResourceStats[] = [];
const totals = this.zeroTotals();
let firstError: string | null = null;
const normalize = { amountUnit: manifest.amount_unit, timezone: manifest.timezone };
if (patientCfg) {
const stats = await this.processPatients(
tables, patientCfg, host.id, tenantResolver, seenTenants, normalize, false,
);
perResource.push(stats);
totals.patientsUpserted += stats.patientsUpserted;
totals.failed += stats.failed;
if (!firstError && stats.failed > 0) firstError ||= `patient: ${stats.failed} rows failed`;
}
for (const cfg of subjectCfgs) {
try {
const stats = await this.processSubject(
tables, cfg, host.id, tenantResolver, seenTenants, normalize, syncLog.id, false,
);
perResource.push(stats);
totals.transactionsWritten += stats.transactionsWritten;
totals.duplicates += stats.duplicates;
totals.failed += stats.failed;
totals.factsCreated += stats.factsCreated;
totals.factsSuperseded += stats.factsSuperseded;
totals.factsUnchanged += stats.factsUnchanged;
totals.factsEvidenceAppended += stats.factsEvidenceAppended;
totals.factsFailed += stats.factsFailed;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
firstError ||= `${cfg.canonical}: ${msg}`;
this.logger.error(`Patient refresh resource ${cfg.canonical} failed: ${msg}`);
}
}
// 7. SyncLog finalize
const fetched =
totals.patientsUpserted + totals.transactionsWritten + totals.duplicates + totals.failed;
const status =
totals.failed === 0
? SyncStatus.SUCCESS
: totals.patientsUpserted + totals.transactionsWritten > 0
? SyncStatus.PARTIAL
: SyncStatus.FAILED;
await this.prisma.syncLog.update({
where: { id: syncLog.id },
data: {
status,
fetched,
transactionsWritten: totals.transactionsWritten,
factsEmitted: totals.factsCreated + totals.factsSuperseded,
duplicates: totals.duplicates,
failed: totals.failed,
errorMessage: firstError,
endedAt: new Date(),
},
});
this.logger.log(
`Patient refresh done: patient=${scope.patientExternalId} status=${status} ` +
`patients=${totals.patientsUpserted} txns=${totals.transactionsWritten} ` +
`dups=${totals.duplicates} facts(created=${totals.factsCreated} superseded=${totals.factsSuperseded})`,
);
return {
runId,
hostId: host.id,
hostName: host.name,
tenantIds: [...seenTenants].sort(),
dryRun: false,
syncLogId: syncLog.id,
status,
totals,
perResource,
};
}
// ─────────────────────────────────────────────────────────
// Patients 主档(用同一份 yaml 形态,但走 upsert 路径) // Patients 主档(用同一份 yaml 形态,但走 upsert 路径)
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -379,6 +589,14 @@ export class ColdImportService { ...@@ -379,6 +589,14 @@ export class ColdImportService {
patientIndex = await this.buildPatientIndex(hostId, tenantId); patientIndex = await this.buildPatientIndex(hostId, tenantId);
patientIndexByTenant.set(tenantId, patientIndex); patientIndexByTenant.set(tenantId, patientIndex);
} }
// ⭐ W4 末 stub auto-create:本批 fact 引用的 patient 主档没拉到 → 即时建空 stub
// 后续 fact_client_out(同 run 反向拉 OR 下次 sync 主档更新)会 upsert 补 PII
// 保证 fact 立即派生不丢,不依赖等主档(详见 docs/dw-data-source-issues.md "patient stub")
const patientExternalId = canonicalRow.patientExternalId as string | undefined;
if (patientExternalId && !patientIndex.has(patientExternalId)) {
const stubId = await this.ensurePatientStub(hostId, tenantId, patientExternalId);
patientIndex.set(patientExternalId, stubId);
}
const txn = this.synthesizer.synthesize({ const txn = this.synthesizer.synthesize({
rawRow: rawSource, rawRow: rawSource,
canonicalRow, canonicalRow,
...@@ -475,6 +693,28 @@ export class ColdImportService { ...@@ -475,6 +693,28 @@ export class ColdImportService {
return new Map(rows.map((p) => [p.externalId, p.id])); return new Map(rows.map((p) => [p.externalId, p.id]));
} }
/// W4 末:本批 fact 引用的 patient 主档没拉到 → 即时建空 stub
/// 只填三段隔离键(hostId/tenantId/externalId)+ active=true,姓名/电话留空待后续 upsert
/// patientProfile 也建空行(scenario SQL JOIN 必须的 doNotContact=false / deceased=false)
/// 调一次约 5-10 ms(单行 upsert + 1:1 profile upsert),N 次循环可接受;后续可批量优化
private async ensurePatientStub(
hostId: string,
tenantId: string,
externalId: string,
): Promise<string> {
const patient = await this.prisma.patient.upsert({
where: { hostId_tenantId_externalId: { hostId, tenantId, externalId } },
create: { hostId, tenantId, externalId, active: true },
update: {}, // 有则 noop(姓名等真实主档 upsert 走 processPatients 路径)
});
await this.prisma.patientProfile.upsert({
where: { patientId: patient.id },
create: { patientId: patient.id, doNotContact: false, deceased: false, tags: [] },
update: {},
});
return patient.id;
}
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// Manifest + assembler + raw tables 读盘 // Manifest + assembler + raw tables 读盘
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
...@@ -492,18 +732,42 @@ export class ColdImportService { ...@@ -492,18 +732,42 @@ export class ColdImportService {
return result.data; return result.data;
} }
/// W4 末:读最近一次成功的 incremental sync_log 的 cursor_after JSON
/// 返回 { tableName: cursorValue } map(无记录返回空对象 → 首跑等价全量)
private async readLastIncrementalCursor(hostId: string): Promise<Record<string, string>> {
const last = await this.prisma.syncLog.findFirst({
where: {
hostId,
resource: 'incremental_bundle',
status: SyncStatus.SUCCESS,
cursorAfter: { not: null },
},
orderBy: { startedAt: 'desc' },
});
if (!last?.cursorAfter) return {};
try {
const parsed = JSON.parse(last.cursorAfter) as Record<string, string>;
return parsed;
} catch (e) {
this.logger.warn(`Cursor JSON parse failed: ${(e as Error).message};当全量处理`);
return {};
}
}
private async loadAllTables( private async loadAllTables(
dir: string, dir: string,
manifest: ColdImportManifest, manifest: ColdImportManifest,
incremental?: IncrementalConfig,
): Promise<Record<string, unknown[]>> { ): Promise<Record<string, unknown[]>> {
// ── ClickHouse 直连模式 ── // ── ClickHouse 直连模式 ──
if (manifest.sql_source) { if (manifest.sql_source) {
this.logger.log( this.logger.log(
`数据源:ClickHouse SQL 直连 — ${manifest.sql_source.connection.url} ` + `数据源:ClickHouse SQL 直连 — ${manifest.sql_source.connection.url} ` +
`database=${manifest.sql_source.connection.database} ` + `database=${manifest.sql_source.connection.database} ` +
`queries=${Object.keys(manifest.sql_source.queries).length}`, `queries=${Object.keys(manifest.sql_source.queries).length}` +
(incremental ? ' [incremental mode]' : ''),
); );
return this.chSource.loadAllTables(manifest.sql_source); return this.chSource.loadAllTables(manifest.sql_source, incremental);
} }
// ── 文件源模式 ── // ── 文件源模式 ──
......
...@@ -63,6 +63,22 @@ export const ClickHouseSourceSchema = z.object({ ...@@ -63,6 +63,22 @@ export const ClickHouseSourceSchema = z.object({
queries: z.record(z.string(), z.string().min(1)), queries: z.record(z.string(), z.string().min(1)),
/// 每个 query 上限(防内存炸);默认 100k 行 /// 每个 query 上限(防内存炸);默认 100k 行
default_limit: z.number().int().positive().default(100000).optional(), default_limit: z.number().int().positive().default(100000).optional(),
/// W4 末:DW 增量配置(per table cursor column)
/// 跑增量模式时 PAC 会读 sync_logs 上次 cursor_after,注入 WHERE cursor_column > '...'
/// 跑完写新 cursor = max(cursor_column) 到 sync_logs
/// 首次跑(无 cursor)= 全量,后续 = 增量
incremental: z
.object({
/// per query 配置;表名(query key)→ cursor_column
/// 例:fact_emr_treatment_out: updated_date / fact_client_out: last_visit_time
per_query: z.record(
z.string(),
z.object({
cursor_column: z.string().min(1),
}),
),
})
.optional(),
}); });
export type ClickHouseSource = z.infer<typeof ClickHouseSourceSchema>; export type ClickHouseSource = z.infer<typeof ClickHouseSourceSchema>;
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
PACDiagnosisCodeSchema, PACDiagnosisCodeSchema,
} from '@pac/types'; } from '@pac/types';
import type { Parser, ParserContext, FactDraft } from './parser.interface'; import type { Parser, ParserContext, FactDraft } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util';
/** /**
* DiagnosisParser — `diagnosis_recorded` 解析器(v2.1 新) * DiagnosisParser — `diagnosis_recorded` 解析器(v2.1 新)
...@@ -51,7 +52,7 @@ export class DiagnosisParser implements Parser { ...@@ -51,7 +52,7 @@ export class DiagnosisParser implements Parser {
: 'name_map' : 'name_map'
: null; : null;
const toothPosition = (c.toothPosition as string | undefined) ?? null; const toothPosition = normalizeToothPosition(c.toothPosition as string | undefined);
const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null; const sourceEncounter = (c.sourceEncounterExternalId as string | undefined) ?? null;
// host 录入习惯尾部带标点("慢性牙龈炎;" / "牙周炎。" / "牙龈炎," / "龋齿?" / "缺失?"...) // host 录入习惯尾部带标点("慢性牙龈炎;" / "牙周炎。" / "牙龈炎," / "龋齿?" / "缺失?"...)
// 在 parser 层一次性清洗:首尾空白 + 尾部中英文标点 → 下游 chain-composer / WhyCard / persona 全部受益 // 在 parser 层一次性清洗:首尾空白 + 尾部中英文标点 → 下游 chain-composer / WhyCard / persona 全部受益
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Action, FactKind, FactStatus, FactType } from '@pac/types'; import { Action, FactKind, FactStatus, FactType } from '@pac/types';
import type { FactDraft, Parser, ParserContext } from './parser.interface'; import type { FactDraft, Parser, ParserContext } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util';
/** /**
* ImageParser — `image_uploaded` 解析器 * ImageParser — `image_uploaded` 解析器
...@@ -26,7 +27,7 @@ export class ImageParser implements Parser { ...@@ -26,7 +27,7 @@ export class ImageParser implements Parser {
const uploadedAt = c.uploadedAt ? new Date(c.uploadedAt as string) : null; const uploadedAt = c.uploadedAt ? new Date(c.uploadedAt as string) : null;
const modality = (c.modality as string | undefined) ?? null; const modality = (c.modality as string | undefined) ?? null;
const finding = (c.finding as string | undefined) ?? null; const finding = (c.finding as string | undefined) ?? null;
const toothPositions = (c.toothPositions as string | undefined) ?? null; const toothPositions = normalizeToothPosition(c.toothPositions as string | undefined);
const encounterExternalId = (c.encounterExternalId as string | undefined) ?? null; const encounterExternalId = (c.encounterExternalId as string | undefined) ?? null;
return [ return [
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
PACDiagnosisCodeSchema, PACDiagnosisCodeSchema,
} from '@pac/types'; } from '@pac/types';
import type { Parser, ParserContext, FactDraft } from './parser.interface'; import type { Parser, ParserContext, FactDraft } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util';
/** /**
* RecommendationParser — `recommendation_extracted` 解析器(v2.1 新) * RecommendationParser — `recommendation_extracted` 解析器(v2.1 新)
...@@ -66,7 +67,7 @@ export class RecommendationParser implements Parser { ...@@ -66,7 +67,7 @@ export class RecommendationParser implements Parser {
summary: null, summary: null,
content: { content: {
code: codeParsed.data, code: codeParsed.data,
tooth_position: (c.toothPosition as string | undefined) ?? null, tooth_position: normalizeToothPosition(c.toothPosition as string | undefined),
doctor_id: c.doctorId ? String(c.doctorId) : null, doctor_id: c.doctorId ? String(c.doctorId) : null,
doctor_name: (c.doctorName as string | undefined) ?? null, doctor_name: (c.doctorName as string | undefined) ?? null,
extracted_by: extractedBy, extracted_by: extractedBy,
......
/**
* 牙位字符串规范化(W4 末)
*
* Host(瑞泰/瑞尔 DW)的 toothPosition 字段经常带牙面后缀:
* FDI 恒牙形态(数字 2 位):
* "17 D" → 17 号牙远中面(distal)
* "37 O" → 37 号牙咬合面(occlusal)
* "46 B;36 B" → 多牙位带颊侧面(buccal)
* "11 M;21 M" → 多牙位带近中面(mesial)
* Palmer 乳牙形态(象限数字 + 字母 A-E,儿童 host 大量使用):
* "1D OD" → 上颌右乳第一磨牙 远中-咬合面
* "1D M" → 上颌右乳第一磨牙 近中面
* "2E MO" → 上颌左乳第二磨牙 近中-咬合面
*
* 牙面修饰:
* D = Distal(远中) M = Mesial(近中)
* O = Occlusal(咬合) B = Buccal(颊侧)
* L = Lingual(舌侧) P = Palatal(腭侧)
* I = Incisal(切端)
*
* 问题:同一颗牙(17 / 1D 等)在不同接诊带不同牙面后缀 → chain-composer 拆链
*
* 处置(本工具职责):**只剥牙面后缀,保留牙位编号(FDI 或 Palmer)本身**。
* FDI:
* "17 D" → "17"
* "46 B;36 B" → "46;36"
* "11 M;21 M" → "11;21"
* "32" → "32" (无变化)
* Palmer 乳牙(W4 末加):
* "1D OD" → "1D" (剥 OD 面后缀,保留 1D 牙位)
* "1D M" → "1D"
* "2E MO" → "2E"
* "1A;1B;1C" → "1A;1B;1C" (无空格 = 无面后缀,不动)
*
* 边界:
* - 只匹配 "牙位 + **空格** + 牙面字母" 形态(空格是关键分隔)
* - 不带空格的 Palmer 单字母(1A/1B/1C/1D/1E)= 完整牙位,保留
* - 输入 null/empty 原样返回
*/
// 牙位 base = FDI 1-2 位数字(11/22/47 等) 或 Palmer 数字+字母(1A/1D/3E 等)
// 后跟空格 + 一组牙面字母(DMOBLPI 大小写) → 把面字母剥掉,保留 base
const SURFACE_SUFFIX_RE = /(\d+[A-Ea-e]?)\s+[DMOBLPIdmoblpi]+/g;
export function normalizeToothPosition(input: string | null | undefined): string | null {
if (input === null || input === undefined) return null;
const trimmed = String(input).trim();
if (!trimmed) return null;
// 替换:把 "牙位 base + 空格 + 牙面字母+" 中的面字母部分去掉,保留 base
const out = trimmed.replace(SURFACE_SUFFIX_RE, '$1');
return out || null;
}
/**
* 牙位串拆分 + 规整化后返回单牙位 set
* 用于 chain target match / scenario tooth-level 排除等场景
*
* 例:
* "1D OD;3E O;4E O" → Set { "1D", "3E", "4E" }
* "11 M;21 M" → Set { "11", "21" }
* "1A;1B;1C" → Set { "1A", "1B", "1C" }
*/
export function toothSet(input: string | null | undefined): Set<string> {
const norm = normalizeToothPosition(input);
if (!norm) return new Set();
return new Set(norm.split(';').map((t) => t.trim()).filter(Boolean));
}
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Action, FactKind, FactStatus, FactType } from '@pac/types'; import { Action, FactKind, FactStatus, FactType } from '@pac/types';
import type { Parser, ParserContext, FactDraft } from './parser.interface'; import type { Parser, ParserContext, FactDraft } from './parser.interface';
import { normalizeToothPosition } from './tooth-position.util';
/** /**
* TreatmentParser — 治疗类 action 共用解析器(v2.1 新) * TreatmentParser — 治疗类 action 共用解析器(v2.1 新)
...@@ -51,7 +52,7 @@ export class TreatmentParser implements Parser { ...@@ -51,7 +52,7 @@ export class TreatmentParser implements Parser {
String(c.status ?? '').toLowerCase(), String(c.status ?? '').toLowerCase(),
); );
const toothPosition = (c.toothPosition as string | undefined) ?? null; const toothPosition = normalizeToothPosition(c.toothPosition as string | undefined);
const subtype = (c.subtype as string | undefined) ?? null; const subtype = (c.subtype as string | undefined) ?? null;
// W4 末:treat_stages — host stage 词已在 assembler enum_mapping 翻译到 PAC 标准 enum // W4 末:treat_stages — host stage 词已在 assembler enum_mapping 翻译到 PAC 标准 enum
// applyEnum array 支持已加;这里只需做 array filter(去除空字符串/非字符串元素) // applyEnum array 支持已加;这里只需做 array filter(去除空字符串/非字符串元素)
......
...@@ -60,6 +60,13 @@ export class SyncController { ...@@ -60,6 +60,13 @@ export class SyncController {
@TenantScope() scope: TenantScopeContext, @TenantScope() scope: TenantScopeContext,
@Param('patientId') patientId: string, @Param('patientId') patientId: string,
) { ) {
return this.sync.runPullForPatient(scope, patientId); const r = await this.sync.runPullForPatient(scope, patientId);
return {
syncLogId: r.syncLogId,
status: r.status,
transactionsWritten: r.transactionsWritten,
factsEmitted: r.factsEmitted,
plansCreated: r.plansCreated,
};
} }
} }
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FactsModule } from '../facts/facts.module'; import { FactsModule } from '../facts/facts.module';
import { PersonaModule } from '../persona/persona.module';
import { PlanModule } from '../plan/plan.module';
import { SyncController } from './sync.controller'; import { SyncController } from './sync.controller';
import { SyncService } from './sync.service'; import { SyncService } from './sync.service';
import { PullStrategyRegistry } from './adapters/adapter.registry'; import { PullStrategyRegistry } from './adapters/adapter.registry';
...@@ -31,7 +33,14 @@ import { HmacVerifier } from './push/hmac-verifier.service'; ...@@ -31,7 +33,14 @@ import { HmacVerifier } from './push/hmac-verifier.service';
* - PipelineModule(synthesizer + parser pipeline) * - PipelineModule(synthesizer + parser pipeline)
*/ */
@Module({ @Module({
imports: [FactsModule, AssemblerModule, TransformsModule, PipelineModule], imports: [
FactsModule,
AssemblerModule,
TransformsModule,
PipelineModule,
PersonaModule, // W4 末:单患者刷新触发 persona.recompute
PlanModule, // W4 末:单患者刷新触发 planEngine.recomputeForPatient
],
controllers: [SyncController, PushController], controllers: [SyncController, PushController],
providers: [ providers: [
SyncService, SyncService,
......
import { Injectable, Logger, NotImplementedException } from '@nestjs/common'; import {
import { Cron, CronExpression } from '@nestjs/schedule'; Injectable,
Logger,
NotImplementedException,
NotFoundException,
} from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'js-yaml';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { FactsService } from '../facts/facts.service'; import { FactsService } from '../facts/facts.service';
import { AlertService } from '../../common/alerting/alert.service'; import { AlertService } from '../../common/alerting/alert.service';
import { PullStrategyRegistry } from './adapters/adapter.registry'; import { PullStrategyRegistry } from './adapters/adapter.registry';
import { ColdImportService } from './cold-import/cold-import.service';
import { PersonaService } from '../persona/persona.service';
import { PlanEngineService } from '../plan/engine/plan-engine.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
/** /**
* SyncService — 增量拉取 + 漂移检测 orchestration * SyncService — 增量拉取 + 漂移检测 orchestration + 单患者刷新
* *
* 暂未实施。当前数据接入只走 cold-import(`pnpm cold-import`,见 ColdImportService); * 暂未实施部分:周期性 pull / push 路径(host 真实 API 就绪后再启用)
* pull / push 路径待 host 真实 API 就绪后再启用,届时复用 AssemblerEngine + TransactionSynthesizer *
* + ParserPipeline 三件套(已实现)。 * 已实施:
* - W4 末 单患者刷新(runPullForPatient)— 详情页"刷新"按钮调用
* · ClickHouse 直连 DW,SQL 注入 WHERE patient_id+brand 精确范围
* · resource='patient_refresh' 跟 daily incremental cursor 完全隔离
* · 跑完顺手 persona.recompute + planEngine.recomputeForPatient
*/ */
@Injectable() @Injectable()
export class SyncService { export class SyncService {
...@@ -21,13 +37,22 @@ export class SyncService { ...@@ -21,13 +37,22 @@ export class SyncService {
private readonly facts: FactsService, private readonly facts: FactsService,
private readonly registry: PullStrategyRegistry, private readonly registry: PullStrategyRegistry,
private readonly alerter: AlertService, private readonly alerter: AlertService,
private readonly coldImport: ColdImportService,
private readonly persona: PersonaService,
private readonly planEngine: PlanEngineService,
) {} ) {}
/** /**
* Sync 拉数据 cron — 每小时跑一次(W2 决策:对召回业务小时级足够 + 不压宿主)。 * Sync 拉数据 cron — env 驱动(env 未设默认不跑)
* PAC_SYNC_HOURLY_CRON:
* 不设 → 不跑(local + 当前所有环境默认 — pull 通道未启用)
* '0 * * * *' → host pull 通道接通后启用,小时级拉
*
* 事件驱动主路径(秒级响应)由 pipeline-dispatcher 入队 persona-recompute,本 cron 只是兜底拉数据。 * 事件驱动主路径(秒级响应)由 pipeline-dispatcher 入队 persona-recompute,本 cron 只是兜底拉数据。
*/ */
@Cron(CronExpression.EVERY_HOUR, { name: 'sync-scheduled-tick' }) @Cron(process.env.PAC_SYNC_HOURLY_CRON ?? '0 0 31 12 *' /* never — pull 通道未启用 */, {
name: 'sync-scheduled-tick',
})
async runScheduledForAll(): Promise<void> { async runScheduledForAll(): Promise<void> {
// 暂未启用 — pull 通道接通后挂回本 cron // 暂未启用 — pull 通道接通后挂回本 cron
} }
...@@ -38,16 +63,138 @@ export class SyncService { ...@@ -38,16 +63,138 @@ export class SyncService {
); );
} }
/**
* 单患者按需刷新(详情页"刷新"按钮)
*
* 流程:
* 1. 查 patient(scope 限定 host+tenant 隔离)
* 2. 反查 manifest tenant_map → brand 中文名(SQL WHERE 用)
* 3. ColdImportService.importPatient → 单患者全量从 DW 拉(SQL 注入 patient_id+brand)
* 4. Persona.recompute(该 patient)
* 5. PlanEngine.recomputeForPatient(该 patient)
*
* 不动 cursor / 不阻塞 daily incremental cron(resource='patient_refresh' 隔离)
*/
async runPullForPatient( async runPullForPatient(
_scope: unknown, scope: TenantScopeContext,
_patientId: string, patientId: string,
): Promise<{ syncLogId: string; status: string; dedupedFromExisting?: boolean }> { ): Promise<{
throw new NotImplementedException( syncLogId: string;
'单患者 pull 暂未实施,数据请通过 cold-import 入站', status: string;
transactionsWritten: number;
factsEmitted: number;
plansCreated: number;
}> {
// 1. patient lookup(隔离基线)
const patient = await this.prisma.patient.findFirst({
where: { id: patientId, hostId: scope.hostId, tenantId: scope.tenantId },
});
if (!patient) {
throw new NotFoundException(`Patient ${patientId} not found in tenant`);
}
// 2. host + manifest 反查 brand
const host = await this.prisma.host.findUnique({ where: { id: scope.hostId } });
if (!host) {
throw new NotFoundException(`Host ${scope.hostId} not found`);
}
const dataDir = this.resolveDataDir(host.name);
const brand = this.resolveBrandFromTenant(dataDir, patient.tenantId);
this.logger.log(
`patient-refresh: host=${host.name} patient=${patient.externalId}(${patient.id}) brand=${brand} dir=${dataDir}`,
); );
// 3. 跑单患者 cold-import(SQL 注入 WHERE patient_id+brand)
const r = await this.coldImport.importPatient(dataDir, {
patientExternalId: patient.externalId,
brand,
triggeredBy: `patient_refresh:${patient.externalId}`,
});
// 4. Persona 重算(失败不阻断,log warn)
try {
await this.persona.recompute({
patientId: patient.id,
source: `patient_refresh:${r.runId}`,
});
} catch (err) {
this.logger.warn(
`patient-refresh persona patient=${patient.id}: ${(err as Error).message}`,
);
}
// 5. Plan 重算(per patient — runForPatient 会跑该 tenant 全量但 selector 只命中该 patient)
let plansCreated = 0;
try {
const r2 = await this.planEngine.recomputeForPatient({
hostId: scope.hostId,
tenantId: scope.tenantId,
patientId: patient.id,
});
plansCreated = r2.plansCreated;
} catch (err) {
this.logger.warn(
`patient-refresh plan patient=${patient.id}: ${(err as Error).message}`,
);
}
this.logger.log(
`patient-refresh DONE: patient=${patient.externalId} status=${r.status} ` +
`txns=${r.totals.transactionsWritten} facts=${r.totals.factsCreated + r.totals.factsSuperseded} ` +
`plansCreated=${plansCreated}`,
);
return {
syncLogId: r.syncLogId!,
status: r.status,
transactionsWritten: r.totals.transactionsWritten,
factsEmitted: r.totals.factsCreated + r.totals.factsSuperseded,
plansCreated,
};
} }
async checkPullDrift(): Promise<void> { async checkPullDrift(): Promise<void> {
// 暂未启用 — pull 通道接通后启用漂移监控 // 暂未启用 — pull 通道接通后启用漂移监控
} }
// ─── helpers ───
/// 数据目录:env PAC_INCREMENTAL_DATA_DIR 优先,否则默认 ../../data/<host>
/// (跟 SyncIncrementalSchedulerService 共用 env,运维只需配一处)
private resolveDataDir(hostName: string): string {
const base =
process.env.PAC_INCREMENTAL_DATA_DIR ??
path.resolve(__dirname, '../../../data');
return path.join(base, hostName);
}
/// 从 manifest.yaml tenant_map 反查 tenantId(UUID) → brand 中文名(SQL WHERE 用)
/// 找不到回退用 tenantId 本身(动态 pass-through host)
private resolveBrandFromTenant(dir: string, tenantId: string): string {
const manifestPath = path.join(dir, 'manifest.yaml');
if (!fs.existsSync(manifestPath)) {
this.logger.warn(`manifest.yaml not found at ${manifestPath}, fallback brand=${tenantId}`);
return tenantId;
}
try {
const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8')) as {
tenant_map?: Record<string, string>;
brand_to_tenant?: Record<string, string>;
};
const map = parsed.tenant_map ?? parsed.brand_to_tenant ?? {};
// 反向找:map { 瑞尔: <UUID> } → 给 UUID 找回 "瑞尔"
for (const [brand, uuid] of Object.entries(map)) {
if (uuid === tenantId) return brand;
}
// 未匹配 → pass-through(field 模式 host 直接用 brand 当 tenantId)
this.logger.warn(
`tenant_map 反查未命中 tenantId=${tenantId},pass-through 用原值当 brand`,
);
return tenantId;
} catch (err) {
this.logger.warn(`manifest 解析失败:${(err as Error).message},fallback`);
return tenantId;
}
}
} }
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
/**
* DwLagMonitorService — 每小时检查 DW 增量数据滞后(W4 末)
*
* 输入:sync_logs 中最近一条成功的 incremental_bundle 的 cursor_after JSON
* 例:{"fact_emr_treatment_out":"2026-05-26 15:30:00",...}
*
* 计算:max(cursor_after.*) vs now() → diff hours
* - diff <= 24h:🟢 健康
* - 24h < diff <= 48h:🟡 注意(DW 可能没刷新,提醒值班)
* - diff > 48h:🔴 异常(数据严重滞后,事件可能漏召)
*
* 当前实现:log + (TODO) 接 webhook/email/钉钉。
*
* 调整阈值:PAC_LAG_WARN_HOURS=24 / PAC_LAG_ERROR_HOURS=48 env
*/
@Injectable()
export class DwLagMonitorService {
private readonly logger = new Logger(DwLagMonitorService.name);
constructor(private readonly prisma: PrismaService) {}
/// DW 数据滞后告警 cron — env 驱动
/// PAC_LAG_MONITOR_CRON:
/// 不设 → 不跑(local 默认)
/// '0 * * * *' → 生产 / staging 推荐(每小时检查)
@Cron(process.env.PAC_LAG_MONITOR_CRON ?? '0 0 31 12 *' /* never — env not set */, {
name: 'dw-lag-monitor',
})
async checkLag(): Promise<void> {
const warnH = Number(process.env.PAC_LAG_WARN_HOURS ?? '24');
const errorH = Number(process.env.PAC_LAG_ERROR_HOURS ?? '48');
const now = Date.now();
const hosts = await this.prisma.host.findMany({ select: { id: true, name: true } });
for (const host of hosts) {
const last = await this.prisma.syncLog.findFirst({
where: {
hostId: host.id,
resource: 'incremental_bundle',
status: 'success',
cursorAfter: { not: null },
},
orderBy: { startedAt: 'desc' },
});
if (!last?.cursorAfter) {
this.logger.warn(`dw-lag: host=${host.name} 还没跑过增量(无 cursor)— 检查 sync-incremental cron`);
continue;
}
let cursors: Record<string, string>;
try {
cursors = JSON.parse(last.cursorAfter) as Record<string, string>;
} catch {
this.logger.warn(`dw-lag: host=${host.name} cursor JSON 解析失败 — ${last.cursorAfter}`);
continue;
}
const maxCursorTs = Math.max(
...Object.values(cursors)
.map((v) => new Date(v).getTime())
.filter((t) => !isNaN(t)),
);
const diffHours = (now - maxCursorTs) / 3_600_000;
const tag =
diffHours > errorH ? '🔴' :
diffHours > warnH ? '🟡' :
'🟢';
const msg = `dw-lag: ${tag} host=${host.name} max_cursor=${new Date(maxCursorTs).toISOString()} lag=${diffHours.toFixed(1)}h (warn=${warnH} error=${errorH})`;
if (diffHours > errorH) {
this.logger.error(msg);
// TODO W5+: 接 alerting service(webhook/email/钉钉) — 现在 log ERROR 让 ops 看
} else if (diffHours > warnH) {
this.logger.warn(msg);
} else {
this.logger.log(msg);
}
}
}
}
...@@ -4,9 +4,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; ...@@ -4,9 +4,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import type { AppConfig } from '../config/configuration'; import type { AppConfig } from '../config/configuration';
import { PersonaModule } from '../modules/persona/persona.module'; import { PersonaModule } from '../modules/persona/persona.module';
import { PlanModule } from '../modules/plan/plan.module'; import { PlanModule } from '../modules/plan/plan.module';
import { SyncModule } from '../modules/sync/sync.module';
import { QueueName } from './queue-names'; import { QueueName } from './queue-names';
import { QueueProducer } from './queue-producer.service'; import { QueueProducer } from './queue-producer.service';
import { StaleScanService } from './stale-scan.service'; import { StaleScanService } from './stale-scan.service';
import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler';
import { DwLagMonitorService } from './dw-lag-monitor.service';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor'; import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
import { PlanRecomputeProcessor } from './processors/plan-recompute.processor'; import { PlanRecomputeProcessor } from './processors/plan-recompute.processor';
import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor'; import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor';
...@@ -51,14 +54,17 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro ...@@ -51,14 +54,17 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
), ),
PersonaModule, PersonaModule,
PlanModule, PlanModule,
SyncModule, // 给 SyncIncrementalSchedulerService 注入 ColdImportService
], ],
providers: [ providers: [
QueueProducer, QueueProducer,
StaleScanService, StaleScanService,
SyncIncrementalSchedulerService,
DwLagMonitorService,
PersonaRecomputeProcessor, PersonaRecomputeProcessor,
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
], ],
exports: [BullModule, QueueProducer, StaleScanService], exports: [BullModule, QueueProducer, StaleScanService, SyncIncrementalSchedulerService],
}) })
export class QueuesModule {} export class QueuesModule {}
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { QueueProducer } from './queue-producer.service'; import { QueueProducer } from './queue-producer.service';
...@@ -27,10 +27,17 @@ export class StaleScanService { ...@@ -27,10 +27,17 @@ export class StaleScanService {
) {} ) {}
/** /**
* 凌晨 02:00 跑,扫所有 host 的 stale patient 补算。 * Stale persona 扫描 cron — env 驱动
*
* PAC_STALE_SCAN_CRON:
* 不设 → 不跑(local 默认)
* '0 2 * * *' → 生产 / staging 推荐(凌晨 02:00,DW 增量同步前一会儿)
*
* 量级估算:5 家试点 30 万患者,扫一次约 1-5 秒,enqueue 部分(预期 < 1% stale)。 * 量级估算:5 家试点 30 万患者,扫一次约 1-5 秒,enqueue 部分(预期 < 1% stale)。
*/ */
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'persona-stale-scan' }) @Cron(process.env.PAC_STALE_SCAN_CRON ?? '0 0 31 12 *' /* never — env not set */, {
name: 'persona-stale-scan',
})
async scanAndEnqueueStale(): Promise<void> { async scanAndEnqueueStale(): Promise<void> {
const startedAt = Date.now(); const startedAt = Date.now();
this.logger.log('stale-scan: START'); this.logger.log('stale-scan: START');
......
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
/**
* SyncIncrementalSchedulerService — W4 末:DW 直连增量自动跑(每天 02:30)
*
* 上游:DW 团队每日凌晨 ~02:00 完成全量刷新
* 我们 02:30 触发:
* 1. ColdImportService.importDirectory({ incremental: true })
* - 读 sync_logs 上次 cursor_after → SQL 注入 WHERE updated_date > '...'
* - 反向拉主档(C 方案)+ stub auto-create(A 方案)→ 无数据丢失
* - 写新 cursor 到 sync_logs
* 2. Persona 重算(affected patient — 本次 sync 写入的 distinct patient_id)
* 3. Plan 重算(per tenant,scenario SQL + 6 因子打分)
*
* 多 host:循环 PAC_INCREMENTAL_HOSTS env(逗号分隔,默认 'jvs-dw')
* 每个 host 期望 data/<host>/manifest.yaml 存在
*
* 跑失败:
* - cursor 不前进(ColdImportService 内 transaction)→ 下次自动 catchup
* - log ERROR + 不抛(让 cron 下次继续)
*/
@Injectable()
export class SyncIncrementalSchedulerService {
private readonly logger = new Logger(SyncIncrementalSchedulerService.name);
constructor(
private readonly prisma: PrismaService,
private readonly coldImport: ColdImportService,
private readonly persona: PersonaService,
private readonly planEngine: PlanEngineService,
) {}
/// DW 增量同步 cron — env 驱动
/// PAC_INCREMENTAL_CRON:
/// 不设 → 不跑(local 默认,避免开发期自动消耗 DW 流量)
/// '30 2 * * *' → 生产推荐(DW 02:00 全量刷新后)
/// '30 3 * * *' → staging 错峰
/// 详见 .env.example
@Cron(process.env.PAC_INCREMENTAL_CRON ?? '0 0 31 12 *' /* never — env not set */, {
name: 'sync-incremental-daily',
})
async runDaily(): Promise<void> {
const hosts = (process.env.PAC_INCREMENTAL_HOSTS ?? 'jvs-dw')
.split(',')
.map((h) => h.trim())
.filter(Boolean);
const dataDir = process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(__dirname, '../../data');
this.logger.log(`sync-incremental: START hosts=[${hosts.join(',')}] dataDir=${dataDir}`);
for (const host of hosts) {
try {
await this.runOne(path.join(dataDir, host));
} catch (err) {
this.logger.error(
`sync-incremental: host=${host} failed: ${(err as Error).message}`,
);
// 不抛 — 下个 host 继续;cursor 没前进自然下次 catchup
}
}
this.logger.log('sync-incremental: ALL DONE');
}
/// 跑单个 host 的完整链路(可手动调,debug 用)
async runOne(dir: string): Promise<void> {
const started = Date.now();
const r = await this.coldImport.importDirectory(dir, { incremental: true });
this.logger.log(
`sync-incremental: cold-import done host=${r.hostName} ` +
`patients=${r.totals.patientsUpserted} txns=${r.totals.transactionsWritten} ` +
`facts(created=${r.totals.factsCreated} superseded=${r.totals.factsSuperseded})`,
);
// ─── Persona recompute(affected patient only)──────
if (r.syncLogId) {
const syncLog = await this.prisma.syncLog.findUnique({ where: { id: r.syncLogId } });
if (syncLog) {
const affected = await this.prisma.patientTransaction.findMany({
where: { hostId: r.hostId, createdAt: { gte: syncLog.startedAt } },
select: { patientId: true },
distinct: ['patientId'],
});
const pids = affected.map((p) => p.patientId).filter((p): p is string => !!p);
let ok = 0;
let failed = 0;
for (const pid of pids) {
try {
await this.persona.recompute({
patientId: pid,
source: `incremental-sync:${r.runId}`,
});
ok++;
} catch (err) {
failed++;
this.logger.warn(`persona patient=${pid}: ${(err as Error).message}`);
}
}
this.logger.log(`persona recompute: success=${ok} failed=${failed} (${pids.length} affected)`);
}
}
// ─── Plan recompute(per tenant)──────
const tenants = await this.prisma.patient.findMany({
where: { hostId: r.hostId },
distinct: ['tenantId'],
select: { tenantId: true },
});
let plansCreated = 0;
for (const t of tenants) {
const r2 = await this.planEngine.runAllForHost({
hostId: r.hostId,
tenantId: t.tenantId,
now: new Date(),
});
plansCreated += r2.plansCreated;
}
this.logger.log(
`plan recompute: ${tenants.length} tenants, plansCreated=${plansCreated} ` +
`(total elapsed=${Date.now() - started}ms)`,
);
}
}
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# PAC Web Dockerfile — Next.js standalone build
ARG NODE_VERSION=22-alpine ARG NODE_VERSION=22-alpine
FROM node:${NODE_VERSION} AS base FROM node:${NODE_VERSION} AS base
...@@ -7,30 +8,31 @@ RUN corepack enable && corepack prepare pnpm@10.13.1 --activate ...@@ -7,30 +8,31 @@ RUN corepack enable && corepack prepare pnpm@10.13.1 --activate
WORKDIR /app WORKDIR /app
FROM base AS deps FROM base AS deps
COPY pnpm-workspace.yaml pnpm-lock.yaml* package.json .npmrc ./ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json .npmrc* ./
COPY turbo.json tsconfig.base.json ./ COPY turbo.json tsconfig.base.json ./
COPY apps/recall-web/package.json apps/recall-web/ COPY apps/pac-web/package.json apps/pac-web/
COPY packages/shared-types/package.json packages/shared-types/ COPY packages/types/package.json packages/types/
COPY packages/shared-utils/package.json packages/shared-utils/ COPY packages/utils/package.json packages/utils/
RUN pnpm install --frozen-lockfile=false RUN pnpm install --frozen-lockfile
FROM deps AS dev FROM deps AS dev
COPY . . COPY . .
WORKDIR /app/apps/recall-web WORKDIR /app/apps/pac-web
EXPOSE 3000 EXPOSE 3000
CMD ["pnpm", "dev"] CMD ["pnpm", "dev"]
FROM deps AS build FROM deps AS build
COPY . . COPY . .
WORKDIR /app/apps/recall-web RUN pnpm --filter @pac/types build
WORKDIR /app/apps/pac-web
RUN pnpm build RUN pnpm build
FROM node:${NODE_VERSION} AS prod FROM node:${NODE_VERSION} AS prod
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
COPY --from=build /app/apps/recall-web/.next/standalone ./ COPY --from=build /app/apps/pac-web/.next/standalone ./
COPY --from=build /app/apps/recall-web/.next/static ./apps/recall-web/.next/static COPY --from=build /app/apps/pac-web/.next/static ./apps/pac-web/.next/static
COPY --from=build /app/apps/recall-web/public ./apps/recall-web/public COPY --from=build /app/apps/pac-web/public ./apps/pac-web/public
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/recall-web/server.js"] CMD ["node", "apps/pac-web/server.js"]
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { use } from 'react'; import { use, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Permission } from '@pac/types'; import { ApiCode, Permission } from '@pac/types';
import { Can } from '@/components/can'; import { Can } from '@/components/can';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
...@@ -44,6 +46,16 @@ export default function PlanDetailRoutePage({ params }: { params: Promise<{ plan ...@@ -44,6 +46,16 @@ export default function PlanDetailRoutePage({ params }: { params: Promise<{ plan
function PlanDetailLoader({ planId }: { planId: string }) { function PlanDetailLoader({ planId }: { planId: string }) {
const { state, refresh } = usePlanAggregate(planId); const { state, refresh } = usePlanAggregate(planId);
const dictionary = useAuthStore((s) => s.user?.dictionary); const dictionary = useAuthStore((s) => s.user?.dictionary);
const router = useRouter();
// W4 末:单患者刷新后 plan 可能已被 supersede / abandoned(scenario 不再命中)
// → /full 接口返回 PLAN_NOT_FOUND → 这里捕获后 toast + 跳列表(避免用户卡在 error 页)
useEffect(() => {
if (state.status === 'error' && state.code === ApiCode.PLAN_NOT_FOUND) {
toast.info('该召回任务已不存在', { description: '可能已完成或被覆盖,返回召回池' });
router.push('/plans');
}
}, [state, router]);
if (state.status === 'loading' || state.status === 'idle') { if (state.status === 'loading' || state.status === 'idle') {
return ( return (
...@@ -54,6 +66,14 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -54,6 +66,14 @@ function PlanDetailLoader({ planId }: { planId: string }) {
} }
if (state.status === 'error') { if (state.status === 'error') {
// PLAN_NOT_FOUND 已 useEffect 跳走,这里只渲染过场骨架(避免一闪而过的 error UI 闪烁)
if (state.code === ApiCode.PLAN_NOT_FOUND) {
return (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground">
召回任务已不存在,返回召回池…
</div>
);
}
return ( return (
<main className="container mx-auto max-w-3xl p-8 space-y-3"> <main className="container mx-auto max-w-3xl p-8 space-y-3">
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
...@@ -81,7 +101,7 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -81,7 +101,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
return ( return (
<div className="bg-slate-50"> <div className="bg-slate-50">
<TaskDrawer currentPlanId={planId} /> <TaskDrawer currentPlanId={planId} />
<PlanDetailApp data={adaptData(state.data, dictionary)} /> <PlanDetailApp data={adaptData(state.data, dictionary)} onRefreshAggregate={refresh} />
</div> </div>
); );
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useAuthBootstrap } from '@/hooks/use-auth-bootstrap'; import { useAuthBootstrap } from '@/hooks/use-auth-bootstrap';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { MockLoginDialog } from '@/components/mock-login-dialog';
const Placeholder = () => ( const Placeholder = () => (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex h-screen items-center justify-center text-sm text-muted-foreground">
...@@ -24,16 +25,22 @@ export function AuthGate({ children }: { children: React.ReactNode }) { ...@@ -24,16 +25,22 @@ export function AuthGate({ children }: { children: React.ReactNode }) {
// mount 后真正分流: // mount 后真正分流:
// - store 已 authenticated → 放行(覆盖 bootstrap 边界 case,如 back 重挂) // - store 已 authenticated → 放行(覆盖 bootstrap 边界 case,如 back 重挂)
// - 还在 bootstrap → 占位 // - 还在 bootstrap → 占位
// - 都不是 → 引导回宿主 // - 都不是 → 弹"快速登录"对话框(开发 / 试部署用,后端 env 门控)
if (isAuthenticated) return <>{children}</>; if (isAuthenticated) return <>{children}</>;
if (status === 'authenticating' || status === 'idle') return <Placeholder />; if (status === 'authenticating' || status === 'idle') return <Placeholder />;
// 未鉴权 fallback —— 透明占位 + 强制 MockLoginDialog
// 真生产把后端 PAC_ENABLE_MOCK_LOGIN=false,mockLogin 返 10107,dialog 会 toast 错误
// 后用户能看到底层占位文案了解需要走宿主 SSO
return ( return (
<div className="flex h-screen flex-col items-center justify-center gap-2 p-8 text-center"> <>
<p className="text-lg font-medium">无法加载工作台</p> <div className="flex h-screen flex-col items-center justify-center gap-2 p-8 text-center">
<p className="max-w-md text-sm text-muted-foreground"> <p className="text-lg font-medium text-slate-700">召回工作台</p>
缺少有效的访问凭证。请回到宿主系统重新打开召回模块(确保 URL 携带一次性 code 参数)。 <p className="max-w-md text-sm text-muted-foreground">
</p> 请在弹窗中选择身份进入。生产环境会自动通过宿主 SSO 登录,无需手动选择。
</div> </p>
</div>
<MockLoginDialog open />
</>
); );
} }
'use client';
import { useState } from 'react';
import { toast } from 'sonner';
import type { UserRole } from '@pac/types';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store';
import { mockLogin } from '@/lib/auth-api';
import { cn } from '@/lib/utils';
/**
* MockLoginDialog — 试部署 / 演示用快速登录(只在 dev 启用)
*
* 用途:还没接 host SSO 时,客服 / 演示者可一键以 瑞尔/瑞泰 × staff/leader/admin
* 身份登录工作台。后端 env `PAC_ENABLE_MOCK_LOGIN=false` 一关即生产模式。
*
* 不可关闭(open=true 强制保持):未鉴权用户**必须选一个身份**才能进列表页。
* 已鉴权后由 AuthGate 接管,放行 children。
*
* 设计:6 个按钮,2 brand × 3 role 网格;点击 → mockLogin API → setTokens → reload。
*/
type TenantSlug = 'ruier' | 'ruitai';
const TENANTS: { slug: TenantSlug; nameZh: string; tone: string }[] = [
{ slug: 'ruier', nameZh: '瑞尔', tone: 'border-teal-200 bg-teal-50/40' },
{ slug: 'ruitai', nameZh: '瑞泰', tone: 'border-sky-200 bg-sky-50/40' },
];
const ROLES: { key: UserRole; nameZh: string; desc: string }[] = [
{ key: 'staff', nameZh: '员工', desc: '执行客服 · 看自己的召回任务' },
{ key: 'leader', nameZh: '主管', desc: '看本团队所有任务 · 可指派 / 回收' },
{ key: 'admin', nameZh: '管理员', desc: '全部权限 · 含后台管理' },
];
export function MockLoginDialog({ open }: { open: boolean }) {
const setTokens = useAuthStore((s) => s.setTokens);
const [busy, setBusy] = useState<string | null>(null); // 'ruier:staff' 等
const pick = async (tenant: TenantSlug, role: UserRole) => {
const key = `${tenant}:${role}`;
if (busy) return;
setBusy(key);
try {
const r = await mockLogin({ tenant, role });
setTokens({
accessToken: r.accessToken,
refreshToken: r.refreshToken,
expiresIn: r.expiresIn,
});
const tNameZh = TENANTS.find((t) => t.slug === tenant)?.nameZh ?? tenant;
const rNameZh = ROLES.find((r) => r.key === role)?.nameZh ?? role;
toast.success(`已登录:${tNameZh} · ${rNameZh}`, {
description: '(模拟身份,真生产环境会接宿主 SSO)',
});
// AuthGate 监听 isAuthenticated,setTokens 后自动放行
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error('登录失败', { description: msg.slice(0, 120) });
setBusy(null);
}
};
return (
<Dialog open={open}>
<DialogContent
// 禁掉 ESC / 点遮罩关闭 — 未鉴权必须选身份才能进
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
// 隐藏右上 X 关闭按钮(子树里的 DialogPrimitive.Close)
className="max-w-2xl [&>button[aria-label='Close']]:hidden"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-teal-600 text-[12px] font-bold text-white">
PAC
</span>
<span>快速登录</span>
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10.5px] font-medium text-amber-700">
模拟身份 · 试部署
</span>
</DialogTitle>
<DialogDescription className="text-[12px]">
生产环境会接宿主 SSO,这里是<strong>开发 / 试部署</strong>的快速通道。请选一个身份进入工作台:
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 pt-2">
{TENANTS.map((t) => (
<div
key={t.slug}
className={cn('rounded-lg border p-3', t.tone)}
>
<div className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-slate-800">
<span>{t.nameZh}</span>
<span className="text-[10.5px] font-normal text-slate-500">
{t.slug === 'ruier' ? '3 家诊所(杭州大厦/高德 · 北京朝阳)' : '2 家诊所(通善学前街 · 上海世纪公园)'}
</span>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{ROLES.map((r) => {
const key = `${t.slug}:${r.key}`;
const loading = busy === key;
return (
<button
key={key}
type="button"
onClick={() => pick(t.slug, r.key)}
disabled={!!busy}
className={cn(
'group flex flex-col items-start gap-1 rounded-md border bg-white px-3 py-2 text-left transition-all',
loading
? 'border-teal-400 ring-2 ring-teal-200'
: 'border-slate-200 hover:border-teal-400 hover:bg-teal-50/40',
busy && !loading && 'opacity-50',
)}
>
<div className="text-[12.5px] font-semibold text-slate-800">
{loading ? '登录中…' : r.nameZh}
</div>
<div className="text-[10.5px] leading-tight text-slate-500">{r.desc}</div>
</button>
);
})}
</div>
</div>
))}
</div>
<p className="pt-1 text-[10.5px] leading-relaxed text-slate-400">
提示:登录后 token 写入浏览器 localStorage(2h 有效 · 自动 refresh),
清浏览器数据或换浏览器会重新弹此对话框。
</p>
</DialogContent>
</Dialog>
);
}
...@@ -6,8 +6,8 @@ ...@@ -6,8 +6,8 @@
*/ */
import { ExecutionChannel, UserRole, personaFeatureMeta, planScenarioLabel } from '@pac/types'; import { ExecutionChannel, UserRole, personaFeatureMeta, planScenarioLabel } from '@pac/types';
import { fmtRel as sharedFmtRel } from '@pac/utils/format'; import { fmtRel as sharedFmtRel } from '@pac/utils/format';
import type { Chain, OutcomeOption, PersonaFeature, PlanReason } from './mock-data'; import type { Chain, PersonaFeature, PlanReason } from './mock-data';
import { mockScript, mockSummaries, outcomeOptions as mockOutcomes } from './mock-data'; import { mockScript, mockSummaries } from './mock-data';
import type { PlanDetailData } from './plan-detail-types'; import type { PlanDetailData } from './plan-detail-types';
import type { TokenDictionary } from '@pac/types'; import type { TokenDictionary } from '@pac/types';
...@@ -176,7 +176,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -176,7 +176,7 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
sections: real.script.sections, sections: real.script.sections,
} as typeof mockScript) } as typeof mockScript)
: mockScript, : mockScript,
outcomeOptions: mockOutcomes as OutcomeOption[], // outcomeOptions 已迁移到 @pac/types EXECUTION_OUTCOME_META,outcome-form 直接 import
fmtRel, fmtRel,
}; };
} }
......
...@@ -45,18 +45,21 @@ type ChainStatusVisual = { ...@@ -45,18 +45,21 @@ type ChainStatusVisual = {
short: string; // 短标签(badge / sidebar 用),如 "已进入" short: string; // 短标签(badge / sidebar 用),如 "已进入"
long: string; // 长标签(全景卡 badge 用),如 "↻ 在管 · 治疗中" long: string; // 长标签(全景卡 badge 用),如 "↻ 在管 · 治疗中"
icon: string; // ★ / ⏵ / ↻ / ✓ icon: string; // ★ / ⏵ / ↻ / ✓
tone: 'rose' | 'amber' | 'sky' | 'emerald'; tone: 'rose' | 'amber' | 'sky' | 'emerald' | 'slate';
}; };
function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage' | 'target'>): ChainStatusVisual { function chainStatusVisual(chain: Pick<Chain, 'status' | 'currentStage' | 'target'>): ChainStatusVisual {
// W4 末:SQL 为准 — target=true(SQL 召回了)优先显示"潜在新链",不管 chain 内部 status // W4 末:口径统一 — entered/ongoing/closed 都不该 ★(SQL ⑤d 已排除 entered,⑤a 排除 ongoing)
// 临床场景:林兆星 K05 — 1 年前 fulfilled "牙周"预约让 chain 算 entered,但 SQL 召回该 K05 // chain 内部状态 = 视觉真理源,target=true 只对 discovered 才上 ★
// 因为 1 年没坚持治疗;target 是 SQL 真理,优先级最高 // target+entered/ongoing 出现 = SQL 漏排或老 plan,按 chain 真实 status 显示更不误导
if (chain.target && chain.status !== 'closed') {
return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' };
}
if (chain.status === 'closed') return { short: '已闭环', long: '✓ 已闭环', icon: '✓', tone: 'emerald' }; if (chain.status === 'closed') return { short: '已闭环', long: '✓ 已闭环', icon: '✓', tone: 'emerald' };
if (chain.status === 'entered') return { short: '已进入', long: '⏵ 已进入', icon: '⏵', tone: 'amber' }; if (chain.status === 'entered') return { short: '已进入', long: '⏵ 已进入', icon: '⏵', tone: 'amber' };
if (chain.status === 'discovered') return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' }; if (chain.target && chain.status === 'discovered') {
return { short: '潜在新链', long: '★ 潜在新链', icon: '★', tone: 'rose' };
}
// W4 末:discovered + target=false(SQL 未召回 — 如同牙位拔除 ⑤c 排除 / cooldown 未过等)
// 不再 ★ 误导客服;改"已发现 · 暂不召回",中性灰色表示"已识别但 SQL 评估暂不进入主流程"
// 典型 case:季根财 K05 全口 — 所有牙位都被同期 surgical 拔除,牙周治疗已无意义
if (chain.status === 'discovered') return { short: '已发现', long: '已发现 · 暂不召回', icon: '⊙', tone: 'slate' };
// ongoing — 按 stage 区分"治疗中" / "复查中" (业务上是不同语义,客服需要知道病人在做啥) // ongoing — 按 stage 区分"治疗中" / "复查中" (业务上是不同语义,客服需要知道病人在做啥)
const sub = chain.currentStage >= 4 ? '复查中' : '治疗中'; const sub = chain.currentStage >= 4 ? '复查中' : '治疗中';
return { short: `在管 · ${sub}`, long: `↻ 在管 · ${sub}`, icon: '↻', tone: 'sky' }; return { short: `在管 · ${sub}`, long: `↻ 在管 · ${sub}`, icon: '↻', tone: 'sky' };
...@@ -422,24 +425,27 @@ function ChainSidebarRow({ chain }: { chain: Chain }) { ...@@ -422,24 +425,27 @@ function ChainSidebarRow({ chain }: { chain: Chain }) {
} }
// status 主色 → 卡片背景 / dot / 文字 三套类名(单一来源,免散写) // status 主色 → 卡片背景 / dot / 文字 三套类名(单一来源,免散写)
type ChainTone = 'rose' | 'amber' | 'sky' | 'emerald'; type ChainTone = 'rose' | 'amber' | 'sky' | 'emerald' | 'slate';
const TONE_WRAP: Record<ChainTone, string> = { const TONE_WRAP: Record<ChainTone, string> = {
rose: 'bg-rose-50/40 border-rose-200/70', rose: 'bg-rose-50/40 border-rose-200/70',
amber: 'bg-amber-50/40 border-amber-200/70', amber: 'bg-amber-50/40 border-amber-200/70',
sky: 'bg-sky-50/40 border-sky-200/70', sky: 'bg-sky-50/40 border-sky-200/70',
emerald: 'bg-emerald-50/40 border-emerald-200/70', emerald: 'bg-emerald-50/40 border-emerald-200/70',
slate: 'bg-slate-50/40 border-slate-200/70',
}; };
const TONE_DOT: Record<ChainTone, string> = { const TONE_DOT: Record<ChainTone, string> = {
rose: 'bg-rose-500 text-white', rose: 'bg-rose-500 text-white',
amber: 'bg-amber-500 text-white', amber: 'bg-amber-500 text-white',
sky: 'bg-sky-500 text-white', sky: 'bg-sky-500 text-white',
emerald: 'bg-emerald-500 text-white', emerald: 'bg-emerald-500 text-white',
slate: 'bg-slate-400 text-white',
}; };
const TONE_TEXT: Record<ChainTone, string> = { const TONE_TEXT: Record<ChainTone, string> = {
rose: 'text-rose-700', rose: 'text-rose-700',
amber: 'text-amber-700', amber: 'text-amber-700',
sky: 'text-sky-700', sky: 'text-sky-700',
emerald: 'text-emerald-700', emerald: 'text-emerald-700',
slate: 'text-slate-700',
}; };
// ────────────────────────────────────────── // ──────────────────────────────────────────
......
...@@ -117,7 +117,7 @@ export function Drawer({ ...@@ -117,7 +117,7 @@ export function Drawer({
return ( return (
<div className="fixed inset-0 z-40 flex" onClick={onClose}> <div className="fixed inset-0 z-40 flex" onClick={onClose}>
<div className="flex-1 bg-slate-900/30 backdrop-blur-sm" /> <div className="flex-1 bg-slate-900/30" />
<aside <aside
className={cn('max-w-[92vw] bg-white shadow-2xl border-l border-slate-200 flex flex-col', width)} className={cn('max-w-[92vw] bg-white shadow-2xl border-l border-slate-200 flex flex-col', width)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
......
...@@ -19,8 +19,7 @@ import { ...@@ -19,8 +19,7 @@ import {
AlertOctagon, AlertOctagon,
} from 'lucide-react'; } from 'lucide-react';
import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types'; import { diagnosisCodeNameZh, treatmentCategoryNameZh } from '@pac/types';
import { cn, formatToothPosition } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Chip } from './shared';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
/** /**
...@@ -66,37 +65,33 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) { ...@@ -66,37 +65,33 @@ export function FactsTimeline({ facts }: { facts: AdaptedFact[] }) {
[facts, selected], [facts, selected],
); );
// W4 末:sticky 顶部 — stats + filter 滚动时固定,时间轴在下方滚过
// 父容器(drawer body)已 overflow-y-auto,sticky 在该 scroll container 内生效
// -mx-5 让 sticky 区域延伸到 drawer padding 边缘,bg-white 遮住下方时间轴
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* 结构化统计摘要 — 资金 / 临床 / 行为 / 时间窗 四块,一眼看清患者画像 */} <div className="sticky top-0 z-10 -mx-5 px-5 pt-1 pb-2 bg-white space-y-3 border-b border-slate-100">
<StatsSummary facts={facts} /> <StatsSummary facts={facts} />
<div className="flex flex-wrap items-center gap-1.5">
{/* 类型多选筛选 chip 行 */} <FilterChip label="全部" count={facts.length} active={allOn} onClick={toggleAll} />
<div className="flex flex-wrap items-center gap-1.5 pb-2 border-b border-slate-100"> <span className="text-slate-200 mx-0.5">|</span>
<FilterChip label="全部" count={facts.length} active={allOn} onClick={toggleAll} /> {allTypes.map((t) => {
<span className="text-slate-200 mx-0.5">|</span> const meta = FACT_META[t] ?? FACT_META_FALLBACK;
{allTypes.map((t) => { return (
const meta = FACT_META[t] ?? FACT_META_FALLBACK; <FilterChip
return ( key={t}
<FilterChip label={meta.label}
key={t} count={typeCounts.get(t) ?? 0}
label={meta.label} active={selected.has(t)}
count={typeCounts.get(t) ?? 0} onClick={() => toggleType(t)}
active={selected.has(t)} />
tone={meta.tone} );
onClick={() => toggleType(t)} })}
/> </div>
);
})}
</div> </div>
{sorted.length === 0 ? (
{/* 选中类型为空的提示 */}
{sorted.length === 0 && (
<div className="text-center py-12 text-sm text-slate-400">已勾选类型无事实</div> <div className="text-center py-12 text-sm text-slate-400">已勾选类型无事实</div>
)} ) : (
{/* 时间轴 */}
{sorted.length > 0 && (
<div className="relative pl-6"> <div className="relative pl-6">
<div className="absolute left-[10px] top-1 bottom-1 w-px bg-slate-200" /> <div className="absolute left-[10px] top-1 bottom-1 w-px bg-slate-200" />
{sorted.map((f) => ( {sorted.map((f) => (
...@@ -240,8 +235,11 @@ function StatBlock({ label, children }: { label: string; children: React.ReactNo ...@@ -240,8 +235,11 @@ function StatBlock({ label, children }: { label: string; children: React.ReactNo
function TimelineRow({ fact }: { fact: AdaptedFact }) { function TimelineRow({ fact }: { fact: AdaptedFact }) {
const meta = FACT_META[fact.type] ?? FACT_META_FALLBACK; const meta = FACT_META[fact.type] ?? FACT_META_FALLBACK;
const Icon = meta.Icon; const Icon = meta.Icon;
const T = toneOf(meta.tone);
const { title, note } = factSummary(fact); // W4 末视觉降噪 — 3 种语义色:rose(异常)/ emerald(钱进来)/ amber(爽约) — 其它 slate 灰
// appointment 特例:status=no_show/cancelled 才转 amber,其它走 slate
const effectiveTone = computeEffectiveTone(fact, meta);
const T = toneOf(effectiveTone);
// planned 事实(预约/计划治疗)显示 plannedFor;加"约"前缀以区分已发生 // planned 事实(预约/计划治疗)显示 plannedFor;加"约"前缀以区分已发生
const tIso = fact.occurredAt ?? fact.plannedFor; const tIso = fact.occurredAt ?? fact.plannedFor;
...@@ -249,8 +247,12 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) { ...@@ -249,8 +247,12 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
? (fact.occurredAt ? '' : '约 ') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.') ? (fact.occurredAt ? '' : '约 ') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.')
: '—'; : '—';
const { title, note } = factSummary(fact);
const rightLines = rightColumn(fact);
return ( return (
<div className="relative pb-3"> <div className="relative pb-3">
{/* icon dot — 默认 slate 灰,异常类型(rose/emerald/amber)着色;ring-white 保持时间轴脉络感 */}
<span <span
className={cn( className={cn(
'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white', 'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white',
...@@ -260,57 +262,145 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) { ...@@ -260,57 +262,145 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
> >
<Icon className="w-3 h-3" /> <Icon className="w-3 h-3" />
</span> </span>
<div className="flex items-center gap-2 flex-wrap"> {/* 2 列网格:左 1fr(时间/类型/标题/副字段),右 auto(牙位/金额/HH:MM/状态)*/}
<span className="text-[11px] text-slate-500 tabular-nums font-mono">{dateStr}</span> <div className="grid grid-cols-[1fr_auto] gap-3 items-start">
<Chip tone={meta.tone} size="xs"> <div className="min-w-0">
{meta.label} <div className="text-[10.5px] text-slate-500 tabular-nums">
</Chip> <span className="font-mono">{dateStr}</span>
{fact.status !== 'active' && ( <span className="mx-1 text-slate-300">·</span>
<Chip tone="slate" size="xs"> <span>{meta.label}</span>
{FACT_STATUS_ZH[fact.status] ?? fact.status} </div>
</Chip> <div className="mt-0.5 text-[13px] font-medium text-slate-900 leading-tight">{title}</div>
{note && (
<div className="text-[11px] text-slate-500 leading-snug mt-0.5 line-clamp-2">{note}</div>
)}
</div>
{rightLines.length > 0 && (
<div className="flex flex-col items-end gap-0.5 text-[11.5px] text-slate-600 tabular-nums whitespace-nowrap">
{rightLines.map((line, i) => (
<span key={i} className={line.className}>{line.text}</span>
))}
</div>
)} )}
</div> </div>
<div className="mt-0.5 text-[12.5px] font-medium text-slate-900">{title}</div>
{note && <div className="text-[11px] text-slate-500 mt-0.5">{note}</div>}
</div> </div>
); );
} }
// 计算 row 实际显示的 tone — appointment status 异常时升 amber
function computeEffectiveTone(fact: AdaptedFact, meta: FactMeta): string {
if (fact.type === 'appointment_record') {
const st = String((fact.content as Record<string, unknown> | null)?.status ?? '');
if (st === 'no_show' || st === 'cancelled') return 'amber';
return 'slate'; // 普通预约不着色
}
return meta.tone;
}
// 状态 badge 统一样式(slate)— W4 末降噪:语义色集中在时间轴 icon dot,badge 不重复表达
const STATUS_BADGE = 'px-1.5 py-px rounded border text-[10.5px] bg-slate-50 text-slate-600 border-slate-200';
// 右列数据 — 按 fact 类型挑最关键的 1-2 行
interface RightLine { text: string; className?: string }
function rightColumn(fact: AdaptedFact): RightLine[] {
const c = (fact.content ?? {}) as Record<string, unknown>;
const out: RightLine[] = [];
switch (fact.type) {
case 'diagnosis_record':
case 'treatment_record':
case 'recommendation_record': {
const tooth = String(c.tooth_position ?? c.tooth_positions ?? '').trim();
if (tooth) {
const teeth = tooth.split(';').map((t) => t.trim()).filter(Boolean);
out.push({ text: teeth.length > 4 ? `${teeth.slice(0, 3).join(';')}${teeth.length} 颗` : teeth.join(';'), className: 'font-medium text-slate-700' });
}
if (fact.type === 'treatment_record') {
const st = String(c.status ?? '');
if (st === 'completed' || st === 'fulfilled') out.push({ text: '已完成', className: STATUS_BADGE });
else if (st === 'cancelled') out.push({ text: '已取消', className: STATUS_BADGE });
}
break;
}
case 'payment_record':
case 'order_record':
case 'recharge_record':
case 'refund_record': {
const cents = Number(c.amount_cents ?? 0);
const yuan = ${(Math.abs(cents) / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const isNegative = fact.type === 'refund_record';
// 金额本身用 emerald/rose 色(语义信号,跟时间轴 icon dot 同色系)
out.push({ text: isNegative ? `- ${yuan}` : yuan, className: cn('font-semibold', isNegative ? 'text-rose-700' : 'text-emerald-700') });
break;
}
case 'appointment_record': {
const at = String(c.scheduled_at ?? '');
if (at) out.push({ text: hhmm(at), className: 'font-mono text-slate-700' });
const st = String(c.status ?? '');
const stZh = APPT_STATUS_ZH[st];
if (stZh) {
// W4 末:状态 badge 样式统一(slate),不同 status 不再有不同色
// 语义信号(爽约 amber / 异常 rose)走时间轴 icon dot,不重复在 badge 上表达
out.push({ text: stZh, className: STATUS_BADGE });
}
break;
}
case 'encounter_record': {
const etype = String(c.encounter_type ?? '');
const zh = ENCOUNTER_TYPE_ZH[etype];
if (zh) out.push({ text: zh, className: 'text-slate-500' });
break;
}
case 'image_record': {
const mod = String(c.modality ?? '');
const modZh = MODALITY_ZH[mod] ?? mod;
if (modZh) out.push({ text: modZh, className: 'text-slate-500' });
const tooths = c.tooth_positions;
const tooth = Array.isArray(tooths) ? tooths.join(';') : String(tooths ?? '');
if (tooth) {
const teeth = tooth.split(';').map((t) => t.trim()).filter(Boolean);
out.push({ text: teeth.length > 4 ? `${teeth.slice(0, 3).join(';')}${teeth.length}` : teeth.join(';'), className: 'text-slate-600' });
}
break;
}
default:
break;
}
return out;
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// 顶部筛选 chip // 顶部筛选 chip
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// W4 末:filter chip 配色统一 — 不再 per-meta-tone 着色(全 sky 主色作"激活"信号)
// 语义色保留给时间轴 icon dot(rose/emerald/amber)— 顶部 chip 表"筛选状态"不表"语义"
function FilterChip({ function FilterChip({
label, label,
count, count,
active, active,
tone = 'slate',
onClick, onClick,
}: { }: {
label: string; label: string;
count: number; count: number;
active: boolean; active: boolean;
tone?: string;
onClick: () => void; onClick: () => void;
}) { }) {
const T = toneOf(tone);
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] border transition-colors', 'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] border transition-colors',
active active
? cn(T.bg, T.text, T.border, 'font-medium') ? 'bg-sky-50 text-sky-700 border-sky-200 font-medium'
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100', : 'bg-white text-slate-500 border-slate-200 hover:bg-slate-50',
)} )}
> >
<span>{label}</span> <span>{label}</span>
<span <span
className={cn( className={cn(
'tabular-nums text-[10px] px-1 rounded', 'tabular-nums text-[10px] px-1 rounded',
active ? 'bg-white/60' : 'bg-slate-200/60 text-slate-600', active ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500',
)} )}
> >
{count} {count}
...@@ -329,22 +419,26 @@ interface FactMeta { ...@@ -329,22 +419,26 @@ interface FactMeta {
Icon: React.ComponentType<{ className?: string }>; Icon: React.ComponentType<{ className?: string }>;
} }
// W4 末降噪 — 全局只 3 语义色:rose(异常/警示)/emerald(钱进来)/amber(警告) — 其它 slate 灰
// icon 已表达类型,不再用颜色重复表达 fact 大类
// appointment.tone='slate' 默认,status=no_show/cancelled 时 TimelineRow.computeEffectiveTone 升 amber
const FACT_META: Record<string, FactMeta> = { const FACT_META: Record<string, FactMeta> = {
diagnosis_record: { label: '诊断', tone: 'rose', Icon: AlertTriangle }, diagnosis_record: { label: '诊断', tone: 'rose', Icon: AlertTriangle },
treatment_record: { label: '治疗', tone: 'teal', Icon: Stethoscope }, complaint_record: { label: '投诉', tone: 'rose', Icon: AlertOctagon },
recommendation_record: { label: '医嘱', tone: 'indigo', Icon: Pill },
encounter_record: { label: '接诊', tone: 'slate', Icon: UserRound },
emr_record: { label: '病历', tone: 'sky', Icon: FileText },
image_record: { label: '影像', tone: 'violet', Icon: ImageIcon },
appointment_record: { label: '预约', tone: 'amber', Icon: Calendar },
payment_record: { label: '收款', tone: 'emerald', Icon: CircleDollarSign },
refund_record: { label: '退费', tone: 'rose', Icon: Undo2 }, refund_record: { label: '退费', tone: 'rose', Icon: Undo2 },
consultation_record: { label: '咨询', tone: 'sky', Icon: MessageCircle }, payment_record: { label: '收款', tone: 'emerald', Icon: CircleDollarSign },
visit_registration_record: { label: '挂号', tone: 'slate', Icon: ClipboardList },
order_record: { label: '医嘱单', tone: 'emerald', Icon: Receipt },
recharge_record: { label: '充值', tone: 'emerald', Icon: Wallet }, recharge_record: { label: '充值', tone: 'emerald', Icon: Wallet },
complaint_record: { label: '投诉', tone: 'rose', Icon: AlertOctagon }, // 其它一律 slate(默认),仅 icon 区分
referral_record: { label: '转介', tone: 'indigo', Icon: Users }, treatment_record: { label: '治疗', tone: 'slate', Icon: Stethoscope },
recommendation_record: { label: '医嘱', tone: 'slate', Icon: Pill },
encounter_record: { label: '接诊', tone: 'slate', Icon: UserRound },
emr_record: { label: '病历', tone: 'slate', Icon: FileText },
image_record: { label: '影像', tone: 'slate', Icon: ImageIcon },
appointment_record: { label: '预约', tone: 'slate', Icon: Calendar },
consultation_record: { label: '咨询', tone: 'slate', Icon: MessageCircle },
visit_registration_record: { label: '挂号', tone: 'slate', Icon: ClipboardList },
order_record: { label: '医嘱单', tone: 'slate', Icon: Receipt },
referral_record: { label: '转介', tone: 'slate', Icon: Users },
}; };
const FACT_META_FALLBACK: FactMeta = { label: '事实', tone: 'slate', Icon: FileText }; const FACT_META_FALLBACK: FactMeta = { label: '事实', tone: 'slate', Icon: FileText };
...@@ -370,14 +464,6 @@ const TONE = { ...@@ -370,14 +464,6 @@ const TONE = {
const toneOf = (tone: string): ToneCls => (TONE as Record<string, ToneCls>)[tone] ?? TONE_FALLBACK; const toneOf = (tone: string): ToneCls => (TONE as Record<string, ToneCls>)[tone] ?? TONE_FALLBACK;
const FACT_STATUS_ZH: Record<string, string> = {
active: '当前',
superseded: '已替代',
cancelled: '已取消',
fulfilled: '已完成',
expired: '已过期',
invalidated: '已失效',
};
const APPT_STATUS_ZH: Record<string, string> = { const APPT_STATUS_ZH: Record<string, string> = {
scheduled: '待就诊', scheduled: '待就诊',
...@@ -390,14 +476,6 @@ const APPT_STATUS_ZH: Record<string, string> = { ...@@ -390,14 +476,6 @@ const APPT_STATUS_ZH: Record<string, string> = {
walk_in: '现场加号', walk_in: '现场加号',
}; };
const TX_STATUS_ZH: Record<string, string> = {
planned: '计划中',
in_progress: '进行中',
completed: '已完成',
cancelled: '已取消',
failed: '失败',
};
const MODALITY_ZH: Record<string, string> = { const MODALITY_ZH: Record<string, string> = {
pa: '根尖片', pa: '根尖片',
bw: '咬翼片', bw: '咬翼片',
...@@ -478,114 +556,85 @@ function truncate(s: string, n: number): string { ...@@ -478,114 +556,85 @@ function truncate(s: string, n: number): string {
function factSummary(f: AdaptedFact): { title: string; note: string } { function factSummary(f: AdaptedFact): { title: string; note: string } {
const c = (f.content ?? {}) as Record<string, unknown>; const c = (f.content ?? {}) as Record<string, unknown>;
const doctor = (c.doctor_name as string | undefined) ?? ''; const doctor = (c.doctor_name as string | undefined) ?? '';
const dr = doctor ? `${doctor}医生` : '';
switch (f.type) { switch (f.type) {
case 'diagnosis_record': { case 'diagnosis_record': {
const code = (c.code as string) ?? '?'; const code = (c.code as string) ?? '?';
const name = (c.name_zh as string) ?? (c.name as string) ?? ''; const name = (c.name_zh as string) ?? (c.name as string) ?? '';
const tooth = (c.tooth_position as string) ?? ''; return { title: name || code, note: dr };
return {
title: name || code,
note: [tooth && `牙位 ${formatToothPosition(tooth, 4)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
} }
case 'treatment_record': { case 'treatment_record': {
const cat = (c.category as string) ?? ''; const cat = (c.category as string) ?? '';
const sub = (c.subtype as string) ?? ''; const sub = (c.subtype as string) ?? '';
const tooth = (c.tooth_position as string) ?? '';
const qty = c.quantity != null ? Number(c.quantity) : null; const qty = c.quantity != null ? Number(c.quantity) : null;
const unit = (c.unit_name as string) ?? ''; const unit = (c.unit_name as string) ?? '';
const qtyStr = qty != null && qty > 0 ? `${qty}${unit ? ' ' + unit : ''}` : ''; const qtyStr = qty != null && qty > 0 ? `${qty}${unit ? ' ' + unit : ''}` : '';
// status 不在 note 显示:fact.status chip 已表达"已完成/已取消"
return { return {
title: sub || treatmentCategoryNameZh(cat) || '治疗', title: sub || treatmentCategoryNameZh(cat) || '治疗',
note: [qtyStr, tooth && `牙位 ${formatToothPosition(tooth, 4)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '), note: [qtyStr, dr].filter(Boolean).join(' · '),
}; };
} }
case 'recommendation_record': { case 'recommendation_record': {
const code = (c.code as string) ?? '?'; const code = (c.code as string) ?? '?';
const tooth = (c.tooth_position as string) ?? ''; return { title: `建议 ${code}`, note: dr };
return {
title: `建议 ${code}`,
note: [tooth && `牙位 ${formatToothPosition(tooth, 4)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
} }
case 'encounter_record': { case 'encounter_record': {
const etype = (c.encounter_type as string) ?? ''; const etype = (c.encounter_type as string) ?? '';
const chief = (c.chief_complaint as string) ?? ''; const chief = (c.chief_complaint as string) ?? '';
const etypeZh = etype ? ENCOUNTER_TYPE_ZH[etype] ?? etype : ''; const etypeZh = etype ? ENCOUNTER_TYPE_ZH[etype] ?? etype : '';
// title 优先用 chief_complaint 主诉(最有信息),fallback 用初/复诊;不写"接诊"(chip 重复) const title = chief ? truncate(chief, 32) : etypeZh || '到诊';
const title = chief return { title, note: dr };
? truncate(chief, 24)
: etypeZh
? etypeZh
: '到诊';
return {
title,
note: [etypeZh && chief && etypeZh, doctor && `${doctor}医生`].filter(Boolean).join(' · '),
};
} }
case 'emr_record': { case 'emr_record': {
// 主诉(L1)+ 治疗计划/处置(L2)+ 医生 — raw id / pre_illness(跟 illness_desc 重复)移除
// title 不再加"病历"前缀(chip 已表达)
const ill = (c.illness_desc as string) ?? ''; const ill = (c.illness_desc as string) ?? '';
const plan = (c.treatment_plan as string) ?? ''; const plan = (c.treatment_plan as string) ?? '';
const disp = parseFirstMessage(c.disposal); const disp = parseFirstMessage(c.disposal);
const planText = plan || disp; const planText = plan || disp;
return { return {
title: ill ? truncate(ill, 30) : '病历记录', title: ill ? truncate(ill, 36) : '病历记录',
note: [planText && `计划:${truncate(planText, 30)}`, doctor && `${doctor}医生`].filter(Boolean).join(' · '), note: [planText && `计划:${truncate(planText, 36)}`, dr].filter(Boolean).join(' · '),
}; };
} }
case 'image_record': { case 'image_record': {
const mod = (c.modality as string) ?? ''; const mod = (c.modality as string) ?? '';
const finding = (c.finding as string) ?? ''; const finding = (c.finding as string) ?? '';
const tooths = (c.tooth_positions as string | string[] | null) ?? null;
const toothStr = Array.isArray(tooths) ? tooths.join(';') : tooths || '';
const modZh = MODALITY_ZH[mod] ?? mod ?? '影像'; const modZh = MODALITY_ZH[mod] ?? mod ?? '影像';
// title 优先 finding(有内容时更直观),fallback modality
return { return {
title: modZh, title: finding ? truncate(finding, 32) : modZh,
note: [ note: [finding && modZh, dr].filter(Boolean).join(' · '),
toothStr && `牙位 ${formatToothPosition(toothStr, 4)}`,
finding && truncate(finding, 30),
doctor && `${doctor}医生`,
]
.filter(Boolean)
.join(' · '),
}; };
} }
case 'appointment_record': { case 'appointment_record': {
const at = (c.scheduled_at as string) ?? '';
const type = (c.appointment_type as string) ?? ''; const type = (c.appointment_type as string) ?? '';
const st = (c.status as string) ?? '';
const cancel = (c.cancellation_reason as string) ?? ''; const cancel = (c.cancellation_reason as string) ?? '';
const complaint = (c.complaint_text as string) ?? (c.complaint_category as string) ?? ''; const complaint = (c.complaint_text as string) ?? (c.complaint_category as string) ?? '';
const time = at ? hhmm(at) : '';
const typeZh = type ? APPT_TYPE_ZH[type] ?? type : ''; const typeZh = type ? APPT_TYPE_ZH[type] ?? type : '';
// title 不加"预约"前缀(chip 已表达);用 type 翻译 + 时段 // title:type + 主诉(时间/状态在右列展示)
// status 仅在非默认态(no_show / cancelled / rescheduled / arrived)时显示,避免普通"scheduled" 噪音
const showStatus = st && st !== 'scheduled' && st !== 'completed';
return { return {
title: [typeZh, time].filter(Boolean).join(' · ') || '预约', title: [typeZh, complaint && truncate(complaint, 18)].filter(Boolean).join(' · ') || '预约',
note: [ note: [cancel && `取消原因:${truncate(cancel, 16)}`, dr].filter(Boolean).join(' · '),
complaint && truncate(complaint, 20),
showStatus && (APPT_STATUS_ZH[st] ?? st),
cancel && `取消原因:${truncate(cancel, 16)}`,
]
.filter(Boolean)
.join(' · '),
}; };
} }
case 'payment_record': { case 'payment_record': {
// title 不加"收款"前缀(chip 已表达),直接金额;note 显示渠道翻译 // 金额在右列;title 用渠道翻译
const cents = Number(c.amount_cents ?? 0); const ch = (c.channel as string) ?? '';
const chZh = CHANNEL_ZH[ch] ?? ch;
return { title: chZh || '收款', note: dr };
}
case 'recharge_record': {
const ch = (c.channel as string) ?? ''; const ch = (c.channel as string) ?? '';
const chZh = CHANNEL_ZH[ch] ?? ch; const chZh = CHANNEL_ZH[ch] ?? ch;
return { title: ${(cents / 100).toFixed(2)}`, note: chZh }; return { title: chZh || '充值', note: dr };
}
case 'order_record': {
const items = (c.items as Array<Record<string, unknown>> | undefined) ?? [];
const itemNames = items.map((i) => String(i?.name ?? '')).filter(Boolean);
return { title: itemNames.length > 0 ? truncate(itemNames.join(';'), 28) : '医嘱单', note: dr };
} }
case 'refund_record': { case 'refund_record': {
const cents = Number(c.amount_cents ?? 0);
const reason = (c.reason as string) ?? ''; const reason = (c.reason as string) ?? '';
return { title: ${(cents / 100).toFixed(2)}`, note: reason }; return { title: reason || '退费', note: dr };
} }
default: { default: {
return { title: f.title ?? f.type, note: f.summary ?? '' }; return { title: f.title ?? f.type, note: f.summary ?? '' };
......
...@@ -6,7 +6,6 @@ ...@@ -6,7 +6,6 @@
*/ */
import { import {
ExecutionOutcome,
ExecutionChannel, ExecutionChannel,
PersonaFeatureKey, PersonaFeatureKey,
PlanScenario, PlanScenario,
...@@ -452,29 +451,9 @@ export const mockScript = { ...@@ -452,29 +451,9 @@ export const mockScript = {
}; };
// ──────────────────────────────────────────────── // ────────────────────────────────────────────────
// Outcome 枚举(对齐系统 ExecutionOutcome + 演示扩展) // Outcome 枚举 — 已迁移到 @pac/types EXECUTION_OUTCOME_META(单一真理源)
// outcome-form.tsx 直接从 types 拉,前后端共享 label / tone / drivesStatus
// 这里不再维护;改 outcome 改 packages/types/src/enums/index.ts
// ──────────────────────────────────────────────── // ────────────────────────────────────────────────
export type OutcomeOption = {
value: string;
label: string;
tone: string;
drives: 'completed' | 'assigned' | 'active' | 'abandoned';
};
export const outcomeOptions: OutcomeOption[] = [
{ value: ExecutionOutcome.SUCCESS_APPOINTED, label: '成功转化为新预约', tone: 'emerald', drives: 'completed' },
{ value: ExecutionOutcome.SCHEDULED_NEXT, label: '约定下次回访', tone: 'amber', drives: 'assigned' },
{ value: 'considering', label: '考虑中,近期再跟进', tone: 'amber', drives: 'assigned' },
{ value: 'needs_doctor', label: '需要找医生', tone: 'sky', drives: 'assigned' },
{ value: 'rescheduled', label: '改约', tone: 'sky', drives: 'assigned' },
{ value: ExecutionOutcome.NO_ANSWER, label: '未接通', tone: 'slate', drives: 'active' },
{ value: 'sms_sent', label: '电话未接,已发短信', tone: 'slate', drives: 'active' },
{ value: 'declined_recent', label: '近期不考虑', tone: 'rose', drives: 'abandoned' },
{ value: 'refused', label: '明确拒绝', tone: 'rose', drives: 'abandoned' },
{ value: 'external_treatment', label: '已在外院治疗', tone: 'rose', drives: 'abandoned' },
{ value: 'pending_info', label: '需进一步确认信息', tone: 'slate', drives: 'assigned' },
{ value: 'marked_invalid', label: '标记为无效', tone: 'rose', drives: 'abandoned' },
{ value: ExecutionOutcome.ABANDONED, label: '客服放弃', tone: 'rose', drives: 'abandoned' },
];
export { NOW }; export { NOW };
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { EXECUTION_OUTCOME_META, type ExecutionOutcome } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { tone } from './shared'; import { tone } from './shared';
import type { OutcomeOption } from './mock-data';
/// outcome 选项从 @pac/types EXECUTION_OUTCOME_META 派生(单一真理源,跟后端共享)
/// 改 enum / label / 状态机映射只在 packages/types 一处改,前后端同步生效
const OUTCOME_OPTIONS = (
Object.entries(EXECUTION_OUTCOME_META) as Array<[ExecutionOutcome, (typeof EXECUTION_OUTCOME_META)[ExecutionOutcome]]>
).map(([value, meta]) => ({
value,
label: meta.labelZh,
tone: meta.tone,
drives: meta.drivesStatus,
}));
const CHANNELS = [ const CHANNELS = [
{ {
...@@ -26,23 +37,24 @@ const CHANNELS = [ ...@@ -26,23 +37,24 @@ const CHANNELS = [
}, },
]; ];
/// drivesStatus → 用户口径中文提示
/// completed — 终态(成功转化 / 客户决策放弃跟进)
/// abandoned — 终态(系统层面无效或客服主动放弃)
/// keep — 任务保留等下次跟进(熔断器到上限会自动 abandon)
const STATE_HINTS: Record<string, string> = { const STATE_HINTS: Record<string, string> = {
completed: '本次任务将结案', completed: '本次任务将结案',
abandoned: '本次任务将关闭(放弃)', abandoned: '本次任务将关闭(放弃)',
assigned: '本次任务保留,等下次跟进', keep: '本次任务保留,等下次跟进',
active: '本次任务退回召回池等下次',
}; };
const ABANDON_REASONS = ['号码空号 / 错号', '已转介他人', '明确不需要治疗', '迁居外地', '投诉 / 不愿打扰', '其他']; const ABANDON_REASONS = ['号码空号 / 错号', '已转介他人', '明确不需要治疗', '迁居外地', '投诉 / 不愿打扰', '其他'];
export function OutcomeForm({ export function OutcomeForm({
outcomeOptions,
plan, plan,
onSubmit, onSubmit,
onCreateAppointment, onCreateAppointment,
defaultChannel = 'phone', defaultChannel = 'phone',
}: { }: {
outcomeOptions: OutcomeOption[];
plan: { contactAttempts: number }; plan: { contactAttempts: number };
onSubmit: (data: { onSubmit: (data: {
channel: string; channel: string;
...@@ -61,7 +73,7 @@ export function OutcomeForm({ ...@@ -61,7 +73,7 @@ export function OutcomeForm({
const [abandonReasons, setAbandonReasons] = useState<string[]>([]); const [abandonReasons, setAbandonReasons] = useState<string[]>([]);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const cur = outcomeOptions.find((o) => o.value === outcome); const cur = OUTCOME_OPTIONS.find((o) => o.value === outcome);
const needsScheduledNext = const needsScheduledNext =
!!outcome && ['scheduled_next', 'considering', 'rescheduled', 'pending_info'].includes(outcome); !!outcome && ['scheduled_next', 'considering', 'rescheduled', 'pending_info'].includes(outcome);
const needsAbandonReasons = !!outcome && ['abandoned', 'refused', 'declined_recent'].includes(outcome); const needsAbandonReasons = !!outcome && ['abandoned', 'refused', 'declined_recent'].includes(outcome);
...@@ -127,7 +139,7 @@ export function OutcomeForm({ ...@@ -127,7 +139,7 @@ export function OutcomeForm({
通话结果 <span className="text-rose-500">*</span> 通话结果 <span className="text-rose-500">*</span>
</div> </div>
<div className="grid grid-cols-2 gap-1"> <div className="grid grid-cols-2 gap-1">
{outcomeOptions.map((o) => { {OUTCOME_OPTIONS.map((o) => {
const T = tone(o.tone); const T = tone(o.tone);
const selected = o.value === outcome; const selected = o.value === outcome;
return ( return (
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RefreshCw } from 'lucide-react';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { import {
...@@ -24,7 +25,6 @@ import { ...@@ -24,7 +25,6 @@ import {
mockPlan, mockPlan,
mockSummaries, mockSummaries,
mockScript, mockScript,
outcomeOptions as mockOutcomes,
fmtRel as mockFmtRel, fmtRel as mockFmtRel,
type PlanReason, type PlanReason,
} from './mock-data'; } from './mock-data';
...@@ -42,7 +42,6 @@ export type PlanDetailAppData = { ...@@ -42,7 +42,6 @@ export type PlanDetailAppData = {
facts?: AdaptedFact[]; facts?: AdaptedFact[];
summaries: typeof mockSummaries; summaries: typeof mockSummaries;
script: typeof mockScript; script: typeof mockScript;
outcomeOptions: typeof mockOutcomes;
fmtRel: typeof mockFmtRel; fmtRel: typeof mockFmtRel;
}; };
...@@ -54,18 +53,22 @@ const FALLBACK_DATA: PlanDetailAppData = { ...@@ -54,18 +53,22 @@ const FALLBACK_DATA: PlanDetailAppData = {
facts: [], facts: [],
summaries: mockSummaries, summaries: mockSummaries,
script: mockScript, script: mockScript,
outcomeOptions: mockOutcomes,
fmtRel: mockFmtRel, fmtRel: mockFmtRel,
}; };
export function PlanDetailApp({ export function PlanDetailApp({
data = FALLBACK_DATA, data = FALLBACK_DATA,
banner, banner,
/// W4 末:单患者从 DW 直连刷新成功后,触发外层 usePlanAggregate.refresh()
/// 拉新 plan 详情 — 若 plan 已消失(scenario 不再命中),loader 层 useEffect
/// 会捕获 PLAN_NOT_FOUND 自动跳列表 + toast
onRefreshAggregate,
}: { }: {
data?: PlanDetailAppData; data?: PlanDetailAppData;
banner?: ReactNode; banner?: ReactNode;
onRefreshAggregate?: () => void | Promise<void>;
}) { }) {
const { patient, chains, persona, plan, summaries, script, outcomeOptions, fmtRel } = data; const { patient, chains, persona, plan, summaries, script, fmtRel } = data;
const facts = data.facts ?? []; const facts = data.facts ?? [];
const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null); const [drawerOpen, setDrawerOpen] = useState<DrawerKind>(null);
const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown'); const [scriptMode, setScriptMode] = useState<ScriptViewMode>('markdown');
...@@ -195,7 +198,13 @@ export function PlanDetailApp({ ...@@ -195,7 +198,13 @@ export function PlanDetailApp({
style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }} style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }}
> >
{banner} {banner}
<TopBar plan={plan} reason={reasons[0]!} /> <TopBar
plan={plan}
reason={reasons[0]!}
patientId={patient.id}
onRefreshAggregate={onRefreshAggregate}
showToast={showToast}
/>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<div className="h-full mx-auto px-5 py-3"> <div className="h-full mx-auto px-5 py-3">
...@@ -331,10 +340,24 @@ export function PlanDetailApp({ ...@@ -331,10 +340,24 @@ export function PlanDetailApp({
)} )}
</div> </div>
<AIDisclaimerFooter <AIDisclaimerFooter
onFeedback={(v) => onFeedback={async (v) => {
showToast(v === 'up' ? 'emerald' : 'amber', '感谢反馈', try {
v === 'up' ? '该话术已标记为有用' : '该话术已标记为待改进 · 已上报给 AI 训练') const r = await plansApi.submitScriptFeedback(plan.id, v);
} if (!r.ok) {
// 后端找不到 invocation(纯模板兜底或老数据无 invocation 关联)
showToast('slate', '本话术暂无 AI 调用记录', '兜底模板话术无法评价');
return;
}
showToast(
v === 'up' ? 'emerald' : 'amber',
v === 'up' ? '感谢反馈 · 已标记为有用' : '感谢反馈 · 已标记为待改进',
'用于后续 AI 话术优化',
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showToast('rose', '反馈提交失败', msg.slice(0, 100));
}
}}
/> />
</section> </section>
</main> </main>
...@@ -350,7 +373,6 @@ export function PlanDetailApp({ ...@@ -350,7 +373,6 @@ export function PlanDetailApp({
</header> </header>
<div className="flex-1 min-h-0 overflow-y-auto p-3"> <div className="flex-1 min-h-0 overflow-y-auto p-3">
<OutcomeForm <OutcomeForm
outcomeOptions={outcomeOptions}
plan={effectivePlan} plan={effectivePlan}
onSubmit={submitOutcome} onSubmit={submitOutcome}
defaultChannel={effectivePlan.recommendedChannel} defaultChannel={effectivePlan.recommendedChannel}
...@@ -389,11 +411,46 @@ export function PlanDetailApp({ ...@@ -389,11 +411,46 @@ export function PlanDetailApp({
function TopBar({ function TopBar({
plan, plan,
reason, reason,
patientId,
onRefreshAggregate,
showToast,
}: { }: {
plan: typeof mockPlan; plan: typeof mockPlan;
reason: typeof mockPlan.reasons[0]; reason: typeof mockPlan.reasons[0];
patientId?: string;
onRefreshAggregate?: () => void | Promise<void>;
showToast?: (kind: string, title: string, msg: string) => void;
}) { }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const [refreshing, setRefreshing] = useState(false);
/// W4 末:从 DW 直连重拉该 patient 全量数据 → 触发 persona + plan 重算
/// 完成后调外层 refresh() 拿新 plan;plan 没了(scenario 不再命中)由 loader 层
/// useEffect 捕获 PLAN_NOT_FOUND 自动跳列表
const handleRefresh = async () => {
if (refreshing || !patientId) return;
setRefreshing(true);
try {
const r = await plansApi.refreshPatient(patientId);
const facts = r.factsEmitted ?? 0;
// 用户口径(不暴露 txn / plan 内部计数):
// - facts > 0 → 发现 N 项更新
// - facts = 0 → 已是最新(数据无变化)
const msg = facts > 0
? `发现 ${facts} 项更新`
: '本次同步未发现新数据';
showToast?.('emerald', '已同步该患者最新资料', msg);
// 触发外层 usePlanAggregate refetch — 若 plan 已被 supersede / abandoned,
// /full 返回 PLAN_NOT_FOUND → loader 层 useEffect 跳列表 + toast
if (onRefreshAggregate) await onRefreshAggregate();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showToast?.('rose', '刷新失败', msg.slice(0, 100));
} finally {
setRefreshing(false);
}
};
return ( return (
<header className="flex flex-none items-center justify-between gap-3 border-b border-slate-200 bg-white px-5 py-3"> <header className="flex flex-none items-center justify-between gap-3 border-b border-slate-200 bg-white px-5 py-3">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
...@@ -417,6 +474,24 @@ function TopBar({ ...@@ -417,6 +474,24 @@ function TopBar({
<PriorityBar score={plan.priorityScore} label="优先级" /> <PriorityBar score={plan.priorityScore} label="优先级" />
</div> </div>
<div className="flex flex-none items-center gap-3 text-[11px] text-slate-600"> <div className="flex flex-none items-center gap-3 text-[11px] text-slate-600">
{/* W4 末:从 DW 直连重拉该 patient 最新数据(单患者,跟 daily incremental cursor 隔离)*/}
{patientId && (
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
title={refreshing ? '正在从 DW 拉数据并重算…' : '从 DW 直连重拉该患者最新数据 → 触发画像+召回重算'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-[11.5px] font-medium transition-colors',
refreshing
? 'cursor-not-allowed border-slate-200 bg-slate-50 text-slate-400'
: 'border-slate-200 bg-white text-slate-700 hover:border-teal-300 hover:bg-teal-50 hover:text-teal-700',
)}
>
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
<span>{refreshing ? '同步中…' : '刷新'}</span>
</button>
)}
{/* 保留要素:回收倒计时 */} {/* 保留要素:回收倒计时 */}
<RecycleCountdown recycleAt={plan.recycleAt} /> <RecycleCountdown recycleAt={plan.recycleAt} />
{/* 跟列表页一致:用户信息 + 头像 */} {/* 跟列表页一致:用户信息 + 头像 */}
...@@ -438,7 +513,7 @@ function TopBar({ ...@@ -438,7 +513,7 @@ function TopBar({
function AIDisclaimerFooter({ function AIDisclaimerFooter({
onFeedback, onFeedback,
}: { }: {
onFeedback: (v: 'up' | 'down') => void; onFeedback: (v: 'up' | 'down') => void | Promise<void>;
}) { }) {
const [chosen, setChosen] = useState<'up' | 'down' | null>(null); const [chosen, setChosen] = useState<'up' | 'down' | null>(null);
const click = (v: 'up' | 'down') => { const click = (v: 'up' | 'down') => {
......
...@@ -49,4 +49,26 @@ export const plansApi = { ...@@ -49,4 +49,26 @@ export const plansApi = {
api.get<{ phone: string | null }>( api.get<{ phone: string | null }>(
`/pac/v1/patients/${encodeURIComponent(patientId)}/phone-reveal`, `/pac/v1/patients/${encodeURIComponent(patientId)}/phone-reveal`,
), ),
/** W4 末:话术 thumbs up/down 反馈("本段是否好用")
* - POST /pac/v1/plans/:id/script-feedback { feedback: 'up'|'down' }
* - 写入 agent_invocations.user_feedback;同 invocation 反复点会覆盖以最新为准 */
submitScriptFeedback: (planId: string, feedback: 'up' | 'down') =>
api.post<{ ok: boolean; invocationId?: string; reason?: string }>(
`/pac/v1/plans/${encodeURIComponent(planId)}/script-feedback`,
{ feedback },
),
/** W4 末:单患者按需刷新(从 DW 直连重拉该 patient 全量,触发 persona+plan recompute)
* - POST /sync/patient/:id/refresh
* - 跟 daily incremental cursor 完全隔离(resource='patient_refresh')
* - 返回新 plansCreated:0 = plan 已消失(scenario 不再命中),前端应跳列表 */
refreshPatient: (patientId: string) =>
api.post<{
syncLogId: string;
status: string;
transactionsWritten?: number;
factsEmitted?: number;
plansCreated?: number;
}>(`/pac/v1/sync/patient/${encodeURIComponent(patientId)}/refresh`),
}; };
...@@ -5,7 +5,7 @@ import { Toaster as SonnerToaster } from 'sonner'; ...@@ -5,7 +5,7 @@ import { Toaster as SonnerToaster } from 'sonner';
export function Toaster() { export function Toaster() {
return ( return (
<SonnerToaster <SonnerToaster
position="bottom-right" position="top-center"
richColors richColors
closeButton closeButton
toastOptions={{ toastOptions={{
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
import type { import type {
ExchangeCodeResponse, ExchangeCodeResponse,
MockLoginRequest,
MockLoginResponse,
RefreshTokenResponse, RefreshTokenResponse,
} from '@pac/types'; } from '@pac/types';
import { api } from './api-client'; import { api } from './api-client';
...@@ -21,3 +23,9 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse> ...@@ -21,3 +23,9 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse>
{ auth: false }, { auth: false },
); );
} }
/// Mock login(开发 / 试部署用)— 按 { tenant, role } 预制 user 直产 JWT
/// 生产 host SSO 接入后,后端 env PAC_ENABLE_MOCK_LOGIN=false 关闭,此调用会返 10107
export async function mockLogin(req: MockLoginRequest): Promise<MockLoginResponse> {
return api.post<MockLoginResponse>('/pac/v1/auth/mock-login', req, { auth: false });
}
# PAC 部署运维
> W4 末:试部署阶段手动 ssh + 跑脚本;W5+ 接 CI/CD 自动化。
## 文件清单
| 文件 | 用途 |
|---|---|
| `deploy.sh` | 一键部署 — `bash deploy.sh staging|production` |
| `systemd/pac-service.service` | NestJS 后端 systemd unit 模板 |
| `systemd/pac-web.service` | Next.js 前端 systemd unit 模板 |
`docs/deployment-data-ingest.md` 配合看 — 那份讲数据 / 监控,本目录讲部署机制。
---
## 首次部署 SOP(staging / production 通用)
### 1. 服务器基础环境
```bash
# Ubuntu 22.04 假设
sudo apt update && sudo apt install -y curl git docker.io
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm i -g pnpm@9
# 创建专用账号
sudo useradd -m -s /bin/bash pac
sudo mkdir -p /var/log/pac && sudo chown pac:pac /var/log/pac
```
### 2. 起 Postgres + Redis(docker 跑法,简单部署够用)
```bash
sudo docker run -d --name pac-postgres --restart unless-stopped \
-p 127.0.0.1:5432:5432 \
-e POSTGRES_USER=pac \
-e POSTGRES_PASSWORD=<强密码> \
-e POSTGRES_DB=pac \
-v pac-pg-data:/var/lib/postgresql/data \
postgres:15
sudo docker run -d --name pac-redis --restart unless-stopped \
-p 127.0.0.1:6379:6379 \
-v pac-redis-data:/data \
redis:7-alpine
```
生产建议用托管 RDS / 云 Redis(本地 docker 数据风险)。
### 3. 拉代码 + 配 env
```bash
sudo mkdir -p /opt/pac && sudo chown pac:pac /opt/pac
sudo -u pac git clone <your-repo> /opt/pac
# 后端 env
sudo -u pac cp /opt/pac/apps/pac-service/.env.example /opt/pac/apps/pac-service/.env
sudo -u pac vim /opt/pac/apps/pac-service/.env
# → 改 NODE_ENV / DATABASE_URL / REDIS_URL / JWT_* / DW_* / DEEPSEEK_API_KEY
# → staging 还要 PAC_ENABLE_MOCK_LOGIN=true / PAC_INCREMENTAL_CRON=30 3 * * *
# → production 要 PAC_ENABLE_MOCK_LOGIN=false / 自己的 prod 配置
sudo chmod 600 /opt/pac/apps/pac-service/.env
# 前端 env(NEXT_PUBLIC_API_BASE_URL build-time 嵌入)
sudo -u pac vim /opt/pac/apps/pac-web/.env
# NEXT_PUBLIC_API_BASE_URL=https://pac-staging.your-domain.com
# (staging / prod 必须各 build 一次,产物不可互换)
```
### 4. 安装 systemd unit
```bash
sudo cp /opt/pac/deploy/systemd/pac-service.service /etc/systemd/system/
sudo cp /opt/pac/deploy/systemd/pac-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable pac-service pac-web
```
允许 `pac` 用户 systemctl restart 这两个 service(deploy.sh 需要):
```bash
sudo visudo -f /etc/sudoers.d/pac
# 加一行:
# pac ALL=(root) NOPASSWD: /bin/systemctl restart pac-service, /bin/systemctl restart pac-web
```
### 5. 首次部署
```bash
sudo -u pac bash /opt/pac/deploy/deploy.sh staging
# 或
sudo -u pac bash /opt/pac/deploy/deploy.sh production
```
### 6. 验证
```bash
curl http://localhost:3001/health # 应返 {"status":"ok"} 或类似
curl http://localhost:3000 # Next.js 首页 HTML
journalctl -u pac-service -n 50 -f # 看日志
journalctl -u pac-web -n 50 -f
```
浏览器开 `https://<your-domain>/plans` — staging 应看到"快速登录"对话框。
---
## 日常部署(代码改了重新部署)
```bash
ssh deploy@<server>
sudo -u pac bash /opt/pac/deploy/deploy.sh staging
```
5-10 分钟完成,期间 service 短暂中断(restart 瞬时)。零停机部署需要 blue-green,W5+ 上。
---
## 数据冷启 / 首次 DW 灌库
```bash
sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm cold-import -- --dir=./data/jvs-dw'
# 生产首跑前:务必把 manifest.yaml 里 cohort 的 LIMIT 100 OFFSET 100 去掉(13 万患者全量)
```
完成后:
```bash
sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm recompute-persona -- --host=jvs-dw'
sudo -u pac bash -c 'cd /opt/pac/apps/pac-service && pnpm recompute-plans -- --host=jvs-dw'
```
之后 cron(02:30)自动维护增量,见 `docs/deployment-data-ingest.md` §三。
---
## 回滚
```bash
cd /opt/pac
git log --oneline -10 # 看历史
git checkout <last-known-good-sha>
sudo -u pac bash /opt/pac/deploy/deploy.sh staging # 同 deploy 流程,只是 git pull 被 checkout 替换
```
⚠️ **DB schema 不会回退** — Prisma migration 是只前向,回滚老代码可能跟新 schema 不兼容。
真要回滚 schema 得手写补 migration。生产 schema 变更前评估前向兼容。
---
## 监控 / 告警
详见 `docs/deployment-data-ingest.md` §四。关键:
- `journalctl -u pac-service -f | grep -E "ERROR|dw-lag"` 看错误 + 滞后告警
- `psql -U pac -d pac -c "SELECT status, count(*) FROM sync_logs WHERE started_at > now()-'1d'::interval GROUP BY status;"` 看同步成功率
- `/admin/queues` Bull Board 看队列
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════════════
# PAC 一键部署脚本(staging / production 通用)
#
# 用法(在服务器上):
# sudo -u pac bash /opt/pac/deploy/deploy.sh staging
# sudo -u pac bash /opt/pac/deploy/deploy.sh production
#
# 前置准备(首次):
# 1. 服务器装 node 20+ / pnpm 9+ / docker / git
# 2. git clone <repo> /opt/pac
# 3. cp /opt/pac/apps/pac-service/.env.example /opt/pac/apps/pac-service/.env
# → vim 改:NODE_ENV / DATABASE_URL / REDIS_URL / JWT_* / DW_* / DEEPSEEK_*
# → chmod 600 .env
# 4. cp /opt/pac/apps/pac-web/.env.example /opt/pac/apps/pac-web/.env (NEXT_PUBLIC_*)
# 5. docker run -d pac-postgres / pac-redis(或独立托管实例)
# 6. systemctl enable pac-service pac-web(systemd unit 见 deploy/systemd/)
#
# 部署流程:
# git pull → install → build types → migrate deploy → build service+web → restart
#
# 安全:
# - 不会跑 prisma migrate dev / reset(生产数据安全)
# - 不会改 .env(只读)
# - 失败任一步立刻停(set -e)
# ═══════════════════════════════════════════════════════════════════════
set -euo pipefail
ENV="${1:-}"
if [[ "$ENV" != "staging" && "$ENV" != "production" ]]; then
echo "❌ 必须指定环境: bash $0 staging|production"
exit 1
fi
# 锁:防并发部署
LOCK="/tmp/pac-deploy.lock"
if [[ -e "$LOCK" ]]; then
echo "❌ 另一次部署正在跑(/tmp/pac-deploy.lock 存在)。如确定无并发,rm $LOCK 后重试"
exit 1
fi
touch "$LOCK"
trap 'rm -f "$LOCK"' EXIT
REPO_DIR="${PAC_REPO_DIR:-/opt/pac}"
cd "$REPO_DIR"
echo "═══════════════════════════════════════════════════════════"
echo "▶ PAC 部署 — 环境: $ENV / repo: $REPO_DIR / $(date '+%F %T')"
echo "═══════════════════════════════════════════════════════════"
# ─── 1. git pull ───
echo "▶ 1/7 git pull"
BRANCH=$([[ "$ENV" == "production" ]] && echo "main" || echo "main") # 都拉 main;后续可拆 staging 分支
git fetch --quiet
git checkout "$BRANCH"
BEFORE_HEAD=$(git rev-parse --short HEAD)
git pull --ff-only
AFTER_HEAD=$(git rev-parse --short HEAD)
echo " $BEFORE_HEAD$AFTER_HEAD"
# ─── 2. 依赖 ───
echo "▶ 2/7 pnpm install"
pnpm install --frozen-lockfile
# ─── 3. types 包 ───
echo "▶ 3/7 build @pac/types"
pnpm --filter @pac/types build
# ─── 4. DB migrations(只 deploy,不 reset)───
echo "▶ 4/7 prisma migrate deploy"
cd apps/pac-service
pnpm exec prisma generate
pnpm exec prisma migrate deploy
cd "$REPO_DIR"
# ─── 5. 编译后端 ───
echo "▶ 5/7 build @pac/service"
cd apps/pac-service
rm -f tsconfig.tsbuildinfo # 防 incremental cache 不输出 dist
pnpm build
cd "$REPO_DIR"
# ─── 6. 编译前端(NEXT_PUBLIC_* 必须 build-time 嵌入,所以每个环境独立 build)───
echo "▶ 6/7 build @pac/web"
pnpm --filter @pac/web build
# ─── 7. 重启服务 ───
echo "▶ 7/7 restart services"
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl restart pac-service
sudo systemctl restart pac-web
sleep 3
systemctl is-active pac-service && echo " ✓ pac-service active"
systemctl is-active pac-web && echo " ✓ pac-web active"
else
# 兜底:pm2 / 裸 node
echo " ⚠️ 未装 systemctl,需要手动重启 pac-service / pac-web"
exit 1
fi
echo
echo "═══════════════════════════════════════════════════════════"
echo "✓ 部署完成 — $AFTER_HEAD ($(date '+%F %T'))"
echo "═══════════════════════════════════════════════════════════"
echo "验证:"
echo " curl http://localhost:3001/health"
echo " 打开 https://<your-domain>/plans"
[Unit]
Description=PAC Service (NestJS API + Cron + BullMQ Worker)
After=network.target postgresql.service redis.service
[Service]
Type=simple
User=pac
Group=pac
WorkingDirectory=/opt/pac/apps/pac-service
EnvironmentFile=/opt/pac/apps/pac-service/.env
ExecStart=/usr/bin/node --enable-source-maps dist/main.js
Restart=on-failure
RestartSec=5s
StandardOutput=append:/var/log/pac/pac-service.log
StandardError=append:/var/log/pac/pac-service.error.log
# 安全加固
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target
[Unit]
Description=PAC Web (Next.js production)
After=network.target pac-service.service
[Service]
Type=simple
User=pac
Group=pac
WorkingDirectory=/opt/pac/apps/pac-web
EnvironmentFile=/opt/pac/apps/pac-web/.env
ExecStart=/usr/bin/pnpm start
Restart=on-failure
RestartSec=5s
StandardOutput=append:/var/log/pac/pac-web.log
StandardError=append:/var/log/pac/pac-web.error.log
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target
...@@ -13,12 +13,12 @@ ...@@ -13,12 +13,12 @@
"state": { "state": {
"type": "markdown", "type": "markdown",
"state": { "state": {
"file": "algorithm/potential-treatment-recall-flow.md", "file": "deployment-data-ingest.md",
"mode": "source", "mode": "source",
"source": false "source": false
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "potential-treatment-recall-flow" "title": "deployment-data-ingest"
} }
} }
] ]
...@@ -186,9 +186,9 @@ ...@@ -186,9 +186,9 @@
}, },
"active": "5c26df11b9d3d65c", "active": "5c26df11b9d3d65c",
"lastOpenFiles": [ "lastOpenFiles": [
"algorithm/potential-treatment-recall-flow.md",
"dw-data-source-issues.md", "dw-data-source-issues.md",
"db-suggest-after-review.md", "db-suggest-after-review.md",
"algorithm/potential-treatment-recall-flow.md",
"algorithm/potential-treatment-recall.md", "algorithm/potential-treatment-recall.md",
"algorithm/canonical-fact-layer.md", "algorithm/canonical-fact-layer.md",
"host-data-request-checklist-v2.md", "host-data-request-checklist-v2.md",
......
# PAC 服务器部署 + 数据摄入手册
> **W4 起试部署:服务器架构 + 数据从 DW 一次冷启 → 日级增量自动跑 → 客服可见的全链路 SOP**
> **目标读者**:运维 / 后端值班
> **状态**:🟢 W4 末投产基线版
---
## 一、服务架构(单机最小版)
```
┌────────────────────────────────────────────────────────────────┐
│ Server(2 核 8G 起步,生产建议 4 核 16G + SSD) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Postgres 15 │ │ Redis 7 │ │ pac-web │ │
│ │ :5432 │ │ :6379 │ │ :3000 │ Next.js │
│ │ pac DB │ │ BullMQ / │ │ │ prod build│
│ │ │ │ session │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ │ │
│ ┌───────▼────────┐ │
│ │ pac-service │ NestJS │
│ │ :3001 │ - HTTP API │
│ │ │ - BullMQ Worker │
│ │ │ - Cron(增量同步/监控) │
│ └───────┬────────┘ │
└──────────────────────────┼─────────────────────────────────────┘
│ TCP/9000(ClickHouse 协议)
┌────────────────────────┐
│ 瑞尔 DW(阿里云 ADS) │
│ cc-xxx.clickhouse.aliyuncs.com:9000 │
│ dw_group.fact_*_out │
└────────────────────────┘
```
### 组件清单
| 组件 | 端口 | 用途 | 数据敏感度 |
|---|---|---|---|
| Postgres 15 | 5432 | PAC 主数据(patients / facts / personas / plans / sync_logs) | 🔴 PII |
| Redis 7 | 6379 | BullMQ 队列 + 会话 | 🟡 session token |
| pac-service | 3001 | API + Cron + Worker | — |
| pac-web | 3000 | 前端(Next.js) | — |
| (远程)瑞尔 DW | 9000 | 数据源(只读),PAC 主动连 | 🔴 PII(只读) |
**NOT 部署在 PAC 这边**:
- 瑞尔 DW(host 那边维护)
---
## 二、首次部署 SOP
### 2.1 环境准备
```bash
# 1. Docker / Docker Compose
docker --version # >= 24
docker compose version
# 2. Node.js 跟 pnpm(若不用 docker 跑 pac-service)
node --version # >= 20
pnpm --version # >= 9
```
### 2.2 拉代码 + 配 env
```bash
git clone <repo> /opt/pac && cd /opt/pac
cp apps/pac-service/.env.example apps/pac-service/.env
```
**必填 env**(`apps/pac-service/.env`):
```ini
# ─── 基础 ───
NODE_ENV=production
PORT=3001
LOG_LEVEL=info
DATABASE_URL=postgresql://pac:<pwd>@localhost:5432/pac?schema=public
REDIS_URL=redis://localhost:6379
JWT_SECRET=<openssl rand -hex 32>
JWT_REFRESH_SECRET=<openssl rand -hex 32>
CORS_ORIGINS=https://<your-pac-web-domain>
# ─── DW 直连 ───
JVS_DW_CLICKHOUSE_PASSWORD=<DW 团队给的密码>
# manifest.yaml 里 password_env: JVS_DW_CLICKHOUSE_PASSWORD
# ─── AI(DeepSeek)───
DEEPSEEK_API_KEY=<vendor 给的 key>
DEEPSEEK_BASE_URL=https://api.deepseek.com
AI_DEFAULT_MODEL=deepseek-v4-flash
# ─── 增量同步定时 ───
PAC_INCREMENTAL_CRON=30 2 * * * # 每天 02:30(DW 02:00 刷完后)
PAC_INCREMENTAL_HOSTS=jvs-dw # 多 host 用逗号分隔
PAC_INCREMENTAL_DATA_DIR=/opt/pac/apps/pac-service/data
# ─── 数据滞后告警阈值 ───
PAC_LAG_WARN_HOURS=24 # > 24h 黄色 WARN
PAC_LAG_ERROR_HOURS=48 # > 48h 红色 ERROR
```
### 2.3 起 Postgres + Redis
```bash
docker run -d --name pac-postgres -p 5432:5432 \
-e POSTGRES_USER=pac -e POSTGRES_PASSWORD=<pwd> -e POSTGRES_DB=pac \
-v pac-pg-data:/var/lib/postgresql/data postgres:15
docker run -d --name pac-redis -p 6379:6379 \
-v pac-redis-data:/data redis:7-alpine
```
### 2.4 构建 + 启动
```bash
cd /opt/pac
pnpm install --frozen-lockfile
pnpm --filter @pac/types build
pnpm --filter @pac/service exec prisma migrate deploy
pnpm --filter @pac/service exec prisma seed # 写入 demo / jvs-dw host 行
pnpm --filter @pac/service build # 编译 NestJS
pnpm --filter @pac/web build # Next.js prod build
# 起 pac-service(systemd / pm2 二选一)
cd apps/pac-service && node --enable-source-maps dist/src/main &
# 起 pac-web
cd apps/pac-web && pnpm start &
```
---
## 三、数据摄入 SOP
### 3.1 首次冷启动(Cold Import)— 把全量历史拉进 PAC
**用途**:首次部署 / 重建库 / yaml 改大版本时
**配置**:`apps/pac-service/data/jvs-dw/manifest.yaml``sql_source.queries` 各表 SQL。Dev 期间带 `LIMIT 100 OFFSET 100` 控量,**生产首跑前必须去掉 LIMIT**
```bash
cd /opt/pac/apps/pac-service
pnpm cold-import -- --dir=./data/jvs-dw
# → 患者 100% 拉 / facts 全量 / 各表去重
# 预估:13 万 patients ≈ 10-30 min,内存峰值 ~2GB
```
**verify**:
```sql
SELECT 'patients' AS t, count(*) FROM patients
UNION ALL SELECT 'facts', count(*) FROM patient_facts
UNION ALL SELECT 'failed_in_sync_log', failed FROM sync_logs ORDER BY started_at DESC LIMIT 1;
-- failed 应当 = 0
```
冷启完成后,**手动触发一次 persona + plan**:
```bash
pnpm recompute-persona -- --host=jvs-dw
pnpm recompute-plans -- --host=jvs-dw
```
### 3.2 日级增量(Incremental Sync)— 自动跑,无需人工
**机制**:`SyncIncrementalSchedulerService` `@Cron` 注解,服务起来自动注册。
```
每天 02:30(可改 PAC_INCREMENTAL_CRON env)
ColdImportService.importDirectory({ incremental: true })
- 读 sync_logs 上次 cursor_after → SQL 注入 WHERE updated_date > '...'
- 拉到新/改的 fact 行 + 反向拉主档(C 方案)+ stub auto-create(A 方案)
- 写新 cursor 到 sync_logs
Persona recompute(本次涉及的 distinct patient)
Plan recompute(per tenant — SQL 召回 + 6 因子打分)
Sync log status=success,带每表 cursor_after JSON
```
**结果可见**:DW 今天的新数据 → 明天早晨 03:00 ± 客服工作台可见。
**手动触发**(debug / 紧急补跑):
```bash
pnpm sync-incremental -- --dir=./data/jvs-dw
# 跳过 persona/plan(只拉数据):
pnpm sync-incremental -- --dir=./data/jvs-dw --no-recompute
# dry-run(不写库,看 SQL cursor 注入预览):
pnpm sync-incremental -- --dir=./data/jvs-dw --dry-run
```
### 3.3 数据完整性保证 — A+C 双修
**A:Patient Stub Auto-Create**
- 增量拉 fact 但 patient 主档 cursor 没动 → 用 `(hostId, tenantId, externalId)` 三段建空 stub
- 真实主档 upsert 时填上姓名/电话(W4 加固)
**C:反向拉主档**
- 每次跑完 fact 表,收集 `(patient_id, brand)` 集合 → 追加 `WHERE (patient_id, brand) IN (...)` 强拉主档
- 跟 fact 同 run,主档跟事实 100% 同步
**实测**(W3 末):
- 修前:51% transactions patient_id 为 null
- 修后:**0%** patient_id 为 null
---
## 四、监控 / 告警
### 4.1 DW 数据滞后监控
`DwLagMonitorService` `@Cron(HOURLY)` 自动跑:
```
读最近一次 incremental_bundle.cursor_after JSON
算 max(cursor) vs now() 差 (小时)
🟢 < 24h LOG info
🟡 24-48h LOG warn (DW 没刷新,提醒值班)
🔴 > 48h LOG ERROR (严重滞后,事件可能漏召)
```
阈值改 env:`PAC_LAG_WARN_HOURS` / `PAC_LAG_ERROR_HOURS`
**接告警通道**(W5+ TODO):log ERROR 后接 webhook → 钉钉/飞书。当前阶段值班看 `journalctl -u pac-service | grep dw-lag` 即可。
### 4.2 BullMQ Bull Board(看队列健康度)
`/admin/queues` 路径(需登录 admin),实时看:
- persona-recompute / plan-recompute / plan-asset-generate 三队列
- 各 worker 处理速度 / 失败重试 / 死信
### 4.3 关键 SQL 检查
```sql
-- 最近一次 sync 结果(应当 success / failed=0)
SELECT to_char(started_at,'YYYY-MM-DD HH24:MI') AS t, resource, status, fetched, failed, error_message
FROM sync_logs ORDER BY started_at DESC LIMIT 5;
-- 数据规模
SELECT 'patients' AS k, count(*) FROM patients
UNION ALL SELECT 'facts', count(*) FROM patient_facts
UNION ALL SELECT 'active_plans', count(*) FROM followup_plans WHERE status IN ('active','assigned');
-- 数据滞后(跟 monitor 一致)
SELECT to_char(started_at,'HH24:MI') AS sync_t, cursor_after
FROM sync_logs WHERE resource='incremental_bundle' AND status='success'
ORDER BY started_at DESC LIMIT 1;
```
---
## 五、运维常见操作
| 场景 | 命令 |
|---|---|
| **重启 pac-service** | `systemctl restart pac-service`(或 pm2 reload pac-service) |
| **手动补跑昨日增量** | `pnpm sync-incremental -- --dir=./data/jvs-dw` |
| **某 patient 召回不对,临时单刷** | `pnpm recompute-plans -- --host=jvs-dw`(全 host)/ 详情页 "刷新" 按钮(单 patient) |
| **看某 patient 治疗链** | `pnpm exec ts-node src/cli/verify-chain.cli.ts --id=<uuid>` |
| **改 yaml/scenario 后强制重算 plan** | 删旧 plan: `TRUNCATE followup_plans CASCADE;` + `pnpm recompute-plans -- --host=jvs-dw` |
| **改 yaml 后强制 reparse 旧数据** | (task #46 待做)目前只能 truncate facts + 重 cold-import |
---
## 六、Yaml 变更管理 SOP
数据/术语 yaml 改了(`apps/pac-service/data/<host>/`)?
| 改动类型 | 影响 | 处理 |
|---|---|---|
| **enum_mapping 加 host 术式** | 新数据生效,老数据需要 reparse | 配 task #46 / 暂时 truncate facts 重导 |
| **field_mapping 改字段名** | 同上 | 同上 |
| **transforms 改 split/derive** | 影响管道 | 同上 |
| **scenario SQL 改召回口径** | 立即生效(下次 cron 跑就用) | 删旧 plan + 等 cron 或手动 recompute-plans |
| **canonical-codes.ts 改字典** | 立即生效(chain composer 用) | 重启 pac-service |
---
## 七、灾备 / 回滚
### 7.1 数据备份
```bash
# 每天凌晨 04:00 备份(增量 03:30 完成后)
pg_dump -U pac -d pac -F c -f /backup/pac-$(date +%F).dump
# 保留 7 天
find /backup -name 'pac-*.dump' -mtime +7 -delete
```
### 7.2 回滚代码版本
```bash
cd /opt/pac && git fetch && git checkout <last-known-good-tag>
pnpm install && pnpm --filter @pac/types build && pnpm --filter @pac/service build
systemctl restart pac-service
```
### 7.3 cursor 倒退(数据要重跑某窗口)
```sql
-- 把最近一次 sync_log 的 cursor_after 改到目标时间
UPDATE sync_logs SET cursor_after = '{"fact_emr_treatment_out":"2026-04-01",...}'
WHERE id = (SELECT id FROM sync_logs WHERE resource='incremental_bundle' AND status='success' ORDER BY started_at DESC LIMIT 1);
```
下次 cron 自动从这个 cursor 拉。
---
## 八、当前已知边界(W4 末)
| 项 | 现状 | 影响 | 何时解决 |
|---|---|---|---|
| 首跑全量 cohort LIMIT 100 | dev manifest 里写死 LIMIT | 生产首跑必须去掉 LIMIT(可能 30 min 跑 13 万 patient) | 部署前手动改 |
| sync-incremental reparse 模式缺 | yaml 改后历史数据需要 truncate 重导 | 改 yaml 不能"原地补"老 facts | task #46(W5+) |
| 告警接通道 | log ERROR + 人工看 | 滞后无即时告警 | W5+ 加 webhook 钉钉/飞书 |
| 多 host 部署 | 支持(`PAC_INCREMENTAL_HOSTS=jvs-dw,friday`)| — | 已就绪 |
| 单机 vs 集群 | 当前单机 | 高 QPS 时 pac-service 可水平扩展(无状态) | 试点期不需要 |
---
## ▎一句话归纳
> **PAC 部署 = Postgres + Redis + pac-service + pac-web 四件套。数据摄入 = 首次 cold-import(一次性)+ 日级 sync-incremental(自动 cron)。监控 = lag monitor + Bull Board + 日常 SQL 检查。**
# 疗效保障(PAC)项目 W3 进展报告
> **报告周期**:第 3 周(W3)
> **汇报路径**:PAC(luoqi)→ CTO(于总)→ 管委会
> **报告人**:luoqi
> **状态**:🟢 W3 目标 100% 达成 + W4(DW 直连增量)提前到位
---
## Page 1 · 一句话 + 里程碑路线
### ▎本周一句话
> **PAC 已直接连瑞尔 DW 真实数据跑通完整闭环 — 100 个真实患者进来,自动算出 25 条召回任务 + 病史链 + AI 话术,业务方可现场点评。W4 起做客服工作台试点对接。**
### ▎答复
| 问 | 答 |
|---|---|
| W3 承诺达到没? | ✅ 全部达成 — 真实(脱敏)数据进 PAC、潜在新链召回出来、话术按医生主诉量身定 |
| W4 承诺呢? | ⭐ DW 直连增量管道已建好,等明天 DW 那边日级刷新启动即可上线 |
| 还差什么才能给试点? | 5i5ya 前端联系人 1 人(W5 把 iframe 嵌进去 2 小时) + 1 家试点 3-5 名客服(W5 末联调半天) |
### ▎6 周走向终态(从 W6 倒推)
```
W6 ⭐ 1 家试点诊所端到端演示(真实患者 / 真实客服 / 真实回写 / 真实营收对照)
↑ 需要:客服培训完成 + 主管 dashboard 上线
W5 客服工作台嵌入 5i5ya(iframe)+ 1 家试点客服联调
↑ 需要:5i5ya 前端联系人就位
W4 客服工作台对接试点 + DW 真实日增量自动刷新数据(本周已 ready,等 DW 启动)
↑ 需要:DW 团队明天起每日全量刷新落地
W3 ✅ 真实数据 demo + 潜在新链召回 + AI 话术 + 治疗链 5 阶段可视化(本次报告)
W2 ✅ 数据接入文档 + 演示能力提前到位 + 召回算法策略落地
W1 ✅ 框架定稿 + 60+ 评审 closure
```
### ▎本周必须推动(求 CTO 协调)
```
🆘 1. DW 团队"每日全量刷新"启动(明天预定)
- 现状:DW 当前数据滞后 6 天(每周左右刷一次),不影响算法但影响实时性
- 影响:启动后客服当天打的电话当天能看到结果
- PAC 这边的增量管道已就位,DW 那边按时启动即可
🆘 2. 5i5ya 前端联系人确认(W5 启动前)
- 用量:2 小时 — 贴 iframe + 配置一次性 code 换 token
- 影响:不到位 W5/W6 试点跑不通,只能停在 PAC 自己的演示页
```
---
## Page 2 · 现场 Demo · 真实患者跑出来的潜在新链召回
> **演示对象**:从瑞尔 DW 100 个真实患者中按规则筛出的 25 个召回案例,任选一例
> **典型样例**:姚嘉萍 / 王永明 / 张澜(都是真实患者,脱敏展示)
> **召回类型**:潜在新链发现(诊断 → 未启动治疗)
### ▎管委会可现场验证的 6 项能力
| # | 能力 | 真实数据下的表现 |
|---|---|---|
| 1 | **真实数据进得来** | 瑞尔 DW(13 万患者池)按 cohort 拉 100 个真实患者 → 6215 条事实写入 PAC、0 失败 |
| 2 | **潜在新链发现准** | 100 个真实患者中 25 个被精准召回:K08 缺牙未做种植 / K04 牙髓未做根管 / K07 错颌未启动正畸 等 5 个子场景全跑通 |
| 3 | **治疗链 5 阶段** | 每个患者的所有治疗链按"发现 → 进入 → 执行 → 复查 → 闭环"5 阶段可视化(陈化冰、季根财、范萍莉等老人多链 case 都正确展示) |
| 4 | **AI 话术按事实定制** | 话术 4 段(开场 / 切入话题 / 异议处理 / 结束确认),自动喂入真实主诊医生名 / 诊所名 / 患者称呼 / 友好牙位(不会说"FDI 36 号牙",会说"右下后牙") |
| 5 | **多品牌隔离** | 瑞尔 / 瑞泰双品牌跨同一 DW,患者 ID 跨品牌重复也自动按 tenant 隔离,A 客服看不到 B 品牌的患者 |
| 6 | **召回准度可解释** | 每条召回都展示"为什么召":诊断码 + 时间窗 + 价值评分 + 不召的理由(冷静期 / 已拔除 / 已进入治疗链) — 业务方能针对每一条评对错 |
### ▎跟 W2 demo(张志远虚构样例)的区别
```
W2 demo: W3 demo:
虚构患者 + Mock 数据 100 个瑞尔真实患者
PAC 后端无业务数据 DW 直连 ClickHouse,数据 100% 真实
靠人脑判断 demo 是否合理 业务方按真实案例点对错(可挑刺、可反对)
1 个固定演示样例 25 个真实召回任意挑(包括边界 case)
```
**演示要点**:这次 demo 完全是"指着真实诊所的真实患者看 PAC 怎么判的",不再是"理论上能这样做"。
---
## Page 3 · W3 交付清单(说结果)
### ▎对照 W2 承诺,W3 完成度 100%
| W2 承诺 W3 要做的 | 状态 | 实际成果 |
|---|---|---|
| 真实(脱敏)患者数据进 PAC | ✅ | 直接连瑞尔 DW,100 真实患者跑通 |
| PAC 自动识别"潜在新链" | ✅ | 25 条召回(5 个子场景全覆盖:种植 / 根管 / 充填 / 牙周 / 正畸) |
| 业务方可点评 | ✅ | 每条召回有完整可解释链,业务方按真实姓名讨论 |
| 召回任务进工作台 + 详情可点开 | ✅ | 工作台 / 详情 / 话术 / 治疗链 / 时间轴 全跑通 |
| W4 DW 直连方案确认 | ⭐ 提前 | 不只确认方案,**直接把 DW 增量管道做完了**(等 DW 启动每日刷新即上线) |
### ▎W3 期间业务能力提升要点(管委会角度)
| 能力 | W2 末 → W3 末 |
|---|---|
| 数据源 | mock 数据 → **瑞尔 DW 真实 100 患者**(可扩到全量 13 万) |
| 治疗链 | 简单 3 状态(发现 / 在管 / 闭环)→ **5 阶段精细判定**(每条链清楚指出"待哪一步""为什么没闭环") |
| 召回算法 | 1 个子场景 → **5 个子场景**(种植 / 根管 / 充填 / 牙周 / 正畸都召) + 4 道排除闸(冷静期 / 已拔牙 / 已预约 / 已进入治疗链 → 噪音 0) |
| AI 话术 | 模板拼接(机器人感)→ **大模型按患者事实生成 4 段 markdown**(看到诊所名 / 主诊医生 / 友好牙位) |
| 数据时效性 | 一次性导入 → **DW 增量管道就绪**(等 DW 启动日刷新即秒级跟新) |
### ▎W3 期间发现并解决的真实临床问题(业务方关心)
> 这些 case 来自真实患者数据,跑出来发现的边界,W3 一边跑一边修
| 真实 case | 临床问题 | PAC 处理 |
|---|---|---|
| 季根财(77 岁老人) | 全口拔得差不多,诊断后被拔的牙仍然在召回 → 客服会打扰 | ⑤c 闸:同牙位有拔牙 actual → 整条信号排除,**他从召回池消失** |
| 林菲菲(已预约正畸) | 已经预约了正畸方案沟通,但仍然在 K07 召回列表里 | ⑤d 闸:诊断后有"正畸"主诉的预约 → 视为已进入治疗链,**不重复召回** |
| 杨明昊(K04 做了部分牙髓切断术) | 临床上算完成治疗(VPT)但被误判"未做根管" | 加 pulpotomy 为正畸合法终末术式,**链直接判闭环** |
| 范萍莉(实际有 6 次种植) | host 字段叫"简单种植术",PAC 字典原来没收 → 看不到 | 字典补 100+ 高频术式,**真种过的患者不再误报** |
| 罗国标(14/15 已拔已种) | 旧的召回列表还有 K04 根管召回 | DW 数据流转后自动 supersede,**SQL 算 ⑤c 不再召** |
**业务方价值**:这些都是"如果不上真实数据永远发现不了的 bug",W3 跑通真实数据 = PAC 算法在临床上真正可信。
---
## Page 4 · 里程碑预告(W4 → W6)
### ▎W4 末(下次报告)· 试点对接启动 + DW 日增量自动跑
```
🎯 给管委会看
1. DW 增量管道日级运行 — 今天的真实数据,明天早晨 PAC 自动可见
2. 1 家试点诊所的真实召回名单(数据规模 ×N)+ 主管/客服角色权限分离
3. 召回准度跟踪指标(本周召了多少 / 重复了多少 / 排除了多少)
4. 5i5ya 前端联系人 / iframe 嵌入方案最终确认
🎯 需要资源到位
- DW 团队启动每日全量刷新(明天预定)
- 5i5ya 前端联系人(2 小时贴 iframe)
- 试点诊所联系人确认(1 家先行)
```
### ▎W5 末 · 客服工作台正式嵌入 5i5ya + 联调
```
🎯 给管委会看
- 客服在 5i5ya 自己界面里看 PAC 召回任务(单点登录,无感跳转)
- 3-5 名客服真实使用,打第一批电话,实际回写结果
- 客服反馈"算得准不准 / 顺不顺手 / 话术地不地道"
```
### ▎W6 末 ⭐ · 1 家试点诊所端到端演示
```
🎯 给管委会看
- 累计召回 N 单 / 触达 M 单 / 成功转化 K 单
- 转化患者带来的实际营收(对照基线)
- 主管 dashboard:团队执行效果可视化
- 业务方最终 GO / NO-GO 决策依据
```
---
## Page 5 · 风险 + 依赖
### ▎本周新发现 / 升级的风险
| # | 风险 | 等级 | 现状 | 求 CTO |
|---|---|---|---|---|
| 1 | **DW 数据新鲜度** | 🟢 已收敛 | 当前 DW 数据滞后 6 天;PAC 这边的增量管道已就绪,等明天 DW 启动日刷新 | 跟 DW 团队确认"明天起每日全量刷新"按时上 |
| 2 | **5i5ya 前端联系人** | 🟡 中 | W5 启动前必到位 | 本周内指定联系人 |
| 3 | **试点诊所联系人** | 🟡 中 | W4 末选 1 家先行 | 业务方建议选 |
| 4 | **召回准度业务方校准** | 🟢 已规避 | W3 跑真实数据已发现 5 类边界并修复(见 Page 3) | 持续 — W4 加场景化抽样审查 |
| 5 | **持续维护字典 / scenario** | 🟢 已规避 | 新增 host 术式 / 新临床场景按需 PR,流程顺畅 | 无 |
### ▎不报喜不报忧(W3 实际遇到的问题 + 怎么解的)
| 问题 | 现象 | 处理 |
|---|---|---|
| host 数据有隐藏 upsert 行为 | 同一行预约从"已约"到"已到诊"会被 DW 直接覆盖 | PAC 用 `updated_date` 做幂等键,host 每次 UPDATE 当新事件处理(版本流) |
| 真实临床有"姑息治疗"边界 | 部分牙髓切断术算不算"治完了"? | 引入"终末术式"概念,临床合法终末算闭环;字典持续扩 |
| 老人全口拔牙工作流 | 诊断后被拔的牙还在召回 → 骚扰 | ⑤c 同牙位 surgical 排除 |
| 已进入治疗链的患者还在新链召回 | 林菲菲预约了正畸还在召 | ⑤d 同口径排除 — chain 跟 SQL 完全对齐 |
| Palmer 乳牙记号(1A/1D等)在算法里互相误匹配 | 5 颗乳牙被当成 1 颗 | 牙位规范化升级,儿童 case 也精准 |
### ▎资源依赖矩阵
```
W3 ✅:1 人 + AI 全部完成(没借力)
W4 :PAC 1 人 + DW 1 人(启动日刷新) + 5i5ya 1 人(2 小时贴 iframe)(轻借力)
W5 :PAC 1 人 + 试点 1 家客服 3-5 人 (半天联调)(中借力)
W6 :PAC 1 人 + 客服培训 1 人 + 试点店长配合(重借力)
```
---
## ▎下一份报告(W4 末)预告
**承诺给管委会看**:
1. DW 日级增量自动跑(今天的真实数据,明天可见)
2. 1 家试点诊所的真实召回列表(数据规模 ×N) + 角色权限分离
3. 5i5ya iframe 嵌入方案最终确认
4. 召回准度跟踪指标(召回/排除/重复)
**汇报形式**:沿用"一页 Demo + 业务语言"风格,继续 Demo over Memo。
---
## ▎附件(留参考)
| 附件 | 内容 |
|---|---|
| algorithm/canonical-fact-layer.md | 数据规范化层 v2(9 道闸 + Layer A.5 transforms) |
| algorithm/treatment-chain-5-stages.md | 5 阶段治疗链模型(W3 末确立) |
| algorithm/potential-treatment-recall.md | 潜在新链召回算法(5 子场景 + 4 道排除闸) |
| dw-data-source-issues.md | 真实数据踩坑 + 字段语义勘误记录 |
---
> **核心信号**:真实数据真的跑通了 + W4 提前到位 + 边界案例边跑边修 + 下次见就是试点起步
...@@ -35,7 +35,7 @@ export default tseslint.config( ...@@ -35,7 +35,7 @@ export default tseslint.config(
// NestJS relies on `emitDecoratorMetadata` for DI — `import type` would // NestJS relies on `emitDecoratorMetadata` for DI — `import type` would
// erase the value at runtime and break Reflect metadata. Disable the // erase the value at runtime and break Reflect metadata. Disable the
// rule for backend source so the linter doesn't push us off that cliff. // rule for backend source so the linter doesn't push us off that cliff.
files: ['apps/recall-service/**/*.ts'], files: ['apps/pac-service/**/*.ts'],
rules: { rules: {
'@typescript-eslint/consistent-type-imports': 'off', '@typescript-eslint/consistent-type-imports': 'off',
}, },
......
{ {
"name": "pac-platform", "name": "pac",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.13.1", "packageManager": "pnpm@10.13.1",
......
...@@ -299,6 +299,11 @@ export const PACTreatmentStep = { ...@@ -299,6 +299,11 @@ export const PACTreatmentStep = {
canal_preparation: '根管预备', canal_preparation: '根管预备',
canal_filling: '根管充填', canal_filling: '根管充填',
post_endo_filling: '根管后充填', post_endo_filling: '根管后充填',
/// 活髓 / 部分牙髓切断术(vital pulpotomy / partial pulpotomy / 干髓术 — 等价术式)
/// 临床:保留根髓,冠髓切除 + MTA/Biodentine 封盖,成年恒牙 80-90% 成功率(AAE/ESE 2019+ 认可为终末术式)
/// 跟 canal_filling 并列作为 endodontic 的 terminalSteps —— 任一命中即 s3Reached
/// 命中 pulpotomy 不要求冠保护(根髓活,牙不变脆)
pulpotomy: '牙髓切断术(活髓 / 部分 / 干髓)',
// 种植 // 种植
implant_placement: '种植体植入', implant_placement: '种植体植入',
abutment_placement: '基台连接', abutment_placement: '基台连接',
...@@ -343,13 +348,24 @@ export interface TreatmentMilestone { ...@@ -343,13 +348,24 @@ export interface TreatmentMilestone {
minSteps: number; minSteps: number;
/// 生命周期类型 → 查 TreatmentLifecycles 拿 maxStage / expectedSpanMonths /// 生命周期类型 → 查 TreatmentLifecycles 拿 maxStage / expectedSpanMonths
lifecycle: TreatmentLifecycleKey; lifecycle: TreatmentLifecycleKey;
/// W4 末新增:**任一命中即视为治疗已完整收尾**(s3Reached=true,跳过 minSteps 检查)
///
/// 用于支持"多路径终末"的临床现实:
/// endodontic:
/// Path A — 完整 RCT (开髓 → 根管预备 → 根管充填) ✅ canal_filling 命中即收尾
/// Path B — 牙髓切断术(活髓 / 部分 / 干髓 — VPT 终末术式)✅ pulpotomy 命中即收尾
///
/// 跟 requiresCrownProtection 配合:
/// 命中 canal_filling → 仍需冠保护(linear_then_crown 规则)
/// 命中 pulpotomy → 不需冠保护(局部 override,chain-composer 处理)
terminalSteps?: readonly PACTreatmentStepKey[];
} }
export const TreatmentMilestones = { export const TreatmentMilestones = {
implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear' }, implant: { steps: ['implant_placement', 'crown_placement'], minSteps: 2, lifecycle: 'linear' },
// endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整 // endodontic 3 步 + minSteps=2(开髓 + 根充至少);patient 真做了开髓+根备+根充才算完整
// lifecycle=linear_then_crown:闭环额外要求 prosthodontic 冠保护(临床:根管后不戴冠折裂率 ~30%) // lifecycle=linear_then_crown:闭环额外要求 prosthodontic 冠保护(临床:根管后不戴冠折裂率 ~30%)
endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling'], minSteps: 2, lifecycle: 'linear_then_crown' }, endodontic: { steps: ['pulp_extirpation', 'canal_preparation', 'canal_filling', 'pulpotomy'], minSteps: 2, lifecycle: 'linear_then_crown', terminalSteps: ['canal_filling', 'pulpotomy'] },
orthodontic: { steps: ['bracket_placement', 'retainer'], minSteps: 1, lifecycle: 'long_term' }, orthodontic: { steps: ['bracket_placement', 'retainer'], minSteps: 1, lifecycle: 'long_term' },
periodontic: { steps: ['supragingival_scaling', 'subgingival_scaling', 'periodontal_maintenance'], minSteps: 1, lifecycle: 'lifelong_maintenance' }, periodontic: { steps: ['supragingival_scaling', 'subgingival_scaling', 'periodontal_maintenance'], minSteps: 1, lifecycle: 'lifelong_maintenance' },
restorative: { steps: ['composite_filling', 'inlay'], minSteps: 1, lifecycle: 'one_shot' }, restorative: { steps: ['composite_filling', 'inlay'], minSteps: 1, lifecycle: 'one_shot' },
...@@ -367,8 +383,11 @@ export const LegacyStepSubtypeKeywords: Partial<Record<PACTreatmentStepKey, read ...@@ -367,8 +383,11 @@ export const LegacyStepSubtypeKeywords: Partial<Record<PACTreatmentStepKey, read
pulp_extirpation: ['开髓', '拔髓'], pulp_extirpation: ['开髓', '拔髓'],
canal_preparation: ['根备', '根管预备'], canal_preparation: ['根备', '根管预备'],
canal_filling: ['根充', '根管充填'], canal_filling: ['根充', '根管充填'],
implant_placement: ['种植体植入', '种植手术', '种植一期'], // pulpotomy 类:活髓切断 / 部分活髓切断 / 部分牙髓切断 / 冠髓切断 / 干髓 / 盖髓 / 牙髓血运重建
crown_placement: ['种植上部修复', '种植冠修复', '种植戴牙'], // 这些都是"保留根髓"的终末术式(VPT — vital pulp therapy),临床等价
pulpotomy: ['活髓切断', '部分活髓切断', '部分牙髓切断', '冠髓切断', '干髓', '盖髓', '牙髓血运重建', '活髓保存'],
implant_placement: ['种植体植入', '种植手术', '种植一期', '简单种植', '复杂种植', '即刻种植', '延期种植', '拔除后种植'],
crown_placement: ['种植上部修复', '种植冠修复', '种植戴牙', '种植二期', '种植三期'],
supragingival_scaling: ['洁治', '洁牙', '洗牙'], supragingival_scaling: ['洁治', '洁牙', '洗牙'],
subgingival_scaling: ['刮治', '龈下'], subgingival_scaling: ['刮治', '龈下'],
periodontal_maintenance: ['维护'], periodontal_maintenance: ['维护'],
......
...@@ -463,26 +463,29 @@ export const PlanStatusSchema = z.enum([ ...@@ -463,26 +463,29 @@ export const PlanStatusSchema = z.enum([
// Plan Execution(接口 2 客服回写) // Plan Execution(接口 2 客服回写)
// ============================================================= // =============================================================
/// plan_executions.outcome — 4 起步 + 8 产品收集 = 12 /// plan_executions.outcome — 4 起步 + 7 产品收集 = 11
/// 形式:`英文_key`(中文含义) /// 形式:`英文_key`(中文含义)
///
/// W4 末调整(2026-05):
/// - 删 PROACTIVE_SMS:前端无对应按钮,生产用不到 → 清理
/// - MARKED_INVALID 状态机映射改 → abandoned(原 completed 语义错;标记无效不该算"成功完成")
export const ExecutionOutcome = { export const ExecutionOutcome = {
// 4 起步(W2 设计) // 4 起步(W2 设计)
ABANDONED: 'abandoned', // 已联系,客服放弃 → Plan abandoned ABANDONED: 'abandoned', // 已联系,客服放弃 → Plan abandoned
NO_ANSWER: 'no_answer', // 未接通,未回复 → Plan 仍 active 等下次 NO_ANSWER: 'no_answer', // 未接通,未回复 → Plan 仍 active 等下次
SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned SCHEDULED_NEXT: 'scheduled_next', // 已联系,约定下次 → Plan 仍 assigned
SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed SUCCESS_APPOINTED: 'success_appointed', // 已联系,成功新预约 → Plan completed(新预约跳到宿主创建,本表不冗余存)
// ── 产品收集(回访中心信息字段.docx — 14 类执行结果细化)── // ── 产品收集(回访中心信息字段.docx — 执行结果细化)──
RESCHEDULED: 'rescheduled', // 已联系,改约 RESCHEDULED: 'rescheduled', // 已联系,改约
CONSIDERING: 'considering', // 已联系,考虑中(近期再跟进) CONSIDERING: 'considering', // 已联系,考虑中(近期再跟进)
DECLINED_RECENT: 'declined_recent', // 已联系,近期不考虑 DECLINED_RECENT: 'declined_recent', // 已联系,近期不考虑
NEEDS_DOCTOR: 'needs_doctor', // 已联系,需要找医生 NEEDS_DOCTOR: 'needs_doctor', // 已联系,需要找医生
EXTERNAL_TREATMENT: 'external_treatment', // 已联系,已在外院治疗(suggestion 应关闭) EXTERNAL_TREATMENT: 'external_treatment', // 已联系,已在外院治疗
REFUSED: 'refused', // 已联系,明确拒绝,不需要治疗 REFUSED: 'refused', // 已联系,明确拒绝,不需要治疗
PENDING_INFO: 'pending_info', // 已联系,需进一步确认信息后回复 PENDING_INFO: 'pending_info', // 已联系,需进一步确认信息后回复
SMS_SENT: 'sms_sent', // 电话未接,已发短信跟进 SMS_SENT: 'sms_sent', // 电话未接,已发短信跟进
PROACTIVE_SMS: 'proactive_sms', // 主动短信沟通(未发起电话) MARKED_INVALID: 'marked_invalid', // 标记为无效(数据错 / 不该召回)→ abandoned
MARKED_INVALID: 'marked_invalid', // 标记为无效(配 invalidReason)
} as const; } as const;
export type ExecutionOutcome = (typeof ExecutionOutcome)[keyof typeof ExecutionOutcome]; export type ExecutionOutcome = (typeof ExecutionOutcome)[keyof typeof ExecutionOutcome];
export const ExecutionOutcomeSchema = z.enum([ export const ExecutionOutcomeSchema = z.enum([
...@@ -499,10 +502,52 @@ export const ExecutionOutcomeSchema = z.enum([ ...@@ -499,10 +502,52 @@ export const ExecutionOutcomeSchema = z.enum([
'refused', 'refused',
'pending_info', 'pending_info',
'sms_sent', 'sms_sent',
'proactive_sms',
'marked_invalid', 'marked_invalid',
]); ]);
/// outcome 提交后 Plan.status 怎么变(状态机决策)
/// completed — 任务结案(成功 / 终态客户决策)
/// abandoned — 任务关闭(放弃 / 无效)
/// keep — Plan 状态不变(active 还是 active / assigned 还是 assigned),等下次跟进
/// 注:keep 模式下熔断器仍可能介入 — contactAttempts 达上限自动 abandoned
export type ExecutionOutcomeDrives = 'completed' | 'abandoned' | 'keep';
/// outcome 的 UI tone(对应 shadcn 调色板),前端按按钮渲染
export type ExecutionOutcomeTone = 'emerald' | 'amber' | 'sky' | 'slate' | 'rose';
/// ExecutionOutcome 单一真理源(label / tone / 状态机)
///
/// 用法:
/// - 前端 outcome-form 直接遍历这个 map 渲染按钮 + state hint
/// - 后端 execution.service.ts OUTCOME_TO_STATUS 派生自 drivesStatus
/// → 前后端口径完全对齐,改 enum / 改 drivesStatus 一处改完处处生效
///
/// 顺序 = UI 显示顺序(JS 对象迭代保留插入顺序),按业务"喜好度 / 终态优先"排:
/// 1. 成功类(emerald)→ 2. 在途类(amber/sky)→ 3. 未触达(slate)→ 4. 终态拒绝(rose)
export const EXECUTION_OUTCOME_META: Record<
ExecutionOutcome,
{ labelZh: string; tone: ExecutionOutcomeTone; drivesStatus: ExecutionOutcomeDrives }
> = {
// ── 成功转化 ──
success_appointed: { labelZh: '成功转化为新预约', tone: 'emerald', drivesStatus: 'completed' },
// ── 在途(下次再跟)──
scheduled_next: { labelZh: '约定下次回访', tone: 'amber', drivesStatus: 'keep' },
considering: { labelZh: '考虑中,近期再跟进', tone: 'amber', drivesStatus: 'keep' },
needs_doctor: { labelZh: '需要找医生', tone: 'sky', drivesStatus: 'keep' },
rescheduled: { labelZh: '改约', tone: 'sky', drivesStatus: 'keep' },
pending_info: { labelZh: '需进一步确认信息', tone: 'slate', drivesStatus: 'keep' },
// ── 未触达 / 弱触达 ──
no_answer: { labelZh: '未接通', tone: 'slate', drivesStatus: 'keep' },
sms_sent: { labelZh: '电话未接,已发短信', tone: 'slate', drivesStatus: 'keep' },
// ── 终态(客户决策)──
declined_recent: { labelZh: '近期不考虑', tone: 'rose', drivesStatus: 'keep' },
refused: { labelZh: '明确拒绝', tone: 'rose', drivesStatus: 'completed' },
external_treatment: { labelZh: '已在外院治疗', tone: 'rose', drivesStatus: 'completed' },
// ── 终态(系统决策)──
marked_invalid: { labelZh: '标记为无效', tone: 'rose', drivesStatus: 'abandoned' },
abandoned: { labelZh: '客服放弃', tone: 'rose', drivesStatus: 'abandoned' },
};
export const ExecutionChannel = { export const ExecutionChannel = {
PHONE: 'phone', PHONE: 'phone',
WECOM: 'wecom', WECOM: 'wecom',
......
...@@ -101,6 +101,27 @@ export const RefreshTokenResponseSchema = z.object({ ...@@ -101,6 +101,27 @@ export const RefreshTokenResponseSchema = z.object({
export type RefreshTokenResponse = z.infer<typeof RefreshTokenResponseSchema>; export type RefreshTokenResponse = z.infer<typeof RefreshTokenResponseSchema>;
// ============================================================= // =============================================================
// Mock Login (POST /pac/v1/auth/mock-login) ⭐ 开发 / 试部署用
// =============================================================
//
// 用途:试部署 / 内部演示场景 — 用户访问 /plans 无 token 时,前端弹"快速登录"对话框,
// 后端按 { tenant, role } 预制 user payload 直接产 JWT(跳过 host SSO)
//
// 安全:env 门控 — 只在 NODE_ENV != 'production' 或 PAC_ENABLE_MOCK_LOGIN=true 时启用,
// 生产 host 真接入后置 false / 删该 endpoint
export const MockLoginRequestSchema = z.strictObject({
/// 租户 slug — 'ruier'(瑞尔) / 'ruitai'(瑞泰),后端映射到 tenant UUID + clinics
tenant: z.enum(['ruier', 'ruitai']),
/// PAC 角色 — staff / leader / admin
role: UserRoleSchema,
});
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
/// 跟 ExchangeCodeResponse 同形(前端直接 setTokens 用)
export const MockLoginResponseSchema = ExchangeCodeResponseSchema;
export type MockLoginResponse = z.infer<typeof MockLoginResponseSchema>;
// =============================================================
// JWT payload — server-only; never sent to host // JWT payload — server-only; never sent to host
// ============================================================= // =============================================================
......
...@@ -30,6 +30,11 @@ export const RefreshPatientResponseSchema = z.object({ ...@@ -30,6 +30,11 @@ export const RefreshPatientResponseSchema = z.object({
status: z.string(), status: z.string(),
/// true = 调用方请求被合并到正在进行中的 run(30s 内去重) /// true = 调用方请求被合并到正在进行中的 run(30s 内去重)
dedupedFromExisting: z.boolean().optional(), dedupedFromExisting: z.boolean().optional(),
/// W4 末:本次写入的 transaction / fact 条数(UI toast 显示)
transactionsWritten: z.number().int().optional(),
factsEmitted: z.number().int().optional(),
/// W4 末:本次刷新后新生成 plan 数(<= 1,plan = patient 级触达单元)
plansCreated: z.number().int().optional(),
}); });
export type RefreshPatientResponse = z.infer<typeof RefreshPatientResponseSchema>; export type RefreshPatientResponse = z.infer<typeof RefreshPatientResponseSchema>;
......
...@@ -21,10 +21,10 @@ ...@@ -21,10 +21,10 @@
"persistent": true, "persistent": true,
"dependsOn": ["^build"] "dependsOn": ["^build"]
}, },
"@recall/shared-types#build": { "@pac/types#build": {
"outputs": ["dist/**"] "outputs": ["dist/**"]
}, },
"@recall/shared-utils#build": { "@pac/utils#build": {
"outputs": ["dist/**"] "outputs": ["dist/**"]
}, },
"lint": { "lint": {
......
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