Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
c9360bcb
Commit
c9360bcb
authored
Oct 18, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge 5i5ya complaint ingestion into test
parents
421dfaa4
3d29ada9
Pipeline
#3825
failed in 0 seconds
Changes
21
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
21 changed files
with
2817 additions
and
71 deletions
+2817
-71
apps/pac-service/data/jvs-dw/assemblers/complaint.yaml
+69
-0
apps/pac-service/data/jvs-dw/manifest.yaml
+46
-0
apps/pac-service/src/modules/sync/assembler/assembler-engine.ts
+8
-0
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
+10
-0
apps/pac-service/src/modules/sync/cold-import/clickhouse-source.service.ts
+6
-8
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
+1
-0
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
+2
-0
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
+6
-1
apps/pac-service/src/modules/sync/pipeline/parsers/complaint.parser.ts
+45
-41
apps/pac-service/src/modules/sync/transforms/operators/derive.op.ts
+2
-2
apps/pac-service/src/modules/sync/transforms/operators/group-rows.op.ts
+63
-0
apps/pac-service/src/modules/sync/transforms/transform-engine.ts
+8
-1
apps/pac-service/src/modules/sync/transforms/transforms.schema.ts
+18
-0
apps/pac-service/tests/canonical-fact-layer.spec.ts
+14
-0
apps/pac-service/tests/jvs-complaint-incremental.spec.ts
+79
-0
apps/pac-service/tests/jvs-complaint-ingestion.spec.ts
+251
-0
docs/qa/2026-10-18-jvs-complaint-clinics.csv
+71
-0
docs/qa/2026-10-18-jvs-complaint-evidence.json
+1792
-0
docs/qa/2026-10-18-jvs-complaint-ingestion-assessment.md
+184
-0
docs/qa/2026-10-18-jvs-complaint-ingestion-local-validation.md
+100
-0
packages/types/src/schemas/canonical.ts
+42
-18
No files found.
apps/pac-service/data/jvs-dw/assemblers/complaint.yaml
0 → 100644
View file @
c9360bcb
# 标准反馈快照;有效投诉由标准性质+状态决定,不能整表/按评分猜投诉。
canonical
:
complaint
emits
:
action
:
complaint_filed
subjectType
:
complaint
occurredAtField
:
filedAt
contentVersion
:
true
primary
:
table
:
complaint_rows
key
:
id
# group_rows 已按 [brand,id] 聚合,不再取首行丢弃跟进。
field_mapping
:
externalId
:
id
patientExternalId
:
patient_id
clinicId
:
organization_id
filedAt
:
complain_date
createdAt
:
created_gmt_at
updatedAt
:
snapshot_changed_at
sourceUpdatedAt
:
updated_gmt_at
number
:
complain_number
content
:
complain_content
feedbackKind
:
complain_concerned
classificationKind
:
complain_classification
category
:
complain_concerned
channel
:
complain_channel
status
:
complain_type
sourceStatus
:
complain_type_name
sourceCategory
:
complain_classification_name
sourceConcern
:
complain_concerned_name
sourceChannel
:
complain_channel_name
relatedOrderExternalId
:
settlement_number
followUps
:
follow_ups
# 未识别枚举保持未知;旧的“其他投诉”默认分类不强行盖过无不满/表扬。
# 仍保留其原文供复核;明确服务/医疗投诉与表扬冲突时由通用 parser 标记未知。
enum_mapping
:
feedbackKind
:
'
1'
:
complaint
'
3'
:
complaint
'
4'
:
complaint
'
5'
:
complaint
'
6'
:
complaint
'
8'
:
praise
'
9'
:
neutral
_default
:
unknown
classificationKind
:
'
1'
:
complaint
'
2'
:
complaint
'
4'
:
praise
_default
:
unknown
category
:
'
1'
:
service
'
3'
:
clinical
'
4'
:
billing
'
5'
:
waiting
'
6'
:
environment
_default
:
unknown
channel
:
'
1'
:
hotline
'
3'
:
third_party
'
4'
:
sms_survey
'
5'
:
mini_program
'
6'
:
sales
_default
:
unknown
status
:
'
1'
:
in_progress
'
2'
:
open
'
4'
:
resolved
'
5'
:
draft
_default
:
unknown
apps/pac-service/data/jvs-dw/manifest.yaml
View file @
c9360bcb
...
...
@@ -100,6 +100,8 @@ sql_source:
incremental
:
per_query
:
fact_client_out
:
{
cursor_column
:
last_visit_time
}
# 仅发现变化;明细查询不加游标,保证整单跟进完整。新表首轮补发现历史患者。
complaint_changes
:
{
cursor_column
:
changed_at
,
bootstrap_full
:
true
}
# 复杂病例:用 SELECT 里的 changed_at 别名(coalesce(updated,created));CH 支持 WHERE 引用别名。
fact_complex_cases_out
:
{
cursor_column
:
changed_at
}
fact_appointment_out
:
{
cursor_column
:
updated_date
}
...
...
@@ -133,12 +135,25 @@ sql_source:
-
fact_settlement_out
-
fact_settlement_mode_out
-
fact_complex_cases_out
-
complaint_changes
# SQL 最朴素化 — host(DW)给的数据范围就是 PAC 该消化的范围。
# PAC 这边不预设诊所 / brand / 时间过滤,数据来什么就是什么。
# dev 期的 cohort LIMIT 已去除 → 全量;只保留必要业务过滤(退费状态)。
# cohort 用 (patient_id, brand) 复合键 — patient_id 跨 brand 撞车,需带 brand 才精确。
queries
:
# 投诉表混合满意度/表扬/投诉,完整读取变化后的状态,不在 SQL 过滤成仅未完成/负面。
# 字符串源时间均为北京时间;沿用 manifest.timezone 格式化游标,canonical 再归一到 UTC。
# 保留正常查询里的 changed_at 别名,供 cohort UNION 和空转探针共同复用。
complaint_changes
:
|
SELECT patient_id, brand,
greatest(ifNull(created_gmt_at, ''), ifNull(updated_gmt_at, ''), ifNull(follow_date, '')) AS changed_at
FROM dw_group.fact_complain_out
WHERE patient_id IS NOT NULL AND patient_id > 0 AND notEmpty(ifNull(brand, ''))
# 不配 per_query:被变化带进 cohort 的患者,拉全部投诉与跟进,而不是只有窗口内子行。
fact_complain_out
:
|
SELECT * FROM dw_group.fact_complain_out
# ── 患者主档 ──
# has_active_complex_case:宿主跟进闸(→ canonical hostFollowUpActive → patient_profiles)。
# DW「潜在治疗池」= FRIDAY 侧「复杂病例」:宿主自己在跟的患者,PAC 不再电话召回。
...
...
@@ -352,6 +367,36 @@ sql_source:
# Layer A.5 transforms(SQL 干过的事搬这里,yaml 声明式)
# ─────────────────────────────────────────────────────────────
transforms
:
# 投诉主单+跟进(源主字段冲突走拒绝举证,不随机匹配;空品牌不回落单命名空间)。
-
kind
:
derive
input
:
fact_complain_out
output
:
fact_complain_out
fields
:
follow_action
:
op
:
recode
from
:
follow_type
default
:
unknown
map
:
{
'
转交'
:
transferred
,
'
完成'
:
completed
,
'
编辑'
:
edited
,
'
跟进'
:
followed_up
,
'
暂时搁置'
:
paused
}
-
kind
:
group_rows
input
:
fact_complain_out
output
:
complaint_rows
by
:
[
brand
,
id
]
consistent_fields
:
[
patient_id
,
organization_id
,
complain_number
,
complain_date
,
created_gmt_at
,
updated_gmt_at
,
complain_content
,
complain_type
,
complain_type_name
,
complain_concerned
,
complain_concerned_name
,
complain_classification
,
complain_classification_name
,
complain_channel
,
complain_channel_name
,
settlement_number
]
max_fields
:
snapshot_changed_at
:
[
created_gmt_at
,
updated_gmt_at
,
follow_date
]
collect
:
follow_ups
:
present_when
:
[
follow_date
,
follow_type
,
follow_content
,
follow_user_id
,
follow_user_name
]
fields
:
occurredAt
:
follow_date
action
:
follow_action
sourceAction
:
follow_type
content
:
follow_content
actorId
:
follow_user_id
actorName
:
follow_user_name
# ── A. EMR 主表 → 只留自由文本(剥离 JSON 字段) ──
-
kind
:
project
input
:
fact_emr_treatment_out
...
...
@@ -1004,6 +1049,7 @@ transforms:
# PAC 写的 assembler 配置(每个 canonical resource 一份)
# ─────────────────────────────────────────────────────────────
assemblers
:
-
{
file
:
assemblers/complaint.yaml
}
-
{
file
:
assemblers/patient.yaml
}
-
{
file
:
assemblers/patient_relation.yaml
}
# 联系人/亲属边(referee → PatientRelation upsert)
-
{
file
:
assemblers/patient_return_visit.yaml
}
# 诊所回访(展示用,全瑞尔 → PatientReturnVisit upsert)
...
...
apps/pac-service/src/modules/sync/assembler/assembler-engine.ts
View file @
c9360bcb
No preview for this file type
apps/pac-service/src/modules/sync/assembler/field-mapper.ts
View file @
c9360bcb
...
...
@@ -188,6 +188,16 @@ export function normalizeCanonical(
}
}
// 嵌套业务时间也按宿主时区解释,禁止跟进裸时间依赖 Node 进程时区。
for
(
const
{
array
,
field
}
of
meta
.
datetimeArrayFields
??
[])
{
const
items
=
coerceArray
(
out
[
array
]);
if
(
items
)
out
[
array
]
=
items
.
map
(
item
=>
{
if
(
!
item
||
typeof
item
!==
'object'
)
return
item
;
const
row
=
item
as
Record
<
string
,
unknown
>
;
return
typeof
row
[
field
]
===
'string'
?
{
...
row
,
[
field
]:
normalizeDatetime
(
row
[
field
]
as
string
,
ctx
.
timezone
)
}
:
item
;
});
}
return
out
;
}
...
...
apps/pac-service/src/modules/sync/cold-import/clickhouse-source.service.ts
View file @
c9360bcb
...
...
@@ -76,7 +76,7 @@ export function resolveOnlyPatientKeys(): OnlyPatientKey[] {
/// W4 末:DW 增量配置(per table cursor)
export
interface
IncrementalConfig
{
/// per query 配置:cursor 列名(host 表那边的字段)+ 上次 cursor 值(从 sync_logs 读)
perQuery
:
Record
<
string
,
{
cursorColumn
:
string
;
cursorValue
:
string
|
null
}
>
;
perQuery
:
Record
<
string
,
{
cursorColumn
:
string
;
cursorValue
:
string
|
null
;
bootstrapFull
?:
boolean
}
>
;
/// 跑完后返回新的 cursor 值(每张表的 max)
cursorAdvances
?:
Record
<
string
,
string
>
;
}
...
...
@@ -223,11 +223,8 @@ export class ClickHouseSourceService {
// 反向结算 is_refund=1、无就诊记录患者 last_visit_time NULL)→ 真实拉取正确地不拉,
// 探针却数到 → 误报"增量空转"CRITICAL(2026-07-25 实测:settlement 正/退表各 5788
// 完全相等,正是这批全为退费单的铁证)。
const
clauses
=
[
`
${
cfg
.
cursorColumn
}
> '
${
cfg
.
cursorValue
.
replace
(
/'/g
,
"''"
)}
'`
,
...
this
.
extractBusinessFilters
(
sql
),
];
const
q
=
`SELECT count(*) AS c FROM
${
m
[
1
]}
WHERE
${
clauses
.
join
(
' AND '
)}
`
;
// 包裹真实增量查询,保留 changed_at 等 SELECT 派生别名,避免探针直查物理表报未知列。
const
q
=
`SELECT count(*) AS c FROM (
${
this
.
injectIncrementalCursor
(
sql
,
cfg
.
cursorColumn
,
cfg
.
cursorValue
)}
)`
;
const
rs
=
await
client
.
query
({
query
:
q
,
format
:
'JSONEachRow'
});
const
rows
=
(
await
rs
.
json
())
as
Array
<
{
c
:
string
|
number
}
>
;
out
[
tableName
]
=
Number
(
rows
[
0
]?.
c
??
0
);
...
...
@@ -483,7 +480,7 @@ export class ClickHouseSourceService {
const incBranches: string[] = [];
if (incremental) {
for (const [tbl, cfg] of Object.entries(incremental.perQuery)) {
if (!cfg.cursorColumn ||
!cfg.cursorValue
) continue;
if (!cfg.cursorColumn ||
(!cfg.cursorValue && !cfg.bootstrapFull)
) continue;
const q = source.queries[tbl];
const keyCols = tenant_key_column
? `
$
{
patient_key_column
},
$
{
tenant_key_column
}
`
...
...
@@ -505,7 +502,8 @@ export class ClickHouseSourceService {
}
if (!branch) {
const fqn = (q && this.splitSelectFrom(q)?.fromTable) ?? tbl;
branch = `
SELECT
$
{
keyCols
}
FROM
$
{
fqn
}
WHERE
$
{
cfg
.
cursorColumn
}
>
'${cfg.cursorValue.replace(/'
/
g
,
"''"
)}
'`;
branch = `
SELECT
$
{
keyCols
}
FROM
$
{
fqn
}
` + (cfg.cursorValue
? `
WHERE
$
{
cfg
.
cursorColumn
}
>
'${cfg.cursorValue.replace(/'
/
g
,
"''"
)}
'` : '');
}
incBranches.push(branch);
}
...
...
apps/pac-service/src/modules/sync/cold-import/cold-import.service.ts
View file @
c9360bcb
...
...
@@ -971,6 +971,7 @@ export class ColdImportService {
table
,
{
cursorColumn
:
cfg
.
cursor_column
,
bootstrapFull
:
cfg
.
bootstrap_full
,
// ⚠️ 必须规范化:DW 的 cursor 列是 String(如 updated_date: Nullable(String),
// 值形如 'YYYY-MM-DD HH:mm:ss')→ WHERE 比较是字典序。游标存的是 run_start ISO
// ('2026-06-09T03:30:00.012Z'),第 11 字符 'T'(0x54) > ' '(0x20) → 所有真实行
...
...
apps/pac-service/src/modules/sync/cold-import/manifest.schema.ts
View file @
c9360bcb
...
...
@@ -66,6 +66,8 @@ export const IncrementalDeclSchema = z.object({
z
.
string
(),
z
.
object
({
cursor_column
:
z
.
string
().
min
(
1
),
// 新资源无水位时主动发现历史患者;默认保持旧资源行为。
bootstrap_full
:
z
.
boolean
().
optional
(),
}),
),
});
...
...
apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
View file @
c9360bcb
...
...
@@ -17,6 +17,7 @@
*/
import
{
z
}
from
'zod'
;
import
{
ComplaintStatusSchema
,
ComplaintFeedbackKindSchema
,
ComplaintCategorySchema
,
ComplaintChannelSchema
,
ClinicalSignalsSchema
,
FactType
,
PACDiagnosisCodeSchema
,
...
...
@@ -445,7 +446,11 @@ const ComplaintRecordContent = z
.
object
({
complaint_external_id
:
z
.
string
().
min
(
1
),
severity
:
z
.
enum
([
'low'
,
'medium'
,
'high'
]).
nullable
().
optional
().
default
(
null
),
status
:
z
.
enum
([
'filed'
,
'in_progress'
,
'resolved'
]).
nullable
().
optional
().
default
(
null
),
status
:
ComplaintStatusSchema
.
nullable
().
optional
().
default
(
null
),
feedback_kind
:
ComplaintFeedbackKindSchema
.
optional
(),
category
:
ComplaintCategorySchema
.
optional
(),
channel
:
ComplaintChannelSchema
.
optional
(),
signal_eligible
:
z
.
boolean
().
optional
(),
content
:
nullableString
(),
})
.
passthrough
();
...
...
apps/pac-service/src/modules/sync/pipeline/parsers/complaint.parser.ts
View file @
c9360bcb
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
Action
,
FactKind
,
FactStatus
,
FactType
}
from
'@pac/types'
;
import
type
{
FactDraft
,
Parser
,
ParserContext
}
from
'./parser.interface'
;
import
{
Injectable
}
from
'@nestjs/common'
;
import
{
Action
,
FactKind
,
FactStatus
,
FactType
,
ComplaintCanonicalSchema
}
from
'@pac/types'
;
import
type
{
FactDraft
,
FactSnapshotScope
,
Parser
,
ParserContext
}
from
'./parser.interface'
;
/**
* ComplaintParser — `complaint_filed` / `complaint_resolved` 解析器
*
* 产 1 个 `complaint_record`(actual)— 投诉事实。
* 同 subject_id 在 filed → resolved 两次 transaction 间走版本流(supersede)。
*
* 合规重要性:Persona.do_not_contact_status 应消费 active complaint_record 触发 DNC。
*/
/** 投诉标准快照;只负责事实,不设置免打扰、不改变召回规则。 */
@
Injectable
()
export
class
ComplaintParser
implements
Parser
{
readonly
name
=
Action
.
COMPLAINT_FILED
;
private
readonly
logger
=
new
Logger
(
ComplaintParser
.
name
);
parse
(
ctx
:
ParserContext
):
FactDraft
[]
{
const
c
=
ctx
.
canonicalRow
;
const
externalId
=
String
(
c
.
externalId
??
ctx
.
transaction
.
subjectId
).
trim
();
if
(
!
externalId
)
{
this
.
logger
.
warn
(
`complaint parser: missing externalId; tx=
${
ctx
.
transaction
.
id
}
;skip`
);
return
[];
snapshotScope
(
ctx
:
ParserContext
):
FactSnapshotScope
{
const
c
=
ComplaintCanonicalSchema
.
parse
(
ctx
.
canonicalRow
);
return
{
anchorSubjectId
:
`
${
FactType
.
COMPLAINT_RECORD
}
:
${
c
.
externalId
}
`
,
memberType
:
FactType
.
COMPLAINT_RECORD
,
contentKey
:
'complaint_external_id'
,
externalId
:
c
.
externalId
,
replace
:
false
};
}
const
filedAt
=
c
.
filedAt
?
new
Date
(
c
.
filedAt
as
string
)
:
null
;
const
resolved
=
ctx
.
transaction
.
action
===
Action
.
COMPLAINT_RESOLVED
;
const
resolvedAt
=
c
.
resolvedAt
?
new
Date
(
c
.
resolvedAt
as
string
)
:
null
;
const
hostStatus
=
String
(
c
.
status
??
(
resolved
?
'resolved'
:
'open'
)).
toLowerCase
();
const
factStatus
=
hostStatus
===
'resolved'
?
FactStatus
.
FULFILLED
:
FactStatus
.
ACTIVE
;
return
[
{
subjectId
:
`
${
FactType
.
COMPLAINT_RECORD
}
:
${
externalId
}
`
,
kind
:
FactKind
.
ACTUAL
,
type
:
FactType
.
COMPLAINT_RECORD
,
status
:
factStatus
,
clinicId
:
ctx
.
transaction
.
clinicId
,
occurredAt
:
filedAt
,
title
:
`投诉
${
externalId
}${
resolved
?
'(已结案)'
:
''
}
`
,
summary
:
(
c
.
content
as
string
|
undefined
)
??
null
,
parse
(
ctx
:
ParserContext
):
FactDraft
[]
{
const
c
=
ComplaintCanonicalSchema
.
parse
(
ctx
.
canonicalRow
);
const
classificationConflict
=
c
.
classificationKind
!==
'unknown'
&&
c
.
feedbackKind
!==
'unknown'
&&
c
.
classificationKind
!==
c
.
feedbackKind
;
const
kind
=
classificationConflict
?
'unknown'
:
c
.
feedbackKind
;
const
eligible
=
kind
===
'complaint'
&&
[
'open'
,
'in_progress'
,
'resolved'
].
includes
(
c
.
status
);
const
followUps
=
c
.
followUps
.
map
(
f
=>
({
...
f
,
occurredAt
:
f
.
occurredAt
?
new
Date
(
f
.
occurredAt
).
toISOString
()
:
null
,
})).
sort
((
a
,
b
)
=>
JSON
.
stringify
(
a
).
localeCompare
(
JSON
.
stringify
(
b
)));
const
completionTimes
=
followUps
.
filter
(
f
=>
f
.
action
===
'completed'
&&
f
.
occurredAt
)
.
map
(
f
=>
f
.
occurredAt
!
);
if
(
c
.
resolvedAt
)
completionTimes
.
push
(
new
Date
(
c
.
resolvedAt
).
toISOString
());
const
resolvedAt
=
c
.
status
===
'resolved'
?
completionTimes
.
sort
().
at
(
-
1
)
??
null
:
null
;
// 非投诉/未知/草稿保留无效快照:新分类能替换旧有效投诉,旧报文重试不能复活它。
// ⛔ 不把这些快照当有效投诉统计;消费时检查 signal_eligible 与最新版本。
return
[{
subjectId
:
`
${
FactType
.
COMPLAINT_RECORD
}
:
${
c
.
externalId
}
`
,
kind
:
FactKind
.
ACTUAL
,
type
:
FactType
.
COMPLAINT_RECORD
,
status
:
!
eligible
?
FactStatus
.
INVALIDATED
:
c
.
status
===
'resolved'
?
FactStatus
.
FULFILLED
:
FactStatus
.
ACTIVE
,
clinicId
:
c
.
clinicId
,
occurredAt
:
new
Date
(
c
.
filedAt
),
title
:
eligible
?
`投诉
${
c
.
status
===
'resolved'
?
'(已完成)'
:
''
}
`
:
'患者反馈(非有效投诉)'
,
summary
:
c
.
content
??
null
,
content
:
{
complaint_external_id
:
externalId
,
host_status
:
hostStatus
,
category
:
c
.
category
??
null
,
content
:
c
.
content
??
null
,
complaint_external_id
:
c
.
externalId
,
number
:
c
.
number
??
null
,
feedback_kind
:
kind
,
status
:
c
.
status
,
category
:
c
.
category
,
channel
:
c
.
channel
,
signal_eligible
:
eligible
,
review_reason
:
classificationConflict
?
'classification_conflict'
:
kind
===
'unknown'
?
'unknown_feedback_kind'
:
c
.
status
===
'unknown'
?
'unknown_status'
:
null
,
content
:
c
.
content
??
null
,
severity
:
null
,
related_treatment_subject_id
:
c
.
relatedTreatmentSubjectId
??
null
,
resolved_at
:
resolvedAt
?.
toISOString
()
??
null
,
},
related_order_external_id
:
c
.
relatedOrderExternalId
??
null
,
resolved_at
:
resolvedAt
,
follow_ups
:
followUps
,
source_updated_at
:
c
.
sourceUpdatedAt
?
new
Date
(
c
.
sourceUpdatedAt
).
toISOString
()
:
null
,
host_status
:
c
.
sourceStatus
??
c
.
status
,
source_category
:
c
.
sourceCategory
??
null
,
source_concern
:
c
.
sourceConcern
??
null
,
source_channel
:
c
.
sourceChannel
??
null
,
},
];
}
];
}
}
apps/pac-service/src/modules/sync/transforms/operators/derive.op.ts
View file @
c9360bcb
...
...
@@ -122,8 +122,8 @@ function evalExpr(expr: DeriveExpr, row: Row): unknown {
}
case
'recode'
:
{
const
v
=
row
[
expr
.
from
];
if
(
typeof
v
!==
'string'
)
return
v
??
null
;
return
Object
.
prototype
.
hasOwnProperty
.
call
(
expr
.
map
,
v
)
?
expr
.
map
[
v
]
:
v
;
if
(
typeof
v
!==
'string'
)
return
expr
.
default
??
v
??
null
;
return
Object
.
prototype
.
hasOwnProperty
.
call
(
expr
.
map
,
v
)
?
expr
.
map
[
v
]
:
(
expr
.
default
??
v
)
;
}
}
}
...
...
apps/pac-service/src/modules/sync/transforms/operators/group-rows.op.ts
0 → 100644
View file @
c9360bcb
import
{
createHash
}
from
'node:crypto'
;
import
type
{
GroupRowsOp
}
from
'../transforms.schema'
;
import
{
SOURCE_ROW_KEY
,
isEmptyText
,
type
Row
}
from
'../row'
;
export
const
GROUP_ERROR
=
'__group_error'
;
const
ENVELOPE
=
'__pac_group_rows'
;
const
SNAPSHOT
=
'__group_snapshot'
;
/** 原文是多行快照时,离线重放恢复原输入表,不把一条跟进冒充整单。 */
export
function
unpackGroupRows
(
table
:
string
,
row
:
Row
):
Row
[]
{
const
envelope
=
row
[
ENVELOPE
]
as
{
table
?:
string
;
rows
?:
Row
[]
}
|
undefined
;
if
(
envelope
?.
table
!==
table
||
!
Array
.
isArray
(
envelope
.
rows
))
return
[
row
];
// 每个封套只算一次短指纹,避免把完整原文重复拼进每条跟进的分组键。
const
snapshot
=
createHash
(
'sha256'
).
update
(
stable
(
envelope
)).
digest
(
'hex'
);
return
envelope
.
rows
.
map
(
raw
=>
({
...
raw
,
[
SOURCE_ROW_KEY
]:
raw
,
[
SNAPSHOT
]:
snapshot
}));
}
function
stable
(
value
:
unknown
):
string
{
return
JSON
.
stringify
(
value
,
(
_key
,
v
)
=>
v
&&
typeof
v
===
'object'
&&
!
Array
.
isArray
(
v
)
?
Object
.
fromEntries
(
Object
.
keys
(
v
).
sort
().
map
(
k
=>
[
k
,
v
[
k
]]))
:
v
);
}
function
uniqueSorted
(
rows
:
Row
[]):
Row
[]
{
return
[...
new
Map
(
rows
.
map
(
r
=>
[
stable
(
r
),
r
])).
entries
()]
.
sort
(([
a
],
[
b
])
=>
a
<
b
?
-
1
:
a
>
b
?
1
:
0
).
map
(([,
r
])
=>
r
);
}
/** 仅聚合配置指定的字段;身份缺失/主体矛盾送 assembler 的拒绝举证。 */
export
function
runGroupRows
(
op
:
GroupRowsOp
,
input
:
Row
[]):
Row
[]
{
const
groups
=
new
Map
<
string
,
Row
[]
>
();
input
.
forEach
((
row
,
i
)
=>
{
const
keys
=
op
.
by
.
map
(
k
=>
row
[
k
]);
const
key
=
keys
.
some
(
isEmptyText
)
?
`invalid:
${
i
}
`
:
stable
([
row
[
SNAPSHOT
]
??
null
,
...
keys
.
map
(
String
)]);
const
rows
=
groups
.
get
(
key
)
??
[];
rows
.
push
(
row
);
groups
.
set
(
key
,
rows
);
});
return
[...
groups
.
values
()].
map
(
rows
=>
{
const
raw
=
uniqueSorted
(
rows
.
map
(
row
=>
(
row
[
SOURCE_ROW_KEY
]
??
row
)
as
Row
));
const
first
=
rows
[
0
]
!
;
const
out
:
Row
=
Object
.
fromEntries
([...
new
Set
([...
op
.
by
,
...
op
.
consistent_fields
])]
.
map
(
k
=>
[
k
,
first
[
k
]
??
null
]));
const
missing
=
op
.
by
.
filter
(
k
=>
isEmptyText
(
first
[
k
]));
const
conflicts
=
op
.
consistent_fields
.
filter
(
k
=>
new
Set
(
rows
.
map
(
row
=>
stable
(
row
[
k
]
??
null
))).
size
>
1
);
if
(
missing
.
length
||
conflicts
.
length
)
{
out
[
GROUP_ERROR
]
=
`group_rows: missing keys [
${
missing
.
join
(
','
)}
]; conflicting fields [
${
conflicts
.
join
(
','
)}
]`
;
}
for
(
const
[
target
,
fields
]
of
Object
.
entries
(
op
.
max_fields
))
{
const
values
=
rows
.
flatMap
(
row
=>
fields
.
map
(
f
=>
row
[
f
])).
filter
(
v
=>
!
isEmptyText
(
v
));
// 此算子只比较同格式源时间字符串,不猜测时区/混合格式。
if
(
values
.
some
(
v
=>
typeof
v
!==
'string'
||
!
/^
\d{4}
-
\d\d
-
\d\d[
T
]\d\d
:
\d\d
:
\d\d(?:\.\d
+
)?
$/
.
test
(
v
)))
{
out
[
GROUP_ERROR
]
=
`group_rows: invalid comparable datetime [
${
target
}
]`
;
}
out
[
target
]
=
values
.
length
?
values
.
map
(
String
).
sort
().
at
(
-
1
)
!
:
null
;
}
for
(
const
[
target
,
collect
]
of
Object
.
entries
(
op
.
collect
))
{
out
[
target
]
=
uniqueSorted
(
rows
.
filter
(
row
=>
collect
.
present_when
.
some
(
f
=>
!
isEmptyText
(
row
[
f
])))
.
map
(
row
=>
Object
.
fromEntries
(
Object
.
entries
(
collect
.
fields
).
map
(([
k
,
f
])
=>
[
k
,
row
[
f
]
??
null
]))));
}
// 顶层保留原始主体字段供租户/命名空间解析,完整原行放有标识的证据封套。
out
[
SOURCE_ROW_KEY
]
=
{
...
raw
[
0
],
[
ENVELOPE
]:
{
table
:
op
.
input
,
rows
:
raw
}
};
return
out
;
});
}
apps/pac-service/src/modules/sync/transforms/transform-engine.ts
View file @
c9360bcb
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
runGroupRows
,
unpackGroupRows
}
from
'./operators/group-rows.op'
;
import
{
runProject
}
from
'./operators/project.op'
;
import
{
runSplitJsonArray
}
from
'./operators/split-json-array.op'
;
import
{
runDerive
}
from
'./operators/derive.op'
;
...
...
@@ -36,7 +37,7 @@ export class TransformEngine {
const
tables
:
TableMap
=
{};
// 入口标记:每个源行带上自身的 __source_row 引用(深拷贝避免后续 operator 修改污染)
for
(
const
[
name
,
rows
]
of
Object
.
entries
(
opts
.
tables
))
{
tables
[
name
]
=
rows
.
map
((
row
)
=>
{
tables
[
name
]
=
rows
.
flatMap
(
row
=>
unpackGroupRows
(
name
,
row
)).
map
((
row
)
=>
{
// 已有 __source_row 的不覆盖(允许 caller 提前注入,比如 Push 路径)
if
(
SOURCE_ROW_KEY
in
row
)
return
row
;
return
{
...
row
,
[
SOURCE_ROW_KEY
]:
row
};
...
...
@@ -63,6 +64,12 @@ export class TransformEngine {
}
switch
(
op
.
kind
)
{
case
'group_rows'
:
{
const
out
=
runGroupRows
(
op
,
input
);
tables
[
op
.
output
]
=
out
;
this
.
logger
.
log
(
`[transform/group_rows] "
${
inputName
}
"(
${
input
.
length
}
) → "
${
op
.
output
}
"(
${
out
.
length
}
)`
);
break
;
}
case
'project'
:
{
const
out
=
runProject
(
op
,
input
);
tables
[
op
.
output
]
=
out
;
...
...
apps/pac-service/src/modules/sync/transforms/transforms.schema.ts
View file @
c9360bcb
...
...
@@ -190,6 +190,7 @@ export const DeriveCoalesceSchema = z.object({
/// { from: std_code, map: { 'K07.303': 'K03' } } → K07.303 改 K03,再交 substring 截短;其余码不变。
export
const
DeriveRecodeSchema
=
z
.
object
({
op
:
z
.
literal
(
'recode'
),
default
:
z
.
string
().
optional
(),
from
:
z
.
string
().
min
(
1
),
map
:
z
.
record
(
z
.
string
(),
z
.
string
()).
describe
(
'原值 → 目标值;未命中则透传原值'
),
});
...
...
@@ -422,6 +423,22 @@ export type LookupOp = z.infer<typeof LookupOpSchema>;
// operator union + transforms[] 顶层
// ─────────────────────────────────────────────────────────
/** 一对多展开表还原稳定主体快照;字段/身份均由宿主配置声明。 */
export
const
GroupRowsOpSchema
=
z
.
object
({
kind
:
z
.
literal
(
'group_rows'
),
input
:
tableNameSchema
,
output
:
tableNameSchema
,
by
:
z
.
array
(
z
.
string
().
min
(
1
)).
min
(
1
),
consistent_fields
:
z
.
array
(
z
.
string
().
min
(
1
)).
min
(
1
),
// 同格式、同时区的源时间字符串求最大;时区归一在 canonical 入口完成。
max_fields
:
z
.
record
(
z
.
string
(),
z
.
array
(
z
.
string
().
min
(
1
)).
min
(
1
)).
default
({}),
collect
:
z
.
record
(
z
.
string
(),
z
.
object
({
fields
:
z
.
record
(
z
.
string
(),
z
.
string
()),
present_when
:
z
.
array
(
z
.
string
().
min
(
1
)).
min
(
1
),
})).
default
({}),
});
export
type
GroupRowsOp
=
z
.
infer
<
typeof
GroupRowsOpSchema
>
;
export
const
TransformOpSchema
=
z
.
discriminatedUnion
(
'kind'
,
[
ProjectOpSchema
,
SplitJsonArrayOpSchema
,
...
...
@@ -431,6 +448,7 @@ export const TransformOpSchema = z.discriminatedUnion('kind', [
FilterOpSchema
,
UnionOpSchema
,
LookupOpSchema
,
GroupRowsOpSchema
,
]);
export
type
TransformOp
=
z
.
infer
<
typeof
TransformOpSchema
>
;
...
...
apps/pac-service/tests/canonical-fact-layer.spec.ts
View file @
c9360bcb
...
...
@@ -23,6 +23,7 @@ import {
PACImageModalities
,
FDI_TOOTH_POSITIONS
,
ImageAnalysisCanonicalSchema
,
ComplaintStatusSchema
,
ComplaintFeedbackKindSchema
,
ComplaintCategorySchema
,
ComplaintChannelSchema
,
}
from
'@pac/types'
;
import
{
FactContentSchemas
}
from
'../src/modules/sync/pipeline/fact-content-schemas'
;
import
{
...
...
@@ -194,6 +195,19 @@ describe('canonical-fact-layer | 闸 4:yaml enum_mapping 目标 ∈ canonical-co
});
continue
;
}
// 同名字段按资源契约校验:投诉的 category/status 不是治疗类别/治疗状态。
if
(
config
.
canonical
===
'complaint'
)
{
const
complaintEnums
:
Record
<
string
,
readonly
string
[]
>
=
{
status
:
ComplaintStatusSchema
.
options
,
category
:
ComplaintCategorySchema
.
options
,
channel
:
ComplaintChannelSchema
.
options
,
feedbackKind
:
ComplaintFeedbackKindSchema
.
options
,
classificationKind
:
ComplaintFeedbackKindSchema
.
options
,
};
const
options
=
complaintEnums
[
canonicalField
];
if
(
options
)
test
(
`
${
host
}
/
${
file
}
:complaint.
${
canonicalField
}
及兜底值符合标准枚举`
,
()
=>
{
for
(
const
target
of
Object
.
values
(
valueMap
))
expect
(
options
).
toContain
(
target
);
});
continue
;
}
const
allowed
=
canonicalField
===
'status'
&&
config
.
canonical
===
'image_analysis'
?
new
Set
<
string
>
(
ImageAnalysisCanonicalSchema
.
shape
.
status
.
options
)
:
closedSets
[
canonicalField
];
...
...
apps/pac-service/tests/jvs-complaint-incremental.spec.ts
0 → 100644
View file @
c9360bcb
import
{
readFileSync
,
writeFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
Logger
}
from
'@nestjs/common'
;
import
{
createClient
}
from
'@clickhouse/client'
;
import
{
ColdImportManifestSchema
}
from
'../src/modules/sync/cold-import/manifest.schema'
;
import
{
ClickHouseSourceService
,
type
IncrementalConfig
}
from
'../src/modules/sync/cold-import/clickhouse-source.service'
;
jest
.
mock
(
'@clickhouse/client'
,
()
=>
({
createClient
:
jest
.
fn
()
}));
const
manifest
=
ColdImportManifestSchema
.
parse
(
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
),
'utf8'
)));
const
source
=
{
...
manifest
.
sql_source
!
,
connection
:
{
...
manifest
.
sql_source
!
.
connection
,
password_env
:
'PAC_TEST_COMPLAINT_CH_PASSWORD'
}
};
beforeAll
(()
=>
{
process
.
env
.
PAC_TEST_COMPLAINT_CH_PASSWORD
=
'mock-only'
;
});
afterAll
(()
=>
{
delete
process
.
env
.
PAC_TEST_COMPLAINT_CH_PASSWORD
;
});
const
svc
=
new
ClickHouseSourceService
();
let
queries
:
string
[]
=
[];
const
cursor
=
'2026-10-16 22:15:00'
;
// 对应 UTC 2026-10-16T14:15:00Z,调用方已按源时区回转
beforeEach
(()
=>
{
Logger
.
overrideLogger
(
false
);
queries
=
[];
(
createClient
as
jest
.
Mock
).
mockReturnValue
({
query
:
jest
.
fn
(
async
({
query
}:
{
query
:
string
})
=>
{
queries
.
push
(
query
);
return
{
json
:
async
()
=>
query
.
startsWith
(
'SELECT count(*)'
)
?
[{
c
:
'3'
}]
:
[]
};
}),
close
:
jest
.
fn
(
async
()
=>
undefined
),
});
});
function
increment
(
value
:
string
|
null
):
IncrementalConfig
{
return
{
perQuery
:
{
complaint_changes
:
{
cursorColumn
:
'changed_at'
,
cursorValue
:
value
,
bootstrapFull
:
true
}
}
};
}
test
(
'已有宿主新增投诉资源:无游标时扫描历史变化患者,其他资源无游标不受影响'
,
async
()
=>
{
await
svc
.
listPatientPairs
(
source
,
increment
(
null
));
expect
(
queries
[
0
]).
toContain
(
'fact_complain_out'
);
expect
(
queries
[
0
]).
toContain
(
'patient_id, brand'
);
expect
(
queries
[
0
]).
not
.
toContain
(
'changed_at >'
);
expect
(
queries
[
0
]).
toContain
(
'patient_id > 0'
);
const
noBootstrap
=
increment
(
null
);
noBootstrap
.
perQuery
.
complaint_changes
!
.
bootstrapFull
=
false
;
queries
=
[];
await
svc
.
listPatientPairs
(
source
,
noBootstrap
);
expect
(
queries
[
0
]).
not
.
toContain
(
'fact_complain_out'
);
});
test
(
'日常增量同时捕捉创建/主单更新/跟进更新,保留源时区游标'
,
async
()
=>
{
await
svc
.
listPatientPairs
(
source
,
increment
(
cursor
));
expect
(
queries
[
0
]).
toContain
(
"greatest(ifNull(created_gmt_at, ''), ifNull(updated_gmt_at, ''), ifNull(follow_date, '')) AS changed_at"
);
expect
(
queries
[
0
]).
toContain
(
`changed_at > '
${
cursor
}
'`
);
expect
(
queries
[
0
]).
toContain
(
'notEmpty'
);
});
test
(
'发现变化与完整读取分开:跟进快照不被增量窗口切断;按配置品牌+患者圈定'
,
async
()
=>
{
const
subset
=
{
...
source
,
queries
:
{
complaint_changes
:
source
.
queries
.
complaint_changes
!
,
fact_complain_out
:
source
.
queries
.
fact_complain_out
!
}
};
await
svc
.
loadTablesForCohort
(
subset
,
[{
key
:
'123'
,
tenant
:
'瑞尔'
}],
increment
(
cursor
));
const
full
=
queries
.
find
(
q
=>
q
.
includes
(
'SELECT *'
))
!
;
expect
(
full
).
toContain
(
"(patient_id, brand) IN (('123', '瑞尔'))"
);
expect
(
full
).
not
.
toContain
(
'changed_at >'
);
expect
(
full
).
not
.
toContain
(
'follow_date >'
);
expect
(
queries
.
find
(
q
=>
q
.
includes
(
'AS changed_at'
))).
toContain
(
`changed_at > '
${
cursor
}
'`
);
});
test
(
'空转探针复用派生游标查询,不把 changed_at 当物理列'
,
async
()
=>
{
expect
(
await
svc
.
probeNewRowCounts
(
source
,
increment
(
cursor
).
perQuery
)).
toEqual
({
complaint_changes
:
3
});
expect
(
queries
[
0
]).
toMatch
(
/^SELECT count
\(\*\)
AS c FROM
\(
SELECT/
);
expect
(
queries
[
0
]).
toContain
(
'AS changed_at'
);
expect
(
queries
[
0
]).
toContain
(
'patient_id > 0'
);
});
(
process
.
env
.
PAC_COMPLAINT_SQL_EVIDENCE
&&
process
.
env
.
PAC_COMPLAINT_REAL_FIXTURE
?
test
:
test
.
skip
)(
'导出实际生成SQL供独立只读数仓验证(仅本地私有路径)'
,
async
()
=>
{
const
raw
=
JSON
.
parse
(
readFileSync
(
process
.
env
.
PAC_COMPLAINT_REAL_FIXTURE
!
,
'utf8'
))
as
Record
<
string
,
unknown
>
[];
const
valid
=
raw
.
filter
(
r
=>
r
.
patient_id
&&
r
.
follow_date
&&
String
(
r
.
follow_date
)
>
String
(
r
.
updated_gmt_at
??
''
));
const
chosen
=
valid
[
0
]
!
;
const
subset
=
{
...
source
,
queries
:
{
complaint_changes
:
source
.
queries
.
complaint_changes
!
,
fact_complain_out
:
source
.
queries
.
fact_complain_out
!
}
};
const
mark
=
String
(
chosen
.
updated_gmt_at
??
chosen
.
created_gmt_at
);
await
svc
.
listPatientPairs
(
subset
,
increment
(
null
));
const
bootstrap
=
queries
[
0
];
queries
=
[];
await
svc
.
loadTablesForCohort
(
subset
,
[{
key
:
String
(
chosen
.
patient_id
),
tenant
:
String
(
chosen
.
brand
)
}],
increment
(
mark
));
const
cohort
=
queries
.
slice
();
queries
=
[];
await
svc
.
probeNewRowCounts
(
subset
,
increment
(
mark
).
perQuery
);
writeFileSync
(
process
.
env
.
PAC_COMPLAINT_SQL_EVIDENCE
!
,
JSON
.
stringify
({
bootstrap
,
cohort
,
probe
:
queries
[
0
],
chosen
:
{
id
:
chosen
.
id
,
brand
:
chosen
.
brand
,
patient_id
:
chosen
.
patient_id
},
cursor
:
mark
}),
{
mode
:
0o600
});
});
apps/pac-service/tests/jvs-complaint-ingestion.spec.ts
0 → 100644
View file @
c9360bcb
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
*
as
yaml
from
'js-yaml'
;
import
{
Logger
}
from
'@nestjs/common'
;
import
{
ComplaintCanonicalSchema
,
FactType
}
from
'@pac/types'
;
import
{
TransformEngine
}
from
'../src/modules/sync/transforms/transform-engine'
;
import
{
ColdImportManifestSchema
}
from
'../src/modules/sync/cold-import/manifest.schema'
;
import
{
AssemblerEngine
}
from
'../src/modules/sync/assembler/assembler-engine'
;
import
{
AssemblerConfigSchema
}
from
'../src/modules/sync/assembler/assembler.schema'
;
import
{
buildSourceUnitResolver
,
buildTenantResolver
}
from
'../src/modules/sync/cold-import/tenant-resolver'
;
import
{
ComplaintParser
}
from
'../src/modules/sync/pipeline/parsers/complaint.parser'
;
import
{
TransactionSynthesizer
}
from
'../src/modules/sync/pipeline/transaction-synthesizer'
;
import
{
FactContentSchemas
}
from
'../src/modules/sync/pipeline/fact-content-schemas'
;
import
type
{
Row
}
from
'../src/modules/sync/transforms/row'
;
export
const
manifest
=
ColdImportManifestSchema
.
parse
(
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/manifest.yaml'
),
'utf8'
)));
export
const
config
=
AssemblerConfigSchema
.
parse
(
yaml
.
load
(
readFileSync
(
join
(
__dirname
,
'../data/jvs-dw/assemblers/complaint.yaml'
),
'utf8'
)));
const
transforms
=
manifest
.
transforms
!
.
filter
(
t
=>
'input'
in
t
&&
t
.
input
===
'fact_complain_out'
);
const
namespace
=
buildSourceUnitResolver
(
manifest
);
export
function
assemble
(
raw
:
Row
[])
{
const
tables
=
new
TransformEngine
().
run
({
tables
:
{
fact_complain_out
:
raw
},
transforms
});
return
new
AssemblerEngine
().
assemble
({
tables
,
config
,
normalize
:
{
timezone
:
manifest
.
timezone
,
amountUnit
:
'yuan'
},
hostId
:
'test-host'
});
}
export
function
parse
(
row
:
ReturnType
<
typeof
assemble
>
[
'rows'
][
number
])
{
const
canonicalRow
=
row
.
canonical
as
Record
<
string
,
unknown
>
;
const
tx
=
new
TransactionSynthesizer
().
synthesize
({
rawRow
:
row
.
rawSource
,
canonicalRow
,
emits
:
config
.
emits
,
resource
:
'complaint'
,
hostId
:
'test-host'
,
tenantId
:
buildTenantResolver
(
manifest
).
resolve
(
row
.
rawSource
)
!
,
sourceUnit
:
namespace
.
resolve
(
row
.
rawSource
),
sourceContext
:
'cold-import'
,
patientIdResolver
:
id
=>
`patient:
${
id
}
`
})
!
;
const
context
=
{
canonicalRow
,
transaction
:
{
id
:
'tx-test'
,
hostId
:
tx
.
hostId
,
tenantId
:
tx
.
tenantId
,
patientId
:
tx
.
patientId
!
,
action
:
'complaint_filed'
as
const
,
subjectType
:
'complaint'
,
subjectId
:
tx
.
subjectId
,
clinicId
:
tx
.
clinicId
,
occurredAt
:
new
Date
(
tx
.
occurredAt
!
)
}
};
const
draft
=
new
ComplaintParser
().
parse
(
context
)[
0
]
!
;
expect
(
FactContentSchemas
[
FactType
.
COMPLAINT_RECORD
].
safeParse
(
draft
.
content
).
success
).
toBe
(
true
);
return
{
tx
,
draft
,
canonicalRow
,
context
};
}
export
const
rawRow
=
(
extra
:
Row
=
{}):
Row
=>
({
id
:
19
,
brand
:
'瑞尔'
,
patient_id
:
77
,
organization_id
:
'original-clinic'
,
complain_number
:
'C19'
,
complain_date
:
'2026-10-17 00:15:00.000'
,
created_gmt_at
:
'2026-10-17 00:16:00.000'
,
updated_gmt_at
:
null
,
complain_type
:
1
,
complain_type_name
:
'处理中'
,
complain_concerned
:
1
,
complain_concerned_name
:
'服务流程'
,
complain_classification
:
null
,
complain_classification_name
:
null
,
complain_channel
:
4
,
complain_channel_name
:
'满意度短信'
,
complain_content
:
'验证用反馈'
,
settlement_number
:
null
,
follow_date
:
'2026-10-17 10:30:00.000'
,
follow_type
:
'跟进'
,
follow_content
:
'已联系'
,
follow_user_id
:
'u1'
,
follow_user_name
:
'测试操作人'
,
...
extra
});
beforeAll
(()
=>
Logger
.
overrideLogger
(
false
));
test
(
'源时区归一:北京跨日转UTC,主单/版本/跟进同口径'
,
()
=>
{
const
result
=
assemble
([
rawRow
()]);
expect
(
result
.
errors
).
toEqual
([]);
const
{
tx
,
draft
,
canonicalRow
}
=
parse
(
result
.
rows
[
0
]
!
);
expect
(
tx
.
occurredAt
).
toEqual
(
new
Date
(
'2026-10-16T16:15:00.000Z'
));
expect
(
new
Date
(
canonicalRow
.
updatedAt
as
string
).
toISOString
()).
toBe
(
'2026-10-17T02:30:00.000Z'
);
expect
((
draft
.
content
as
any
).
follow_ups
[
0
].
occurredAt
).
toBe
(
'2026-10-17T02:30:00.000Z'
);
expect
(
draft
.
clinicId
).
toBe
(
'original-clinic'
);
expect
(
draft
.
status
).
toBe
(
'active'
);
});
test
(
'品牌由既有命名空间解析;同投诉ID/患者ID不跨品牌合并'
,
()
=>
{
const
result
=
assemble
([
rawRow
(),
rawRow
({
brand
:
'瑞泰'
})]);
expect
(
result
.
rows
).
toHaveLength
(
2
);
const
parsed
=
result
.
rows
.
map
(
parse
);
expect
(
new
Set
(
parsed
.
map
(
x
=>
x
.
tx
.
sourceEventId
)).
size
).
toBe
(
2
);
expect
(
result
.
rows
.
map
(
r
=>
namespace
.
resolve
(
r
.
rawSource
)).
sort
()).
toEqual
([
'瑞尔'
,
'瑞泰'
]);
expect
(
buildSourceUnitResolver
({
...
manifest
,
identity_namespace_field
:
'issuer'
}).
resolve
({
issuer
:
'scope'
})).
toBe
(
'scope'
);
});
test
(
'完整跟进稳定聚合:乱序/重复输入不改变幂等键;可离线重放'
,
()
=>
{
const
a
=
rawRow
(),
b
=
rawRow
({
follow_date
:
'2026-10-18 09:00:00.000'
,
follow_type
:
'完成'
});
const
original
=
[
a
,
b
];
const
r
=
assemble
(
original
);
expect
(
r
.
rows
).
toHaveLength
(
1
);
const
first
=
parse
(
r
.
rows
[
0
]
!
);
expect
((
first
.
canonicalRow
.
followUps
as
unknown
[])).
toHaveLength
(
2
);
expect
(
parse
(
assemble
([
b
,
a
,
a
]).
rows
[
0
]
!
).
tx
.
sourceEventId
).
toBe
(
first
.
tx
.
sourceEventId
);
expect
(
parse
(
assemble
([
r
.
rows
[
0
]
!
.
rawSource
]).
rows
[
0
]
!
).
tx
.
sourceEventId
).
toBe
(
first
.
tx
.
sourceEventId
);
expect
(
original
[
0
]).
not
.
toHaveProperty
(
'follow_action'
);
});
test
(
'离线多个历史快照不混拼成一个冲突主体'
,
()
=>
{
const
before
=
assemble
([
rawRow
()]).
rows
[
0
]
!
;
const
after
=
assemble
([
rawRow
({
complain_type
:
4
,
updated_gmt_at
:
'2026-10-18 09:00:00.000'
})]).
rows
[
0
]
!
;
const
replay
=
assemble
([
before
.
rawSource
,
after
.
rawSource
]);
expect
(
replay
.
errors
).
toEqual
([]);
expect
(
replay
.
rows
).
toHaveLength
(
2
);
});
test
.
each
([{
patient_id
:
null
},
{
brand
:
''
},
{
organization_id
:
''
},
{
complain_date
:
'bad-date'
}])(
'缺身份/非法业务时间拒绝:%j'
,
change
=>
{
const
r
=
assemble
([
rawRow
(
change
)]);
expect
(
r
.
rows
).
toHaveLength
(
0
);
expect
(
r
.
stats
.
failed
).
toBe
(
1
);
});
test
(
'主体冲突拒绝整单且不污染其他单'
,
()
=>
{
const
r
=
assemble
([
rawRow
(),
rawRow
({
patient_id
:
88
}),
rawRow
({
id
:
20
})]);
expect
(
r
.
stats
.
failed
).
toBe
(
1
);
expect
(
r
.
rows
).
toHaveLength
(
1
);
expect
(
r
.
errors
[
0
]
!
.
reason
).
toContain
(
'patient_id'
);
});
test
.
each
([
8
,
9
,
null
,
100
])(
'非负面/未分类不形成有效投诉 %s'
,
concern
=>
{
const
{
draft
}
=
parse
(
assemble
([
rawRow
({
complain_concerned
:
concern
})]).
rows
[
0
]
!
);
expect
(
draft
.
status
).
toBe
(
'invalidated'
);
expect
((
draft
.
content
as
any
).
signal_eligible
).
toBe
(
false
);
});
test
.
each
([
5
,
999
,
null
])(
'草稿/未知状态不默认open %s'
,
state
=>
{
expect
(
parse
(
assemble
([
rawRow
({
complain_type
:
state
})]).
rows
[
0
]
!
).
draft
.
status
).
toBe
(
'invalidated'
);
});
test
(
'明确相互矛盾的分类进入待确认;默认其他分类不覆盖实际关注点'
,
()
=>
{
const
conflict
=
parse
(
assemble
([
rawRow
({
complain_classification
:
4
})]).
rows
[
0
]
!
).
draft
;
expect
((
conflict
.
content
as
any
).
review_reason
).
toBe
(
'classification_conflict'
);
expect
(
conflict
.
status
).
toBe
(
'invalidated'
);
expect
(
parse
(
assemble
([
rawRow
({
complain_classification
:
3
,
complain_concerned
:
9
})]).
rows
[
0
]
!
).
draft
.
status
).
toBe
(
'invalidated'
);
});
test
(
'结案用完成跟进的UTC时间,无记录不伪造;重新处理清除当前结案时间'
,
()
=>
{
const
closed
=
rawRow
({
complain_type
:
4
,
follow_type
:
'完成'
});
expect
((
parse
(
assemble
([
closed
]).
rows
[
0
]
!
).
draft
.
content
as
any
).
resolved_at
).
toBe
(
'2026-10-17T02:30:00.000Z'
);
expect
((
parse
(
assemble
([
rawRow
({
complain_type
:
4
})]).
rows
[
0
]
!
).
draft
.
content
as
any
).
resolved_at
).
toBeNull
();
expect
((
parse
(
assemble
([
rawRow
({
follow_type
:
'完成'
})]).
rows
[
0
]
!
).
draft
.
content
as
any
).
resolved_at
).
toBeNull
();
});
test
(
'跟进未知动作有原文;时间不合法不能入库'
,
()
=>
{
const
{
draft
}
=
parse
(
assemble
([
rawRow
({
follow_type
:
'新动作'
})]).
rows
[
0
]
!
);
expect
((
draft
.
content
as
any
).
follow_ups
[
0
]).
toMatchObject
({
action
:
'unknown'
,
sourceAction
:
'新动作'
});
expect
(
assemble
([
rawRow
({
follow_date
:
'2026-99-99 00:00:00.000'
})]).
stats
.
failed
).
toBe
(
1
);
});
test
(
'标准契约缺状态/性质时保守未知'
,
()
=>
{
const
c
=
ComplaintCanonicalSchema
.
parse
({
externalId
:
'1'
,
patientExternalId
:
'1'
,
clinicId
:
'c'
,
filedAt
:
'2026-10-18T00:00:00Z'
});
expect
(
c
.
status
).
toBe
(
'unknown'
);
expect
(
c
.
feedbackKind
).
toBe
(
'unknown'
);
});
(
process
.
env
.
PAC_COMPLAINT_REAL_FIXTURE
?
test
:
test
.
skip
)(
'只读源真实样本:全链路标准化与重复重放'
,
()
=>
{
const
raw
=
JSON
.
parse
(
readFileSync
(
process
.
env
.
PAC_COMPLAINT_REAL_FIXTURE
!
,
'utf8'
))
as
Row
[];
const
r
=
assemble
(
raw
);
const
groups
=
new
Map
<
string
,
Row
[]
>
();
raw
.
forEach
(
row
=>
{
const
k
=
`
${
row
.
brand
}
:
${
row
.
id
}
`
;
groups
.
set
(
k
,
[...(
groups
.
get
(
k
)
??
[]),
row
]);
});
const
bad
=
[...
groups
.
values
()].
filter
(
rows
=>
!
rows
[
0
]
!
.
patient_id
).
length
;
expect
(
r
.
rows
.
length
+
r
.
stats
.
failed
).
toBe
(
groups
.
size
);
expect
(
r
.
stats
.
failed
).
toBe
(
bad
);
const
repeated
=
assemble
(
raw
.
slice
().
reverse
());
const
keys
=
r
.
rows
.
map
(
row
=>
parse
(
row
).
tx
.
sourceEventId
).
sort
();
expect
(
repeated
.
rows
.
map
(
row
=>
parse
(
row
).
tx
.
sourceEventId
).
sort
()).
toEqual
(
keys
);
const
statuses
:
Record
<
string
,
number
>
=
{};
for
(
const
row
of
r
.
rows
)
{
const
{
draft
}
=
parse
(
row
);
statuses
[
draft
.
status
!
]
=
(
statuses
[
draft
.
status
!
]
??
0
)
+
1
;
}
console
.
info
(
JSON
.
stringify
({
realRows
:
raw
.
length
,
subjects
:
groups
.
size
,
accepted
:
r
.
rows
.
length
,
rejected
:
bad
,
statuses
}));
});
// 显式启用的真实 PostgreSQL 验证:仅准许独立本地库,绝不连接服务使用的数据库。
(
process
.
env
.
PAC_COMPLAINT_LOCAL_DB_ENV
&&
process
.
env
.
PAC_COMPLAINT_REAL_FIXTURE
?
test
:
test
.
skip
)(
'真实源样本落本地库:幂等、品牌隔离、同时间修订、旧快照和身份冲突保护'
,
async
()
=>
{
const
{
PrismaClient
}
=
await
import
(
'@prisma/client'
);
const
{
FactWriter
}
=
await
import
(
'../src/modules/sync/pipeline/fact-writer.service'
);
const
{
ParserPipeline
}
=
await
import
(
'../src/modules/sync/pipeline/parser-pipeline.service'
);
const
{
ClinicalSignalService
}
=
await
import
(
'../src/modules/clinical-signals/clinical-signal.service'
);
const
{
Action
}
=
await
import
(
'@pac/types'
);
const
env
=
JSON
.
parse
(
readFileSync
(
process
.
env
.
PAC_COMPLAINT_LOCAL_DB_ENV
!
,
'utf8'
));
const
url
=
new
URL
(
env
.
DATABASE_URL
);
if
(
url
.
hostname
!==
'127.0.0.1'
||
url
.
port
!==
'5532'
||
url
.
pathname
!==
'/pac_complaint_verify_20261018'
)
{
throw
new
Error
(
'拒绝连接非独立本地验收库'
);
}
const
db
=
new
PrismaClient
({
datasources
:
{
db
:
{
url
:
url
.
toString
()
}
}
});
const
host
=
await
db
.
host
.
create
({
data
:
{
name
:
`complaint-verification-
${
Date
.
now
()}
`
,
appId
:
`local-complaint-
${
Date
.
now
()}
`
,
appSecretHash
:
'local-test-only'
}
});
try
{
const
raw
=
JSON
.
parse
(
readFileSync
(
process
.
env
.
PAC_COMPLAINT_REAL_FIXTURE
!
,
'utf8'
))
as
Row
[];
const
rows
=
assemble
(
raw
).
rows
.
map
(
row
=>
({
...
row
,
canonical
:
ComplaintCanonicalSchema
.
parse
(
row
.
canonical
)
}));
const
patients
=
new
Map
<
string
,
string
>
();
for
(
const
row
of
rows
)
{
const
c
=
row
.
canonical
as
Record
<
string
,
unknown
>
;
const
sourceUnit
=
namespace
.
resolve
(
row
.
rawSource
);
const
key
=
`
${
sourceUnit
}
:
${
c
.
patientExternalId
}
`
;
if
(
!
patients
.
has
(
key
))
{
const
patient
=
await
db
.
patient
.
create
({
data
:
{
hostId
:
host
.
id
,
tenantId
:
manifest
.
tenant_id
!
,
sourceUnit
,
externalId
:
String
(
c
.
patientExternalId
)
}
});
patients
.
set
(
key
,
patient
.
id
);
}
}
const
signals
=
new
ClinicalSignalService
();
signals
.
onModuleInit
();
const
parser
=
new
ComplaintParser
();
const
prisma
=
db
as
unknown
as
import
(
'../src/prisma/prisma.service'
).
PrismaService
;
const
registry
=
{
get
:
(
action
:
string
)
=>
action
===
Action
.
COMPLAINT_FILED
?
parser
:
undefined
}
as
unknown
as
import
(
'../src/modules/sync/pipeline/parsers/parser.registry'
).
ParserRegistry
;
const
pipeline
=
new
ParserPipeline
(
registry
,
new
FactWriter
(
prisma
),
prisma
,
signals
);
const
ingest
=
async
(
row
:
ReturnType
<
typeof
assemble
>
[
'rows'
][
number
])
=>
{
const
c
=
row
.
canonical
as
Record
<
string
,
unknown
>
,
sourceUnit
=
namespace
.
resolve
(
row
.
rawSource
);
const
data
=
new
TransactionSynthesizer
().
synthesize
({
rawRow
:
row
.
rawSource
,
canonicalRow
:
c
,
emits
:
config
.
emits
,
resource
:
'complaint'
,
hostId
:
host
.
id
,
tenantId
:
manifest
.
tenant_id
!
,
sourceUnit
,
sourceContext
:
'cold-import'
,
patientIdResolver
:
id
=>
patients
.
get
(
`
${
sourceUnit
}
:
${
id
}
`
)
})
!
;
let
transaction
=
await
db
.
patientTransaction
.
findFirst
({
where
:
{
hostId
:
host
.
id
,
sourceEventId
:
data
.
sourceEventId
}
});
if
(
!
transaction
)
transaction
=
await
db
.
patientTransaction
.
create
({
data
});
const
metrics
=
await
pipeline
.
runForTransaction
({
transaction
:
{
...
transaction
,
action
:
Action
.
COMPLAINT_FILED
},
canonicalRow
:
c
});
return
{
transaction
,
metrics
};
};
for
(
const
row
of
rows
)
expect
((
await
ingest
(
row
)).
metrics
.
factsFailed
).
toBe
(
0
);
const
initialFacts
=
await
db
.
patientFact
.
findMany
({
where
:
{
hostId
:
host
.
id
}
});
expect
(
initialFacts
).
toHaveLength
(
65
);
expect
(
initialFacts
.
filter
(
f
=>
f
.
status
===
'active'
)).
toHaveLength
(
5
);
expect
(
initialFacts
.
filter
(
f
=>
f
.
status
===
'fulfilled'
)).
toHaveLength
(
16
);
expect
(
initialFacts
.
filter
(
f
=>
f
.
status
===
'invalidated'
)).
toHaveLength
(
44
);
for
(
const
row
of
rows
.
slice
().
reverse
())
{
const
result
=
await
ingest
(
row
);
expect
(
result
.
metrics
.
factsCreated
+
result
.
metrics
.
factsSuperseded
+
result
.
metrics
.
factsFailed
).
toBe
(
0
);
}
expect
(
await
db
.
patientFact
.
count
({
where
:
{
hostId
:
host
.
id
}
})).
toBe
(
65
);
expect
(
await
db
.
patientTransaction
.
count
({
where
:
{
hostId
:
host
.
id
}
})).
toBe
(
65
);
for
(
const
fact
of
initialFacts
)
{
const
row
=
rows
.
find
(
r
=>
namespace
.
resolve
(
r
.
rawSource
)
===
fact
.
sourceUnit
&&
`complaint_record:
${
r
.
canonical
.
externalId
}
`
===
fact
.
subjectId
)
!
;
expect
(
fact
.
occurredAt
!
.
toISOString
()).
toBe
(
new
Date
(
String
(
row
.
canonical
.
filedAt
)).
toISOString
());
expect
(
fact
.
sourceUpdatedAt
!
.
toISOString
()).
toBe
(
new
Date
(
String
(
row
.
canonical
.
updatedAt
)).
toISOString
());
}
// 同一编号跨品牌各保留自己的版本,不能互相去重。
expect
(
new
Set
(
initialFacts
.
map
(
f
=>
f
.
subjectId
)).
size
).
toBeLessThan
(
initialFacts
.
length
);
const
active
=
rows
.
find
(
r
=>
parse
(
r
).
draft
.
status
===
'active'
)
!
;
const
originalRaw
=
raw
.
filter
(
r
=>
String
(
r
.
id
)
===
active
.
canonical
.
externalId
&&
r
.
brand
===
active
.
rawSource
.
brand
);
// 模拟源端修改分类却不改更新时间;内容版本键+留账序号仍能撤销,旧报文不能复活。
const
neutral
=
assemble
(
originalRaw
.
map
(
r
=>
({
...
r
,
complain_concerned
:
9
,
complain_concerned_name
:
'无不满'
,
complain_classification
:
null
,
complain_classification_name
:
null
}))).
rows
[
0
]
!
;
expect
((
await
ingest
(
neutral
)).
metrics
.
factsSuperseded
).
toBe
(
1
);
expect
((
await
ingest
(
active
)).
metrics
.
factsStaleSkipped
).
toBe
(
1
);
const
latest
=
await
db
.
patientFact
.
findFirst
({
where
:
{
hostId
:
host
.
id
,
sourceUnit
:
namespace
.
resolve
(
active
.
rawSource
),
subjectId
:
`complaint_record:
${
active
.
canonical
.
externalId
}
`
},
orderBy
:
{
version
:
'desc'
}
});
expect
(
latest
?.
status
).
toBe
(
'invalidated'
);
expect
((
latest
?.
content
as
Record
<
string
,
unknown
>
).
signal_eligible
).
toBe
(
false
);
// 实际冷导入使用批量管线;从已留原文重建多历史版本,旧快照即使排后面也不能覆盖新快照。
const
stored
=
await
db
.
patientTransaction
.
findMany
({
where
:
{
hostId
:
host
.
id
},
orderBy
:
{
eventSeq
:
'desc'
}
});
const
replayRows
=
assemble
(
stored
.
map
(
t
=>
t
.
rawPayload
as
Row
)).
rows
;
const
replayItems
=
replayRows
.
map
(
row
=>
{
const
c
=
row
.
canonical
as
Record
<
string
,
unknown
>
,
sourceUnit
=
namespace
.
resolve
(
row
.
rawSource
);
const
data
=
new
TransactionSynthesizer
().
synthesize
({
rawRow
:
row
.
rawSource
,
canonicalRow
:
c
,
emits
:
config
.
emits
,
resource
:
'complaint'
,
hostId
:
host
.
id
,
tenantId
:
manifest
.
tenant_id
!
,
sourceUnit
,
sourceContext
:
'cold-import'
,
patientIdResolver
:
id
=>
patients
.
get
(
`
${
sourceUnit
}
:
${
id
}
`
)
})
!
;
const
tx
=
stored
.
find
(
t
=>
t
.
sourceEventId
===
data
.
sourceEventId
)
!
;
expect
(
tx
).
toBeDefined
();
return
{
transaction
:
{
...
tx
,
action
:
Action
.
COMPLAINT_FILED
},
canonicalRow
:
c
};
});
const
batch
=
await
pipeline
.
runForBatch
(
replayItems
);
expect
(
batch
.
factsFailed
+
batch
.
factsCreated
+
batch
.
factsSuperseded
).
toBe
(
0
);
expect
(
await
db
.
patientFact
.
count
({
where
:
{
hostId
:
host
.
id
}
})).
toBe
(
66
);
// 归属变化不能因为同号自动搬给另一患者;保留拒绝证据供源端核对。
const
another
=
rows
.
find
(
r
=>
r
.
canonical
.
patientExternalId
!==
active
.
canonical
.
patientExternalId
&&
namespace
.
resolve
(
r
.
rawSource
)
===
namespace
.
resolve
(
active
.
rawSource
))
!
;
const
conflict
=
assemble
(
originalRaw
.
map
(
r
=>
({
...
r
,
patient_id
:
another
.
canonical
.
patientExternalId
}))).
rows
[
0
]
!
;
const
blocked
=
await
ingest
(
conflict
);
expect
(
blocked
.
metrics
.
factsFailed
).
toBe
(
1
);
expect
(
blocked
.
metrics
.
factRejects
[
0
]?.
issues
.
join
(
' '
)).
toContain
(
'identity changed'
);
console
.
info
(
JSON
.
stringify
({
localDatabase
:
'pac_complaint_verify_20261018'
,
sourceSubjects
:
rows
.
length
,
firstFacts
:
initialFacts
.
length
,
duplicateNewFacts
:
0
,
utcVerified
:
true
,
revisionVerified
:
true
,
identityConflictBlocked
:
true
}));
}
finally
{
await
db
.
patientFact
.
deleteMany
({
where
:
{
hostId
:
host
.
id
}
});
await
db
.
patientTransaction
.
deleteMany
({
where
:
{
hostId
:
host
.
id
}
});
await
db
.
patient
.
deleteMany
({
where
:
{
hostId
:
host
.
id
}
});
await
db
.
host
.
delete
({
where
:
{
id
:
host
.
id
}
});
await
db
.
$disconnect
();
}
},
60000
);
docs/qa/2026-10-18-jvs-complaint-clinics.csv
0 → 100644
View file @
c9360bcb
品牌,源诊所ID,源诊所名称,PAC字典名称,反馈单总数,负面关注点候选,候选未完成,表扬,无不满,关注点未分类,全类缺患者ID,候选草稿,候选非草稿且患者可关联源主档,候选可关联当前生产PAC患者,候选未完成可关联当前生产PAC患者,最近反馈时间
瑞尔,bcda42d627dc4fe09eb248e21f9fd2f7,瑞尔齿科深圳诺德诊所,瑞尔齿科深圳诺德诊所,399,92,1,33,274,0,7,2,84,37,0,2026-09-03 16:41:41.000
瑞尔,ceed7716d1cc40c8a6392fcb8928944a,瑞尔齿科厦门国际银行大厦诊所,瑞尔齿科厦门国际银行大厦诊所,139,49,0,5,77,8,0,0,49,27,0,2026-10-16 17:28:57.000
瑞尔,ff90434c695f409d8f0fdfb35a5a6b76,瑞尔齿科深圳华侨城诊所,瑞尔齿科深圳华侨城诊所,171,43,0,27,100,1,1,0,42,23,0,2026-09-13 12:38:25.000
瑞尔,031f22411ab94afea8355ef42264030f,瑞尔齿科深圳欢乐海岸诊所,瑞尔齿科深圳欢乐海岸诊所,376,42,0,52,282,0,2,1,40,20,0,2026-10-16 09:01:40.000
瑞尔,2523178e193346c883118718672eeed8,瑞尔齿科广州甲子花园诊所,瑞尔齿科广州甲子花园诊所,112,38,3,1,73,0,4,0,34,12,0,2026-01-27 09:04:00.000
瑞尔,ada5fcfe2a234eab9242d6d5d8dc0166,瑞尔齿科广州高德诊所,瑞尔齿科广州高德诊所,308,38,3,23,196,51,1,0,37,16,2,2026-10-16 12:57:05.000
瑞尔,d53e735b28f944aa8770f24ed7ff8803,地王诊所,瑞尔齿科深圳地王诊所,184,37,0,10,137,0,6,0,32,16,0,2024-11-16 20:03:01.000
瑞尔,d063b969850f461192f443a7f6a58ad0,卓越世纪诊所,卓越世纪诊所,147,26,0,12,109,0,0,0,26,13,0,2022-11-05 22:16:41.000
瑞尔,b6f0dc73b6c240d98ac8e00f66ac3688,瑞尔齿科深圳南山恒裕中心诊所,瑞尔齿科深圳南山恒裕中心诊所,171,23,0,26,121,1,0,0,23,7,0,2026-10-17 19:38:01.000
瑞尔,28cbf0a2e8484a75806c2246f896ad43,猎德诊所,瑞尔齿科广州猎德诊所,130,21,0,14,95,0,0,0,21,5,0,2022-12-12 16:00:21.000
瑞尔,8301dc0da8274b1ba0a389bb0e736224,中石化诊所,中石化诊所,129,20,0,4,105,0,1,0,19,6,0,2024-08-14 15:13:25.000
瑞泰,4c13b9c33330401fba469d871aaab6ec,成都瑞泰口腔医院武侯总院,,1495,276,0,232,983,4,41,0,244,45,0,2026-10-14 11:13:14.000
瑞泰,1e9a41cd8c644859a157f70a7105bf7b,长沙瑞泰口腔科尔雅定王台医院,,717,141,0,138,437,1,20,0,122,13,0,2026-04-18 12:30:41.000
瑞泰,484060c2ea1e4ead943cb731a746d20f,重庆瑞泰口腔医院,,1324,129,1,826,344,25,14,0,115,21,0,2026-10-12 15:16:43.000
瑞泰,0f18cf193b0147f487467ac152968dab,西安瑞泰口腔尔仓医院,,674,113,0,142,416,3,12,0,101,10,0,2026-10-17 17:00:34.000
瑞泰,38803ab6a9dc42f495390075b100cbe3,上海瑞泰口腔前滩门诊,,443,100,4,64,273,6,21,0,79,15,0,2026-09-27 15:07:26.000
瑞泰,95eb5789205843c682f97d154534e374,西安瑞泰口腔雁塔医院,,504,95,0,140,269,0,16,0,79,1,0,2026-05-31 16:54:44.000
瑞泰,13b0035726f742888274f3e30fffed2d,重庆瑞泰口腔江北门诊,,807,91,0,418,297,1,9,0,82,13,0,2026-10-17 16:15:13.000
瑞泰,39d283de8ad84c3eb6bf6b5cbf26318e,青岛瑞泰口腔市北门诊,,350,83,0,55,212,0,16,0,69,4,0,2026-09-05 18:22:43.000
瑞泰,9e256b519d1e4beea34400616387be89,长沙瑞泰科尔雅口腔医院中庭分院,,318,80,0,21,209,8,9,0,72,7,0,2026-08-22 09:27:39.000
瑞泰,491cb4ee54c84e5a97f1c711f258586f,重庆瑞泰久悦口腔万象城门诊,,661,76,0,405,179,1,5,0,72,21,0,2023-11-26 11:00:20.000
瑞泰,5d8b66cdd71c4806938e114007ac9f5b,重庆瑞泰口腔南方花园门诊,,596,74,0,336,186,0,2,0,72,11,0,2025-07-23 11:21:46.000
瑞泰,70dbe1e5728843129c970805fcfd9249,重庆瑞泰口腔南CBD门诊,,454,72,1,144,237,1,10,0,63,10,0,2026-10-16 12:05:25.000
瑞泰,4839f58bc8364da4b49ca1f3c51b4d7f,重庆瑞泰口腔爱琴海门诊,,339,69,0,135,135,0,11,0,59,12,0,2025-07-27 08:41:27.000
瑞泰,2f44ac9c1f04425796d0af892e361733,北京瑞泰口腔医院工体分院,,278,66,0,99,112,1,0,0,66,6,0,2026-08-26 09:32:03.000
瑞泰,235d58fd98ce4bcbb85cf91ad9055d37,成都瑞泰口腔医院贝森分院,,503,64,0,139,300,0,12,0,52,6,0,2024-03-29 09:05:08.000
瑞泰,f5180b97b6de4705a6e5b1fb619dbfff,大连瑞泰口腔葆嘉门诊,,212,62,1,68,80,2,10,0,52,9,0,2026-10-12 11:00:53.000
瑞泰,f464495631cf4c4ea3136a38480a017c,北京瑞泰口腔医院望京分院,,257,62,0,68,127,0,2,0,61,6,0,2026-09-28 11:28:32.000
瑞泰,ad4a4eb7e36d4cadb5a2b0fb82250767,重庆瑞泰口腔龙湖门诊,,757,61,0,289,407,0,6,0,56,6,0,2026-04-24 09:29:17.000
瑞泰,ef4254c21e6547e092c033780c758916,西安瑞泰口腔医院太白分院,,242,61,0,46,133,2,9,0,52,5,0,2026-10-05 11:34:05.000
瑞泰,5b4a90f4f19f484691e2aec68e88b417,重庆瑞泰口腔汽博门诊,,489,60,0,134,294,1,12,0,49,10,0,2026-10-12 15:00:43.000
瑞泰,c1021ae5c2334777ac7920a8518562f6,成都瑞泰口腔医院银泰分院,,324,58,0,78,188,0,11,0,47,12,0,2026-10-15 17:46:46.000
瑞泰,e1ce24a05f7a48029233821cf231b88c,江苏瑞泰口腔江阴美嘉欣门诊,,372,58,0,141,173,0,8,0,51,7,0,2026-09-11 10:08:00.000
瑞泰,70525b35ab1e408a9a0da5e7e451b005,长沙瑞泰科尔雅口腔医院西奥分院,,210,57,0,65,87,1,7,0,50,2,0,2025-10-07 00:15:18.000
瑞泰,859e5c7401a5483697a5a24e6d1cf103,北京瑞泰口腔医院陶然亭分院,,371,57,0,50,264,0,3,0,56,4,0,2026-09-14 14:00:39.000
瑞泰,dcbfb4173cbd41bc94e964cabef92567,济南瑞泰济东口腔医院,,221,56,0,26,127,12,6,0,50,1,0,2023-08-29 10:30:29.000
瑞泰,99a60449410d4f05af232da8560bda93,成都瑞泰口腔医院建设分院,,250,55,1,47,148,0,11,0,44,4,1,2026-01-23 11:13:50.000
瑞泰,23a773fa1f464d05aa0aa00e87e4cddb,重庆瑞泰口腔巴南门诊,,311,53,0,108,142,8,12,0,41,3,0,2026-10-13 10:01:19.000
瑞泰,d673c106bb614ef19130c24fc5450581,长沙瑞泰科尔雅口腔医院齐美分院,,195,52,0,38,102,3,10,0,43,1,0,2026-10-14 11:01:38.000
瑞泰,69165bd994ec49429dcc66cf8c3c54d6,重庆瑞泰口腔大坪门诊,,383,49,0,242,92,0,11,0,38,5,0,2025-08-16 16:00:32.000
瑞泰,1baf80fbdd7842a19e63b19e46d90d9b,西安瑞泰口腔医院高科分院,,268,49,1,108,110,1,5,0,44,5,0,2026-10-17 16:42:34.000
瑞泰,6cd7d4408e7e46ad9c9fad0b764e6d66,重庆瑞泰口腔大学城门诊,,791,49,3,329,361,52,14,0,35,13,2,2026-10-12 12:18:38.000
瑞泰,46daed76087f4aa9a36709413c21d9e7,西安瑞泰口腔医院龙首分院,,337,47,1,77,212,1,11,0,36,2,0,2026-10-01 16:07:19.000
瑞泰,ce6db69026b44347b527b0158a05bb8f,北京瑞泰口腔医院亦庄分院,,250,47,1,62,140,1,1,0,46,7,0,2026-10-16 19:04:53.000
瑞泰,e83d432a38bb4f6284713b36db4e7497,上海瑞泰口腔世纪公园门诊,上海瑞泰口腔世纪公园门诊,245,44,3,31,156,14,10,0,34,34,1,2026-09-01 16:10:45.000
瑞泰,830e216eed28413cb57c00beb71a1854,北京瑞泰口腔医院上地分院,,274,44,0,18,212,0,0,0,44,0,0,2025-08-12 17:41:47.000
瑞泰,358c9c024e254d3586856aab770eb217,重庆瑞泰华西口腔巴国城门诊,,428,41,0,223,163,1,7,0,35,4,0,2025-06-21 11:46:22.000
瑞泰,83f93882fa884b96bb3bb9fdddb75541,重庆瑞泰久悦口腔沙坪坝门诊,,697,41,0,228,428,0,9,0,32,8,0,2025-06-29 17:01:22.000
瑞泰,c50b5e509354470889020d5674cb888f,成都瑞泰口腔医院世纪城分院,,219,40,0,48,126,5,19,0,25,5,0,2026-10-17 15:20:47.000
瑞泰,81d67cc568f441b78a0da3ee9dffe96b,青岛瑞泰口腔江西路门诊,,345,39,0,77,228,1,2,0,37,1,0,2026-10-18 08:37:34.000
瑞泰,f338d89191d64056964966cc9c5d23a2,长沙瑞泰科尔雅口腔医院北辰分院,,242,39,0,86,116,1,3,0,36,1,0,2025-09-03 16:03:21.000
瑞泰,d0d5976bf8924544a59fd6cb0bed3b96,北京瑞泰双井分院,,186,36,1,36,114,0,1,0,35,1,0,2025-01-10 14:33:58.000
瑞泰,ce07474115f5463586605381049cb45d,重庆瑞泰久悦口腔源著门诊,,328,36,0,138,154,0,6,0,30,3,0,2024-07-12 17:50:53.000
瑞泰,3a9ee869eb4e4121bcafe6fef490f9f6,重庆瑞泰口腔英利门诊,,233,35,1,86,111,1,10,0,26,1,0,2026-10-16 12:00:59.000
瑞泰,b44d5e738fb842278e69a29bd76929ad,靖江瑞泰口腔门诊部,,124,35,0,27,62,0,5,0,31,3,0,2023-05-05 09:57:59.000
瑞泰,384741cdca30437e81ff4477ddc30979,重庆瑞泰口腔长寿门诊,,355,35,0,117,178,25,10,0,25,4,0,2026-10-12 16:52:39.000
瑞泰,e147b68f65a14c33a80299d34040f92d,青岛瑞泰东和齿科同安路门诊,,142,33,0,27,82,0,14,0,21,3,0,2024-09-14 18:01:01.000
瑞泰,30f4f6702e22441a9ac40e1141fe739a,长沙瑞泰科尔雅口腔医院麓南分院,,136,31,0,22,81,2,10,0,22,1,0,2025-06-05 15:33:19.000
瑞泰,f07ae73a506d4aaa99c76091fb2199f8,北京瑞泰广外分院,,290,29,0,83,59,119,1,0,29,3,0,2026-09-11 13:30:34.000
瑞泰,829fe22dc2c044fc962b4cd981b88af8,重庆瑞泰口腔礼嘉门诊,,261,29,0,111,121,0,8,0,22,3,0,2024-09-10 15:28:07.000
瑞泰,924a6a4489f74f43886e8e07c679fa5f,西安瑞泰口腔医院曲江分院,,305,26,0,95,184,0,1,0,25,4,0,2026-07-22 11:39:46.000
瑞泰,9100447bec654e689e4f7f42b202d361,成都瑞泰口腔医院悦街分院,,277,22,2,84,171,0,10,0,12,2,0,2026-06-27 16:23:54.000
瑞泰,aca95973f9ac43d0b5cee4cb25f7330e,青岛瑞泰口腔五四广场门诊,,124,22,0,16,86,0,7,0,15,0,0,2024-09-02 09:07:38.000
瑞泰,c78931a84e244bf99bcba68e4dc02a66,成都瑞泰口腔医院西宸分院,,160,19,0,57,84,0,10,0,9,2,0,2024-08-12 14:54:01.000
瑞泰,4e86b5a2a23e4f2baa541643d541d152,重庆瑞泰口腔U城天街门诊,,479,18,3,295,147,19,10,0,8,0,0,2026-07-03 15:02:30.000
瑞泰,d2b64687bc4a41418f0cbc5f03b0a44f,长沙瑞泰科尔雅口腔医院昊天分院,,196,13,0,46,137,0,3,0,10,2,0,2025-07-24 17:26:22.000
瑞泰,b905a2bc04ef4abfa3bcc47f3d695e36,大连瑞泰口腔和平广场门诊,,77,12,1,20,38,7,4,0,8,1,0,2026-09-16 16:14:56.000
瑞泰,f3d67e38ffd847128590566bded9bd1e,大连瑞泰口腔三八广场门诊,,93,11,1,39,43,0,5,0,6,0,0,2025-08-19 14:19:32.000
瑞泰,c686add3b0254eadbcb27d07e9702b90,上海瑞泰静安昌平路门诊,,228,7,1,44,154,23,4,0,3,0,0,2024-11-27 21:22:51.000
瑞泰,48cd2a167556491c8bfe65b19c86cc26,北京瑞泰云岗分院,,75,6,0,4,65,0,0,0,6,0,0,2025-06-13 14:45:17.000
docs/qa/2026-10-18-jvs-complaint-evidence.json
0 → 100644
View file @
c9360bcb
{
"scope"
:
"Read-only DW and production PAC; no data modifications. 2026-10-18 Asia/Shanghai"
,
"source_table"
:
"dw_group.fact_complain_out"
,
"base_main"
:
"76e7d5bc69e98d9f61d4a967c131c4a58d3d0207"
,
"note"
:
"Counts use (brand,id), negative means source concern in 1,3,4,5,6; not clinically or manually verified complaints."
,
"source_aggregates"
:
{
"quality"
:
[
{
"complaints"
:
"24488"
,
"null_patient"
:
"538"
,
"zero_patient"
:
"0"
,
"negative_patient"
:
"0"
,
"no_filed"
:
"0"
,
"invalid_filed"
:
"0"
,
"future_filed"
:
"0"
,
"invalid_updated"
:
"199"
,
"no_category"
:
"23195"
,
"category_unknown"
:
"8"
,
"praise"
:
"4"
,
"draft"
:
"3"
,
"completed_no_resolution"
:
"31"
,
"uncompleted_with_resolution"
:
"0"
,
"follow_after_update"
:
"58"
,
"extension_present"
:
"23375"
}
],
"join"
:
[
{
"brand"
:
"瑞泰"
,
"complaints"
:
"22222"
,
"valid_patient"
:
"21706"
,
"found_master"
:
"21706"
,
"found_current_cohort"
:
"21706"
,
"missing_master"
:
"0"
},
{
"brand"
:
"瑞尔"
,
"complaints"
:
"2266"
,
"valid_patient"
:
"2244"
,
"found_master"
:
"2244"
,
"found_current_cohort"
:
"2244"
,
"missing_master"
:
"0"
}
],
"status"
:
[
{
"brand"
:
"瑞尔"
,
"state"
:
1
,
"state_name"
:
"处理中"
,
"complaints"
:
"21"
,
"bad_patient"
:
"1"
},
{
"brand"
:
"瑞尔"
,
"state"
:
2
,
"state_name"
:
"未处理"
,
"complaints"
:
"73"
,
"bad_patient"
:
"4"
},
{
"brand"
:
"瑞尔"
,
"state"
:
4
,
"state_name"
:
"已完成"
,
"complaints"
:
"2169"
,
"bad_patient"
:
"15"
},
{
"brand"
:
"瑞尔"
,
"state"
:
5
,
"state_name"
:
"草稿"
,
"complaints"
:
"3"
,
"bad_patient"
:
"2"
},
{
"brand"
:
"瑞泰"
,
"state"
:
1
,
"state_name"
:
"处理中"
,
"complaints"
:
"167"
,
"bad_patient"
:
"10"
},
{
"brand"
:
"瑞泰"
,
"state"
:
2
,
"state_name"
:
"未处理"
,
"complaints"
:
"373"
,
"bad_patient"
:
"8"
},
{
"brand"
:
"瑞泰"
,
"state"
:
4
,
"state_name"
:
"已完成"
,
"complaints"
:
"21682"
,
"bad_patient"
:
"498"
}
],
"consistency"
:
[
{
"conflicting_status"
:
"0"
,
"conflicting_category"
:
"0"
,
"conflicting_number"
:
"0"
,
"cross_brand"
:
"0"
}
],
"number_duplicates"
:
[
{
"duplicate_numbers"
:
"3"
,
"affected_complaints"
:
"6"
}
],
"years"
:
[
{
"brand"
:
"瑞尔"
,
"year"
:
"2020"
,
"complaints"
:
"83"
,
"unresolved"
:
"27"
,
"no_category"
:
"0"
},
{
"brand"
:
"瑞尔"
,
"year"
:
"2021"
,
"complaints"
:
"976"
,
"unresolved"
:
"1"
,
"no_category"
:
"938"
},
{
"brand"
:
"瑞尔"
,
"year"
:
"2022"
,
"complaints"
:
"641"
,
"unresolved"
:
"0"
,
"no_category"
:
"641"
},
{
"brand"
:
"瑞尔"
,
"year"
:
"2023"
,
"complaints"
:
"183"
,
"unresolved"
:
"3"
,
"no_category"
:
"183"
},
{
"brand"
:
"瑞尔"
,
"year"
:
"2024"
,
"complaints"
:
"125"
,
"unresolved"
:
"0"
,
"no_category"
:
"125"
},
{
"brand"
:
"瑞尔"
,
"year"
:
"2025"
,
"complaints"
:
"146"
,
"unresolved"
:
"47"
,
"no_category"
:
"102"
},
{
"brand"
:
"瑞尔"
,
"year"
:
"2026"
,
"complaints"
:
"112"
,
"unresolved"
:
"16"
,
"no_category"
:
"96"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2020"
,
"complaints"
:
"441"
,
"unresolved"
:
"173"
,
"no_category"
:
"4"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2021"
,
"complaints"
:
"9434"
,
"unresolved"
:
"24"
,
"no_category"
:
"9082"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2022"
,
"complaints"
:
"9819"
,
"unresolved"
:
"24"
,
"no_category"
:
"9795"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2023"
,
"complaints"
:
"993"
,
"unresolved"
:
"58"
,
"no_category"
:
"934"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2024"
,
"complaints"
:
"658"
,
"unresolved"
:
"56"
,
"no_category"
:
"610"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2025"
,
"complaints"
:
"445"
,
"unresolved"
:
"97"
,
"no_category"
:
"359"
},
{
"brand"
:
"瑞泰"
,
"year"
:
"2026"
,
"complaints"
:
"432"
,
"unresolved"
:
"108"
,
"no_category"
:
"326"
}
],
"fields"
:
[
{
"complain_concerned"
:
9
,
"complain_concerned_name"
:
"无不满"
,
"complaints"
:
"12845"
},
{
"complain_concerned"
:
8
,
"complain_concerned_name"
:
"表扬"
,
"complaints"
:
"7535"
},
{
"complain_concerned"
:
1
,
"complain_concerned_name"
:
"服务流程"
,
"complaints"
:
"1883"
},
{
"complain_concerned"
:
3
,
"complain_concerned_name"
:
"医疗质量"
,
"complaints"
:
"990"
},
{
"complain_concerned"
:
4
,
"complain_concerned_name"
:
"价格"
,
"complaints"
:
"617"
},
{
"complain_concerned"
:
null
,
"complain_concerned_name"
:
null
,
"complaints"
:
"414"
},
{
"complain_concerned"
:
6
,
"complain_concerned_name"
:
"就诊环境"
,
"complaints"
:
"145"
},
{
"complain_concerned"
:
5
,
"complain_concerned_name"
:
"等候时间"
,
"complaints"
:
"59"
}
],
"channels"
:
[
{
"complain_channel"
:
4
,
"complain_channel_name"
:
"满意度短信"
,
"complaints"
:
"23375"
},
{
"complain_channel"
:
1
,
"complain_channel_name"
:
"400热线"
,
"complaints"
:
"590"
},
{
"complain_channel"
:
3
,
"complain_channel_name"
:
"第三方平台"
,
"complaints"
:
"515"
},
{
"complain_channel"
:
6
,
"complain_channel_name"
:
"销售渠道"
,
"complaints"
:
"5"
},
{
"complain_channel"
:
null
,
"complain_channel_name"
:
null
,
"complaints"
:
"2"
},
{
"complain_channel"
:
5
,
"complain_channel_name"
:
"小程序自助"
,
"complaints"
:
"1"
}
],
"clock"
:
[
{
"queried_at"
:
"2026-10-18 15:56:51"
,
"server_timezone"
:
"Asia/Shanghai"
}
]
},
"details"
:
{
"semantic_summary"
:
[
{
"brand"
:
"瑞泰"
,
"total"
:
"22222"
,
"negative"
:
"3265"
,
"praise"
:
"7328"
,
"neutral"
:
"11276"
,
"unknown"
:
"353"
,
"negative_submitted"
:
"3265"
,
"negative_unresolved"
:
"27"
,
"negative_linkable"
:
"2784"
,
"negative_unresolved_linkable"
:
"10"
},
{
"brand"
:
"瑞尔"
,
"total"
:
"2266"
,
"negative"
:
"429"
,
"praise"
:
"207"
,
"neutral"
:
"1569"
,
"unknown"
:
"61"
,
"negative_submitted"
:
"426"
,
"negative_unresolved"
:
"7"
,
"negative_linkable"
:
"407"
,
"negative_unresolved_linkable"
:
"2"
}
],
"quality_times"
:
[
{
"bad_created"
:
"0"
,
"updated_before_created"
:
"0"
,
"updated_before_filed"
:
"0"
,
"follow_after_updated"
:
"58"
,
"eligible_bad_updated"
:
"5"
,
"negative_completed_no_resolution"
:
"14"
}
],
"conflicts"
:
[
{
"concerns"
:
"0"
,
"channels"
:
"0"
,
"dates"
:
"0"
,
"createds"
:
"0"
,
"clinicnames"
:
"0"
}
],
"repeated_ids"
:
[
{
"shared_ids"
:
"2208"
}
],
"neg_topics"
:
[
{
"complain_concerned_name"
:
"服务流程"
,
"count"
:
"1883"
},
{
"complain_concerned_name"
:
"医疗质量"
,
"count"
:
"990"
},
{
"complain_concerned_name"
:
"价格"
,
"count"
:
"617"
},
{
"complain_concerned_name"
:
"就诊环境"
,
"count"
:
"145"
},
{
"complain_concerned_name"
:
"等候时间"
,
"count"
:
"59"
}
],
"unknown_states"
:
[
{
"state"
:
1
,
"n"
:
"169"
,
"linkable"
:
"169"
},
{
"state"
:
2
,
"n"
:
"224"
,
"linkable"
:
"223"
},
{
"state"
:
4
,
"n"
:
"21"
,
"linkable"
:
"16"
}
],
"negative_years"
:
[
{
"year"
:
"2020"
,
"n"
:
"7"
,
"unresolved"
:
"0"
},
{
"year"
:
"2021"
,
"n"
:
"1984"
,
"unresolved"
:
"2"
},
{
"year"
:
"2022"
,
"n"
:
"1037"
,
"unresolved"
:
"1"
},
{
"year"
:
"2023"
,
"n"
:
"320"
,
"unresolved"
:
"4"
},
{
"year"
:
"2024"
,
"n"
:
"209"
,
"unresolved"
:
"6"
},
{
"year"
:
"2025"
,
"n"
:
"71"
,
"unresolved"
:
"17"
},
{
"year"
:
"2026"
,
"n"
:
"66"
,
"unresolved"
:
"4"
}
]
},
"checks"
:
{
"null_versions"
:
[
{
"rows"
:
"81756"
,
"nulls"
:
"199"
,
"empty"
:
"0"
,
"invalid"
:
"0"
,
"invalid_follow"
:
"0"
}
],
"conflicts"
:
[
{
"negative_class_positive_concern"
:
"864"
,
"positive_class_negative_concern"
:
"0"
}
],
"content_quality"
:
[
{
"cases"
:
"24488"
,
"blank"
:
"0"
,
"very_short"
:
"5"
}
]
},
"pac_patient_coverage"
:
[
{
"brand"
:
"瑞泰"
,
"total"
:
22222
,
"found_patient"
:
3207
,
"eligible"
:
2784
,
"eligible_found"
:
383
},
{
"brand"
:
"瑞尔"
,
"total"
:
2266
,
"found_patient"
:
1059
,
"eligible"
:
407
,
"eligible_found"
:
182
}
],
"pac_complaint_fact_count"
:
0
,
"clinics"
:
[
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"bcda42d627dc4fe09eb248e21f9fd2f7"
,
"clinic_name"
:
"瑞尔齿科深圳诺德诊所"
,
"total"
:
"399"
,
"negative"
:
"92"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"33"
,
"neutral"
:
"274"
,
"unknown"
:
"0"
,
"unlinked"
:
"7"
,
"negative_draft"
:
"2"
,
"eligible"
:
"84"
,
"last_filed"
:
"2026-09-03 16:41:41.000"
,
"eligible_in_pac"
:
37
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科深圳诺德诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"ceed7716d1cc40c8a6392fcb8928944a"
,
"clinic_name"
:
"瑞尔齿科厦门国际银行大厦诊所"
,
"total"
:
"139"
,
"negative"
:
"49"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"5"
,
"neutral"
:
"77"
,
"unknown"
:
"8"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"49"
,
"last_filed"
:
"2026-10-16 17:28:57.000"
,
"eligible_in_pac"
:
27
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科厦门国际银行大厦诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"ff90434c695f409d8f0fdfb35a5a6b76"
,
"clinic_name"
:
"瑞尔齿科深圳华侨城诊所"
,
"total"
:
"171"
,
"negative"
:
"43"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"27"
,
"neutral"
:
"100"
,
"unknown"
:
"1"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"42"
,
"last_filed"
:
"2026-09-13 12:38:25.000"
,
"eligible_in_pac"
:
23
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科深圳华侨城诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"031f22411ab94afea8355ef42264030f"
,
"clinic_name"
:
"瑞尔齿科深圳欢乐海岸诊所"
,
"total"
:
"376"
,
"negative"
:
"42"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"52"
,
"neutral"
:
"282"
,
"unknown"
:
"0"
,
"unlinked"
:
"2"
,
"negative_draft"
:
"1"
,
"eligible"
:
"40"
,
"last_filed"
:
"2026-10-16 09:01:40.000"
,
"eligible_in_pac"
:
20
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科深圳欢乐海岸诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"2523178e193346c883118718672eeed8"
,
"clinic_name"
:
"瑞尔齿科广州甲子花园诊所"
,
"total"
:
"112"
,
"negative"
:
"38"
,
"negative_unresolved"
:
"3"
,
"praise"
:
"1"
,
"neutral"
:
"73"
,
"unknown"
:
"0"
,
"unlinked"
:
"4"
,
"negative_draft"
:
"0"
,
"eligible"
:
"34"
,
"last_filed"
:
"2026-01-27 09:04:00.000"
,
"eligible_in_pac"
:
12
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科广州甲子花园诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"ada5fcfe2a234eab9242d6d5d8dc0166"
,
"clinic_name"
:
"瑞尔齿科广州高德诊所"
,
"total"
:
"308"
,
"negative"
:
"38"
,
"negative_unresolved"
:
"3"
,
"praise"
:
"23"
,
"neutral"
:
"196"
,
"unknown"
:
"51"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"37"
,
"last_filed"
:
"2026-10-16 12:57:05.000"
,
"eligible_in_pac"
:
16
,
"unresolved_in_pac"
:
2
,
"pac_clinic_name"
:
"瑞尔齿科广州高德诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"d53e735b28f944aa8770f24ed7ff8803"
,
"clinic_name"
:
"地王诊所"
,
"total"
:
"184"
,
"negative"
:
"37"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"10"
,
"neutral"
:
"137"
,
"unknown"
:
"0"
,
"unlinked"
:
"6"
,
"negative_draft"
:
"0"
,
"eligible"
:
"32"
,
"last_filed"
:
"2024-11-16 20:03:01.000"
,
"eligible_in_pac"
:
16
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科深圳地王诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"d063b969850f461192f443a7f6a58ad0"
,
"clinic_name"
:
"卓越世纪诊所"
,
"total"
:
"147"
,
"negative"
:
"26"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"12"
,
"neutral"
:
"109"
,
"unknown"
:
"0"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"26"
,
"last_filed"
:
"2022-11-05 22:16:41.000"
,
"eligible_in_pac"
:
13
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"卓越世纪诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"b6f0dc73b6c240d98ac8e00f66ac3688"
,
"clinic_name"
:
"瑞尔齿科深圳南山恒裕中心诊所"
,
"total"
:
"171"
,
"negative"
:
"23"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"26"
,
"neutral"
:
"121"
,
"unknown"
:
"1"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"23"
,
"last_filed"
:
"2026-10-17 19:38:01.000"
,
"eligible_in_pac"
:
7
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科深圳南山恒裕中心诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"28cbf0a2e8484a75806c2246f896ad43"
,
"clinic_name"
:
"猎德诊所"
,
"total"
:
"130"
,
"negative"
:
"21"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"14"
,
"neutral"
:
"95"
,
"unknown"
:
"0"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"21"
,
"last_filed"
:
"2022-12-12 16:00:21.000"
,
"eligible_in_pac"
:
5
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"瑞尔齿科广州猎德诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞尔"
,
"organization_id"
:
"8301dc0da8274b1ba0a389bb0e736224"
,
"clinic_name"
:
"中石化诊所"
,
"total"
:
"129"
,
"negative"
:
"20"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"4"
,
"neutral"
:
"105"
,
"unknown"
:
"0"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"19"
,
"last_filed"
:
"2024-08-14 15:13:25.000"
,
"eligible_in_pac"
:
6
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
"中石化诊所"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"4c13b9c33330401fba469d871aaab6ec"
,
"clinic_name"
:
"成都瑞泰口腔医院武侯总院"
,
"total"
:
"1495"
,
"negative"
:
"276"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"232"
,
"neutral"
:
"983"
,
"unknown"
:
"4"
,
"unlinked"
:
"41"
,
"negative_draft"
:
"0"
,
"eligible"
:
"244"
,
"last_filed"
:
"2026-10-14 11:13:14.000"
,
"eligible_in_pac"
:
45
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"1e9a41cd8c644859a157f70a7105bf7b"
,
"clinic_name"
:
"长沙瑞泰口腔科尔雅定王台医院"
,
"total"
:
"717"
,
"negative"
:
"141"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"138"
,
"neutral"
:
"437"
,
"unknown"
:
"1"
,
"unlinked"
:
"20"
,
"negative_draft"
:
"0"
,
"eligible"
:
"122"
,
"last_filed"
:
"2026-04-18 12:30:41.000"
,
"eligible_in_pac"
:
13
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"484060c2ea1e4ead943cb731a746d20f"
,
"clinic_name"
:
"重庆瑞泰口腔医院"
,
"total"
:
"1324"
,
"negative"
:
"129"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"826"
,
"neutral"
:
"344"
,
"unknown"
:
"25"
,
"unlinked"
:
"14"
,
"negative_draft"
:
"0"
,
"eligible"
:
"115"
,
"last_filed"
:
"2026-10-12 15:16:43.000"
,
"eligible_in_pac"
:
21
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"0f18cf193b0147f487467ac152968dab"
,
"clinic_name"
:
"西安瑞泰口腔尔仓医院"
,
"total"
:
"674"
,
"negative"
:
"113"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"142"
,
"neutral"
:
"416"
,
"unknown"
:
"3"
,
"unlinked"
:
"12"
,
"negative_draft"
:
"0"
,
"eligible"
:
"101"
,
"last_filed"
:
"2026-10-17 17:00:34.000"
,
"eligible_in_pac"
:
10
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"38803ab6a9dc42f495390075b100cbe3"
,
"clinic_name"
:
"上海瑞泰口腔前滩门诊"
,
"total"
:
"443"
,
"negative"
:
"100"
,
"negative_unresolved"
:
"4"
,
"praise"
:
"64"
,
"neutral"
:
"273"
,
"unknown"
:
"6"
,
"unlinked"
:
"21"
,
"negative_draft"
:
"0"
,
"eligible"
:
"79"
,
"last_filed"
:
"2026-09-27 15:07:26.000"
,
"eligible_in_pac"
:
15
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"95eb5789205843c682f97d154534e374"
,
"clinic_name"
:
"西安瑞泰口腔雁塔医院"
,
"total"
:
"504"
,
"negative"
:
"95"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"140"
,
"neutral"
:
"269"
,
"unknown"
:
"0"
,
"unlinked"
:
"16"
,
"negative_draft"
:
"0"
,
"eligible"
:
"79"
,
"last_filed"
:
"2026-05-31 16:54:44.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"13b0035726f742888274f3e30fffed2d"
,
"clinic_name"
:
"重庆瑞泰口腔江北门诊"
,
"total"
:
"807"
,
"negative"
:
"91"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"418"
,
"neutral"
:
"297"
,
"unknown"
:
"1"
,
"unlinked"
:
"9"
,
"negative_draft"
:
"0"
,
"eligible"
:
"82"
,
"last_filed"
:
"2026-10-17 16:15:13.000"
,
"eligible_in_pac"
:
13
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"39d283de8ad84c3eb6bf6b5cbf26318e"
,
"clinic_name"
:
"青岛瑞泰口腔市北门诊"
,
"total"
:
"350"
,
"negative"
:
"83"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"55"
,
"neutral"
:
"212"
,
"unknown"
:
"0"
,
"unlinked"
:
"16"
,
"negative_draft"
:
"0"
,
"eligible"
:
"69"
,
"last_filed"
:
"2026-09-05 18:22:43.000"
,
"eligible_in_pac"
:
4
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"9e256b519d1e4beea34400616387be89"
,
"clinic_name"
:
"长沙瑞泰科尔雅口腔医院中庭分院"
,
"total"
:
"318"
,
"negative"
:
"80"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"21"
,
"neutral"
:
"209"
,
"unknown"
:
"8"
,
"unlinked"
:
"9"
,
"negative_draft"
:
"0"
,
"eligible"
:
"72"
,
"last_filed"
:
"2026-08-22 09:27:39.000"
,
"eligible_in_pac"
:
7
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"491cb4ee54c84e5a97f1c711f258586f"
,
"clinic_name"
:
"重庆瑞泰久悦口腔万象城门诊"
,
"total"
:
"661"
,
"negative"
:
"76"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"405"
,
"neutral"
:
"179"
,
"unknown"
:
"1"
,
"unlinked"
:
"5"
,
"negative_draft"
:
"0"
,
"eligible"
:
"72"
,
"last_filed"
:
"2023-11-26 11:00:20.000"
,
"eligible_in_pac"
:
21
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"5d8b66cdd71c4806938e114007ac9f5b"
,
"clinic_name"
:
"重庆瑞泰口腔南方花园门诊"
,
"total"
:
"596"
,
"negative"
:
"74"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"336"
,
"neutral"
:
"186"
,
"unknown"
:
"0"
,
"unlinked"
:
"2"
,
"negative_draft"
:
"0"
,
"eligible"
:
"72"
,
"last_filed"
:
"2025-07-23 11:21:46.000"
,
"eligible_in_pac"
:
11
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"70dbe1e5728843129c970805fcfd9249"
,
"clinic_name"
:
"重庆瑞泰口腔南CBD门诊"
,
"total"
:
"454"
,
"negative"
:
"72"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"144"
,
"neutral"
:
"237"
,
"unknown"
:
"1"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"63"
,
"last_filed"
:
"2026-10-16 12:05:25.000"
,
"eligible_in_pac"
:
10
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"4839f58bc8364da4b49ca1f3c51b4d7f"
,
"clinic_name"
:
"重庆瑞泰口腔爱琴海门诊"
,
"total"
:
"339"
,
"negative"
:
"69"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"135"
,
"neutral"
:
"135"
,
"unknown"
:
"0"
,
"unlinked"
:
"11"
,
"negative_draft"
:
"0"
,
"eligible"
:
"59"
,
"last_filed"
:
"2025-07-27 08:41:27.000"
,
"eligible_in_pac"
:
12
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"2f44ac9c1f04425796d0af892e361733"
,
"clinic_name"
:
"北京瑞泰口腔医院工体分院"
,
"total"
:
"278"
,
"negative"
:
"66"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"99"
,
"neutral"
:
"112"
,
"unknown"
:
"1"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"66"
,
"last_filed"
:
"2026-08-26 09:32:03.000"
,
"eligible_in_pac"
:
6
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"235d58fd98ce4bcbb85cf91ad9055d37"
,
"clinic_name"
:
"成都瑞泰口腔医院贝森分院"
,
"total"
:
"503"
,
"negative"
:
"64"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"139"
,
"neutral"
:
"300"
,
"unknown"
:
"0"
,
"unlinked"
:
"12"
,
"negative_draft"
:
"0"
,
"eligible"
:
"52"
,
"last_filed"
:
"2024-03-29 09:05:08.000"
,
"eligible_in_pac"
:
6
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"f5180b97b6de4705a6e5b1fb619dbfff"
,
"clinic_name"
:
"大连瑞泰口腔葆嘉门诊"
,
"total"
:
"212"
,
"negative"
:
"62"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"68"
,
"neutral"
:
"80"
,
"unknown"
:
"2"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"52"
,
"last_filed"
:
"2026-10-12 11:00:53.000"
,
"eligible_in_pac"
:
9
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"f464495631cf4c4ea3136a38480a017c"
,
"clinic_name"
:
"北京瑞泰口腔医院望京分院"
,
"total"
:
"257"
,
"negative"
:
"62"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"68"
,
"neutral"
:
"127"
,
"unknown"
:
"0"
,
"unlinked"
:
"2"
,
"negative_draft"
:
"0"
,
"eligible"
:
"61"
,
"last_filed"
:
"2026-09-28 11:28:32.000"
,
"eligible_in_pac"
:
6
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"ad4a4eb7e36d4cadb5a2b0fb82250767"
,
"clinic_name"
:
"重庆瑞泰口腔龙湖门诊"
,
"total"
:
"757"
,
"negative"
:
"61"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"289"
,
"neutral"
:
"407"
,
"unknown"
:
"0"
,
"unlinked"
:
"6"
,
"negative_draft"
:
"0"
,
"eligible"
:
"56"
,
"last_filed"
:
"2026-04-24 09:29:17.000"
,
"eligible_in_pac"
:
6
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"ef4254c21e6547e092c033780c758916"
,
"clinic_name"
:
"西安瑞泰口腔医院太白分院"
,
"total"
:
"242"
,
"negative"
:
"61"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"46"
,
"neutral"
:
"133"
,
"unknown"
:
"2"
,
"unlinked"
:
"9"
,
"negative_draft"
:
"0"
,
"eligible"
:
"52"
,
"last_filed"
:
"2026-10-05 11:34:05.000"
,
"eligible_in_pac"
:
5
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"5b4a90f4f19f484691e2aec68e88b417"
,
"clinic_name"
:
"重庆瑞泰口腔汽博门诊"
,
"total"
:
"489"
,
"negative"
:
"60"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"134"
,
"neutral"
:
"294"
,
"unknown"
:
"1"
,
"unlinked"
:
"12"
,
"negative_draft"
:
"0"
,
"eligible"
:
"49"
,
"last_filed"
:
"2026-10-12 15:00:43.000"
,
"eligible_in_pac"
:
10
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"c1021ae5c2334777ac7920a8518562f6"
,
"clinic_name"
:
"成都瑞泰口腔医院银泰分院"
,
"total"
:
"324"
,
"negative"
:
"58"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"78"
,
"neutral"
:
"188"
,
"unknown"
:
"0"
,
"unlinked"
:
"11"
,
"negative_draft"
:
"0"
,
"eligible"
:
"47"
,
"last_filed"
:
"2026-10-15 17:46:46.000"
,
"eligible_in_pac"
:
12
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"e1ce24a05f7a48029233821cf231b88c"
,
"clinic_name"
:
"江苏瑞泰口腔江阴美嘉欣门诊"
,
"total"
:
"372"
,
"negative"
:
"58"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"141"
,
"neutral"
:
"173"
,
"unknown"
:
"0"
,
"unlinked"
:
"8"
,
"negative_draft"
:
"0"
,
"eligible"
:
"51"
,
"last_filed"
:
"2026-09-11 10:08:00.000"
,
"eligible_in_pac"
:
7
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"70525b35ab1e408a9a0da5e7e451b005"
,
"clinic_name"
:
"长沙瑞泰科尔雅口腔医院西奥分院"
,
"total"
:
"210"
,
"negative"
:
"57"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"65"
,
"neutral"
:
"87"
,
"unknown"
:
"1"
,
"unlinked"
:
"7"
,
"negative_draft"
:
"0"
,
"eligible"
:
"50"
,
"last_filed"
:
"2025-10-07 00:15:18.000"
,
"eligible_in_pac"
:
2
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"859e5c7401a5483697a5a24e6d1cf103"
,
"clinic_name"
:
"北京瑞泰口腔医院陶然亭分院"
,
"total"
:
"371"
,
"negative"
:
"57"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"50"
,
"neutral"
:
"264"
,
"unknown"
:
"0"
,
"unlinked"
:
"3"
,
"negative_draft"
:
"0"
,
"eligible"
:
"56"
,
"last_filed"
:
"2026-09-14 14:00:39.000"
,
"eligible_in_pac"
:
4
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"dcbfb4173cbd41bc94e964cabef92567"
,
"clinic_name"
:
"济南瑞泰济东口腔医院"
,
"total"
:
"221"
,
"negative"
:
"56"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"26"
,
"neutral"
:
"127"
,
"unknown"
:
"12"
,
"unlinked"
:
"6"
,
"negative_draft"
:
"0"
,
"eligible"
:
"50"
,
"last_filed"
:
"2023-08-29 10:30:29.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"99a60449410d4f05af232da8560bda93"
,
"clinic_name"
:
"成都瑞泰口腔医院建设分院"
,
"total"
:
"250"
,
"negative"
:
"55"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"47"
,
"neutral"
:
"148"
,
"unknown"
:
"0"
,
"unlinked"
:
"11"
,
"negative_draft"
:
"0"
,
"eligible"
:
"44"
,
"last_filed"
:
"2026-01-23 11:13:50.000"
,
"eligible_in_pac"
:
4
,
"unresolved_in_pac"
:
1
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"23a773fa1f464d05aa0aa00e87e4cddb"
,
"clinic_name"
:
"重庆瑞泰口腔巴南门诊"
,
"total"
:
"311"
,
"negative"
:
"53"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"108"
,
"neutral"
:
"142"
,
"unknown"
:
"8"
,
"unlinked"
:
"12"
,
"negative_draft"
:
"0"
,
"eligible"
:
"41"
,
"last_filed"
:
"2026-10-13 10:01:19.000"
,
"eligible_in_pac"
:
3
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"d673c106bb614ef19130c24fc5450581"
,
"clinic_name"
:
"长沙瑞泰科尔雅口腔医院齐美分院"
,
"total"
:
"195"
,
"negative"
:
"52"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"38"
,
"neutral"
:
"102"
,
"unknown"
:
"3"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"43"
,
"last_filed"
:
"2026-10-14 11:01:38.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"69165bd994ec49429dcc66cf8c3c54d6"
,
"clinic_name"
:
"重庆瑞泰口腔大坪门诊"
,
"total"
:
"383"
,
"negative"
:
"49"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"242"
,
"neutral"
:
"92"
,
"unknown"
:
"0"
,
"unlinked"
:
"11"
,
"negative_draft"
:
"0"
,
"eligible"
:
"38"
,
"last_filed"
:
"2025-08-16 16:00:32.000"
,
"eligible_in_pac"
:
5
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"1baf80fbdd7842a19e63b19e46d90d9b"
,
"clinic_name"
:
"西安瑞泰口腔医院高科分院"
,
"total"
:
"268"
,
"negative"
:
"49"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"108"
,
"neutral"
:
"110"
,
"unknown"
:
"1"
,
"unlinked"
:
"5"
,
"negative_draft"
:
"0"
,
"eligible"
:
"44"
,
"last_filed"
:
"2026-10-17 16:42:34.000"
,
"eligible_in_pac"
:
5
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"6cd7d4408e7e46ad9c9fad0b764e6d66"
,
"clinic_name"
:
"重庆瑞泰口腔大学城门诊"
,
"total"
:
"791"
,
"negative"
:
"49"
,
"negative_unresolved"
:
"3"
,
"praise"
:
"329"
,
"neutral"
:
"361"
,
"unknown"
:
"52"
,
"unlinked"
:
"14"
,
"negative_draft"
:
"0"
,
"eligible"
:
"35"
,
"last_filed"
:
"2026-10-12 12:18:38.000"
,
"eligible_in_pac"
:
13
,
"unresolved_in_pac"
:
2
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"46daed76087f4aa9a36709413c21d9e7"
,
"clinic_name"
:
"西安瑞泰口腔医院龙首分院"
,
"total"
:
"337"
,
"negative"
:
"47"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"77"
,
"neutral"
:
"212"
,
"unknown"
:
"1"
,
"unlinked"
:
"11"
,
"negative_draft"
:
"0"
,
"eligible"
:
"36"
,
"last_filed"
:
"2026-10-01 16:07:19.000"
,
"eligible_in_pac"
:
2
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"ce6db69026b44347b527b0158a05bb8f"
,
"clinic_name"
:
"北京瑞泰口腔医院亦庄分院"
,
"total"
:
"250"
,
"negative"
:
"47"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"62"
,
"neutral"
:
"140"
,
"unknown"
:
"1"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"46"
,
"last_filed"
:
"2026-10-16 19:04:53.000"
,
"eligible_in_pac"
:
7
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"e83d432a38bb4f6284713b36db4e7497"
,
"clinic_name"
:
"上海瑞泰口腔世纪公园门诊"
,
"total"
:
"245"
,
"negative"
:
"44"
,
"negative_unresolved"
:
"3"
,
"praise"
:
"31"
,
"neutral"
:
"156"
,
"unknown"
:
"14"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"34"
,
"last_filed"
:
"2026-09-01 16:10:45.000"
,
"eligible_in_pac"
:
34
,
"unresolved_in_pac"
:
1
,
"pac_clinic_name"
:
"上海瑞泰口腔世纪公园门诊"
,
"in_pac_clinic_dictionary"
:
true
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"830e216eed28413cb57c00beb71a1854"
,
"clinic_name"
:
"北京瑞泰口腔医院上地分院"
,
"total"
:
"274"
,
"negative"
:
"44"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"18"
,
"neutral"
:
"212"
,
"unknown"
:
"0"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"44"
,
"last_filed"
:
"2025-08-12 17:41:47.000"
,
"eligible_in_pac"
:
0
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"358c9c024e254d3586856aab770eb217"
,
"clinic_name"
:
"重庆瑞泰华西口腔巴国城门诊"
,
"total"
:
"428"
,
"negative"
:
"41"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"223"
,
"neutral"
:
"163"
,
"unknown"
:
"1"
,
"unlinked"
:
"7"
,
"negative_draft"
:
"0"
,
"eligible"
:
"35"
,
"last_filed"
:
"2025-06-21 11:46:22.000"
,
"eligible_in_pac"
:
4
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"83f93882fa884b96bb3bb9fdddb75541"
,
"clinic_name"
:
"重庆瑞泰久悦口腔沙坪坝门诊"
,
"total"
:
"697"
,
"negative"
:
"41"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"228"
,
"neutral"
:
"428"
,
"unknown"
:
"0"
,
"unlinked"
:
"9"
,
"negative_draft"
:
"0"
,
"eligible"
:
"32"
,
"last_filed"
:
"2025-06-29 17:01:22.000"
,
"eligible_in_pac"
:
8
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"c50b5e509354470889020d5674cb888f"
,
"clinic_name"
:
"成都瑞泰口腔医院世纪城分院"
,
"total"
:
"219"
,
"negative"
:
"40"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"48"
,
"neutral"
:
"126"
,
"unknown"
:
"5"
,
"unlinked"
:
"19"
,
"negative_draft"
:
"0"
,
"eligible"
:
"25"
,
"last_filed"
:
"2026-10-17 15:20:47.000"
,
"eligible_in_pac"
:
5
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"81d67cc568f441b78a0da3ee9dffe96b"
,
"clinic_name"
:
"青岛瑞泰口腔江西路门诊"
,
"total"
:
"345"
,
"negative"
:
"39"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"77"
,
"neutral"
:
"228"
,
"unknown"
:
"1"
,
"unlinked"
:
"2"
,
"negative_draft"
:
"0"
,
"eligible"
:
"37"
,
"last_filed"
:
"2026-10-18 08:37:34.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"f338d89191d64056964966cc9c5d23a2"
,
"clinic_name"
:
"长沙瑞泰科尔雅口腔医院北辰分院"
,
"total"
:
"242"
,
"negative"
:
"39"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"86"
,
"neutral"
:
"116"
,
"unknown"
:
"1"
,
"unlinked"
:
"3"
,
"negative_draft"
:
"0"
,
"eligible"
:
"36"
,
"last_filed"
:
"2025-09-03 16:03:21.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"d0d5976bf8924544a59fd6cb0bed3b96"
,
"clinic_name"
:
"北京瑞泰双井分院"
,
"total"
:
"186"
,
"negative"
:
"36"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"36"
,
"neutral"
:
"114"
,
"unknown"
:
"0"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"35"
,
"last_filed"
:
"2025-01-10 14:33:58.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"ce07474115f5463586605381049cb45d"
,
"clinic_name"
:
"重庆瑞泰久悦口腔源著门诊"
,
"total"
:
"328"
,
"negative"
:
"36"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"138"
,
"neutral"
:
"154"
,
"unknown"
:
"0"
,
"unlinked"
:
"6"
,
"negative_draft"
:
"0"
,
"eligible"
:
"30"
,
"last_filed"
:
"2024-07-12 17:50:53.000"
,
"eligible_in_pac"
:
3
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"3a9ee869eb4e4121bcafe6fef490f9f6"
,
"clinic_name"
:
"重庆瑞泰口腔英利门诊"
,
"total"
:
"233"
,
"negative"
:
"35"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"86"
,
"neutral"
:
"111"
,
"unknown"
:
"1"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"26"
,
"last_filed"
:
"2026-10-16 12:00:59.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"b44d5e738fb842278e69a29bd76929ad"
,
"clinic_name"
:
"靖江瑞泰口腔门诊部"
,
"total"
:
"124"
,
"negative"
:
"35"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"27"
,
"neutral"
:
"62"
,
"unknown"
:
"0"
,
"unlinked"
:
"5"
,
"negative_draft"
:
"0"
,
"eligible"
:
"31"
,
"last_filed"
:
"2023-05-05 09:57:59.000"
,
"eligible_in_pac"
:
3
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"384741cdca30437e81ff4477ddc30979"
,
"clinic_name"
:
"重庆瑞泰口腔长寿门诊"
,
"total"
:
"355"
,
"negative"
:
"35"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"117"
,
"neutral"
:
"178"
,
"unknown"
:
"25"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"25"
,
"last_filed"
:
"2026-10-12 16:52:39.000"
,
"eligible_in_pac"
:
4
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"e147b68f65a14c33a80299d34040f92d"
,
"clinic_name"
:
"青岛瑞泰东和齿科同安路门诊"
,
"total"
:
"142"
,
"negative"
:
"33"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"27"
,
"neutral"
:
"82"
,
"unknown"
:
"0"
,
"unlinked"
:
"14"
,
"negative_draft"
:
"0"
,
"eligible"
:
"21"
,
"last_filed"
:
"2024-09-14 18:01:01.000"
,
"eligible_in_pac"
:
3
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"30f4f6702e22441a9ac40e1141fe739a"
,
"clinic_name"
:
"长沙瑞泰科尔雅口腔医院麓南分院"
,
"total"
:
"136"
,
"negative"
:
"31"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"22"
,
"neutral"
:
"81"
,
"unknown"
:
"2"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"22"
,
"last_filed"
:
"2025-06-05 15:33:19.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"f07ae73a506d4aaa99c76091fb2199f8"
,
"clinic_name"
:
"北京瑞泰广外分院"
,
"total"
:
"290"
,
"negative"
:
"29"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"83"
,
"neutral"
:
"59"
,
"unknown"
:
"119"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"29"
,
"last_filed"
:
"2026-09-11 13:30:34.000"
,
"eligible_in_pac"
:
3
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"829fe22dc2c044fc962b4cd981b88af8"
,
"clinic_name"
:
"重庆瑞泰口腔礼嘉门诊"
,
"total"
:
"261"
,
"negative"
:
"29"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"111"
,
"neutral"
:
"121"
,
"unknown"
:
"0"
,
"unlinked"
:
"8"
,
"negative_draft"
:
"0"
,
"eligible"
:
"22"
,
"last_filed"
:
"2024-09-10 15:28:07.000"
,
"eligible_in_pac"
:
3
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"924a6a4489f74f43886e8e07c679fa5f"
,
"clinic_name"
:
"西安瑞泰口腔医院曲江分院"
,
"total"
:
"305"
,
"negative"
:
"26"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"95"
,
"neutral"
:
"184"
,
"unknown"
:
"0"
,
"unlinked"
:
"1"
,
"negative_draft"
:
"0"
,
"eligible"
:
"25"
,
"last_filed"
:
"2026-07-22 11:39:46.000"
,
"eligible_in_pac"
:
4
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"9100447bec654e689e4f7f42b202d361"
,
"clinic_name"
:
"成都瑞泰口腔医院悦街分院"
,
"total"
:
"277"
,
"negative"
:
"22"
,
"negative_unresolved"
:
"2"
,
"praise"
:
"84"
,
"neutral"
:
"171"
,
"unknown"
:
"0"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"12"
,
"last_filed"
:
"2026-06-27 16:23:54.000"
,
"eligible_in_pac"
:
2
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"aca95973f9ac43d0b5cee4cb25f7330e"
,
"clinic_name"
:
"青岛瑞泰口腔五四广场门诊"
,
"total"
:
"124"
,
"negative"
:
"22"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"16"
,
"neutral"
:
"86"
,
"unknown"
:
"0"
,
"unlinked"
:
"7"
,
"negative_draft"
:
"0"
,
"eligible"
:
"15"
,
"last_filed"
:
"2024-09-02 09:07:38.000"
,
"eligible_in_pac"
:
0
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"c78931a84e244bf99bcba68e4dc02a66"
,
"clinic_name"
:
"成都瑞泰口腔医院西宸分院"
,
"total"
:
"160"
,
"negative"
:
"19"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"57"
,
"neutral"
:
"84"
,
"unknown"
:
"0"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"9"
,
"last_filed"
:
"2024-08-12 14:54:01.000"
,
"eligible_in_pac"
:
2
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"4e86b5a2a23e4f2baa541643d541d152"
,
"clinic_name"
:
"重庆瑞泰口腔U城天街门诊"
,
"total"
:
"479"
,
"negative"
:
"18"
,
"negative_unresolved"
:
"3"
,
"praise"
:
"295"
,
"neutral"
:
"147"
,
"unknown"
:
"19"
,
"unlinked"
:
"10"
,
"negative_draft"
:
"0"
,
"eligible"
:
"8"
,
"last_filed"
:
"2026-07-03 15:02:30.000"
,
"eligible_in_pac"
:
0
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"d2b64687bc4a41418f0cbc5f03b0a44f"
,
"clinic_name"
:
"长沙瑞泰科尔雅口腔医院昊天分院"
,
"total"
:
"196"
,
"negative"
:
"13"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"46"
,
"neutral"
:
"137"
,
"unknown"
:
"0"
,
"unlinked"
:
"3"
,
"negative_draft"
:
"0"
,
"eligible"
:
"10"
,
"last_filed"
:
"2025-07-24 17:26:22.000"
,
"eligible_in_pac"
:
2
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"b905a2bc04ef4abfa3bcc47f3d695e36"
,
"clinic_name"
:
"大连瑞泰口腔和平广场门诊"
,
"total"
:
"77"
,
"negative"
:
"12"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"20"
,
"neutral"
:
"38"
,
"unknown"
:
"7"
,
"unlinked"
:
"4"
,
"negative_draft"
:
"0"
,
"eligible"
:
"8"
,
"last_filed"
:
"2026-09-16 16:14:56.000"
,
"eligible_in_pac"
:
1
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"f3d67e38ffd847128590566bded9bd1e"
,
"clinic_name"
:
"大连瑞泰口腔三八广场门诊"
,
"total"
:
"93"
,
"negative"
:
"11"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"39"
,
"neutral"
:
"43"
,
"unknown"
:
"0"
,
"unlinked"
:
"5"
,
"negative_draft"
:
"0"
,
"eligible"
:
"6"
,
"last_filed"
:
"2025-08-19 14:19:32.000"
,
"eligible_in_pac"
:
0
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"c686add3b0254eadbcb27d07e9702b90"
,
"clinic_name"
:
"上海瑞泰静安昌平路门诊"
,
"total"
:
"228"
,
"negative"
:
"7"
,
"negative_unresolved"
:
"1"
,
"praise"
:
"44"
,
"neutral"
:
"154"
,
"unknown"
:
"23"
,
"unlinked"
:
"4"
,
"negative_draft"
:
"0"
,
"eligible"
:
"3"
,
"last_filed"
:
"2024-11-27 21:22:51.000"
,
"eligible_in_pac"
:
0
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
},
{
"brand"
:
"瑞泰"
,
"organization_id"
:
"48cd2a167556491c8bfe65b19c86cc26"
,
"clinic_name"
:
"北京瑞泰云岗分院"
,
"total"
:
"75"
,
"negative"
:
"6"
,
"negative_unresolved"
:
"0"
,
"praise"
:
"4"
,
"neutral"
:
"65"
,
"unknown"
:
"0"
,
"unlinked"
:
"0"
,
"negative_draft"
:
"0"
,
"eligible"
:
"6"
,
"last_filed"
:
"2025-06-13 14:45:17.000"
,
"eligible_in_pac"
:
0
,
"unresolved_in_pac"
:
0
,
"pac_clinic_name"
:
""
,
"in_pac_clinic_dictionary"
:
false
}
]
}
docs/qa/2026-10-18-jvs-complaint-ingestion-assessment.md
0 → 100644
View file @
c9360bcb
# 5i5ya 投诉数据核查与 PAC 接入建议
> 本文为修改前的只读评估。后续本地实现及验收见 [本地验收](./2026-10-18-jvs-complaint-ingestion-local-validation.md)。未部署。
> 2026-10-18|设计与数据评估,尚未实施摄入。以 `origin/main` 的 `76e7d5b` 为基线,独立分支 `codex/jvs-complaint-ingestion`。
> 只读核查生产使用的 5i5ya / jvs-dw 数仓及生产 PAC;下列数据是本次查询快照,不代表全部源业务系统的完整覆盖。
## 1. 结论
**可以接,但不能把 `fact_complain_out` 整表当成投诉。**
这张表同时包含满意度短信、表扬、无不满反馈和负面反馈,且一张单因多次跟进重复多行。
PAC 已有
`complaint`
标准结构、事件枚举和解析器;5i5ya 没有配置该资源,生产
`jvs-dw`
的
`complaint_record`
为
**0**
。标准字段、状态和下游消费也尚未完全接齐,不能只补一条 SQL 就认为完成。
建议:
**先按品牌和投诉 ID 还原一张业务单,区分反馈性质;明确的投诉/负面反馈才形成投诉事实。身份缺失、分类不明或矛盾的记录留待核查,不猜患者、不默认投诉。**
投诉事实与召回限制分开:历史投诉不等于永久免打扰。
## 2. 实际有什么数据
统计单位为
`(brand, id)`
,不是原始行数,也不是投诉编号。查询于 2026-10-18 15:56 起进行(上海时区)。
| 品牌 | 原始行 | 反馈单 | 有记录诊所 | 负面关注点候选 | 表扬 | 无不满 | 关注点未分类 |
|---|---:|---:|---:|---:|---:|---:|---:|
| 瑞尔 | 9,001 | 2,266 | 11 | 429 | 207 | 1,569 | 61 |
| 瑞泰 | 72,755 | 22,222 | 59 | 3,265 | 7,328 | 11,276 | 353 |
| 合计 |
**81,756**
|
**24,488**
|
**70**
|
**3,694**
|
**7,535**
|
**12,845**
|
**414**
|
这里的“负面候选”仅指源端关注点为服务流程、医疗质量、价格、等候时间、就诊环境,不是人工确认的投诉数。原始内容抽样中既有具体文字,也有单独的“1”“3”“4”“3.5”等评分回复。
**不能自行把某个分值解释为投诉或严重程度。**
-
**23,375 单来自满意度短信**
;“无不满+表扬”合计 20,380 单,占全部反馈约 83.2%。
-
负面候选按关注点分布:服务流程 1,883、医疗质量 990、价格 617、环境 145、等候 59。
-
`complain_type`
实际是处理状态:
`1=处理中、2=未处理、4=已完成、5=草稿`
,不是投诉类别。
-
`complain_classification`
约 94.7% 为空。另有
**864 单**
分类标成“投诉”但关注点为“无不满/表扬”,不能仅靠 classification 判投诉;需源端确认默认分类和人工归类机制。
-
负面候选中的 3,021 单发生于 2021—2022 年;2026 年有 66 单。不能把多年累计数当成当前诊所投诉率或服务质量排名。
### 2.1 能否可靠关联和更新
| 核查项 | 实测 | 接入含义 |
|---|---|---|
| 一单多行 | 23,478 单多行,最多 145 行;重复行主要是跟进不同 | 一单形成一个有版本的投诉事实,跟进单独聚合 |
| 主体一致性 | 同品牌同 ID 的患者、诊所、内容、状态、关注点、类别、创建/更新时间未发现多值 | 当前快照具备聚合基础;未来冲突仍需检测,不能任意取首行 |
| 跨品牌撞 ID | 2,208 个 ID 同时出现在两个品牌 | 投诉和患者身份都必须携带
`sourceUnit=brand`
|
| 投诉编号 | 瑞泰有 3 个重复编号,对应 6 张不同 ID 的单 |
`complain_number`
只作展示,不作幂等主键 |
| 患者关联 | 538 单无患者 ID;其余 23,950 单全部能关联
`(brand, patient_id)`
源主档,且满足现有有就诊记录 cohort 条件 | 缺 ID 的隔离;不通过姓名、手机或投诉正文猜人 |
| 业务时间 | filed/created 均可解析,未发现未来投诉时间;199 单 updated 为空 | 区分发生时间和版本时间;不能因缺 updated 永久漏摄 |
| 跟进时间 | 58 单最新跟进晚于主单 updated | 只以 updated 为游标可能漏跟进变化 |
| 结案时间 | 已完成反馈中 31 单找不到“完成”跟进时间;负面候选中 14 单 | 结案状态可以保留,
`resolvedAt`
允许空,不拿更新/摄入时间冒充 |
| 删除与 ETL 时间 | 本表未提供删除标记、ETL 入仓时间、跟进记录 ID | 当前快照不能证明可捕获所有删除、迟到更新及跟进事件身份 |
`customize_extend`
抽样是编号样式的字符串,并非可直接展开的标准 JSON;暂作不透明来源字段留底,不猜测业务含义。
### 2.2 真正具备首批关联条件的规模
从 3,694 个负面候选排除 3 个草稿,再排除缺患者 ID 的记录:
| 品牌 | 非草稿且有源患者 | 可关联当前生产 PAC 患者 | 候选未完成且有源患者 | 其中已在 PAC |
|---|---:|---:|---:|---:|
| 瑞尔 | 407 | 182 | 2 | 2 |
| 瑞泰 | 2,784 | 383 | 10 | 4 |
| 合计 |
**3,191**
|
**565**
|
**12**
|
**6**
|
因此
**3,191 是可关联的候选上限,不是已经确认可上线的投诉数**
。其中 2,626 单的患者尚不在当前生产 PAC;源主档存在,接入需通过现有身份/主档摄入机制补拉,不能静默丢掉,也不能创建错品牌患者。本次没有补拉或写入。
另有 34 单负面候选处于未处理/处理中,其中 22 单缺患者 ID;414 单未分类反馈里有 393 单未完成。不能把后者全部当成“正在投诉”,也不能悄悄丢弃,需单列待确认。
## 3. 各诊所分布与覆盖
诊所按投诉单自己的
`organization_id`
归属。
**投诉发生在哪家诊所,就保留在哪家;不能替换成患者最近就诊诊所。**
跨店患者风险是否在品牌内共享,是后续业务规则,不改变原事实归属。
①“可关联源患者”=负面候选、非草稿、有患者 ID;②“已在 PAC”=①中已有对应生产 PAC 患者。两列都未做人工投诉语义确认。
### 瑞尔:源表实际出现的 11 家(全部)
| 诊所 | 反馈单 | 负面候选 | 未完成候选 | 可关联源患者① | 已在 PAC② |
|---|---:|---:|---:|---:|---:|
| 瑞尔齿科深圳诺德诊所 | 399 | 92 | 1 | 84 | 37 |
| 瑞尔齿科厦门国际银行大厦诊所 | 139 | 49 | 0 | 49 | 27 |
| 瑞尔齿科深圳华侨城诊所 | 171 | 43 | 0 | 42 | 23 |
| 瑞尔齿科深圳欢乐海岸诊所 | 376 | 42 | 0 | 40 | 20 |
| 瑞尔齿科广州甲子花园诊所 | 112 | 38 | 3 | 34 | 12 |
| 瑞尔齿科广州高德诊所 | 308 | 38 | 3 | 37 | 16 |
| 地王诊所 | 184 | 37 | 0 | 32 | 16 |
| 卓越世纪诊所 | 147 | 26 | 0 | 26 | 13 |
| 瑞尔齿科深圳南山恒裕中心诊所 | 171 | 23 | 0 | 23 | 7 |
| 猎德诊所 | 130 | 21 | 0 | 21 | 5 |
| 中石化诊所 | 129 | 20 | 0 | 19 | 6 |
**当前瑞尔记录只覆盖广州、深圳、厦门这 11 家。**
杭州大厦、杭州高德、北京朝阳公园等在 PAC 存在的诊所,没有出现在这次全表投诉结果里。只能说“此数仓出口未查到”,不能据此认定源端从未有投诉;上线前须确认出口范围。
### 瑞泰:负面候选数前 10 家
| 诊所 | 反馈单 | 负面候选 | 未完成候选 | 可关联源患者① | 已在 PAC② |
|---|---:|---:|---:|---:|---:|
| 成都瑞泰口腔医院武侯总院 | 1495 | 276 | 0 | 244 | 45 |
| 长沙瑞泰口腔科尔雅定王台医院 | 717 | 141 | 0 | 122 | 13 |
| 重庆瑞泰口腔医院 | 1324 | 129 | 1 | 115 | 21 |
| 西安瑞泰口腔尔仓医院 | 674 | 113 | 0 | 101 | 10 |
| 上海瑞泰口腔前滩门诊 | 443 | 100 | 4 | 79 | 15 |
| 西安瑞泰口腔雁塔医院 | 504 | 95 | 0 | 79 | 1 |
| 重庆瑞泰口腔江北门诊 | 807 | 91 | 0 | 82 | 13 |
| 青岛瑞泰口腔市北门诊 | 350 | 83 | 0 | 69 | 4 |
| 长沙瑞泰科尔雅口腔医院中庭分院 | 318 | 80 | 0 | 72 | 7 |
| 重庆瑞泰久悦口腔万象城门诊 | 661 | 76 | 0 | 72 | 21 |
完整
**70 家**
的 ID、反馈性质、患者关联、未完成情况和最近反馈时间见
[
诊所明细 CSV
](
./2026-10-18-jvs-complaint-clinics.csv
)
。其中“全类缺患者 ID”覆盖全部反馈,不能直接从负面候选数中相减。
当前 PAC 宿主名字典有 66 家,与投诉表仅重合 12 家(瑞尔 11、瑞泰 1)。其余 58 个源诊所 ID 不在该字典,
**这不等于诊所无效或没有授权**
;需按现有组织机制核对和补全,不能按名称猜 ID,也不能用字典缺失直接过滤掉投诉。CSV 同时保留源名称和 PAC 字典名称。
## 4. PAC 现状:已有骨架,尚未形成业务闭环
| 层次 | 已有 | 缺口 |
|---|---|---|
| 标准契约 |
`ComplaintCanonicalSchema`
:ID、患者、诊所、时间、内容、类别、状态、结案时间 | 状态仍是任意字符串并默认 open;缺反馈性质、渠道、完整来源值和跟进结构 |
| 5i5ya 适配 | 现有 manifest / transform / assembler 框架 | 没有投诉查询、变化发现、投诉 assembler |
| 事实 | complaint_filed / complaint_resolved → ComplaintParser → complaint_record | parser 写
`host_status`
,事实 schema 的标准
`status`
可能仍为 null;默认非 resolved 全当 active,无法稳妥表达草稿/未知/纠正 |
| 画像和召回 | 有 complaint_history 枚举;免打扰读取 patient_profiles | 未注册投诉历史提取器;召回现有闸门读 do_not_contact,不会因写入投诉事实自动生效 |
| 生产数据 | 已核查 jvs-dw | complaint_record = 0 |
代码中的“投诉应触发 DNC”是注释意图,不代表实现。文档里“不能给投诉过的人推销”也不能直接转成永久封禁所有历史反馈的规则。
## 5. 建议的 PAC 标准字段
沿用现有 complaint 资源和事实版本链,补齐标准语义;宿主字段和编码留在适配层。
**以下是建议契约,不是已经提交的代码。**
| PAC 字段 | 语义 / 建议取值 | 5i5ya 来源与约束 |
|---|---|---|
| externalId | 业务单稳定 ID | id 转字符串;唯一身份含 host/tenant/sourceUnit |
| patientExternalId | 源患者 ID | patient_id;不可缺失或猜测 |
| clinicId | 本次反馈归属诊所 | organization_id;保留原业务归属 |
| filedAt | 反馈发生时间 | complain_date,按 Asia/Shanghai 规范化 |
| createdAt / updatedAt | 源创建 / 主体版本时间 | created_gmt_at / updated_gmt_at;保留真实口径,缺值明确处理 |
| feedbackKind | complaint / praise / neutral / unknown | 先做性质分流;源关注点和分类矛盾时留证据、标待确认,禁止默认 complaint |
| status | open / in_progress / resolved / draft / unknown | 主单 2→open、1→in_progress、4→resolved、5→draft;未知不默认 open |
| category | service / clinical / billing / environment / waiting / other / unknown | 关注点 1/3/4/6/5 对应前五项;不直接把宿主数字传给下游 |
| content | 原始反馈正文 | complain_content;评分原文保留,不自行推断量表含义 |
| channel | sms_survey / hotline / third_party / sales / mini_program / unknown | 渠道字段显式映射,保留原编码和名称 |
| resolvedAt | 已完成时间,可空 | 当前已完成时取可确认的“完成”跟进时间;缺失不伪造 |
| followUps
[
] | 时间、动作、内容、操作人来源 ID/名称 | 聚合 follow_
*
;不把“编辑/转交/跟进”误作主单状态 |
| relatedOrderExternalId | 可选关联结算/单据 | settlement_number;属于单据关联,不冒充治疗事实 ID |
| sourceMetadata | 原编号、原状态、原分类/关注点/渠道、关联单号、扩展值 | 用于解释映射与复核,不让业务层依赖美化后的文本 |
`sourceUnit`
仍由现有摄入上下文从 brand 传递,不另造品牌患者体系。
`severity`
没有可靠源字段,保持未知;“医疗质量”不是“严重投诉”的同义词。
`feedbackKind`
用于分流和纠正追踪:投诉事实只表示可确认的投诉/负面反馈;表扬、无不满不生成负面事实,未分类进入待核查。若已摄入的投诉后来被改成表扬或无不满,应撤销原负面事实效力并保留版本,不能简单过滤新行后让旧投诉永远有效。具体原始留存/隔离载体实施时沿用项目机制;当前不能仅靠 YAML 过滤保证这件事。
## 6. 怎么接,改动放在哪里
```
mermaid
flowchart LR
A[5i5ya 反馈及跟进原始行] --> B[按品牌和单ID聚合
身份校验与性质分流]
B --> C[PAC complaint 标准资源]
B --> Q[表扬/中性/待核查
留来源与处理原因]
C --> D[现有 transaction → complaint_record 版本链]
D --> E[投诉历史与当前状态]
E --> F[按已确认规则决定召回限制]
```
1.
**先保真,再接业务。**
在 jvs-dw manifest 增加源查询和变化发现;适配层还原主体+跟进,检测主字段冲突。通用引擎不硬编码 5i5ya 字段。当前
`dedup_by`
只是取首行,不能单独解决跟进聚合、最新结案时间和冲突举证。
2.
**主体快照与跟进分开。**
主体键
`(brand,id)`
;跟进没有源 ID,可用规范化时间、动作、人员 ID、内容构造稳定指纹,明确它只能去除完全相同记录,不能保证区分所有同内容的真实跟进。不要把跟进行分别当一次投诉。
3.
**变化发现覆盖主单及跟进。**
主单新增/更新、跟进新增都应触发整张单重拉,不能只拿窗口内跟进拼一个不完整快照。可按 created/updated/follow 的有效时间并集发现变化,再按单 ID 拉完整行;具体查询需避免大范围扫描,先测试耗时。
4.
**幂等和迟到是两件事。**
稳定排序后的主体+跟进可用现有 contentVersion 扩展能力留内容版本,配合事实乱序保护;重复采集不重复造投诉。但内容哈希不能发现没有被拉到的迟到记录。源表没有 ETL 入仓时间,沿用回看窗口仍有窗口外漏数边界,需要源端变更承诺或后续周期核对。
5.
**初次全量只补投诉资源。**
不触发全宿主历史事实重解析或全量召回重算;需要的患者经现有身份机制补拉。无患者 ID、未知状态、分类冲突都要有数量和原因,不能悄悄吞掉。
6.
**消费单独验收。**
标准事实落库成功不等于召回已避让。后续确认投诉历史展示、未完成投诉风险提示及营销联系限制,按受影响患者定向更新。不要把多年已完成记录直接写成永久 doNotContact;明确拒绝联系仍沿用独立免打扰语义。
## 7. 开始实施前最关键的确认
-
**性质口径**
:关注点由谁填写,classification 是否有默认值,评分量表如何定义;尤其 864 单字段矛盾和 414 单未分类记录。初版可保守隔离,不能靠猜测补齐。
-
**出口覆盖**
:为何瑞尔仅 11 家、部分 PAC 现有诊所无记录;投诉表与主档/其他事实出口的覆盖范围是否一致。
-
**变化保证**
:主单状态变更是否总更新 updated、跟进新增是否独立更新、删除/撤回如何暴露;目前字段不足以承诺全量变更不漏。
-
**业务效果**
:投诉中是否暂停营销召回、处理完成是否自动解除、跨诊所是否共享限制。这些是独立于“源数据存在”的产品决定。
可以先完成标准字段、保守分流、聚合、隔离与本地验证;没有确认前不默认开启永久免打扰或全历史患者封禁。
本地验收至少覆盖:跨品牌同 ID、多跟进只产一单、表扬/无不满不误标、缺患者隔离、未知状态不默认 open、结案时间缺失、跟进晚于主单 updated、重复采集不重复、乱序不旧覆新、投诉改为非投诉能撤销旧效力、缺主档可补拉且不跨品牌。
## 8. 核查依据与交付
-
数据:只读
`dw_group.fact_complain_out`
,关联
`fact_client_out`
;生产 PAC 只读 hosts / patients / patient_facts。ClickHouse 限单线程及查询时限,PostgreSQL 使用只读事务;未写入、未重算、未部署。
-
[
完整诊所明细
](
./2026-10-18-jvs-complaint-clinics.csv
)
;
[
不含患者姓名/手机号/正文的汇总证据
](
./2026-10-18-jvs-complaint-evidence.json
)
。
-
核查先确认同键主字段无冲突,再按
`(brand,id)`
聚合;跟进用最大时间核查,计数用源枚举,不对正文做自动情绪判断。70 家逐行分类之和、品牌汇总、患者覆盖数已经本地交叉校验。
-
代码:
[
canonical
](
../../packages/types/src/schemas/canonical.ts
)
、
[
投诉 parser
](
../../apps/pac-service/src/modules/sync/pipeline/parsers/complaint.parser.ts
)
、
[
事实 content schema
](
../../apps/pac-service/src/modules/sync/pipeline/fact-content-schemas.ts
)
、
[
jvs-dw manifest
](
../../apps/pac-service/data/jvs-dw/manifest.yaml
)
、
[
画像注册
](
../../apps/pac-service/src/modules/persona/features/feature.registry.ts
)
、
[
召回闸门
](
../../apps/pac-service/src/modules/plan/engine/scenarios/treatment-initiation-recall.scenario.ts
)
。
本次只新增这份评估及汇总附件,不修改运行代码、不提交;主目录已有改动、本地服务和美亚工作区保持原状。
docs/qa/2026-10-18-jvs-complaint-ingestion-local-validation.md
0 → 100644
View file @
c9360bcb
# 5i5ya 投诉标准化与增量采集:本地验收
日期:2026-10-18(Asia/Shanghai)
分支:
`codex/jvs-complaint-ingestion`
基线:
`main`
/
`76e7d5bc69e98d9f61d4a967c131c4a58d3d0207`
**投诉摄入已实现并完成本地验证。未提交、未合并、未部署;没有修改召回规则、画像提取器或免打扰字段。**
## 1. 接入方式
沿用现有链路:
`5i5ya 数仓 → jvs-dw manifest → transform / assembler → transaction → parser → patient_facts`
-
源表为
`dw_group.fact_complain_out`
,没有绕过现有摄入另建定时任务。
-
患者沿用
`tenant_id=ruier-grp`
和
`identity_namespace_field=brand`
,按宿主、租户、命名空间、患者外部 ID 关联。
-
反馈单按配置的
`(brand,id)`
聚合;投诉编号只用于展示。通用聚合算子不硬编码品牌或 5i5ya 字段。
-
`clinicId`
保留投诉单的
`organization_id`
,不改成患者最近就诊门店,不因名称字典缺失过滤诊所。
-
共用现有事务账本和事实版本表,无新数据库表、迁移或后台服务。
## 2. 标准化口径
标准字段包括:单 ID、患者 ID、诊所、发生时间、版本时间、反馈性质、投诉类别、渠道、处理状态、正文、结案时间及完整跟进数组。
| 信息 | 实现口径 |
|---|---|
| 性质 | 关注点明确为负面才标
`complaint`
;表扬、中性、未知分别保留,不靠评分猜投诉 |
| 分类冲突 | 明确的服务/医疗投诉分类与关注点性质冲突,标未知并保留原因;源默认“其他投诉”不覆盖关注点 |
| 状态 | 未处理→
`open`
、处理中→
`in_progress`
、已完成→
`resolved`
、草稿→
`draft`
;未知不默认未处理 |
| 有效信号 | 性质为投诉,且状态为未处理、处理中或已完成;严重度没有可靠来源,留空 |
| 非投诉快照 | 共用版本链,以
`invalidated`
和
`signal_eligible=false`
保存;用于撤销先前投诉和阻止旧版本恢复,不计为有效投诉 |
| 跟进 | 一单多行还原为完整数组;相同跟进去重、稳定排序,保留动作原文和人员来源信息 |
| 结案时间 | 已完成时取“完成”跟进时间;没有则空,不用采集时间冒充 |
| 时间 | 主单、创建、源更新、跟进均按
`Asia/Shanghai`
解释;事实/事务落库为 UTC |
| 原文 | 保留完整原始多行封套,可从账本离线重新标准化,不依赖再次查询源端 |
`updatedAt`
是本次完整反馈快照的版本时间:
`max(created_gmt_at, updated_gmt_at, follow_date)`
;源主单原更新时间另存
`sourceUpdatedAt`
。因此只有跟进更新,也能正确升级快照。
缺患者、缺诊所、非法时间、同键主体冲突不能强行归属;标准化阶段按单拒绝并接入现有拒绝举证。正常增量发现会先排除无患者编号的源行,这部分无法进入患者 cohort,需要保留源侧核查统计,不能声称它们已进入 PAC 拒绝账本。
## 3. 增量与幂等
**先找变化的患者,再读取该患者完整的投诉及跟进。**
-
`complaint_changes`
查询用上述三个时间的最大值作游标;SQL 只过滤无法关联的患者身份,不过滤成“仅未完成”或“仅投诉”,以便捕获结案和改分类。
-
`fact_complain_out`
完整查询只加现有患者 cohort 范围,不再加时间窗,避免老跟进丢失。
-
新资源没有历史游标时,通过显式
`bootstrap_full`
发现存量患者。该开关只启用于投诉变化查询,不改变其他资源的默认行为。
-
继续使用既有调度、游标、48 小时回看和患者分批机制。首轮可能带入较多患者;这些患者的其他资源仍按现有 cohort 管线处理,不是另开全宿主重解析或重算任务。
-
内容变化参与事务幂等键;同源快照乱序、重复输入保持同一键。事实使用已有快照水位/事件序号防护,同时间的分类修订能够替代旧状态,旧报文重试不能恢复旧投诉。
-
空转探针复用完整增量 SQL,支持
`changed_at`
派生别名,避免把它误当作物理列。
## 4. 实测结果
### 真实源样本 → 本地 PostgreSQL
只读抽取
**139 行、79 张反馈单**
;样本含不同品牌、性质、状态、跨品牌同号、空更新时间、跟进晚于主单、缺患者等情况。原始样本仅保存在本机私有临时目录,不加入仓库。
| 检查 | 结果 |
|---|---|
| 可标准化并落库 | 65 张 |
| 缺患者编号拒绝 | 14 张 |
| 有效投诉 | 21 张:5 张未完成、16 张已完成 |
| 无效反馈快照 | 44 张:非投诉、未知、草稿或冲突,均无有效投诉信号 |
| 全样本再次摄入 | 事务、事实均无重复新增 |
| 投诉改为“无不满”,不修改源时间 | 旧投诉效力撤销;旧报文重放不复活 |
| 从已存原文批量重放历史版本 | 无新增或错误覆盖,当前版本保持正确 |
| 同号跨品牌 | 分别落库,不互相去重或覆盖 |
| 同号改挂另一个患者 | 拒绝,不搬移事实 |
| 北京跨日时间 |
`2026-10-17 00:15`
→
`2026-10-16T16:15:00Z`
;所有样本事实发生/版本时间与规范化结果逐一一致 |
验证使用独立本地数据库
`pac_complaint_verify_20261018`
,应用现有迁移;测试完成后清除本次测试宿主及样本。未触碰本地应用数据库的数据,未启动/重启应用或容器。
### 生成 SQL → 数仓只读验证
直接执行新采集器生成的查询,限制单线程和查询时限:
-
首轮存量发现:
**19,114 位患者**
(品牌+患者 ID 去重;不是投诉人数)。
-
选取存在“跟进晚于主单更新时间”的真实患者:增量查询
**2 行**
,完整查询
**4 行**
。
-
确认新跟进被发现,窗口前的旧跟进仍保留。
-
派生游标探针成功执行,该验证窗口返回
**2,386 行**
;不是本轮实际导入数。
### 自动验证
-
相关回归:
**17 组、345 项通过**
;另 1 项为可选 SQL 导出步骤,已单独启用运行并用于上述实测。
-
覆盖投诉标准化、身份隔离、时区、完整聚合、重复/乱序重放、事实批写,以及原有病历、影像、cohort、原文追溯和增量探针。
-
类型检查:后端源码及 tests 一起检查通过;共享类型构建通过。
-
没有运行召回全量计算。
## 5. 仍然存在的源端边界
1.
数仓没有 ETL 入仓时间和删除标记。48 小时回看能覆盖窗口内迟到,
**不能保证发现窗口外的回填、原地修改但所有时间均未变化、物理删除**
。这些需要源端补充变更保证或后续对账。
2.
跟进没有独立 ID,相同内容只能按规范化内容去重,无法区分两次内容完全一致的真实跟进。
3.
分类含义不清、患者身份缺失的记录不猜测;目前出口覆盖范围仍以源数据评估为准。
4.
本次接通数据,不表示已有投诉展示或召回避让效果;投诉如何影响召回另行设计。
源码入口:
[
manifest
](
../../apps/pac-service/data/jvs-dw/manifest.yaml
)
、
[
投诉 assembler
](
../../apps/pac-service/data/jvs-dw/assemblers/complaint.yaml
)
、
[
标准字段
](
../../packages/types/src/schemas/canonical.ts
)
、
[
parser
](
../../apps/pac-service/src/modules/sync/pipeline/parsers/complaint.parser.ts
)
。
初始源数据调查见
[
摄入评估
](
./2026-10-18-jvs-complaint-ingestion-assessment.md
)
和
[
70 家诊所汇总
](
./2026-10-18-jvs-complaint-clinics.csv
)
。初始评估描述的是修改前状态,以本验收说明为当前本地实现状态。
packages/types/src/schemas/canonical.ts
View file @
c9360bcb
...
...
@@ -496,25 +496,47 @@ export const RechargeCanonicalSchema = z
.
passthrough
();
export
type
RechargeCanonical
=
z
.
infer
<
typeof
RechargeCanonicalSchema
>
;
/// 投诉记录(合规营销避雷的关键信号)
/// 产品收集
export
const
ComplaintCanonicalSchema
=
z
.
object
({
externalId
:
z
.
string
().
min
(
1
),
patientExternalId
:
z
.
string
().
min
(
1
),
clinicId
:
z
.
string
().
min
(
1
),
filedAt
:
isoDateTime
,
/// 投诉内容自由文本
/// 投诉/反馈快照:是否形成有效投诉信号与源记录留存分开。
export
const
ComplaintStatusSchema
=
z
.
enum
([
'open'
,
'in_progress'
,
'resolved'
,
'draft'
,
'unknown'
]);
export
const
ComplaintFeedbackKindSchema
=
z
.
enum
([
'complaint'
,
'praise'
,
'neutral'
,
'unknown'
]);
export
const
ComplaintCategorySchema
=
z
.
enum
([
'service'
,
'clinical'
,
'billing'
,
'environment'
,
'waiting'
,
'other'
,
'unknown'
]);
export
const
ComplaintChannelSchema
=
z
.
enum
([
'sms_survey'
,
'hotline'
,
'third_party'
,
'sales'
,
'mini_program'
,
'unknown'
]);
const
complaintDateTime
=
z
.
iso
.
datetime
({
offset
:
true
});
const
optionalComplaintDateTime
=
z
.
preprocess
(
v
=>
v
===
''
?
null
:
v
,
complaintDateTime
.
nullable
().
optional
());
export
const
ComplaintFollowUpSchema
=
z
.
object
({
occurredAt
:
optionalComplaintDateTime
,
action
:
z
.
enum
([
'transferred'
,
'completed'
,
'edited'
,
'followed_up'
,
'paused'
,
'unknown'
]),
sourceAction
:
z
.
string
().
nullable
().
optional
(),
content
:
z
.
string
().
nullable
().
optional
(),
actorId
:
z
.
coerce
.
string
().
nullable
().
optional
(),
actorName
:
z
.
string
().
nullable
().
optional
(),
});
const
complaintId
=
z
.
union
([
z
.
string
().
trim
().
min
(
1
),
z
.
number
().
int
().
safe
()]).
transform
(
String
);
export
const
ComplaintCanonicalSchema
=
z
.
object
({
externalId
:
complaintId
,
patientExternalId
:
complaintId
,
clinicId
:
complaintId
,
filedAt
:
complaintDateTime
,
createdAt
:
optionalComplaintDateTime
,
updatedAt
:
optionalComplaintDateTime
,
sourceUpdatedAt
:
optionalComplaintDateTime
,
content
:
z
.
string
().
optional
().
nullable
(),
/// 关联治疗(如有)— 引用 treatment_record subject_id 或 host 治疗 id
relatedTreatmentSubjectId
:
z
.
string
().
optional
().
nullable
(),
/// 投诉类别(host 自定义,如 service / clinical / billing)
category
:
z
.
string
().
optional
().
nullable
(),
/// 处理状态(open / resolved / pending)— host 编码,assembler.yaml enum_mapping 翻译
status
:
z
.
string
().
optional
().
default
(
'open'
),
resolvedAt
:
optionalIsoDateTime
,
})
.
passthrough
();
relatedOrderExternalId
:
z
.
string
().
optional
().
nullable
(),
number
:
z
.
string
().
optional
().
nullable
(),
feedbackKind
:
z
.
preprocess
(
v
=>
v
==
null
?
undefined
:
v
,
ComplaintFeedbackKindSchema
.
default
(
'unknown'
)),
// 宿主另一分类维度,仅用于识别互相矛盾的证据;没有则未知。
classificationKind
:
z
.
preprocess
(
v
=>
v
==
null
?
undefined
:
v
,
ComplaintFeedbackKindSchema
.
default
(
'unknown'
)),
category
:
z
.
preprocess
(
v
=>
v
==
null
?
undefined
:
v
,
ComplaintCategorySchema
.
default
(
'unknown'
)),
channel
:
z
.
preprocess
(
v
=>
v
==
null
?
undefined
:
v
,
ComplaintChannelSchema
.
default
(
'unknown'
)),
status
:
z
.
preprocess
(
v
=>
v
==
null
?
undefined
:
v
,
ComplaintStatusSchema
.
default
(
'unknown'
)),
sourceStatus
:
z
.
string
().
optional
().
nullable
(),
sourceCategory
:
z
.
string
().
optional
().
nullable
(),
sourceConcern
:
z
.
string
().
optional
().
nullable
(),
sourceChannel
:
z
.
string
().
optional
().
nullable
(),
resolvedAt
:
optionalComplaintDateTime
,
followUps
:
z
.
array
(
ComplaintFollowUpSchema
).
default
([]),
}).
passthrough
();
export
type
ComplaintCanonical
=
z
.
infer
<
typeof
ComplaintCanonicalSchema
>
;
/// 转介绍记录(老客带新)
...
...
@@ -595,6 +617,7 @@ export const CanonicalResourceMeta: Record<
moneyFields
:
readonly
string
[];
moneyArrayFields
:
readonly
{
array
:
string
;
field
:
string
}[];
// 数组里嵌套 money 字段
datetimeFields
:
readonly
string
[];
datetimeArrayFields
?:
readonly
{
array
:
string
;
field
:
string
}[];
booleanFields
:
readonly
string
[];
}
>
=
{
...
...
@@ -716,7 +739,8 @@ export const CanonicalResourceMeta: Record<
complaint
:
{
moneyFields
:
[],
moneyArrayFields
:
[],
datetimeFields
:
[
'filedAt'
,
'resolvedAt'
],
datetimeFields
:
[
'filedAt'
,
'resolvedAt'
,
'sourceUpdatedAt'
],
datetimeArrayFields
:
[{
array
:
'followUps'
,
field
:
'occurredAt'
}],
booleanFields
:
[],
},
referral
:
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment