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 段)' })
......
...@@ -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;
......
...@@ -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"> <div className="flex h-screen flex-col items-center justify-center gap-2 p-8 text-center">
<p className="text-lg font-medium">无法加载工作台</p> <p className="text-lg font-medium text-slate-700">召回工作台</p>
<p className="max-w-md text-sm text-muted-foreground"> <p className="max-w-md text-sm text-muted-foreground">
缺少有效的访问凭证。请回到宿主系统重新打开召回模块(确保 URL 携带一次性 code 参数) 请在弹窗中选择身份进入。生产环境会自动通过宿主 SSO 登录,无需手动选择
</p> </p>
</div> </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()}
......
...@@ -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",
......
...@@ -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