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
3cd4acf2
Commit
3cd4acf2
authored
Oct 09, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge unused step summary removal into test
parents
8c99b8d6
0cf90b7f
Pipeline
#3732
failed in 0 seconds
Changes
26
Pipelines
1
Show whitespace changes
Inline
Side-by-side
Showing
26 changed files
with
35 additions
and
634 deletions
+35
-634
apps/ai-service/src/modules/pac/pac.service.ts
+0
-34
apps/ai-service/tests/outreach-purpose.spec.ts
+5
-5
apps/ai-service/tests/pac-host-from-tenant.spec.ts
+0
-1
apps/pac-docs/openapi/pac.json
+0
-16
apps/pac-service/package.json
+0
-2
apps/pac-service/prisma/migrations/20261009160000_remove_unused_patient_step_latest/migration.sql
+6
-0
apps/pac-service/prisma/schema.prisma
+0
-42
apps/pac-service/src/cli/backfill-step-latest.cli.ts
+0
-54
apps/pac-service/src/modules/open/open-appointment.service.ts
+1
-1
apps/pac-service/src/modules/open/open-fact-gate.service.ts
+2
-2
apps/pac-service/src/modules/open/open-fact-window.service.ts
+1
-72
apps/pac-service/src/modules/open/open-patients.controller.ts
+0
-35
apps/pac-service/src/modules/open/open-patients.schema.ts
+1
-33
apps/pac-service/src/modules/persona/persona.module.ts
+1
-4
apps/pac-service/src/modules/persona/persona.service.ts
+0
-12
apps/pac-service/src/modules/persona/step-latest.service.ts
+0
-158
apps/pac-service/src/modules/plan/engine/chain-composer.service.ts
+1
-1
apps/pac-service/tests/open-appointment-purpose.spec.ts
+8
-8
apps/pac-service/tests/open-fact-window.spec.ts
+3
-81
apps/pac-service/tests/persona-watermark.spec.ts
+0
-3
docs/friday-ai-test-deployment.md
+1
-5
packages/pac-client/src/client.spec.ts
+2
-6
packages/pac-client/src/client.ts
+0
-11
packages/pac-client/src/index.ts
+0
-3
packages/pac-client/src/types.ts
+0
-42
packages/types/src/canonical-codes.ts
+3
-3
No files found.
apps/ai-service/src/modules/pac/pac.service.ts
View file @
3cd4acf2
...
@@ -7,7 +7,6 @@ import {
...
@@ -7,7 +7,6 @@ import {
type
PacPatientProfileItem
,
type
PacPatientProfileItem
,
type
PacAppointment
,
type
PacAppointment
,
type
PacWindowFact
,
type
PacWindowFact
,
type
PacStepLatest
,
type
PacOrgNode
,
type
PacOrgNode
,
}
from
'@pac/pac-client'
;
}
from
'@pac/pac-client'
;
import
{
BizError
}
from
'../../common/errors/biz-error'
;
import
{
BizError
}
from
'../../common/errors/biz-error'
;
...
@@ -409,39 +408,6 @@ export class PacService {
...
@@ -409,39 +408,6 @@ export class PacService {
}
}
/**
/**
* ⭐⭐ 「这几步最近一次落在这段时间里」的患者 —— 周期维护(2026-09-15,PAC `steps/latest-window`)。
*
* ⚠️⚠️ 语义是**每位患者这几步里最近的那一次**落在窗口里,⛔ 不是「窗口里做过」——
* 「半年前洗过、上周又洗过」的人不会出现,这正是周期维护要的。
* ⚠️ 洁牙在 PAC 里有两种记法(牙周类 `supragingival_scaling` / 预防类 `professional_cleaning`),
* 调用方要一起传。
* ⚠️ 要不要排除「已经有预约」是我们自己的业务规则,PAC ⛔ 不管。
* ⚠️ 翻页、purpose、范围同 `getFactWindowForOrgUnit`。
*/
async
getStepLatestWindowForOrgUnit
(
tenantId
:
string
,
orgUnitId
:
string
,
query
:
{
steps
:
string
[];
from
:
Date
;
to
:
Date
},
opts
:
{
maxPages
?:
number
;
pageSize
?:
number
}
=
{},
):
Promise
<
{
items
:
PacStepLatest
[];
truncated
:
boolean
}
>
{
const
scope
=
await
this
.
scope
.
pacScopeForOrgUnit
(
tenantId
,
orgUnitId
);
const
c
=
this
.
require
();
return
this
.
pageAll
(
'步骤最近一次'
,
scope
,
opts
,
(
cursor
)
=>
c
.
getStepLatestWindow
({
host
:
scope
.
host
,
tenantId
:
scope
.
tenantId
,
orgScope
:
scope
.
orgScope
,
purpose
:
'outreach'
,
steps
:
query
.
steps
,
from
:
query
.
from
.
toISOString
(),
to
:
query
.
to
.
toISOString
(),
limit
:
opts
.
pageSize
??
200
,
...(
cursor
?
{
cursor
}
:
{}),
}),
);
}
/**
* 事实优先端点共用的翻页 —— ⛔ 别各写一份,那道 `maxPages` 硬上限漏一处就是无限翻页。
* 事实优先端点共用的翻页 —— ⛔ 别各写一份,那道 `maxPages` 硬上限漏一处就是无限翻页。
*
*
* ⚠️⚠️ **必须翻到 nextCursor 为 null 为止** —— PAC 侧的越权过滤在分页**之后**,
* ⚠️⚠️ **必须翻到 nextCursor 为 null 为止** —— PAC 侧的越权过滤在分页**之后**,
...
...
apps/ai-service/tests/outreach-purpose.spec.ts
View file @
3cd4acf2
...
@@ -45,17 +45,17 @@ describe('外呼意图 —— 合规判据归 PAC', () => {
...
@@ -45,17 +45,17 @@ describe('外呼意图 —— 合规判据归 PAC', () => {
});
});
/**
/**
* ⭐ 2026-09-15 PAC 加了事实窗口
、步骤最近一次两个事实优先
端点 —— **同样说清意图**。
* ⭐ 2026-09-15 PAC 加了事实窗口端点 —— **同样说清意图**。
* ⚠️ 翻页收在一个 `pageAll` 里(maxPages 硬上限只写一次),⛔
三
个入口各写一个 for 循环。
* ⚠️ 翻页收在一个 `pageAll` 里(maxPages 硬上限只写一次),⛔
不要让每
个入口各写一个 for 循环。
*/
*/
it
(
'⭐
三
个事实优先的读法都声明 outreach,翻页只有一份'
,
()
=>
{
it
(
'⭐
两
个事实优先的读法都声明 outreach,翻页只有一份'
,
()
=>
{
const
src
=
stripTsComments
(
read
(
'src/modules/pac/pac.service.ts'
));
const
src
=
stripTsComments
(
read
(
'src/modules/pac/pac.service.ts'
));
for
(
const
call
of
[
'c.getAppointmentWindow('
,
'c.getFactWindow('
,
'c.getStepLatestWindow('
])
{
for
(
const
call
of
[
'c.getAppointmentWindow('
,
'c.getFactWindow('
])
{
const
seg
=
src
.
slice
(
src
.
indexOf
(
call
),
src
.
indexOf
(
call
)
+
400
);
const
seg
=
src
.
slice
(
src
.
indexOf
(
call
),
src
.
indexOf
(
call
)
+
400
);
expect
({
call
,
outreach
:
/purpose: 'outreach'/
.
test
(
seg
)
}).
toEqual
({
call
,
outreach
:
true
});
expect
({
call
,
outreach
:
/purpose: 'outreach'/
.
test
(
seg
)
}).
toEqual
({
call
,
outreach
:
true
});
}
}
expect
(
src
.
match
(
/for
\(
let page = 0; page < maxPages; page
\+
= 1
\)
/g
)?.
length
).
toBe
(
1
);
expect
(
src
.
match
(
/for
\(
let page = 0; page < maxPages; page
\+
= 1
\)
/g
)?.
length
).
toBe
(
1
);
expect
(
src
.
match
(
/this
\.
pageAll
\(
/g
)?.
length
).
toBe
(
3
);
expect
(
src
.
match
(
/this
\.
pageAll
\(
/g
)?.
length
).
toBe
(
2
);
});
});
/**
/**
...
...
apps/ai-service/tests/pac-host-from-tenant.spec.ts
View file @
3cd4acf2
...
@@ -83,7 +83,6 @@ describe('每个请求都带宿主', () => {
...
@@ -83,7 +83,6 @@ describe('每个请求都带宿主', () => {
[
'getAppointmentWindow'
,
()
=>
pac
.
getAppointmentWindowForAccount
(
TENANT
,
'a1'
,
win
),
page
],
[
'getAppointmentWindow'
,
()
=>
pac
.
getAppointmentWindowForAccount
(
TENANT
,
'a1'
,
win
),
page
],
[
'getAppointmentWindow'
,
()
=>
pac
.
getAppointmentWindowForOrgUnit
(
TENANT
,
'n1'
,
win
),
page
],
[
'getAppointmentWindow'
,
()
=>
pac
.
getAppointmentWindowForOrgUnit
(
TENANT
,
'n1'
,
win
),
page
],
[
'getFactWindow'
,
()
=>
pac
.
getFactWindowForOrgUnit
(
TENANT
,
'n1'
,
{
...
win
,
types
:
[
'treatment_record'
],
anchor
:
'occurred'
}),
page
],
[
'getFactWindow'
,
()
=>
pac
.
getFactWindowForOrgUnit
(
TENANT
,
'n1'
,
{
...
win
,
types
:
[
'treatment_record'
],
anchor
:
'occurred'
}),
page
],
[
'getStepLatestWindow'
,
()
=>
pac
.
getStepLatestWindowForOrgUnit
(
TENANT
,
'n1'
,
{
...
win
,
steps
:
[
'professional_cleaning'
]
}),
page
],
]
as
const
)(
'⭐ %s 带着坐标里的 host'
,
async
(
method
,
run
,
resolved
)
=>
{
]
as
const
)(
'⭐ %s 带着坐标里的 host'
,
async
(
method
,
run
,
resolved
)
=>
{
const
spy
=
jest
.
spyOn
(
PacClient
.
prototype
,
method
).
mockResolvedValue
(
resolved
as
never
);
const
spy
=
jest
.
spyOn
(
PacClient
.
prototype
,
method
).
mockResolvedValue
(
resolved
as
never
);
await
run
();
await
run
();
...
...
apps/pac-docs/openapi/pac.json
View file @
3cd4acf2
...
@@ -3408,22 +3408,6 @@
...
@@ -3408,22 +3408,6 @@
]
]
}
}
},
},
"/pac/v1/open/patients/steps/latest-window"
:
{
"post"
:
{
"description"
:
"Body:{ host, tenantId, orgScope(必填非空), purpose(必填), steps[1..16](必填,PAC 标准 step key), from, to, clinicIds?, cursor?, limit?≤200 }。返回每位患者在 steps 里**最近一次**落在 [from,to) 的那一步:{ patientId, step, lastAt, clinicId, subjectId }。窗口最长 31 天,按 (lastAt, patientId) 升序 keyset 分页;**必须翻到 nextCursor=null 为止**。⭐ purpose=outreach 时 PAC 过掉不打扰/已故/已归档;clinicIds 卡在最近那次做的诊所上。"
,
"operationId"
:
"OpenPatientsController_stepLatestWindow"
,
"parameters"
:
[],
"responses"
:
{
"200"
:
{
"description"
:
""
}
},
"summary"
:
"按「某几步最近一次」的时间窗取患者(HMAC 验签,免登录)"
,
"tags"
:
[
"open"
]
}
},
"/pac/v1/open/org/nodes"
:
{
"/pac/v1/open/org/nodes"
:
{
"post"
:
{
"post"
:
{
"description"
:
"Headers:X-PAC-Timestamp / X-PAC-Signature;Body:{ host, tenantId }。返回 { nodes: [{ ref, parentRef, kind(group|brand|region|clinic), name, aliases }] } —— ref 就是 orgScope 里要传的值。"
,
"description"
:
"Headers:X-PAC-Timestamp / X-PAC-Signature;Body:{ host, tenantId }。返回 { nodes: [{ ref, parentRef, kind(group|brand|region|clinic), name, aliases }] } —— ref 就是 orgScope 里要传的值。"
,
...
...
apps/pac-service/package.json
View file @
3cd4acf2
...
@@ -29,8 +29,6 @@
...
@@ -29,8 +29,6 @@
"import-patient"
:
"ts-node --transpile-only src/cli/import-patient.cli.ts"
,
"import-patient"
:
"ts-node --transpile-only src/cli/import-patient.cli.ts"
,
"recompute-persona"
:
"ts-node --transpile-only src/cli/recompute-persona.cli.ts"
,
"recompute-persona"
:
"ts-node --transpile-only src/cli/recompute-persona.cli.ts"
,
"backfill-plan-labels"
:
"ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts"
,
"backfill-plan-labels"
:
"ts-node --transpile-only src/cli/backfill-plan-labels.cli.ts"
,
"backfill-step-latest"
:
"ts-node --transpile-only src/cli/backfill-step-latest.cli.ts"
,
"backfill-step-latest:prod"
:
"node dist/cli/backfill-step-latest.cli.js"
,
"recompute-persona:prod"
:
"node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js"
,
"recompute-persona:prod"
:
"node --max-old-space-size=8192 dist/cli/recompute-persona.cli.js"
,
"verify-gap-equivalence"
:
"ts-node --transpile-only src/cli/verify-gap-equivalence.cli.ts"
,
"verify-gap-equivalence"
:
"ts-node --transpile-only src/cli/verify-gap-equivalence.cli.ts"
,
"verify-gap-equivalence:prod"
:
"node --max-old-space-size=8192 dist/cli/verify-gap-equivalence.cli.js"
,
"verify-gap-equivalence:prod"
:
"node --max-old-space-size=8192 dist/cli/verify-gap-equivalence.cli.js"
,
...
...
apps/pac-service/prisma/migrations/20261009160000_remove_unused_patient_step_latest/migration.sql
0 → 100644
View file @
3cd4acf2
-- 移除尚无业务使用的治疗步骤汇总;患者原始事实及治疗链不变。
-- 历史建表迁移保持不变;新库和已部署环境都通过前向迁移收敛。
-- 限制等锁时间,避免删除派生表时长期阻塞正在处理的请求。
SET
lock_timeout
=
'5s'
;
DROP
TABLE
IF
EXISTS
"patient_step_latest"
;
RESET
lock_timeout
;
apps/pac-service/prisma/schema.prisma
View file @
3cd4acf2
...
@@ -249,8 +249,6 @@ model Patient {
...
@@ -249,8 +249,6 @@ model Patient {
relationsIn
PatientRelation
[]
@
relation
(
"PatientRelationsIn"
)
relationsIn
PatientRelation
[]
@
relation
(
"PatientRelationsIn"
)
///
诊所回访任务记录
(
展示用
,
5
试点
;
非召回信号
)
///
诊所回访任务记录
(
展示用
,
5
试点
;
非召回信号
)
returnVisits
PatientReturnVisit
[]
returnVisits
PatientReturnVisit
[]
///
每一步治疗最近一次做的时间
(
派生
,
见
PatientStepLatest
)
stepLatest
PatientStepLatest
[]
///
跨同步
upsert
去重
+
集团内
external_id
撞号消歧
(
同号不同源
=
不同人
,
靠
source_unit
分开
)
///
跨同步
upsert
去重
+
集团内
external_id
撞号消歧
(
同号不同源
=
不同人
,
靠
source_unit
分开
)
@@
unique
([
hostId
,
tenantId
,
sourceUnit
,
externalId
])
@@
unique
([
hostId
,
tenantId
,
sourceUnit
,
externalId
])
...
@@ -403,46 +401,6 @@ model PatientRelation {
...
@@ -403,46 +401,6 @@ model PatientRelation {
///
性质
=
诊所自己的外呼
/
回访运营记录
(
常规
/
术后
/
咨询回访
),**
不是临床
fact
,
不进召回信号
**
。
///
性质
=
诊所自己的外呼
/
回访运营记录
(
常规
/
术后
/
咨询回访
),**
不是临床
fact
,
不进召回信号
**
。
///
详情页
"回访记录"
块按
taskDate
倒序展示
,
让客服看到诊所已做
/
已排的回访
→
避免重复外呼
+
上下文。
///
详情页
"回访记录"
块按
taskDate
倒序展示
,
让客服看到诊所已做
/
已排的回访
→
避免重复外呼
+
上下文。
///
独立表
(
非
transaction
/
fact
):
摄入按
organization_id
∈
5
试点
+
customer_id
→
patient
解析
upsert
。
///
独立表
(
非
transaction
/
fact
):
摄入按
organization_id
∈
5
试点
+
customer_id
→
patient
解析
upsert
。
///
⭐⭐
**
每位患者、每一步治疗,最近一次做的时间
**
——
派生表(
2026
-
09
-
15
)。
///
///
═══
为什么要物化
═══════════════════════════════════════════════════════════
///
///
周期维护要问「
**
上次
**
洁牙在
6
个月前」的人
——
那是每位患者治疗事实的
**
最大值
**
,
///
而
step
是按
`
treatmentStepsOf
`
(
@
pac
/
types
,词根归一)算出来的,⛔
SQL
里做不了
///
(做了就是第二份词表,改一个词两边就漂)。⇒
同
`
patient_profiles
.
last_visit_at
`:
///
///
```
///
存日期不存分档
「
6
个月前」在查询时按
now
现算,时间流逝不会让它变错
⇒
不需要到期重扫
///
画像重算顺路刷新
事实变了
⇒
推进事实水位
⇒
触发重算
⇒
这里跟着刷新(同一趟已经加载了全部事实)
///
```
///
///
⚠️
只收
`
kind
=
actual
`
的治疗事实(做过的),⛔
不收
planned
(「计划洁牙」不是洁过牙)。
///
⚠️
改了词表
/
口径之后要跑
`
pnpm
backfill
-
step
-
latest
--
--
all
`
,⛔
别等画像水位自己动
///
(事实没变,画像重算会被水位闸
noop
掉,这张表就停在旧口径)。
model
PatientStepLatest
{
patientId
String
@
map
(
"patient_id"
)
@
db
.
Uuid
///
PAC
标准
step
key
(`
professional_cleaning
`
/
`
canal_filling
`
/
…
)
step
String
hostId
String
@
map
(
"host_id"
)
@
db
.
Uuid
tenantId
String
@
map
(
"tenant_id"
)
///
最近一次做这一步的时刻
(
事实的
occurred_at
)
lastAt
DateTime
@
map
(
"last_at"
)
@
db
.
Timestamptz
(
3
)
///
最近那次在哪家诊所做的
clinicId
String
?
@
map
(
"clinic_id"
)
///
最近那次对应的事实身份
(
treatment_record
的
subject_id
)
subjectId
String
@
map
(
"subject_id"
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
@
db
.
Timestamptz
(
3
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
@
db
.
Timestamptz
(
3
)
patient
Patient
@
relation
(
fields
:
[
patientId
],
references
:
[
id
],
onDelete
:
Cascade
)
@@
id
([
patientId
,
step
])
///
开放面「步骤最近一次」
:
按
step
圈,再按
last_at
取窗口
@@
index
([
hostId
,
tenantId
,
step
,
lastAt
])
@@
map
(
"patient_step_latest"
)
}
model
PatientReturnVisit
{
model
PatientReturnVisit
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
hostId
String
@
map
(
"host_id"
)
@
db
.
Uuid
hostId
String
@
map
(
"host_id"
)
@
db
.
Uuid
...
...
apps/pac-service/src/cli/backfill-step-latest.cli.ts
deleted
100644 → 0
View file @
8c99b8d6
/**
* Backfill Step Latest CLI —— 回填 `patient_step_latest`(每位患者每一步治疗最近一次做的时间)。
*
* 🔴 **上线后必须立刻跑一次**:迁移只建了空表,而开放面「步骤最近一次」只查它 ⇒
* 不跑的话周期维护一个人都查不到。⛔ 别指望画像重算自愈 —— 事实没变的患者会被水位闸 noop 掉,永远补不上。
* 🔴 **改了 step 词表 / 口径之后也要跑**(`@pac/types` 的 LegacyStepSubtypeKeywords / TreatmentMilestones):
* 理由同上,事实没变画像就不重算。
*
* ⚠️ 幂等:值没变的患者一行都不写,重复跑安全。
*
* Usage:
* pnpm backfill-step-latest # 全量翻一遍,只写有变化的
* pnpm backfill-step-latest -- --check # 只报表里现在有多少,不写库
*/
import
{
NestFactory
}
from
'@nestjs/core'
;
import
{
Logger
}
from
'@nestjs/common'
;
import
{
AppModule
}
from
'../app.module'
;
import
{
PrismaService
}
from
'../prisma/prisma.service'
;
import
{
StepLatestService
}
from
'../modules/persona/step-latest.service'
;
import
{
disableSchedulersForCli
}
from
'./bootstrap-flags'
;
async
function
main
():
Promise
<
void
>
{
const
log
=
new
Logger
(
'backfill-step-latest'
);
const
checkOnly
=
process
.
argv
.
slice
(
2
).
includes
(
'--check'
);
// ⚠️ 必须在建应用上下文之前:否则会清掉 service 里正在跑的同步锁(见 bootstrap-flags)
disableSchedulersForCli
();
const
app
=
await
NestFactory
.
createApplicationContext
(
AppModule
,
{
logger
:
[
'error'
,
'warn'
,
'log'
]
});
try
{
const
prisma
=
app
.
get
(
PrismaService
);
const
report
=
async
()
=>
{
const
rows
=
await
prisma
.
$queryRaw
<
{
step
:
string
;
patients
:
bigint
}[]
>
`
SELECT step, count(*) AS patients FROM patient_step_latest GROUP BY step ORDER BY 2 DESC`
;
log
.
log
(
`patient_step_latest 现状:
${
rows
.
map
((
r
)
=>
`
${
r
.
step
}
=
${
r
.
patients
}
`
).
join
(
' '
)
||
'空表'
}
`
);
};
await
report
();
if
(
checkOnly
)
return
;
const
t0
=
Date
.
now
();
await
app
.
get
(
StepLatestService
).
backfill
({
onBatch
:
({
patients
,
written
})
=>
log
.
log
(
`已翻
${
patients
}
位患者,改了
${
written
}
位`
),
});
log
.
log
(
`耗时
${((
Date
.
now
()
-
t0
)
/
1000
).
toFixed
(
1
)}
s`
);
await
report
();
}
finally
{
await
app
.
close
();
}
}
main
().
catch
((
e
)
=>
{
console
.
error
(
e
);
process
.
exit
(
1
);
});
apps/pac-service/src/modules/open/open-appointment.service.ts
View file @
3cd4acf2
...
@@ -28,7 +28,7 @@ export interface OpenAppointment {
...
@@ -28,7 +28,7 @@ export interface OpenAppointment {
*
*
* 越权防线在 `PatientService.getByIds(scope, ids)`,而事实优先的查询天然不在那条路上。
* 越权防线在 `PatientService.getByIds(scope, ids)`,而事实优先的查询天然不在那条路上。
* ⇒ **先查 fact,再把 patientId 送回两道闸**(越权收窄 + 合规闸),活下来的才出库。
* ⇒ **先查 fact,再把 patientId 送回两道闸**(越权收窄 + 合规闸),活下来的才出库。
* ⭐ 两道闸 2026-09-15 收进了 `OpenFactGate` —— 事实窗口
、步骤最近一次
也走同一份,
* ⭐ 两道闸 2026-09-15 收进了 `OpenFactGate` —— 事实窗口也走同一份,
* ⛔ 别为了省一次查询改成"直接拿 fact.clinic_id 判范围":那一刻两套判据就并存了。
* ⛔ 别为了省一次查询改成"直接拿 fact.clinic_id 判范围":那一刻两套判据就并存了。
*
*
* ⚠️ 副作用:**先分页后过滤** ⇒ 一页可能返回少于 limit 条,甚至 0 条,
* ⚠️ 副作用:**先分页后过滤** ⇒ 一页可能返回少于 limit 条,甚至 0 条,
...
...
apps/pac-service/src/modules/open/open-fact-gate.service.ts
View file @
3cd4acf2
...
@@ -14,9 +14,9 @@ export type OpenPurpose = 'outreach' | 'operations';
...
@@ -14,9 +14,9 @@ export type OpenPurpose = 'outreach' | 'operations';
* 患者优先的三个端点,越权防线在 `PatientService.getByIds(scope, ids)`,后面只用它返回的 id。
* 患者优先的三个端点,越权防线在 `PatientService.getByIds(scope, ids)`,后面只用它返回的 id。
* 事实优先的查询**天然不在那条路上**:先有 fact 才有 patient。
* 事实优先的查询**天然不在那条路上**:先有 fact 才有 patient。
* 最早只有预约窗口一个这样的端点,两道闸就写在 `OpenAppointmentService` 里;
* 最早只有预约窗口一个这样的端点,两道闸就写在 `OpenAppointmentService` 里;
*
2026-09-15 加了事实窗口和「步骤最近一次」两个,⛔ 抄三份就是三
套判据 ——
*
增加事实窗口后,⛔ 不要为新端点复制另一
套判据 ——
* 而漂掉的那一侧是**静默**的洞(多给一个别家诊所的人,没有任何东西会报错)。
* 而漂掉的那一侧是**静默**的洞(多给一个别家诊所的人,没有任何东西会报错)。
* ⇒ 收在这里,
三个端点
都调它。
* ⇒ 收在这里,
预约和事实窗口
都调它。
*
*
* ```
* ```
* ① 越权收窄 patientId 原样送回 getByIds —— 端点在构造上不可能比患者优先的三个看得更宽
* ① 越权收窄 patientId 原样送回 getByIds —— 端点在构造上不可能比患者优先的三个看得更宽
...
...
apps/pac-service/src/modules/open/open-fact-window.service.ts
View file @
3cd4acf2
...
@@ -9,20 +9,8 @@ export interface OpenWindowFact extends OpenFact {
...
@@ -9,20 +9,8 @@ export interface OpenWindowFact extends OpenFact {
patientId
:
string
;
patientId
:
string
;
}
}
/** 「步骤最近一次」里的一位患者 */
export
interface
OpenStepLatest
{
patientId
:
string
;
/** 给的几个 step 里,最近做的是哪一个 */
step
:
string
;
/** 最近那次的时刻(ISO) */
lastAt
:
string
;
clinicId
:
string
|
null
;
/** 最近那次对应的治疗事实身份 —— 要看详情走 profile */
subjectId
:
string
;
}
/**
/**
* ⭐⭐
开放面里**另外两个事实优先的查询**(2026-09-15):按时间窗取事实、步骤最近一次
。
* ⭐⭐
按时间窗取事实的开放查询
。
*
*
* ⚠️ 和预约窗口是同一个形状,两道闸、分页契约、脱敏**全部走同一份**:
* ⚠️ 和预约窗口是同一个形状,两道闸、分页契约、脱敏**全部走同一份**:
* 越权收窄 + 合规闸 → `OpenFactGate`;一条事实怎么出库 → `toOpenFact`。
* 越权收窄 + 合规闸 → `OpenFactGate`;一条事实怎么出库 → `toOpenFact`。
...
@@ -108,65 +96,6 @@ export class OpenFactWindowService {
...
@@ -108,65 +96,6 @@ export class OpenFactWindowService {
nextCursor: rows.length === input.limit ? `
$
{
last
.
at
.
toISOString
()}
|
$
{
last
.
id
}
` : null,
nextCursor: rows.length === input.limit ? `
$
{
last
.
at
.
toISOString
()}
|
$
{
last
.
id
}
` : null,
};
};
}
}
/**
* 「给的这几步里,最近一次落在 [from, to) 的患者」。读 `
patient_step_latest
`(派生表,见 schema 头注)。
*
* ⚠️ 先按患者取**这几步里最近的那一次**,再看它落不落在窗口里 ——
* ⛔ 不是「窗口里做过这几步的人」(那会把之后又做过的人也捞进来,而周期维护恰恰要排除他们)。
*/
async stepLatest(
scope: TenantScopeContext,
input: {
steps: string[];
from: string;
to: string;
clinicIds?: string[];
cursor?: string;
limit: number;
purpose: OpenPurpose;
},
): Promise<{ items: OpenStepLatest[]; nextCursor: string | null }> {
const clinics = this.gate.clinicsOf(scope, input.clinicIds);
if (clinics && clinics.length === 0) return { items: [], nextCursor: null };
const cur = parseCursor(input.cursor);
const rows = await this.prisma.$queryRaw<
{ patient_id: string; step: string; last_at: Date; clinic_id: string | null; subject_id: string }[]
>`
SELECT
patient_id
,
step
,
last_at
,
clinic_id
,
subject_id
FROM
(
SELECT
DISTINCT
ON
(
patient_id
)
patient_id
,
step
,
last_at
,
clinic_id
,
subject_id
FROM
patient_step_latest
WHERE
host_id
=
$
{
scope
.
hostId
}::
uuid
AND
tenant_id
=
$
{
scope
.
tenantId
}
AND step = ANY(
${
input
.
steps
}
::text[])
ORDER BY patient_id, last_at DESC, step ASC
) latest
WHERE last_at >=
${
new
Date
(
input
.
from
)}
AND last_at <
${
new
Date
(
input
.
to
)}
-- ⚠️ 诊所维卡在**最近那次**做的诊所上:「上次在这家店洗的牙」才归这家店提醒
AND (
${
clinics
}
::text[] IS NULL OR clinic_id = ANY(
${
clinics
}
::text[]))
AND (
${
cur
===
null
}
::boolean
OR (last_at, patient_id) > (
${
cur
?.
at
??
null
}::
timestamptz
,
$
{
cur
?.
id
??
null
}::
uuid
))
ORDER
BY
last_at
ASC
,
patient_id
ASC
LIMIT
$
{
input
.
limit
}
`;
if (rows.length === 0) return { items: [], nextCursor: null };
const admitted = await this.gate.admit(scope, rows.map((r) => r.patient_id), input.purpose, '步骤最近一次');
const last = rows[rows.length - 1]!;
return {
items: rows
.filter((r) => admitted.has(r.patient_id))
.map((r) => ({
patientId: r.patient_id,
step: r.step,
lastAt: r.last_at.toISOString(),
clinicId: r.clinic_id,
subjectId: r.subject_id,
})),
nextCursor: rows.length === input.limit ? `
$
{
last
.
last_at
.
toISOString
()}
|
$
{
last
.
patient_id
}
` : null,
};
}
}
}
/**
/**
...
...
apps/pac-service/src/modules/open/open-patients.controller.ts
View file @
3cd4acf2
...
@@ -22,8 +22,6 @@ import {
...
@@ -22,8 +22,6 @@ import {
type
OpenAppointmentWindowRequest
,
type
OpenAppointmentWindowRequest
,
OpenFactWindowSchema
,
OpenFactWindowSchema
,
type
OpenFactWindowRequest
,
type
OpenFactWindowRequest
,
OpenStepLatestWindowSchema
,
type
OpenStepLatestWindowRequest
,
}
from
'./open-patients.schema'
;
}
from
'./open-patients.schema'
;
/**
/**
...
@@ -268,39 +266,6 @@ export class OpenPatientsController {
...
@@ -268,39 +266,6 @@ export class OpenPatientsController {
return
this
.
windows
.
facts
(
scope
.
tenant
,
{
types
,
anchor
,
from
,
to
,
clinicIds
,
cursor
,
limit
,
purpose
});
return
this
.
windows
.
facts
(
scope
.
tenant
,
{
types
,
anchor
,
from
,
to
,
clinicIds
,
cursor
,
limit
,
purpose
});
}
}
/**
* ⭐⭐ 步骤最近一次 —— 「上次洁牙 / 涂氟落在这段时间里」的患者(2026-09-15)。
*
* ⚠️ 语义是**每位患者这几步里最近的那一次**落在窗口里,⛔ 不是「窗口里做过这几步」——
* 后者会把之后又做过的人也捞进来,而周期维护恰恰要排除他们。
* ⚠️ 读派生表 `patient_step_latest`(存日期不存分档,画像重算顺路刷新)。
* ⚠️ 两道闸、窗口上限、分页契约同上。
*/
@
Post
(
'steps/latest-window'
)
@
Public
()
@
HttpCode
(
200
)
@
ApiOperation
({
summary
:
'按「某几步最近一次」的时间窗取患者(HMAC 验签,免登录)'
,
description
:
'Body:{ host, tenantId, orgScope(必填非空), purpose(必填), steps[1..16](必填,PAC 标准 step key), from, to, clinicIds?, cursor?, limit?≤200 }。'
+
'返回每位患者在 steps 里**最近一次**落在 [from,to) 的那一步:{ patientId, step, lastAt, clinicId, subjectId }。'
+
'窗口最长 31 天,按 (lastAt, patientId) 升序 keyset 分页;**必须翻到 nextCursor=null 为止**。'
+
'⭐ purpose=outreach 时 PAC 过掉不打扰/已故/已归档;clinicIds 卡在最近那次做的诊所上。'
,
})
async
stepLatestWindow
(
@
Req
()
req
:
Request
&
{
rawBody
?:
Buffer
},
@
Headers
(
'x-pac-timestamp'
)
timestampHeader
:
string
|
undefined
,
@
Headers
(
'x-pac-signature'
)
signatureHeader
:
string
|
undefined
,
@
Body
()
body
:
OpenStepLatestWindowRequest
,
)
{
const
scope
=
await
this
.
authorize
(
req
,
timestampHeader
,
signatureHeader
,
body
,
()
=>
OpenStepLatestWindowSchema
.
parse
(
body
),
);
const
{
steps
,
from
,
to
,
clinicIds
,
cursor
,
limit
,
purpose
}
=
scope
.
parsed
;
assertWindow
(
from
,
to
);
return
this
.
windows
.
stepLatest
(
scope
.
tenant
,
{
steps
,
from
,
to
,
clinicIds
,
cursor
,
limit
,
purpose
});
}
// ───────────────────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────────────────
/**
/**
...
...
apps/pac-service/src/modules/open/open-patients.schema.ts
View file @
3cd4acf2
...
@@ -158,7 +158,7 @@ const Purpose = z
...
@@ -158,7 +158,7 @@ const Purpose = z
);
);
/**
/**
* ⭐⭐ **按时间窗取预约** —— 只读开放面里**第一个「事实优先」的查询**(
2026-09-15 起还有事实窗口、步骤最近一次
)。
* ⭐⭐ **按时间窗取预约** —— 只读开放面里**第一个「事实优先」的查询**(
也提供事实窗口
)。
*
*
* ═══ 为什么非要它不可 ═══════════════════════════════════════════════════════
* ═══ 为什么非要它不可 ═══════════════════════════════════════════════════════
*
*
...
@@ -247,35 +247,3 @@ export const OpenFactWindowSchema = z.object({
...
@@ -247,35 +247,3 @@ export const OpenFactWindowSchema = z.object({
});
});
export
type
OpenFactWindowRequest
=
z
.
infer
<
typeof
OpenFactWindowSchema
>
;
export
type
OpenFactWindowRequest
=
z
.
infer
<
typeof
OpenFactWindowSchema
>
;
/**
* ⭐⭐ **「某几步最近一次做的时间落在窗口里」的患者** —— 周期维护(洁牙、涂氟)要的人群查询(2026-09-15)。
*
* ═══ 为什么不能拿事实窗口拼 ═══════════════════════════════════════════════
*
* 「上次洁牙在 6 个月前」是**每位患者的最近一次**,⛔ 不是「6 个月前那天有人洁过牙」——
* 后者会把这之后又来洁过牙的人也捞进来。要回答前者就得对全部治疗事实按患者取最大值,
* 而 step 是词根归一出来的,⛔ SQL 里做不了(做了就是第二份词表)。
* ⇒ 物化成 `patient_step_latest`(同「上次到诊日期」:存日期不存分档,画像重算顺路刷新),这里只查它。
*
* ⚠️ 语义:给了几个 step,取**这几个里最近的那一次**(`professional_cleaning` 和 `supragingival_scaling`
* 都是洁牙,按任一算)。⚠️ 两道闸、31 天窗口上限、翻到 nextCursor=null —— 同事实窗口。
* ⚠️ 只回 patientId + 最近那次的 step / 时间 / 诊所 / 事实身份。要不要排除「已有预约」
* 是调用方的业务规则,⛔ 不在这里加参数(那一刻就在给某个场景定制了)。
*/
export
const
OpenStepLatestWindowSchema
=
z
.
object
({
host
:
HostName
,
tenantId
:
z
.
string
().
min
(
1
).
describe
(
'集团 id'
),
orgScope
:
OrgScope
,
purpose
:
Purpose
,
/** PAC 标准 step key。⛔ PAC 不校验取值(同 factTypes 的理由) */
steps
:
z
.
array
(
z
.
string
().
min
(
1
)).
min
(
1
).
max
(
16
).
describe
(
'PAC 标准 step key,如 professional_cleaning'
),
from
:
z
.
string
().
datetime
({
offset
:
true
}).
describe
(
'最近一次落在 [from,to) 里,ISO8601 带时区'
),
to
:
z
.
string
().
datetime
({
offset
:
true
}),
/** 只要最近那次在这几家诊所做的。⚠️ 再收窄,⛔ 不是放宽 */
clinicIds
:
z
.
array
(
z
.
string
().
min
(
1
)).
max
(
50
).
optional
(),
cursor
:
z
.
string
().
max
(
160
).
optional
(),
limit
:
z
.
number
().
int
().
min
(
1
).
max
(
200
).
optional
().
default
(
100
),
});
export
type
OpenStepLatestWindowRequest
=
z
.
infer
<
typeof
OpenStepLatestWindowSchema
>
;
apps/pac-service/src/modules/persona/persona.module.ts
View file @
3cd4acf2
...
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
...
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import
{
PersonaController
}
from
'./persona.controller'
;
import
{
PersonaController
}
from
'./persona.controller'
;
import
{
PersonaService
}
from
'./persona.service'
;
import
{
PersonaService
}
from
'./persona.service'
;
import
{
PersonaCoverageMonitorService
}
from
'./persona-coverage-monitor.service'
;
import
{
PersonaCoverageMonitorService
}
from
'./persona-coverage-monitor.service'
;
import
{
StepLatestService
}
from
'./step-latest.service'
;
import
{
FeatureRegistry
,
FEATURE_EXTRACTOR_PROVIDERS
}
from
'./features/feature.registry'
;
import
{
FeatureRegistry
,
FEATURE_EXTRACTOR_PROVIDERS
}
from
'./features/feature.registry'
;
import
{
ClinicalGapModule
}
from
'../clinical-gap/clinical-gap.module'
;
import
{
ClinicalGapModule
}
from
'../clinical-gap/clinical-gap.module'
;
...
@@ -12,12 +11,10 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
...
@@ -12,12 +11,10 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
providers
:
[
providers
:
[
PersonaService
,
PersonaService
,
PersonaCoverageMonitorService
,
PersonaCoverageMonitorService
,
// ⭐ patient_step_latest —— 画像重算顺路刷新,回填 CLI 也用它(见 step-latest.service.ts)
StepLatestService
,
FeatureRegistry
,
FeatureRegistry
,
// extractor 清单收在 feature.registry.ts(与 FeatureRegistry 构造参数同一份,免两边漏改)
// extractor 清单收在 feature.registry.ts(与 FeatureRegistry 构造参数同一份,免两边漏改)
...
FEATURE_EXTRACTOR_PROVIDERS
,
...
FEATURE_EXTRACTOR_PROVIDERS
,
],
],
exports
:
[
PersonaService
,
StepLatestService
],
exports
:
[
PersonaService
],
})
})
export
class
PersonaModule
{}
export
class
PersonaModule
{}
apps/pac-service/src/modules/persona/persona.service.ts
View file @
3cd4acf2
...
@@ -8,7 +8,6 @@ import { FeatureRegistry } from './features/feature.registry';
...
@@ -8,7 +8,6 @@ import { FeatureRegistry } from './features/feature.registry';
import
{
visitFactsOf
,
visitSpanOf
}
from
'./features/visit-facts'
;
import
{
visitFactsOf
,
visitSpanOf
}
from
'./features/visit-facts'
;
import
{
PotentialTreatmentSelector
}
from
'../clinical-gap/potential-treatment.selector'
;
import
{
PotentialTreatmentSelector
}
from
'../clinical-gap/potential-treatment.selector'
;
import
{
personaWriteVerdict
,
type
StoredPersonaFeature
}
from
'./persona-diff'
;
import
{
personaWriteVerdict
,
type
StoredPersonaFeature
}
from
'./persona-diff'
;
import
{
StepLatestService
}
from
'./step-latest.service'
;
import
type
{
import
type
{
ActiveFact
,
ActiveFact
,
FeatureExtractorContext
,
FeatureExtractorContext
,
...
@@ -53,7 +52,6 @@ export class PersonaService {
...
@@ -53,7 +52,6 @@ export class PersonaService {
private
readonly
prisma
:
PrismaService
,
private
readonly
prisma
:
PrismaService
,
private
readonly
registry
:
FeatureRegistry
,
private
readonly
registry
:
FeatureRegistry
,
private
readonly
gapSelector
:
PotentialTreatmentSelector
,
private
readonly
gapSelector
:
PotentialTreatmentSelector
,
private
readonly
stepLatest
:
StepLatestService
,
)
{}
)
{}
/**
/**
...
@@ -474,16 +472,6 @@ export class PersonaService {
...
@@ -474,16 +472,6 @@ export class PersonaService {
`到诊派生字段刷新失败 patient=
${
patient
.
id
}
:
${
err
instanceof
Error
?
err
.
message
:
err
}
`
,
`到诊派生字段刷新失败 patient=
${
patient
.
id
}
:
${
err
instanceof
Error
?
err
.
message
:
err
}
`
,
),
),
);
);
// ─── 每一步治疗最近一次(**同样不是画像**)—— 同上:借这一趟已加载的事实,失败只告警 ───
// 开放面「步骤最近一次」(周期维护:上次洁牙 / 涂氟)读它。见 step-latest.service.ts。
await
this
.
stepLatest
.
refresh
(
patient
,
ctx
.
factsByType
.
get
(
FactType
.
TREATMENT_RECORD
)
??
[])
.
catch
((
err
)
=>
this
.
logger
.
warn
(
`步骤最近一次刷新失败 patient=
${
patient
.
id
}
:
${
err
instanceof
Error
?
err
.
message
:
err
}
`
,
),
);
// ─── 写入判定(写侧省写)───
// ─── 写入判定(写侧省写)───
// 闸放宽到 fact 水位之后,「算了但内容没变」会变多(reparse 常常只动了不进画像的字段)。
// 闸放宽到 fact 水位之后,「算了但内容没变」会变多(reparse 常常只动了不进画像的字段)。
// 这里决定:不写 / 就地刷新 / 升版本。三种都要推水位,否则闸永远开着、下次还白算一遍。
// 这里决定:不写 / 就地刷新 / 升版本。三种都要推水位,否则闸永远开着、下次还白算一遍。
...
...
apps/pac-service/src/modules/persona/step-latest.service.ts
deleted
100644 → 0
View file @
8c99b8d6
import
{
Injectable
,
Logger
}
from
'@nestjs/common'
;
import
{
FactType
,
treatmentStepsOf
}
from
'@pac/types'
;
import
{
PrismaService
}
from
'../../prisma/prisma.service'
;
/** 算「每一步最近一次」要用的那几列 —— 画像重算加载的 `ActiveFact` 和回填查询都满足它 */
export
interface
StepSourceFact
{
subjectId
:
string
;
type
:
string
;
kind
:
string
;
status
:
string
;
occurredAt
:
Date
|
null
;
clinicId
:
string
|
null
;
content
:
unknown
;
}
export
interface
StepLatest
{
step
:
string
;
lastAt
:
Date
;
clinicId
:
string
|
null
;
subjectId
:
string
;
}
/**
* ⭐⭐ `patient_step_latest` 的维护 —— 每位患者、每一步治疗最近一次做的时间。
*
* ═══ 两条写入路,同一个算法 ═══════════════════════════════════════════════
*
* ```
* 日常 画像重算顺路刷新(PersonaService.recompute —— 那一趟已经加载了该患者全部事实)
* 存量 pnpm backfill-step-latest(上线后 / 改了词表之后跑一次)
* ```
* ⚠️ 两条路都调 `latestOf` + `write`,⛔ 别在 CLI 里另写一份「最近一次」的判定。
*
* ⚠️ step 的口径是 `@pac/types` 的 `treatmentStepsOf`(和治疗链、开放面 `steps` 同一个函数)。
*/
@
Injectable
()
export
class
StepLatestService
{
private
readonly
logger
=
new
Logger
(
StepLatestService
.
name
);
constructor
(
private
readonly
prisma
:
PrismaService
)
{}
/**
* 纯函数:一位患者的事实 → 每一步最近一次。
*
* ⚠️ 只收**做过的**治疗:`type=treatment_record`、`kind=actual`、`status ∈ {active, fulfilled}`、有发生时间。
* ⛔ 不收 planned —— 「计划洁牙」不是洁过牙,收进来会让周期维护漏掉真正该提醒的人。
* ⚠️ 同一时刻两条(同一天同一步两条记录)取 subjectId 大的那条 —— 只为结果稳定可复现,⛔ 没有业务含义。
*/
static
latestOf
(
facts
:
readonly
StepSourceFact
[]):
StepLatest
[]
{
const
best
=
new
Map
<
string
,
StepLatest
>
();
for
(
const
f
of
facts
)
{
if
(
f
.
type
!==
FactType
.
TREATMENT_RECORD
||
f
.
kind
!==
'actual'
||
!
f
.
occurredAt
)
continue
;
if
(
f
.
status
!==
'active'
&&
f
.
status
!==
'fulfilled'
)
continue
;
for
(
const
step
of
treatmentStepsOf
(
f
.
content
))
{
const
cur
=
best
.
get
(
step
);
const
t
=
f
.
occurredAt
.
getTime
();
if
(
!
cur
||
t
>
cur
.
lastAt
.
getTime
()
||
(
t
===
cur
.
lastAt
.
getTime
()
&&
f
.
subjectId
>
cur
.
subjectId
)
)
{
best
.
set
(
step
,
{
step
,
lastAt
:
f
.
occurredAt
,
clinicId
:
f
.
clinicId
,
subjectId
:
f
.
subjectId
});
}
}
}
return
[...
best
.
values
()].
sort
((
a
,
b
)
=>
a
.
step
.
localeCompare
(
b
.
step
));
}
/**
* 把一位患者的「每一步最近一次」写成 `want` 这样。
*
* ⚠️ **值没变就不写** —— 画像重算是全量遍历,无谓的 UPDATE 会把表的死行堆起来(同 refreshVisitDerivedFields)。
* ⚠️ `want` 里没有的 step **要删**:事实被作废 / 改了类目之后,那一步就不该再算做过。
* @returns 这一次有没有真的改库
*/
async
write
(
patient
:
{
id
:
string
;
hostId
:
string
;
tenantId
:
string
},
want
:
readonly
StepLatest
[]):
Promise
<
boolean
>
{
const
have
=
await
this
.
prisma
.
patientStepLatest
.
findMany
({
where
:
{
patientId
:
patient
.
id
},
select
:
{
step
:
true
,
lastAt
:
true
,
clinicId
:
true
,
subjectId
:
true
},
});
const
key
=
(
x
:
StepLatest
)
=>
`
${
x
.
step
}
|
${
x
.
lastAt
.
getTime
()}
|
${
x
.
clinicId
??
''
}
|
$
{
x
.
subjectId
}
`;
const haveKeys = new Set(have.map(key));
const wantSteps = new Set(want.map((w) => w.step));
const changed = want.filter((w) => !haveKeys.has(key(w)));
const gone = have.filter((h) => !wantSteps.has(h.step)).map((h) => h.step);
if (changed.length === 0 && gone.length === 0) return false;
await this.prisma.$transaction(async (tx) => {
if (gone.length) {
await tx.patientStepLatest.deleteMany({ where: { patientId: patient.id, step: { in: gone } } });
}
for (const w of changed) {
const data = { lastAt: w.lastAt, clinicId: w.clinicId, subjectId: w.subjectId };
await tx.patientStepLatest.upsert({
where: { patientId_step: { patientId: patient.id, step: w.step } },
update: data,
create: { patientId: patient.id, step: w.step, hostId: patient.hostId, tenantId: patient.tenantId, ...data },
});
}
});
return true;
}
/** 画像重算那条路:事实已经在手上 */
async refresh(patient: { id: string; hostId: string; tenantId: string }, facts: readonly StepSourceFact[]): Promise<boolean> {
return this.write(patient, StepLatestService.latestOf(facts));
}
/**
* 存量回填 —— 按患者 id keyset 翻,一批一批算、一位一位写。
*
* ⚠️ 只翻「有做过的治疗事实」的患者。一条都没有的患者本来就不该有行;
* 曾经有、后来事实全被作废的,由画像重算那条路删(`
want
` 为空 ⇒ 全删)。
* @param onBatch 每批结束报一次进度(CLI 打日志用)
*/
async backfill(
opts: { batchSize?: number; onBatch?: (p: { patients: number; written: number }) => void } = {},
): Promise<{ patients: number; written: number }> {
const batchSize = opts.batchSize ?? 500;
let cursor = '00000000-0000-0000-0000-000000000000';
let patients = 0;
let written = 0;
for (;;) {
const ids = await this.prisma.$queryRaw<{ patient_id: string }[]>`
SELECT
DISTINCT
patient_id
FROM
patient_facts
WHERE
type
=
'treatment_record'
AND
kind
=
'actual'
AND
status
IN
(
'active'
,
'fulfilled'
)
AND
occurred_at
IS
NOT
NULL
AND
patient_id
>
$
{
cursor
}::
uuid
ORDER
BY
patient_id
LIMIT
$
{
batchSize
}
`
;
if
(
ids
.
length
===
0
)
break
;
const
facts
=
await
this
.
prisma
.
patientFact
.
findMany
({
where
:
{
patientId
:
{
in
:
ids
.
map
((
r
)
=>
r
.
patient_id
)
},
type
:
FactType
.
TREATMENT_RECORD
,
kind
:
'actual'
,
status
:
{
in
:
[
'active'
,
'fulfilled'
]
},
},
select
:
{
patientId
:
true
,
hostId
:
true
,
tenantId
:
true
,
subjectId
:
true
,
type
:
true
,
kind
:
true
,
status
:
true
,
occurredAt
:
true
,
clinicId
:
true
,
content
:
true
,
},
});
const
byPatient
=
new
Map
<
string
,
typeof
facts
>
();
for
(
const
f
of
facts
)
byPatient
.
set
(
f
.
patientId
,
[...(
byPatient
.
get
(
f
.
patientId
)
??
[]),
f
]);
for
(
const
[
patientId
,
list
]
of
byPatient
)
{
const
first
=
list
[
0
]
!
;
if
(
await
this
.
refresh
({
id
:
patientId
,
hostId
:
first
.
hostId
,
tenantId
:
first
.
tenantId
},
list
))
written
+=
1
;
}
patients
+=
ids
.
length
;
cursor
=
ids
[
ids
.
length
-
1
]
!
.
patient_id
;
opts
.
onBatch
?.({
patients
,
written
});
}
this
.
logger
.
log
(
`patient_step_latest 回填完成:翻了
${
patients
}
位患者,改了
${
written
}
位`
);
return
{
patients
,
written
};
}
}
apps/pac-service/src/modules/plan/engine/chain-composer.service.ts
View file @
3cd4acf2
...
@@ -912,7 +912,7 @@ function matchMilestoneSteps(
...
@@ -912,7 +912,7 @@ function matchMilestoneSteps(
}
}
const matched: string[] = [];
const matched: string[] = [];
for (const step of milestone.steps) {
for (const step of milestone.steps) {
// ⭐ 单条判定收在 @pac/types 的 treatmentStepMatch —— 开放面的 steps
、patient_step_latest
用同一个函数,
// ⭐ 单条判定收在 @pac/types 的 treatmentStepMatch —— 开放面的 steps 用同一个函数,
// ⛔ 别在这里再写一份词根(改一处就会「链上显示做过、按步骤查人却查不到」)。
// ⛔ 别在这里再写一份词根(改一处就会「链上显示做过、按步骤查人却查不到」)。
// ① 优先 treat_stages 精确匹配(PAC 标准 enum)
// ① 优先 treat_stages 精确匹配(PAC 标准 enum)
// ② fallback:host 没填 stages → LegacyStepSubtypeKeywords 词根匹配 subtype
// ② fallback:host 没填 stages → LegacyStepSubtypeKeywords 词根匹配 subtype
...
...
apps/pac-service/tests/open-appointment-purpose.spec.ts
View file @
3cd4acf2
...
@@ -25,7 +25,7 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
...
@@ -25,7 +25,7 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
const
schema
=
strip
(
read
(
'src/modules/open/open-patients.schema.ts'
));
const
schema
=
strip
(
read
(
'src/modules/open/open-patients.schema.ts'
));
/**
/**
* ⭐ 2026-09-15:两道闸从 `OpenAppointmentService` 收进 `OpenFactGate`,
* ⭐ 2026-09-15:两道闸从 `OpenAppointmentService` 收进 `OpenFactGate`,
* 预约窗口、事实窗口
、步骤最近一次三个端点共用 —— 这里守的是**那一份**,外加「三
个都真的调了它」。
* 预约窗口、事实窗口
两个端点共用 —— 这里守的是**那一份**,外加「两
个都真的调了它」。
*/
*/
const
gate
=
strip
(
read
(
'src/modules/open/open-fact-gate.service.ts'
));
const
gate
=
strip
(
read
(
'src/modules/open/open-fact-gate.service.ts'
));
const
appointment
=
strip
(
read
(
'src/modules/open/open-appointment.service.ts'
));
const
appointment
=
strip
(
read
(
'src/modules/open/open-appointment.service.ts'
));
...
@@ -33,8 +33,8 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
...
@@ -33,8 +33,8 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
it
(
'⭐ purpose 是必填的两档枚举,而且只定义一次'
,
()
=>
{
it
(
'⭐ purpose 是必填的两档枚举,而且只定义一次'
,
()
=>
{
expect
(
schema
).
toMatch
(
/const Purpose = z
\s
*
\.
enum
\(\[\s
*'outreach',
\s
*'operations'
\s
*
\]\)
/
);
expect
(
schema
).
toMatch
(
/const Purpose = z
\s
*
\.
enum
\(\[\s
*'outreach',
\s
*'operations'
\s
*
\]\)
/
);
//
三
个事实优先的 schema 都用这一份
//
两
个事实优先的 schema 都用这一份
expect
(
schema
.
match
(
/purpose: Purpose,/g
)?.
length
).
toBe
(
3
);
expect
(
schema
.
match
(
/purpose: Purpose,/g
)?.
length
).
toBe
(
2
);
expect
(
schema
).
not
.
toMatch
(
/purpose:
\s
*z
\.
/
);
expect
(
schema
).
not
.
toMatch
(
/purpose:
\s
*z
\.
/
);
});
});
...
@@ -80,11 +80,11 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
...
@@ -80,11 +80,11 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
});
});
/**
/**
* ⭐⭐
三
个端点都必须过同一道闸 —— ⛔ 谁都不许自己判范围、自己查 do_not_contact。
* ⭐⭐
两
个端点都必须过同一道闸 —— ⛔ 谁都不许自己判范围、自己查 do_not_contact。
*/
*/
it
(
'⭐⭐ 事实优先的
三
个端点都走 OpenFactGate,⛔ 不各写一份'
,
()
=>
{
it
(
'⭐⭐ 事实优先的
两
个端点都走 OpenFactGate,⛔ 不各写一份'
,
()
=>
{
expect
(
appointment
.
match
(
/this
\.
gate
\.
admit
\(
/g
)?.
length
).
toBe
(
1
);
expect
(
appointment
.
match
(
/this
\.
gate
\.
admit
\(
/g
)?.
length
).
toBe
(
1
);
expect
(
windows
.
match
(
/this
\.
gate
\.
admit
\(
/g
)?.
length
).
toBe
(
2
);
expect
(
windows
.
match
(
/this
\.
gate
\.
admit
\(
/g
)?.
length
).
toBe
(
1
);
for
(
const
src
of
[
appointment
,
windows
])
{
for
(
const
src
of
[
appointment
,
windows
])
{
expect
(
src
).
not
.
toMatch
(
/getByIds|do_not_contact|deceased/
);
expect
(
src
).
not
.
toMatch
(
/getByIds|do_not_contact|deceased/
);
// 诊所交集同一份;交集为空直接回空
// 诊所交集同一份;交集为空直接回空
...
@@ -100,7 +100,7 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
...
@@ -100,7 +100,7 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
it
(
'⭐ 游标仍然跟过滤前的最后一条 —— 加闸不改分页契约'
,
()
=>
{
it
(
'⭐ 游标仍然跟过滤前的最后一条 —— 加闸不改分页契约'
,
()
=>
{
expect
(
appointment
).
toMatch
(
/const last = rows
\[
rows
\.
length - 1
\]
/
);
expect
(
appointment
).
toMatch
(
/const last = rows
\[
rows
\.
length - 1
\]
/
);
expect
(
appointment
).
toMatch
(
/nextCursor:
\s
*rows
\.
length === input
\.
limit/
);
expect
(
appointment
).
toMatch
(
/nextCursor:
\s
*rows
\.
length === input
\.
limit/
);
expect
(
windows
.
match
(
/const last = rows
\[
rows
\.
length - 1
\]
/g
)?.
length
).
toBe
(
2
);
expect
(
windows
.
match
(
/const last = rows
\[
rows
\.
length - 1
\]
/g
)?.
length
).
toBe
(
1
);
expect
(
windows
.
match
(
/nextCursor: rows
\.
length === input
\.
limit/g
)?.
length
).
toBe
(
2
);
expect
(
windows
.
match
(
/nextCursor: rows
\.
length === input
\.
limit/g
)?.
length
).
toBe
(
1
);
});
});
});
});
apps/pac-service/tests/open-fact-window.spec.ts
View file @
3cd4acf2
import
{
readFileSync
}
from
'node:fs'
;
import
{
readFileSync
}
from
'node:fs'
;
import
{
join
}
from
'node:path'
;
import
{
join
}
from
'node:path'
;
import
{
treatmentStepsOf
,
treatmentStepMatch
}
from
'@pac/types'
;
import
{
treatmentStepsOf
,
treatmentStepMatch
}
from
'@pac/types'
;
import
{
OpenFactWindowSchema
,
Open
StepLatestWindowSchema
,
Open
AppointmentWindowSchema
}
from
'../src/modules/open/open-patients.schema'
;
import
{
OpenFactWindowSchema
,
OpenAppointmentWindowSchema
}
from
'../src/modules/open/open-patients.schema'
;
import
{
toOpenFact
}
from
'../src/modules/open/open-fact'
;
import
{
toOpenFact
}
from
'../src/modules/open/open-fact'
;
import
{
StepLatestService
,
type
StepSourceFact
}
from
'../src/modules/persona/step-latest.service'
;
/**
/**
* ⭐⭐ 开放面 2026-09-15
扩的三件事:按时间窗取事实 / 治疗事实带 steps / 步骤最近一次
。
* ⭐⭐ 开放面 2026-09-15
按时间窗取事实 / 治疗事实带 steps
。
*
*
* ⚠️ 场景来自 friday-ai 的待办调研:诊后随访要「昨天给谁做了治疗」
,周期维护要「上次洁牙在 N 个月前」
。
* ⚠️ 场景来自 friday-ai 的待办调研:诊后随访要「昨天给谁做了治疗」。
* ⚠️ 词根样本全部照本地库真实的 treatment_record 抄(category / subtype 实测取值),⛔ 不是凭想象造的。
* ⚠️ 词根样本全部照本地库真实的 treatment_record 抄(category / subtype 实测取值),⛔ 不是凭想象造的。
*/
*/
...
@@ -29,7 +28,6 @@ describe('契约 —— 没有安全默认值的字段就不给默认值', () =>
...
@@ -29,7 +28,6 @@ describe('契约 —— 没有安全默认值的字段就不给默认值', () =>
test
(
'⭐ host 必填(验签按它找钥匙,见 HmacVerifier.verifyRead)'
,
()
=>
{
test
(
'⭐ host 必填(验签按它找钥匙,见 HmacVerifier.verifyRead)'
,
()
=>
{
const
{
host
:
_host
,
...
noHost
}
=
base
;
const
{
host
:
_host
,
...
noHost
}
=
base
;
expect
(()
=>
OpenFactWindowSchema
.
parse
({
...
noHost
,
types
:
[
'treatment_record'
],
anchor
:
'occurred'
})).
toThrow
();
expect
(()
=>
OpenFactWindowSchema
.
parse
({
...
noHost
,
types
:
[
'treatment_record'
],
anchor
:
'occurred'
})).
toThrow
();
expect
(()
=>
OpenStepLatestWindowSchema
.
parse
({
...
noHost
,
steps
:
[
'professional_cleaning'
]
})).
toThrow
();
});
});
test
(
'⭐ 事实窗口:types 必填且非空,anchor 必填(occurred / planned),purpose 必填'
,
()
=>
{
test
(
'⭐ 事实窗口:types 必填且非空,anchor 必填(occurred / planned),purpose 必填'
,
()
=>
{
...
@@ -44,24 +42,7 @@ describe('契约 —— 没有安全默认值的字段就不给默认值', () =>
...
@@ -44,24 +42,7 @@ describe('契约 —— 没有安全默认值的字段就不给默认值', () =>
expect
(()
=>
OpenFactWindowSchema
.
parse
({
...
base
,
orgScope
:
[],
types
:
[
'treatment_record'
],
anchor
:
'occurred'
})).
toThrow
();
expect
(()
=>
OpenFactWindowSchema
.
parse
({
...
base
,
orgScope
:
[],
types
:
[
'treatment_record'
],
anchor
:
'occurred'
})).
toThrow
();
});
});
test
(
'⭐ 步骤最近一次:steps 必填且非空,purpose 必填;PAC ⛔ 不校验 step 取值(加新 step 不让老调用方 400)'
,
()
=>
{
expect
(
OpenStepLatestWindowSchema
.
parse
({
...
base
,
steps
:
[
'professional_cleaning'
,
'a_step_from_the_future'
]
}).
steps
).
toHaveLength
(
2
);
expect
(()
=>
OpenStepLatestWindowSchema
.
parse
({
...
base
,
steps
:
[]
})).
toThrow
();
const
{
purpose
:
_p
,
...
noPurpose
}
=
base
;
expect
(()
=>
OpenStepLatestWindowSchema
.
parse
({
...
noPurpose
,
steps
:
[
'professional_cleaning'
]
})).
toThrow
();
});
test
(
'⚠️ 预约窗口的请求形状只多了 host(2026-09-21 读方改按宿主授权,调用方 friday-ai 同批改)'
,
()
=>
{
expect
(
Object
.
keys
(
OpenAppointmentWindowSchema
.
shape
).
sort
()).
toEqual
(
[
'clinicIds'
,
'cursor'
,
'from'
,
'host'
,
'limit'
,
'orgScope'
,
'purpose'
,
'tenantId'
,
'to'
].
sort
(),
);
});
test
(
'⭐⭐ 三个事实优先的端点都过 31 天窗口上限(同一个 assertWindow)'
,
()
=>
{
const
ctl
=
strip
(
read
(
'src/modules/open/open-patients.controller.ts'
));
expect
(
ctl
.
match
(
/assertWindow
\(
from, to
\)
;/g
)?.
length
).
toBe
(
3
);
expect
(
ctl
).
toMatch
(
/span > 31
\*
24
\*
3600_000/
);
});
});
});
describe
(
'steps —— 跨宿主临床归一(单条口径)'
,
()
=>
{
describe
(
'steps —— 跨宿主临床归一(单条口径)'
,
()
=>
{
...
@@ -126,62 +107,3 @@ describe('steps —— 跨宿主临床归一(单条口径)', () => {
...
@@ -126,62 +107,3 @@ describe('steps —— 跨宿主临床归一(单条口径)', () => {
expect
(
strip
(
read
(
'src/modules/open/open-fact-window.service.ts'
))).
toMatch
(
/
\.\.\.
toOpenFact
\(
r
\)
/
);
expect
(
strip
(
read
(
'src/modules/open/open-fact-window.service.ts'
))).
toMatch
(
/
\.\.\.
toOpenFact
\(
r
\)
/
);
});
});
});
});
describe
(
'步骤最近一次 —— 派生表的算法和查询语义'
,
()
=>
{
const
fact
=
(
over
:
Partial
<
StepSourceFact
>
):
StepSourceFact
=>
({
subjectId
:
'treatment_record:1'
,
type
:
'treatment_record'
,
kind
:
'actual'
,
status
:
'fulfilled'
,
occurredAt
:
new
Date
(
'2025-06-01T02:00:00Z'
),
clinicId
:
'c1'
,
content
:
{
category
:
'periodontic'
,
subtype
:
'洁牙'
},
...
over
,
});
test
(
'⭐⭐ 每一步取最近一次;planned ⛔ 不算做过;作废的 ⛔ 不算;别的类型 ⛔ 不算'
,
()
=>
{
const
got
=
StepLatestService
.
latestOf
([
fact
({
subjectId
:
'treatment_record:old'
,
occurredAt
:
new
Date
(
'2024-01-01T02:00:00Z'
)
}),
fact
({
subjectId
:
'treatment_record:new'
,
occurredAt
:
new
Date
(
'2025-06-01T02:00:00Z'
),
clinicId
:
'c2'
}),
// 「计划洁牙」—— ⛔ 不是洁过牙
fact
({
subjectId
:
'treatment_record:plan'
,
kind
:
'planned'
,
occurredAt
:
new
Date
(
'2026-01-01T02:00:00Z'
)
}),
fact
({
subjectId
:
'treatment_record:void'
,
status
:
'invalidated'
,
occurredAt
:
new
Date
(
'2026-02-01T02:00:00Z'
)
}),
fact
({
subjectId
:
'encounter_record:x'
,
type
:
'encounter_record'
,
occurredAt
:
new
Date
(
'2026-03-01T02:00:00Z'
)
}),
fact
({
subjectId
:
'treatment_record:f'
,
content
:
{
category
:
'preventive'
,
subtype
:
'OHI,涂氟'
},
occurredAt
:
new
Date
(
'2025-03-01T02:00:00Z'
)
}),
fact
({
subjectId
:
'treatment_record:noTime'
,
occurredAt
:
null
}),
]);
expect
(
got
).
toEqual
([
{
step
:
'fluoride_application'
,
lastAt
:
new
Date
(
'2025-03-01T02:00:00Z'
),
clinicId
:
'c1'
,
subjectId
:
'treatment_record:f'
},
{
step
:
'supragingival_scaling'
,
lastAt
:
new
Date
(
'2025-06-01T02:00:00Z'
),
clinicId
:
'c2'
,
subjectId
:
'treatment_record:new'
},
]);
});
test
(
'⚠️ 同一时刻两条 ⇒ 结果稳定(取 subjectId 大的),⛔ 不随输入顺序变'
,
()
=>
{
const
at
=
new
Date
(
'2025-06-01T02:00:00Z'
);
const
a
=
fact
({
subjectId
:
'treatment_record:a'
,
occurredAt
:
at
});
const
b
=
fact
({
subjectId
:
'treatment_record:b'
,
occurredAt
:
at
});
expect
(
StepLatestService
.
latestOf
([
a
,
b
])[
0
]
!
.
subjectId
).
toBe
(
'treatment_record:b'
);
expect
(
StepLatestService
.
latestOf
([
b
,
a
])[
0
]
!
.
subjectId
).
toBe
(
'treatment_record:b'
);
});
/**
* ⭐⭐⭐ 查询语义:**先按患者取这几步里最近的一次,再看它落不落在窗口里**。
* ⛔ 反过来(先按窗口圈、再按患者去重)会把「6 个月前洗过、上周又洗过」的人也捞进来 ——
* 周期维护恰恰要排除他们。按源码守:窗口条件必须在 DISTINCT ON 子查询**外面**。
*/
test
(
'⭐⭐⭐ 窗口条件在「最近一次」子查询外面'
,
()
=>
{
const
svc
=
strip
(
read
(
'src/modules/open/open-fact-window.service.ts'
));
const
seg
=
svc
.
slice
(
svc
.
indexOf
(
'FROM patient_step_latest'
));
const
inner
=
seg
.
indexOf
(
') latest'
);
expect
(
inner
).
toBeGreaterThan
(
0
);
expect
(
seg
.
indexOf
(
'last_at >='
)).
toBeGreaterThan
(
inner
);
expect
(
seg
.
slice
(
0
,
inner
)).
toMatch
(
/ORDER BY patient_id, last_at DESC/
);
});
test
(
'⭐ 画像重算顺路刷新 + 回填 CLI 用同一个算法'
,
()
=>
{
const
persona
=
strip
(
read
(
'src/modules/persona/persona.service.ts'
));
expect
(
persona
).
toMatch
(
/this
\.
stepLatest
\s
*
\.
refresh
\(
patient, ctx
\.
factsByType
\.
get
\(
FactType
\.
TREATMENT_RECORD
\)
\?\?
\[\]\)
/
);
const
cli
=
strip
(
read
(
'src/cli/backfill-step-latest.cli.ts'
));
expect
(
cli
).
toMatch
(
/app
\.
get
\(
StepLatestService
\)\.
backfill
\(
/
);
});
});
apps/pac-service/tests/persona-watermark.spec.ts
View file @
3cd4acf2
import
{
Test
}
from
'@nestjs/testing'
;
import
{
Test
}
from
'@nestjs/testing'
;
import
{
StepLatestService
}
from
'../src/modules/persona/step-latest.service'
;
import
{
PersonaService
}
from
'../src/modules/persona/persona.service'
;
import
{
PersonaService
}
from
'../src/modules/persona/persona.service'
;
import
{
PrismaService
}
from
'../src/prisma/prisma.service'
;
import
{
PrismaService
}
from
'../src/prisma/prisma.service'
;
import
{
FeatureRegistry
}
from
'../src/modules/persona/features/feature.registry'
;
import
{
FeatureRegistry
}
from
'../src/modules/persona/features/feature.registry'
;
...
@@ -163,8 +162,6 @@ async function build(o: Opts) {
...
@@ -163,8 +162,6 @@ async function build(o: Opts) {
{
provide
:
PrismaService
,
useValue
:
prisma
},
{
provide
:
PrismaService
,
useValue
:
prisma
},
{
provide
:
FeatureRegistry
,
useValue
:
stubRegistry
(
o
.
draft
===
undefined
?
RFM
()
:
o
.
draft
)
},
{
provide
:
FeatureRegistry
,
useValue
:
stubRegistry
(
o
.
draft
===
undefined
?
RFM
()
:
o
.
draft
)
},
{
provide
:
PotentialTreatmentSelector
,
useValue
:
{
selectForPatient
:
jest
.
fn
().
mockResolvedValue
([])
}
},
{
provide
:
PotentialTreatmentSelector
,
useValue
:
{
selectForPatient
:
jest
.
fn
().
mockResolvedValue
([])
}
},
// patient_step_latest 顺路刷新 —— 与水位闸无关,这里只占位
{
provide
:
StepLatestService
,
useValue
:
{
refresh
:
jest
.
fn
().
mockResolvedValue
(
false
)
}
},
],
],
}).
compile
();
}).
compile
();
return
{
svc
:
mod
.
get
(
PersonaService
),
calls
,
prisma
};
return
{
svc
:
mod
.
get
(
PersonaService
),
calls
,
prisma
};
...
...
docs/friday-ai-test-deployment.md
View file @
3cd4acf2
...
@@ -22,11 +22,7 @@
...
@@ -22,11 +22,7 @@
本地调试账号由测试中转配置,日常 AI 发布不修改账号归属、不重启中转。已有
`.env`
和私钥继续使用,不用示例覆盖,也不重新生成密钥。
本地调试账号由测试中转配置,日常 AI 发布不修改账号归属、不重启中转。已有
`.env`
和私钥继续使用,不用示例覆盖,也不重新生成密钥。
首次发布 PAC 步骤查询接口、或后续调整治疗步骤口径时,PAC 发布后还须执行一次步骤汇总表
`patient_step_latest`
的回填并等待完成;日常 AI 升级跳过:
2026-10-09 移除未启用的治疗步骤汇总能力:执行迁移后删除
`patient_step_latest`
,无需回填。既有环境升级前备份该派生表,完成镜像构建后停止旧 PAC 后端,再执行迁移并启动新版;患者事实、画像和预约数据保留。历史建表迁移不修改。
```
sh
docker
exec
pac-pac-service-1 node dist/cli/backfill-step-latest.cli.js
```
## 2. 更新代码、准备私钥
## 2. 更新代码、准备私钥
...
...
packages/pac-client/src/client.spec.ts
View file @
3cd4acf2
...
@@ -116,27 +116,23 @@ describe('PacClient | orgScope 护栏', () => {
...
@@ -116,27 +116,23 @@ describe('PacClient | orgScope 护栏', () => {
});
});
});
});
describe
(
'PacClient | 事实窗口
/ 步骤最近一次
(2026-09-15)'
,
()
=>
{
describe
(
'PacClient | 事实窗口(2026-09-15)'
,
()
=>
{
const
base
=
{
host
:
'jvs-dw'
,
tenantId
:
'ruier-grp'
,
orgScope
:
[
'clinic-1'
],
purpose
:
'outreach'
as
const
,
from
:
'2026-06-23T00:00:00+08:00'
,
to
:
'2026-06-24T00:00:00+08:00'
};
const
base
=
{
host
:
'jvs-dw'
,
tenantId
:
'ruier-grp'
,
orgScope
:
[
'clinic-1'
],
purpose
:
'outreach'
as
const
,
from
:
'2026-06-23T00:00:00+08:00'
,
to
:
'2026-06-24T00:00:00+08:00'
};
test
(
'⭐ 打到对的路径,请求体原样发出(purpose / anchor / types 都带上)'
,
async
()
=>
{
test
(
'⭐ 打到对的路径,请求体原样发出(purpose / anchor / types 都带上)'
,
async
()
=>
{
const
{
impl
,
captured
}
=
fakeFetch
(
ok
({
items
:
[],
nextCursor
:
null
}));
const
{
impl
,
captured
}
=
fakeFetch
(
ok
({
items
:
[],
nextCursor
:
null
}));
const
c
=
make
(
impl
);
const
c
=
make
(
impl
);
await
c
.
getFactWindow
({
...
base
,
types
:
[
'treatment_record'
],
anchor
:
'occurred'
});
await
c
.
getFactWindow
({
...
base
,
types
:
[
'treatment_record'
],
anchor
:
'occurred'
});
await
c
.
getStepLatestWindow
({
...
base
,
steps
:
[
'professional_cleaning'
,
'supragingival_scaling'
]
});
expect
(
captured
.
map
((
x
)
=>
x
.
url
)).
toEqual
([
expect
(
captured
.
map
((
x
)
=>
x
.
url
)).
toEqual
([
'http://pac.test/pac/v1/open/patients/facts/window'
,
'http://pac.test/pac/v1/open/patients/facts/window'
,
'http://pac.test/pac/v1/open/patients/steps/latest-window'
,
]);
]);
expect
(
JSON
.
parse
(
captured
[
0
]
!
.
body
)).
toMatchObject
({
purpose
:
'outreach'
,
anchor
:
'occurred'
,
types
:
[
'treatment_record'
]
});
expect
(
JSON
.
parse
(
captured
[
0
]
!
.
body
)).
toMatchObject
({
purpose
:
'outreach'
,
anchor
:
'occurred'
,
types
:
[
'treatment_record'
]
});
expect
(
JSON
.
parse
(
captured
[
1
]
!
.
body
)).
toMatchObject
({
steps
:
[
'professional_cleaning'
,
'supragingival_scaling'
]
});
});
});
test
(
'⛔ 空 orgScope
两个都挡
,不发请求'
,
async
()
=>
{
test
(
'⛔ 空 orgScope
挡住
,不发请求'
,
async
()
=>
{
const
{
impl
,
captured
}
=
fakeFetch
(
ok
({
items
:
[],
nextCursor
:
null
}));
const
{
impl
,
captured
}
=
fakeFetch
(
ok
({
items
:
[],
nextCursor
:
null
}));
const
c
=
make
(
impl
);
const
c
=
make
(
impl
);
await
expect
(
c
.
getFactWindow
({
...
base
,
orgScope
:
[],
types
:
[
'treatment_record'
],
anchor
:
'occurred'
})).
rejects
.
toThrow
(
/orgScope 不能为空/
);
await
expect
(
c
.
getFactWindow
({
...
base
,
orgScope
:
[],
types
:
[
'treatment_record'
],
anchor
:
'occurred'
})).
rejects
.
toThrow
(
/orgScope 不能为空/
);
await
expect
(
c
.
getStepLatestWindow
({
...
base
,
orgScope
:
[],
steps
:
[
'professional_cleaning'
]
})).
rejects
.
toThrow
(
/orgScope 不能为空/
);
expect
(
captured
).
toHaveLength
(
0
);
expect
(
captured
).
toHaveLength
(
0
);
});
});
});
});
...
...
packages/pac-client/src/client.ts
View file @
3cd4acf2
...
@@ -11,8 +11,6 @@ import {
...
@@ -11,8 +11,6 @@ import {
type
PacAppointmentWindowResponse
,
type
PacAppointmentWindowResponse
,
type
PacFactWindowRequest
,
type
PacFactWindowRequest
,
type
PacFactWindowResponse
,
type
PacFactWindowResponse
,
type
PacStepLatestWindowRequest
,
type
PacStepLatestWindowResponse
,
type
PacOrgNode
,
type
PacOrgNode
,
type
PacOrgNodesRequest
,
type
PacOrgNodesRequest
,
}
from
'./types'
;
}
from
'./types'
;
...
@@ -155,15 +153,6 @@ export class PacClient {
...
@@ -155,15 +153,6 @@ export class PacClient {
}
}
/**
/**
* ⭐⭐ 「某几步最近一次落在窗口里」的患者(周期维护:上次洁牙 / 涂氟在 N 个月前)。
* ⚠️⚠️ **必须翻到 `nextCursor` 为 null 为止**,理由同 `getAppointmentWindow`。
*/
async
getStepLatestWindow
(
req
:
PacStepLatestWindowRequest
):
Promise
<
PacStepLatestWindowResponse
>
{
this
.
assertCoordinates
(
req
);
return
this
.
post
<
PacStepLatestWindowResponse
>
(
'/open/patients/steps/latest-window'
,
req
);
}
/**
* ⭐ 这个集团在 PAC 里有哪些组织节点(集团 / 品牌 / 诊所)—— 管理员把自己的门店**对应**到 PAC 用。
* ⭐ 这个集团在 PAC 里有哪些组织节点(集团 / 品牌 / 诊所)—— 管理员把自己的门店**对应**到 PAC 用。
* ⚠️ 只有结构和名字,⛔ 没有患者数据,所以也没有 orgScope。
* ⚠️ 只有结构和名字,⛔ 没有患者数据,所以也没有 orgScope。
*/
*/
...
...
packages/pac-client/src/index.ts
View file @
3cd4acf2
...
@@ -24,9 +24,6 @@ export {
...
@@ -24,9 +24,6 @@ export {
type
PacFactWindowRequest
,
type
PacFactWindowRequest
,
type
PacFactWindowResponse
,
type
PacFactWindowResponse
,
type
PacWindowFact
,
type
PacWindowFact
,
type
PacStepLatestWindowRequest
,
type
PacStepLatestWindowResponse
,
type
PacStepLatest
,
type
PacOrgNode
,
type
PacOrgNode
,
type
PacOrgNodesRequest
,
type
PacOrgNodesRequest
,
}
from
'./types'
;
}
from
'./types'
;
packages/pac-client/src/types.ts
View file @
3cd4acf2
...
@@ -341,48 +341,6 @@ export interface PacFactWindowResponse {
...
@@ -341,48 +341,6 @@ export interface PacFactWindowResponse {
nextCursor
:
string
|
null
;
nextCursor
:
string
|
null
;
}
}
/**
* ⭐⭐ 「某几步最近一次落在窗口里」的患者 —— 周期维护(上次洁牙 / 涂氟在 N 个月前)。
*
* ⚠️⚠️ 语义是**每位患者在这几步里最近的那一次**落在 [from, to),
* ⛔ 不是「窗口里做过这几步」—— 后者会把之后又做过的人也捞进来。
* ⚠️ 给几个 step 就按这几个里最近的算(洁牙在牙周类和预防类记法不同:
* `supragingival_scaling` 和 `professional_cleaning` 要一起传)。
* ⚠️ 窗口最长 31 天;**必须翻到 `nextCursor` 为 null 为止**。
* ⚠️ 要不要排除「已经有预约」是调用方自己的业务规则,PAC ⛔ 不管。
*/
export
interface
PacStepLatestWindowRequest
{
/** 宿主名,见 {@link PacSearchRequest.host} */
host
:
string
;
tenantId
:
string
;
orgScope
:
string
[];
purpose
:
'outreach'
|
'operations'
;
/** PAC 标准 step key,1..16 个 */
steps
:
string
[];
from
:
string
;
to
:
string
;
/** 只要最近那次在这几家诊所做的。⚠️ 再收窄,⛔ 不是放宽 */
clinicIds
?:
string
[];
cursor
?:
string
;
limit
?:
number
;
}
export
interface
PacStepLatest
{
patientId
:
string
;
/** 给的几个 step 里,最近做的是哪一个 */
step
:
string
;
/** 最近那次的时刻(ISO) */
lastAt
:
string
;
clinicId
:
string
|
null
;
/** 最近那次的治疗事实身份 —— 要看详情走 profile */
subjectId
:
string
;
}
export
interface
PacStepLatestWindowResponse
{
items
:
PacStepLatest
[];
nextCursor
:
string
|
null
;
}
/** `listOrgNodes` 的请求 —— ⚠️ 没有 orgScope:这里问的是结构,⛔ 不读患者 */
/** `listOrgNodes` 的请求 —— ⚠️ 没有 orgScope:这里问的是结构,⛔ 不读患者 */
export
interface
PacOrgNodesRequest
{
export
interface
PacOrgNodesRequest
{
/** 宿主名,见 {@link PacSearchRequest.host} */
/** 宿主名,见 {@link PacSearchRequest.host} */
...
...
packages/types/src/canonical-codes.ts
View file @
3cd4acf2
...
@@ -1018,9 +1018,9 @@ export function lookupTreatmentMilestone(
...
@@ -1018,9 +1018,9 @@ export function lookupTreatmentMilestone(
* ═══ 为什么放在这里 ═══════════════════════════════════════════════════════════
* ═══ 为什么放在这里 ═══════════════════════════════════════════════════════════
*
*
* 规则原来只长在 chain-composer 的 `matchMilestoneSteps` 里(给治疗链详情页用)。
* 规则原来只长在 chain-composer 的 `matchMilestoneSteps` 里(给治疗链详情页用)。
* 开放面
要把 step 带给系统调用方、`patient_step_latest` 要按它物化
——
* 开放面
也要把 step 带给系统调用方
——
*
三
处各写一份,改一个关键词就会出现「链上显示做过洁牙、按洁牙查人却查不到」。
*
两
处各写一份,改一个关键词就会出现「链上显示做过洁牙、按洁牙查人却查不到」。
* ⇒ 单条判定收在这一个函数,
三
处都调它。
* ⇒ 单条判定收在这一个函数,
两
处都调它。
*
*
* ═══ 口径(逐字等于 chain-composer 的两级匹配)═══════════════════════════════
* ═══ 口径(逐字等于 chain-composer 的两级匹配)═══════════════════════════════
*
*
...
...
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