Commit 7b0124bb by luoqi

merge: feat/friday-return-visit → test(FRIDAY 回访摄入)

customer_return_visit → PatientReturnVisit,任务头 customer_task 的 task_date/task_status
由宿主 inline(同计划行 inline 计划头)。枚举翻中文与 jvs-dw 逐字对齐;
新增 strip_html 算子剥富文本(实测 31% 回访内容带 HTML)。
契约文档新增第 7 节,source 数 9 → 10。
parents 6ac62b2c 0333c49b
Pipeline #3660 failed in 0 seconds
--- ---
title: FRIDAY 推送数据契约 title: FRIDAY 推送数据契约
description: FRIDAY SaaS 按形态 A 推送的 9 个 source 及字段定义;宿主推自洽的业务表(自己的引用 inline 进相关行),PAC 做跨宿主临床归一。 description: FRIDAY SaaS 按形态 A 推送的 10 个 source 及字段定义;宿主推自洽的业务表(自己的引用 inline 进相关行),PAC 做跨宿主临床归一。
icon: FileJson icon: FileJson
--- ---
...@@ -290,11 +290,57 @@ icon: FileJson ...@@ -290,11 +290,57 @@ icon: FileJson
--- ---
## 7. 对接自测(预检 + 推送记录) ## 7. 回访(1 个 source)
### `customer_return_visit` — 客户回访(任务属性已 inline)
> **回访任务 `customer_task` 不再单独推**——它是**统一任务表**,按 `task_type` 分流
> (`1`咨询 → `customer_consult`、`2`回访 → 本表、`4`预约备注、`10`…),PAC 只要回访那一支的
> 「排程日期 + 任务状态」两个属性,任务头本身不是 PAC 实体。宿主把 `customer_task` 的
> 相关列 **inline 进每条回访记录**即可(关联:`customer_return_visit.task_id = customer_task.id`,
> 实测 2552/2552 全中、零空值)。这与计划行 inline 计划头属性是同一形态。
>
> ⚠️ **`task_date` 含未来排程**——界面「设回访」就是设在未来日期。PAC 据此区分
> 「已发生的回访」与「排了但还没做的」:详情页按它倒序展示,召回话术**只把已发生的**
> 算作"联系过"。**不要为了让数据好看而把未来日期改成当天**。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | ✅ | 回访记录主键(= PAC externalId,幂等键)|
| `tenant_id` | string | ✅ | 品牌 GUID |
| `organization_id` | string | ✅ | 诊所 |
| `customer_id` | string | ✅ | → 患者 id |
| `task_id` | string | | → `customer_task.id`(留作溯源)|
| `task_date` | string(date) | | **任务日期(宿主 inline,取自 `customer_task`)——含未来排程** |
| `task_status` | string/number | | **任务状态(宿主 inline)**——`1`进行中 `2`已完成 `3`未完成 `4`已预约 `5`创建新回访 `6`未回访 |
| `return_visit_type` | string/number | | 回访类型——`1`咨询回访 `2`术后回访 `3`常规回访 `4`自定义/事件驱动(含「取消预约回访」)`5`系统诊断召回。⚠️ 官方注释只写了 1-3,**4/5 是实测存在的**(占 12%) |
| `return_visit_status` | string/number | | 回访状态——`1`已回访 `2`未回访 |
| `treatment_items` | string | | 关联治疗项(大类,如「种植」)|
| `treatment_items_two` | string | | 关联治疗项(子项,如「单颗种植」)。⚠️ 与上一列**成对出现**,PAC 合并成单字段展示 |
| `follow_content` | string | | 回访任务内容。⚠️ **可能含 HTML 富文本**(界面是富文本编辑器),PAC 摄入时剥标签 |
| `return_visit_result` | string | | 回访结果 |
| `task_director_id` / `task_director` | string | | **执行本次回访的人**。⚠️ 见下方说明 |
| `return_visit_method` | string/number | | 回访方式——`1`打电话 `2`发微信 `3`发短信 |
| `created_gmt_at` / `updated_gmt_at` | string(datetime) | ✅ | 创建 / 末次修改时间 |
**建议一并 inline 的 `customer_task` 列**(PAC 当前不映射,原样存进 `raw_payload` 备用,
将来要用不必再找宿主改):`suggested_return_person`(建议回访人)、`actual_return_person` /
`actual_return_time`(实际回访人/时间)、`customer_status`(客户状态)、`is_first`(初诊/复诊)。
> **`task_director` 是「该任务当时派给谁」,不是患者的专属客服。**
> PAC 用它**按诊所反推客服名册**供主管指派(宿主不提供「客服归属诊所」字段,只能按行为反推)。
> 参照宿主 jvs-dw 实测:近 12 月 367 万对回访,「任务负责人」与「患者主档专属客服」仅 **23.1%** 相同。
>
> ⚠️ **`task_director` 可能是 `System`**(系统自动生成的回访,实测 158 条、全部是
> `return_visit_type=5` 的诊断召回)。PAC 侧**不会把 `System` 计入客服名册** —— 它不是人。
> 宿主照推即可,不需要过滤。
## 8. 对接自测(预检 + 推送记录)
对接期不必盲推 —— PAC 提供两个自助工具,**测试环境** `https://pac.jarvismedical.asia`: 对接期不必盲推 —— PAC 提供两个自助工具,**测试环境** `https://pac.jarvismedical.asia`:
### 7.1 预检:`?dryRun=1`(强烈建议先跑) ### 8.1 预检:`?dryRun=1`(强烈建议先跑)
``` ```
POST /pac/v1/push/rows?dryRun=1 POST /pac/v1/push/rows?dryRun=1
...@@ -315,7 +361,7 @@ POST /pac/v1/push/rows?dryRun=1 ...@@ -315,7 +361,7 @@ POST /pac/v1/push/rows?dryRun=1
**自测通过标准**:`failed=0` 且 `mappingMisses=0` 且 `suspectFields=0`,再看 `samples` 里的字段值对不对 —— 都 OK 才去掉 `?dryRun=1` 正式推。 **自测通过标准**:`failed=0` 且 `mappingMisses=0` 且 `suspectFields=0`,再看 `samples` 里的字段值对不对 —— 都 OK 才去掉 `?dryRun=1` 正式推。
### 7.2 推送记录:事后可查(两种查法) ### 8.2 推送记录:事后可查(两种查法)
**① 免登录 —— 用已有 push 凭据验签(联调推荐)** **① 免登录 —— 用已有 push 凭据验签(联调推荐)**
``` ```
...@@ -335,7 +381,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50 ...@@ -335,7 +381,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50
`source / status / dryRun / fetched / transactionsWritten / duplicates / failed / errorMessage`。 `source / status / dryRun / fetched / transactionsWritten / duplicates / failed / errorMessage`。
当时的同步响应没留存也能事后查:**哪批错了、错在哪**。预检批次由 `dryRun:true` 标识,不与正式推送混淆。 当时的同步响应没留存也能事后查:**哪批错了、错在哪**。预检批次由 `dryRun:true` 标识,不与正式推送混淆。
### 7.3 其他可用面 ### 8.3 其他可用面
| 用途 | 地址 | | 用途 | 地址 |
|---|---| |---|---|
...@@ -350,7 +396,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50 ...@@ -350,7 +396,7 @@ GET /pac/v1/admin/host/self/push-logs?limit=50
--- ---
## 8. 语义澄清记录(2026-07,源码 + 数据双证) ## 9. 语义澄清记录(2026-07,源码 + 数据双证)
以下曾是开放问题,现已定案 —— 推送方无需再确认,列此备查: 以下曾是开放问题,现已定案 —— 推送方无需再确认,列此备查:
......
# patient_return_visit — FRIDAY 客户回访(customer_return_visit → patient_return_visits 独立表)
#
# upsert 资源(无 emits,同 patient/patient_relation):落 PatientReturnVisit 表,
# **不进 transaction / fact、不参与召回**。用途:患者详情页「回访记录」展示 + 按诊所反推客服名册。
#
# ── 源表关系 ──
# 宿主是「统一任务表 + 分类型记录表」结构:
# customer_task(task_type: 1咨询 / 2回访 / 4预约备注 / 10…)
# ├─ task_type=1 → customer_consult (咨询,PAC 已单独摄入)
# └─ task_type=2 → customer_return_visit (回访,本 assembler)
# 关联 customer_return_visit.task_id = customer_task.id,实测 2552/2552 全中、零空值。
# 任务头不是 PAC 实体,故 **不单独摄入** —— 宿主把 task_date / task_status 等 inline 进回访行
# (同 customer_treat_plan_item inline 计划头的 organization_id/plan_name)。
#
# ── 枚举归一到中文,与 jvs-dw 对齐 ──
# jvs-dw 的 DW 侧本就是 *_name 中文列(常规回访/已回访/已完成…),FRIDAY 是数字码。
# 这里翻成**同样的中文**,否则同一张 PatientReturnVisit 表里两个宿主的值对不齐,
# 前端展示和按类型筛选都会分叉。
canonical: patient_return_visit
primary:
table: customer_return_visit
key: id
dedup_by: id
field_mapping:
externalId: id
patientExternalId: customer_id
clinicId: organization_id
# 排程日期(宿主 inline 自 customer_task)—— **含未来**,界面「设回访」就是设未来日期。
# PAC 据此区分「已发生」与「排了没做」:详情页倒序展示、召回话术只算已发生的。
# ⚠️ 客服名册判「在岗」用 sourceCreatedAt 而非本字段,正因为本字段含未来排程。
taskDate: task_date
taskStatus: task_status
type: return_visit_type
status: return_visit_status
# 大类·子项两列合并成单字段(同 jvs-dw)。实测:两列都有 499、只一级 257、只二级 0、都空 1796
# —— 与 jvs-dw「总是成对」同构;只有一级时合并结果尾部的分隔符由 normalizeMergedItems 归一掉。
treatmentItems: treatment_items_full
followContent: follow_content_text # 已剥 HTML(transforms;界面是富文本编辑器)
result: return_visit_result
# 执行本次回访的人(≠ 患者主档专属客服)。用于按诊所反推客服名册供主管指派。
# ⚠️ 可能是 'System'(系统自动生成的诊断召回,实测 158 条全是 return_visit_type=5),
# 不是真人 —— 名册侧已排除,见 transforms 说明。
taskDirectorId: task_director_id
taskDirectorName: task_director
# 宿主侧业务时间(≠ PAC 入库时间 created_at/updated_at,后者重摄会被刷新)
sourceCreatedAt: created_gmt_at
sourceUpdatedAt: updated_gmt_at
# 数字码 → 中文,与 jvs-dw 的 DW *_name 列取值逐字对齐
enum_mapping:
type:
'1': 咨询回访
'2': 术后回访
'3': 常规回访
# ⚠️ 官方注释只写了 1-3,但实测存在 4/5(占 12%,311/2552),不能落 _default 丢掉语义:
# 4 → 100% 带治疗项,取值含「取消预约回访」「自定义」→ 事件驱动的自定义回访
# 5 → 100% 带治疗项且全是**诊断名**(残根/龋齿/缺失牙/根尖周炎),task_director 全为 System
# → 系统按诊断自动生成的召回任务
'4': 自定义回访
'5': 系统召回
_default: ''
status:
'1': 已回访
'2': 未回访
_default: ''
taskStatus:
'1': 进行中
'2': 已完成
'3': 未完成
'4': 已预约
'5': 创建新回访
'6': 未回访
_default: ''
...@@ -146,6 +146,18 @@ mysql_csv "SELECT uuid,tenant_id,organization_id,patient_id,doctor_id,status,rec ...@@ -146,6 +146,18 @@ mysql_csv "SELECT uuid,tenant_id,organization_id,patient_id,doctor_id,status,rec
# 结算明细原表(全量含 is_refund,PAC 侧切退费明细)。JOIN 结算头:① 挂靠限定 cohort # 结算明细原表(全量含 is_refund,PAC 侧切退费明细)。JOIN 结算头:① 挂靠限定 cohort
# ② 反填真 patient_id(spec.patient_id 部分品牌是诊所本地 id,不可信 → 取结算头 ps.patient_id inline) # ② 反填真 patient_id(spec.patient_id 部分品牌是诊所本地 id,不可信 → 取结算头 ps.patient_id inline)
mysql_csv "SELECT sp.id,sp.tenant_id,sp.organization_id,ps.patient_id,sp.settlement_id,sp.cure_name,sp.service_project_name,sp.receivable_this,sp.net_receipts_this,sp.is_refund,sp.created_gmt_at,sp.updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement_spec sp JOIN \`arrail-settlement-server\`.patient_settlement ps ON ps.uuid=sp.settlement_id $(pf ps.patient_id)" patient_settlement_spec.csv mysql_csv "SELECT sp.id,sp.tenant_id,sp.organization_id,ps.patient_id,sp.settlement_id,sp.cure_name,sp.service_project_name,sp.receivable_this,sp.net_receipts_this,sp.is_refund,sp.created_gmt_at,sp.updated_gmt_at FROM \`arrail-settlement-server\`.patient_settlement_spec sp JOIN \`arrail-settlement-server\`.patient_settlement ps ON ps.uuid=sp.settlement_id $(pf ps.patient_id)" patient_settlement_spec.csv
# 客户回访 + inline 任务属性(取自统一任务表 customer_task 的 task_type=2 那一支;任务头不单独导)
# 关联 rv.task_id = t.id,实测 2552/2552 全中。task_date 含未来排程(界面「设回访」设未来日期),
# 不要在导出侧过滤掉未来行 —— PAC 靠它区分「已发生」与「排了没做」。
# 末尾几列 PAC 当前不映射,原样带出存进 raw_payload 备用(宿主多给无妨,PAC 选择性摄入)。
mysql_csv "SELECT rv.id,rv.tenant_id,rv.organization_id,rv.customer_id,rv.task_id,
t.task_date,t.task_status,
rv.return_visit_type,rv.return_visit_status,rv.treatment_items,rv.treatment_items_two,
rv.follow_content,rv.return_visit_result,rv.task_director_id,rv.task_director,
rv.return_visit_method,rv.created_gmt_at,rv.updated_gmt_at,
t.suggested_return_person,t.actual_return_person,t.actual_return_time,t.customer_status,t.is_first
FROM customer.customer_return_visit rv
LEFT JOIN customer.customer_task t ON t.id=rv.task_id AND t.task_type=2 $(pf rv.customer_id)" customer_return_visit.csv
# 转介绍圈(患者-患者关系边,双向成对存;referee_relationship 为宿主字典 id,PAC 侧统计推断解码) # 转介绍圈(患者-患者关系边,双向成对存;referee_relationship 为宿主字典 id,PAC 侧统计推断解码)
mysql_csv "SELECT id,tenant_id,organization_id,customer_id,referee_patient_id,referee_relationship,type,created_gmt_at,updated_gmt_at FROM customer.customer_referee_circle $(pf customer_id)" customer_referee_circle.csv mysql_csv "SELECT id,tenant_id,organization_id,customer_id,referee_patient_id,referee_relationship,type,created_gmt_at,updated_gmt_at FROM customer.customer_referee_circle $(pf customer_id)" customer_referee_circle.csv
# 咨询(意向 potential_treatment/未成交原因;测试库仅 45 行,结构真实) # 咨询(意向 potential_treatment/未成交原因;测试库仅 45 行,结构真实)
......
...@@ -60,6 +60,7 @@ incremental: ...@@ -60,6 +60,7 @@ incremental:
customer_treat_plan_item: { cursor_column: updated_gmt_at } customer_treat_plan_item: { cursor_column: updated_gmt_at }
customer_referee_circle: { cursor_column: updated_gmt_at } customer_referee_circle: { cursor_column: updated_gmt_at }
customer_consult: { cursor_column: updated_gmt_at } customer_consult: { cursor_column: updated_gmt_at }
customer_return_visit: { cursor_column: updated_gmt_at }
# ⭐ 宿主推「自洽的业务表」:私有字典码 / 1:N 派生 / 头-行属性 都由宿主 within-host join 后 inline 进相关行 # ⭐ 宿主推「自洽的业务表」:私有字典码 / 1:N 派生 / 头-行属性 都由宿主 within-host join 后 inline 进相关行
# (契约见 docs/integration/friday-push-payload)。故不再摄入纯字典表(std_diag/std_check_class)、 # (契约见 docs/integration/friday-push-payload)。故不再摄入纯字典表(std_diag/std_check_class)、
...@@ -78,6 +79,9 @@ tables: ...@@ -78,6 +79,9 @@ tables:
- { table: patient_settlement_spec, file: patient_settlement_spec.csv } # 结算明细原表(全量,含 is_refund) - { table: patient_settlement_spec, file: patient_settlement_spec.csv } # 结算明细原表(全量,含 is_refund)
- { table: customer_referee_circle, file: customer_referee_circle.csv } # 转介绍圈(患者-患者边) - { table: customer_referee_circle, file: customer_referee_circle.csv } # 转介绍圈(患者-患者边)
- { table: customer_consult, file: customer_consult.csv } # 咨询(意向/未成交原因) - { table: customer_consult, file: customer_consult.csv } # 咨询(意向/未成交原因)
# 回访:宿主「统一任务表 customer_task」按 task_type 分流,2=回访 → 本表(1=咨询已由 customer_consult 接)。
# 任务头不单独摄入,task_date/task_status 等由宿主 inline 进回访行(同计划行 inline 计划头)。
- { table: customer_return_visit, file: customer_return_visit.csv } # 客户回访(含 inline task_date/task_status)
transforms: transforms:
# ── 患者 phone:宿主已按「默认号→本人→最早」挑好、inline 进 customer_basic_info 的 # ── 患者 phone:宿主已按「默认号→本人→最早」挑好、inline 进 customer_basic_info 的
...@@ -388,6 +392,28 @@ transforms: ...@@ -388,6 +392,28 @@ transforms:
op: concat op: concat
parts: ['${referee_relationship}', '|', '${referee_sex}'] parts: ['${referee_relationship}', '|', '${referee_sex}']
# ═══════════ 回访(customer_return_visit → patient_return_visits 独立表)═══════════
# J.1 治疗项两列合并成单字段(同 jvs-dw:大类「种植」+ 子项「单颗种植」)。
# 实测 2552 行:两列都有 499、只一级 257、只二级 0、都空 1796 —— 与 jvs-dw「总是成对」同构。
# 只有一级时合并出 "种植 · ",尾部分隔符由落库前的 normalizeMergedItems 归一掉;
# 两列全空合并出 " · ",同样被归一成 null(回访常无治疗项,占 70%)。
- kind: derive
input: customer_return_visit
output: customer_return_visit
fields:
treatment_items_full:
op: concat
parts: ['${treatment_items}', ' · ', '${treatment_items_two}']
# J.2 回访内容剥富文本标签 —— 宿主界面是富文本编辑器,实测 794/2552(31%)带 <p>/<ol>/<li>。
# PAC 侧该字段是纯文本语义(详情页直接展示 + 喂召回话术 LLM),带标签会原样显示成 "<p>xxx</p>"。
- kind: derive
input: customer_return_visit
output: customer_return_visit
fields:
follow_content_text:
op: strip_html
from: follow_content
assemblers: assemblers:
- { file: assemblers/patient.yaml } - { file: assemblers/patient.yaml }
- { file: assemblers/appointment.yaml } - { file: assemblers/appointment.yaml }
...@@ -404,3 +430,4 @@ assemblers: ...@@ -404,3 +430,4 @@ assemblers:
- { file: assemblers/refund_item.yaml } - { file: assemblers/refund_item.yaml }
- { file: assemblers/patient_relation.yaml } - { file: assemblers/patient_relation.yaml }
- { file: assemblers/consult.yaml } - { file: assemblers/consult.yaml }
- { file: assemblers/patient_return_visit.yaml } # 客户回访(展示 + 客服名册)→ PatientReturnVisit upsert
...@@ -97,6 +97,11 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown { ...@@ -97,6 +97,11 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
if (typeof v !== 'string') return v ?? null; if (typeof v !== 'string') return v ?? null;
return v.trim().replace(TRAILING_NOISE, '').trim(); return v.trim().replace(TRAILING_NOISE, '').trim();
} }
case 'strip_html': {
const v = row[expr.from];
if (typeof v !== 'string') return v ?? null;
return stripHtml(v);
}
case 'normalize': { case 'normalize': {
const v = row[expr.from]; const v = row[expr.from];
if (typeof v !== 'string') return v ?? null; if (typeof v !== 'string') return v ?? null;
...@@ -116,3 +121,37 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown { ...@@ -116,3 +121,37 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
} }
} }
} }
/**
* 剥 HTML 富文本标签 → 纯文本。
*
* 宿主用富文本编辑器录入的字段(FRIDAY 回访内容实测 31% 带 <p>/<ol>/<li>/<em>)进 PAC 后
* 是**纯文本语义** —— 详情页直接展示、喂 LLM 生成话术。带标签会被原样显示成 "<p>xxx</p>",
* 也污染 LLM 上下文,所以在摄入层剥掉,而不是留给每个消费方各自处理。
*
* 口径:
* - 块级标签(</p> </li> </div> <br>)→ 换行,否则多段文字会黏成一行
* - 其余标签直接去掉
* - 常见 HTML 实体还原(&nbsp; &amp; &lt; &gt; &quot; &#39;)
* - 收尾清理连续空行/首尾空白;全部剥完只剩空白 → 返回 null(空壳不入库)
*/
export function stripHtml(input: string): string | null {
const text = input
// 块级结束/换行标签先转成换行,保住段落边界
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
.replace(/<\/\s*(p|div|li|tr|h[1-6]|blockquote)\s*>/gi, '\n')
// 其余标签一律剥掉
.replace(/<[^>]*>/g, '')
// 实体还原(&amp; 放最后,避免 &amp;lt; 被二次解码成 <)
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/gi, '&')
// 连续空行压成一个,首尾清干净
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{2,}/g, '\n')
.trim();
return text === '' ? null : text;
}
...@@ -125,6 +125,7 @@ export type SplitJsonArrayOp = z.infer<typeof SplitJsonArrayOpSchema>; ...@@ -125,6 +125,7 @@ export type SplitJsonArrayOp = z.infer<typeof SplitJsonArrayOpSchema>;
* - normalize: { op: normalize, from: field } — trim 升级版 + CJK→ASCII 中段标点 * - normalize: { op: normalize, from: field } — trim 升级版 + CJK→ASCII 中段标点
* (中括号()/中逗号,/中分号;/中尖括号 < >)统一 ASCII,中文宿主 enum 匹配必备 * (中括号()/中逗号,/中分号;/中尖括号 < >)统一 ASCII,中文宿主 enum 匹配必备
* - coalesce: { op: coalesce, from: [f1, f2, ...] } — 取第一个非空字段值(诊断码兜底:stdCode→message) * - coalesce: { op: coalesce, from: [f1, f2, ...] } — 取第一个非空字段值(诊断码兜底:stdCode→message)
* - strip_html: { op: strip_html, from: field } — 剥富文本标签取纯文本(宿主用富文本编辑器时)
* *
* concat parts 里 `${fieldName}` 是字段引用,其他视为字面量。 * concat parts 里 `${fieldName}` 是字段引用,其他视为字面量。
* 限定不允许任意表达式(canonical-fact-layer.md 原则 1)。 * 限定不允许任意表达式(canonical-fact-layer.md 原则 1)。
...@@ -162,6 +163,15 @@ export const DeriveTrimSchema = z.object({ ...@@ -162,6 +163,15 @@ export const DeriveTrimSchema = z.object({
from: z.string().min(1), from: z.string().min(1),
}); });
/// 剥 HTML 富文本标签 → 纯文本。宿主侧用富文本编辑器录入时,原值会带 <p>/<ol>/<li>/<em> 等
/// (FRIDAY 回访内容实测 794/2552 = 31% 含标签)。PAC 的这些字段是**纯文本语义**
/// (详情页直接展示、喂 LLM 生成话术),带标签进来会被原样显示成 "<p>xxx</p>",
/// 也会污染 LLM 上下文。故在摄入层剥掉,而不是留给每个消费方各自处理。
export const DeriveStripHtmlSchema = z.object({
op: z.literal('strip_html'),
from: z.string().min(1),
});
/// W4 末加:CJK→ASCII 标点 + trim 一站式归一(给中文宿主 enum_mapping 字符串匹配用) /// W4 末加:CJK→ASCII 标点 + trim 一站式归一(给中文宿主 enum_mapping 字符串匹配用)
/// trim 是子集(只去首尾噪音);normalize 还做"中文括号/逗号/分号 → ASCII"中段替换 /// trim 是子集(只去首尾噪音);normalize 还做"中文括号/逗号/分号 → ASCII"中段替换
/// 适用任何中文宿主的脏字段归一(不是 jvs-dw 特化),所以是通用 op /// 适用任何中文宿主的脏字段归一(不是 jvs-dw 特化),所以是通用 op
...@@ -191,6 +201,7 @@ export const DeriveExprSchema = z.discriminatedUnion('op', [ ...@@ -191,6 +201,7 @@ export const DeriveExprSchema = z.discriminatedUnion('op', [
DeriveUpperSchema, DeriveUpperSchema,
DeriveDefaultSchema, DeriveDefaultSchema,
DeriveTrimSchema, DeriveTrimSchema,
DeriveStripHtmlSchema,
DeriveNormalizeSchema, DeriveNormalizeSchema,
DeriveCoalesceSchema, DeriveCoalesceSchema,
DeriveRecodeSchema, DeriveRecodeSchema,
......
...@@ -179,6 +179,11 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co ...@@ -179,6 +179,11 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co
for (const { host, file, config } of yamls) { for (const { host, file, config } of yamls) {
const mappings = config.enum_mapping ?? {}; const mappings = config.enum_mapping ?? {};
for (const [canonicalField, valueMap] of Object.entries(mappings)) { for (const [canonicalField, valueMap] of Object.entries(mappings)) {
// patient_return_visit 特例:它的 status/type 是**展示用自由文本**,不是受控码。
// closedSets.status 装的是 PACTreatmentStatuses(治疗状态),与回访的"已回访/未回访"同名不同义。
// PAC 侧这几列直接落 PatientReturnVisit 原样展示(不进 fact、不参与召回),
// 取值口径是"与 jvs-dw 的 DW *_name 中文列逐字对齐",而非 canonical-codes 闭集。
if (config.canonical === 'patient_return_visit') continue;
// appointment.status 特例:用 host-side enum // appointment.status 特例:用 host-side enum
if (canonicalField === 'status' && config.canonical === 'appointment') { if (canonicalField === 'status' && config.canonical === 'appointment') {
test(`${host}/${file} appointment.status enum_mapping 目标在 [scheduled/arrived/cancelled/no_show]`, () => { test(`${host}/${file} appointment.status enum_mapping 目标在 [scheduled/arrived/cancelled/no_show]`, () => {
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import * as yaml from 'js-yaml';
import { TransformEngine } from '../src/modules/sync/transforms/transform-engine';
import type { TransformsConfig } from '../src/modules/sync/transforms/transforms.schema';
import { stripHtml } from '../src/modules/sync/transforms/operators/derive.op';
/**
* FRIDAY 回访摄入(customer_return_visit → PatientReturnVisit)。
*
* 【源表结构:统一任务表 + 分类型记录表】
* customer_task(task_type: 1咨询 / 2回访 / 4预约备注 / 10…)
* ├─ 1 → customer_consult (咨询,PAC 早已单独摄入)
* └─ 2 → customer_return_visit (回访,本次)
* 实测零交叉:consult 的 64 条 task_id 全指向 type=1,回访 2552 条全指向 type=2。
* 关联 customer_return_visit.task_id = customer_task.id,2552/2552 全中、零空值。
*
* 【为什么枚举要翻成中文】jvs-dw 的 DW 侧本就是 *_name 中文列(常规回访/已回访/已完成),
* FRIDAY 是数字码。同一张 PatientReturnVisit 表里两个宿主的值必须逐字对齐,
* 否则前端展示和按类型筛选会分叉。
*
* 【task_date 含未来】界面「设回访」就是设未来日期。PAC 靠它区分「已发生」与「排了没做」:
* 详情页倒序展示、召回话术只把已发生的算作"联系过";客服名册判在岗则用 sourceCreatedAt,
* 正因为 task_date 含未来排程(jvs-dw 实测最远 2033)。
*/
describe('FRIDAY 回访摄入', () => {
const dir = join(__dirname, '../data/friday');
const manifest = yaml.load(readFileSync(join(dir, 'manifest.yaml'), 'utf8')) as {
tables: Array<{ table: string; file: string }>;
incremental: { per_query: Record<string, unknown> };
transforms: unknown[];
assemblers: Array<{ file: string }>;
};
const asm = yaml.load(
readFileSync(join(dir, 'assemblers/patient_return_visit.yaml'), 'utf8'),
) as {
canonical: string;
primary: { table: string };
field_mapping: Record<string, string>;
enum_mapping: Record<string, Record<string, string>>;
};
describe('① manifest 接线完整', () => {
test('源表已声明,文件名 .csv', () => {
const t = manifest.tables.find((x) => x.table === 'customer_return_visit');
expect(t?.file).toBe('customer_return_visit.csv');
});
test('增量游标已配(push / 增量都要)', () => {
expect(manifest.incremental.per_query).toHaveProperty('customer_return_visit');
});
test('assembler 已注册', () => {
expect(manifest.assemblers.map((a) => a.file)).toContain(
'assemblers/patient_return_visit.yaml',
);
});
test('⭐ 任务头 customer_task 不单独摄入 —— 属性由宿主 inline', () => {
expect(manifest.tables.map((t) => t.table)).not.toContain('customer_task');
});
});
describe('② assembler 映射', () => {
test('是 upsert 资源(无 emits,不进 transaction/fact/召回)', () => {
expect(asm.canonical).toBe('patient_return_visit');
expect(asm).not.toHaveProperty('emits');
});
test.each([
['externalId', 'id'],
['patientExternalId', 'customer_id'],
['clinicId', 'organization_id'],
['taskDate', 'task_date'],
['taskStatus', 'task_status'],
['taskDirectorId', 'task_director_id'],
['sourceCreatedAt', 'created_gmt_at'],
])('%s ← %s', (canonical, host) => {
expect(asm.field_mapping[canonical]).toBe(host);
});
test('⭐ 治疗项走合并列、回访内容走剥标签列(而非原列)', () => {
expect(asm.field_mapping.treatmentItems).toBe('treatment_items_full');
expect(asm.field_mapping.followContent).toBe('follow_content_text');
});
});
describe('③ 枚举翻中文,与 jvs-dw 逐字对齐', () => {
test.each([
['1', '咨询回访'],
['2', '术后回访'],
['3', '常规回访'],
])('type 码%s → %s', (code, zh) => {
expect(asm.enum_mapping.type[code]).toBe(zh);
});
test('⭐ 码 4/5 必须显式配置 —— 官方注释只写 1-3,实测占 12% 不能丢', () => {
expect(asm.enum_mapping.type['4']).toBeTruthy();
expect(asm.enum_mapping.type['5']).toBeTruthy();
});
test('status / taskStatus 全码覆盖', () => {
expect(asm.enum_mapping.status['1']).toBe('已回访');
expect(asm.enum_mapping.status['2']).toBe('未回访');
for (const c of ['1', '2', '3', '4', '5', '6']) {
expect(`taskStatus[${c}]=${asm.enum_mapping.taskStatus[c]}`).not.toContain('undefined');
}
});
});
describe('④ transforms:两列合并 + 剥富文本', () => {
const engine = new TransformEngine();
const rows = [
{
id: '1',
treatment_items: '种植',
treatment_items_two: '单颗种植',
follow_content: '<p>骨灰盒或或</p>',
},
{
id: '2',
treatment_items: '诊后回访',
treatment_items_two: '',
follow_content: '<ol><li>甲</li><li>乙</li></ol>',
},
{ id: '3', treatment_items: '', treatment_items_two: '', follow_content: '纯文本' },
];
const out = engine.run({
tables: { customer_return_visit: rows },
transforms: manifest.transforms as TransformsConfig,
});
const got = (out['customer_return_visit'] ?? []) as Array<Record<string, unknown>>;
test('两列都有 → 用 · 连接', () => {
expect(got[0]!.treatment_items_full).toBe('种植 · 单颗种植');
});
test('只有一级 → 尾部留分隔符(落库前由 normalizeMergedItems 归一)', () => {
expect(String(got[1]!.treatment_items_full).startsWith('诊后回访')).toBe(true);
});
test('⭐ 富文本剥成纯文本,列表项保住段落边界', () => {
expect(got[0]!.follow_content_text).toBe('骨灰盒或或');
expect(got[1]!.follow_content_text).toBe('甲\n乙');
});
test('纯文本原样通过', () => {
expect(got[2]!.follow_content_text).toBe('纯文本');
});
});
describe('⑤ stripHtml 算子', () => {
test.each([
['<p>骨灰盒</p>', '骨灰盒'],
['<ol><li>甲</li><li>乙</li></ol>', '甲\n乙'],
['a<br>b', 'a\nb'],
['a&nbsp;b &amp; c', 'a b & c'],
['纯文本', '纯文本'],
])('%s → %s', (input, expected) => {
expect(stripHtml(input)).toBe(expected);
});
test('⭐ 全是标签/空白 → null(空壳不入库)', () => {
expect(stripHtml('<p> </p>')).toBeNull();
expect(stripHtml('')).toBeNull();
});
test('&amp;lt; 不被二次解码成 <(实体还原顺序)', () => {
expect(stripHtml('&amp;lt;')).toBe('&lt;');
});
});
});
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