Commit 3cd4acf2 by luoqi

Merge unused step summary removal into test

parents 8c99b8d6 0cf90b7f
Pipeline #3732 failed in 0 seconds
......@@ -7,7 +7,6 @@ import {
type PacPatientProfileItem,
type PacAppointment,
type PacWindowFact,
type PacStepLatest,
type PacOrgNode,
} from '@pac/pac-client';
import { BizError } from '../../common/errors/biz-error';
......@@ -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` 硬上限漏一处就是无限翻页。
*
* ⚠️⚠️ **必须翻到 nextCursor 为 null 为止** —— PAC 侧的越权过滤在分页**之后**,
......
......@@ -45,17 +45,17 @@ describe('外呼意图 —— 合规判据归 PAC', () => {
});
/**
* ⭐ 2026-09-15 PAC 加了事实窗口、步骤最近一次两个事实优先端点 —— **同样说清意图**。
* ⚠️ 翻页收在一个 `pageAll` 里(maxPages 硬上限只写一次),⛔ 三个入口各写一个 for 循环。
* ⭐ 2026-09-15 PAC 加了事实窗口端点 —— **同样说清意图**。
* ⚠️ 翻页收在一个 `pageAll` 里(maxPages 硬上限只写一次),⛔ 不要让每个入口各写一个 for 循环。
*/
it('⭐ 三个事实优先的读法都声明 outreach,翻页只有一份', () => {
it('⭐ 两个事实优先的读法都声明 outreach,翻页只有一份', () => {
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);
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(/this\.pageAll\(/g)?.length).toBe(3);
expect(src.match(/this\.pageAll\(/g)?.length).toBe(2);
});
/**
......
......@@ -83,7 +83,6 @@ describe('每个请求都带宿主', () => {
['getAppointmentWindow', () => pac.getAppointmentWindowForAccount(TENANT, 'a1', win), page],
['getAppointmentWindow', () => pac.getAppointmentWindowForOrgUnit(TENANT, 'n1', win), 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) => {
const spy = jest.spyOn(PacClient.prototype, method).mockResolvedValue(resolved as never);
await run();
......
......@@ -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": {
"post": {
"description": "Headers:X-PAC-Timestamp / X-PAC-Signature;Body:{ host, tenantId }。返回 { nodes: [{ ref, parentRef, kind(group|brand|region|clinic), name, aliases }] } —— ref 就是 orgScope 里要传的值。",
......
......@@ -29,8 +29,6 @@
"import-patient": "ts-node --transpile-only src/cli/import-patient.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-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",
"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",
......
-- 移除尚无业务使用的治疗步骤汇总;患者原始事实及治疗链不变。
-- 历史建表迁移保持不变;新库和已部署环境都通过前向迁移收敛。
-- 限制等锁时间,避免删除派生表时长期阻塞正在处理的请求。
SET lock_timeout = '5s';
DROP TABLE IF EXISTS "patient_step_latest";
RESET lock_timeout;
......@@ -249,8 +249,6 @@ model Patient {
relationsIn PatientRelation[] @relation("PatientRelationsIn")
/// 诊所回访任务记录(展示用,5 试点;非召回信号)
returnVisits PatientReturnVisit[]
/// 每一步治疗最近一次做的时间(派生,见 PatientStepLatest)
stepLatest PatientStepLatest[]
/// 跨同步 upsert 去重 + 集团内 external_id 撞号消歧(同号不同源 = 不同人,靠 source_unit 分开)
@@unique([hostId, tenantId, sourceUnit, externalId])
......@@ -403,46 +401,6 @@ model PatientRelation {
/// 性质 = 诊所自己的外呼/回访运营记录(常规/术后/咨询回访),**不是临床 fact,不进召回信号**。
/// 详情页"回访记录"块按 taskDate 倒序展示,让客服看到诊所已做/已排的回访 → 避免重复外呼 + 上下文。
/// 独立表(非 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 {
id String @id @default(uuid()) @db.Uuid
hostId String @map("host_id") @db.Uuid
......
/**
* 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);
});
......@@ -28,7 +28,7 @@ export interface OpenAppointment {
*
* 越权防线在 `PatientService.getByIds(scope, ids)`,而事实优先的查询天然不在那条路上。
* ⇒ **先查 fact,再把 patientId 送回两道闸**(越权收窄 + 合规闸),活下来的才出库。
* ⭐ 两道闸 2026-09-15 收进了 `OpenFactGate` —— 事实窗口、步骤最近一次也走同一份,
* ⭐ 两道闸 2026-09-15 收进了 `OpenFactGate` —— 事实窗口也走同一份,
* ⛔ 别为了省一次查询改成"直接拿 fact.clinic_id 判范围":那一刻两套判据就并存了。
*
* ⚠️ 副作用:**先分页后过滤** ⇒ 一页可能返回少于 limit 条,甚至 0 条,
......
......@@ -14,9 +14,9 @@ export type OpenPurpose = 'outreach' | 'operations';
* 患者优先的三个端点,越权防线在 `PatientService.getByIds(scope, ids)`,后面只用它返回的 id。
* 事实优先的查询**天然不在那条路上**:先有 fact 才有 patient。
* 最早只有预约窗口一个这样的端点,两道闸就写在 `OpenAppointmentService` 里;
* 2026-09-15 加了事实窗口和「步骤最近一次」两个,⛔ 抄三份就是三套判据 ——
* 增加事实窗口后,⛔ 不要为新端点复制另一套判据 ——
* 而漂掉的那一侧是**静默**的洞(多给一个别家诊所的人,没有任何东西会报错)。
* ⇒ 收在这里,三个端点都调它。
* ⇒ 收在这里,预约和事实窗口都调它。
*
* ```
* ① 越权收窄 patientId 原样送回 getByIds —— 端点在构造上不可能比患者优先的三个看得更宽
......
......@@ -9,20 +9,8 @@ export interface OpenWindowFact extends OpenFact {
patientId: string;
}
/** 「步骤最近一次」里的一位患者 */
export interface OpenStepLatest {
patientId: string;
/** 给的几个 step 里,最近做的是哪一个 */
step: string;
/** 最近那次的时刻(ISO) */
lastAt: string;
clinicId: string | null;
/** 最近那次对应的治疗事实身份 —— 要看详情走 profile */
subjectId: string;
}
/**
* ⭐⭐ 开放面里**另外两个事实优先的查询**(2026-09-15):按时间窗取事实、步骤最近一次。
* ⭐⭐ 按时间窗取事实的开放查询。
*
* ⚠️ 和预约窗口是同一个形状,两道闸、分页契约、脱敏**全部走同一份**:
* 越权收窄 + 合规闸 → `OpenFactGate`;一条事实怎么出库 → `toOpenFact`。
......@@ -108,65 +96,6 @@ export class OpenFactWindowService {
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,
};
}
}
/**
......
......@@ -22,8 +22,6 @@ import {
type OpenAppointmentWindowRequest,
OpenFactWindowSchema,
type OpenFactWindowRequest,
OpenStepLatestWindowSchema,
type OpenStepLatestWindowRequest,
} from './open-patients.schema';
/**
......@@ -268,39 +266,6 @@ export class OpenPatientsController {
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 });
}
// ───────────────────────────────────────────────────────────────────────────
/**
......
......@@ -158,7 +158,7 @@ const Purpose = z
);
/**
* ⭐⭐ **按时间窗取预约** —— 只读开放面里**第一个「事实优先」的查询**(2026-09-15 起还有事实窗口、步骤最近一次)。
* ⭐⭐ **按时间窗取预约** —— 只读开放面里**第一个「事实优先」的查询**(也提供事实窗口)。
*
* ═══ 为什么非要它不可 ═══════════════════════════════════════════════════════
*
......@@ -247,35 +247,3 @@ export const OpenFactWindowSchema = z.object({
});
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>;
......@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { PersonaController } from './persona.controller';
import { PersonaService } from './persona.service';
import { PersonaCoverageMonitorService } from './persona-coverage-monitor.service';
import { StepLatestService } from './step-latest.service';
import { FeatureRegistry, FEATURE_EXTRACTOR_PROVIDERS } from './features/feature.registry';
import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
......@@ -12,12 +11,10 @@ import { ClinicalGapModule } from '../clinical-gap/clinical-gap.module';
providers: [
PersonaService,
PersonaCoverageMonitorService,
// ⭐ patient_step_latest —— 画像重算顺路刷新,回填 CLI 也用它(见 step-latest.service.ts)
StepLatestService,
FeatureRegistry,
// extractor 清单收在 feature.registry.ts(与 FeatureRegistry 构造参数同一份,免两边漏改)
...FEATURE_EXTRACTOR_PROVIDERS,
],
exports: [PersonaService, StepLatestService],
exports: [PersonaService],
})
export class PersonaModule {}
......@@ -8,7 +8,6 @@ import { FeatureRegistry } from './features/feature.registry';
import { visitFactsOf, visitSpanOf } from './features/visit-facts';
import { PotentialTreatmentSelector } from '../clinical-gap/potential-treatment.selector';
import { personaWriteVerdict, type StoredPersonaFeature } from './persona-diff';
import { StepLatestService } from './step-latest.service';
import type {
ActiveFact,
FeatureExtractorContext,
......@@ -53,7 +52,6 @@ export class PersonaService {
private readonly prisma: PrismaService,
private readonly registry: FeatureRegistry,
private readonly gapSelector: PotentialTreatmentSelector,
private readonly stepLatest: StepLatestService,
) {}
/**
......@@ -474,16 +472,6 @@ export class PersonaService {
`到诊派生字段刷新失败 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 常常只动了不进画像的字段)。
// 这里决定:不写 / 就地刷新 / 升版本。三种都要推水位,否则闸永远开着、下次还白算一遍。
......
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 };
}
}
......@@ -912,7 +912,7 @@ function matchMilestoneSteps(
}
const matched: string[] = [];
for (const step of milestone.steps) {
// ⭐ 单条判定收在 @pac/types 的 treatmentStepMatch —— 开放面的 steps、patient_step_latest 用同一个函数,
// ⭐ 单条判定收在 @pac/types 的 treatmentStepMatch —— 开放面的 steps 用同一个函数,
// ⛔ 别在这里再写一份词根(改一处就会「链上显示做过、按步骤查人却查不到」)。
// ① 优先 treat_stages 精确匹配(PAC 标准 enum)
// ② fallback:host 没填 stages → LegacyStepSubtypeKeywords 词根匹配 subtype
......
......@@ -25,7 +25,7 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
const schema = strip(read('src/modules/open/open-patients.schema.ts'));
/**
* ⭐ 2026-09-15:两道闸从 `OpenAppointmentService` 收进 `OpenFactGate`,
* 预约窗口、事实窗口、步骤最近一次三个端点共用 —— 这里守的是**那一份**,外加「三个都真的调了它」。
* 预约窗口、事实窗口两个端点共用 —— 这里守的是**那一份**,外加「两个都真的调了它」。
*/
const gate = strip(read('src/modules/open/open-fact-gate.service.ts'));
const appointment = strip(read('src/modules/open/open-appointment.service.ts'));
......@@ -33,8 +33,8 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
it('⭐ purpose 是必填的两档枚举,而且只定义一次', () => {
expect(schema).toMatch(/const Purpose = z\s*\.enum\(\[\s*'outreach',\s*'operations'\s*\]\)/);
// 三个事实优先的 schema 都用这一份
expect(schema.match(/purpose: Purpose,/g)?.length).toBe(3);
// 两个事实优先的 schema 都用这一份
expect(schema.match(/purpose: Purpose,/g)?.length).toBe(2);
expect(schema).not.toMatch(/purpose:\s*z\./);
});
......@@ -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(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]) {
expect(src).not.toMatch(/getByIds|do_not_contact|deceased/);
// 诊所交集同一份;交集为空直接回空
......@@ -100,7 +100,7 @@ describe('事实优先端点的 purpose —— 合规闸归 PAC', () => {
it('⭐ 游标仍然跟过滤前的最后一条 —— 加闸不改分页契约', () => {
expect(appointment).toMatch(/const last = rows\[rows\.length - 1\]/);
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(/nextCursor: rows\.length === input\.limit/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(1);
});
});
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { treatmentStepsOf, treatmentStepMatch } from '@pac/types';
import { OpenFactWindowSchema, OpenStepLatestWindowSchema, OpenAppointmentWindowSchema } 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 { 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 实测取值),⛔ 不是凭想象造的。
*/
......@@ -29,7 +28,6 @@ describe('契约 —— 没有安全默认值的字段就不给默认值', () =>
test('⭐ host 必填(验签按它找钥匙,见 HmacVerifier.verifyRead)', () => {
const { host: _host, ...noHost } = base;
expect(() => OpenFactWindowSchema.parse({ ...noHost, types: ['treatment_record'], anchor: 'occurred' })).toThrow();
expect(() => OpenStepLatestWindowSchema.parse({ ...noHost, steps: ['professional_cleaning'] })).toThrow();
});
test('⭐ 事实窗口:types 必填且非空,anchor 必填(occurred / planned),purpose 必填', () => {
......@@ -44,24 +42,7 @@ describe('契约 —— 没有安全默认值的字段就不给默认值', () =>
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 —— 跨宿主临床归一(单条口径)', () => {
......@@ -126,62 +107,3 @@ describe('steps —— 跨宿主临床归一(单条口径)', () => {
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\(/);
});
});
import { Test } from '@nestjs/testing';
import { StepLatestService } from '../src/modules/persona/step-latest.service';
import { PersonaService } from '../src/modules/persona/persona.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { FeatureRegistry } from '../src/modules/persona/features/feature.registry';
......@@ -163,8 +162,6 @@ async function build(o: Opts) {
{ provide: PrismaService, useValue: prisma },
{ provide: FeatureRegistry, useValue: stubRegistry(o.draft === undefined ? RFM() : o.draft) },
{ provide: PotentialTreatmentSelector, useValue: { selectForPatient: jest.fn().mockResolvedValue([]) } },
// patient_step_latest 顺路刷新 —— 与水位闸无关,这里只占位
{ provide: StepLatestService, useValue: { refresh: jest.fn().mockResolvedValue(false) } },
],
}).compile();
return { svc: mod.get(PersonaService), calls, prisma };
......
......@@ -22,11 +22,7 @@
本地调试账号由测试中转配置,日常 AI 发布不修改账号归属、不重启中转。已有 `.env` 和私钥继续使用,不用示例覆盖,也不重新生成密钥。
首次发布 PAC 步骤查询接口、或后续调整治疗步骤口径时,PAC 发布后还须执行一次步骤汇总表 `patient_step_latest` 的回填并等待完成;日常 AI 升级跳过:
```sh
docker exec pac-pac-service-1 node dist/cli/backfill-step-latest.cli.js
```
2026-10-09 移除未启用的治疗步骤汇总能力:执行迁移后删除 `patient_step_latest`,无需回填。既有环境升级前备份该派生表,完成镜像构建后停止旧 PAC 后端,再执行迁移并启动新版;患者事实、画像和预约数据保留。历史建表迁移不修改。
## 2. 更新代码、准备私钥
......
......@@ -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' };
test('⭐ 打到对的路径,请求体原样发出(purpose / anchor / types 都带上)', async () => {
const { impl, captured } = fakeFetch(ok({ items: [], nextCursor: null }));
const c = make(impl);
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([
'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[1]!.body)).toMatchObject({ steps: ['professional_cleaning', 'supragingival_scaling'] });
});
test('⛔ 空 orgScope 两个都挡,不发请求', async () => {
test('⛔ 空 orgScope 挡住,不发请求', async () => {
const { impl, captured } = fakeFetch(ok({ items: [], nextCursor: null }));
const c = make(impl);
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);
});
});
......
......@@ -11,8 +11,6 @@ import {
type PacAppointmentWindowResponse,
type PacFactWindowRequest,
type PacFactWindowResponse,
type PacStepLatestWindowRequest,
type PacStepLatestWindowResponse,
type PacOrgNode,
type PacOrgNodesRequest,
} from './types';
......@@ -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 用。
* ⚠️ 只有结构和名字,⛔ 没有患者数据,所以也没有 orgScope。
*/
......
......@@ -24,9 +24,6 @@ export {
type PacFactWindowRequest,
type PacFactWindowResponse,
type PacWindowFact,
type PacStepLatestWindowRequest,
type PacStepLatestWindowResponse,
type PacStepLatest,
type PacOrgNode,
type PacOrgNodesRequest,
} from './types';
......@@ -341,48 +341,6 @@ export interface PacFactWindowResponse {
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:这里问的是结构,⛔ 不读患者 */
export interface PacOrgNodesRequest {
/** 宿主名,见 {@link PacSearchRequest.host} */
......
......@@ -1018,9 +1018,9 @@ export function lookupTreatmentMilestone(
* ═══ 为什么放在这里 ═══════════════════════════════════════════════════════════
*
* 规则原来只长在 chain-composer 的 `matchMilestoneSteps` 里(给治疗链详情页用)。
* 开放面要把 step 带给系统调用方、`patient_step_latest` 要按它物化 ——
* 三处各写一份,改一个关键词就会出现「链上显示做过洁牙、按洁牙查人却查不到」。
* ⇒ 单条判定收在这一个函数,三处都调它。
* 开放面也要把 step 带给系统调用方 ——
* 两处各写一份,改一个关键词就会出现「链上显示做过洁牙、按洁牙查人却查不到」。
* ⇒ 单条判定收在这一个函数,两处都调它。
*
* ═══ 口径(逐字等于 chain-composer 的两级匹配)═══════════════════════════════
*
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment