Commit 9cdf04ef by luoqi

chore: 入库会话前既有的工作区改动

会话前已存在、本次一并提交:cold-import.service / sync-incremental scheduler /
clinical-gap potential-treatment-gap SQL / cold-import manifest.schema /
dw-lag-monitor / canonical-codes / ingestion·monitoring 文档 / jvs-dw manifest.yaml /
package.json / pnpm-lock / .gitignore(next-env.d.ts)/ docs/friday-saas-mapping-temp。
非本次会话产出,随此次部署一并落地。
parent 1cf137ac
Pipeline #3340 failed in 0 seconds
...@@ -35,3 +35,6 @@ docs/.obsidian/ ...@@ -35,3 +35,6 @@ docs/.obsidian/
# 本地 IDE / Claude 配置 # 本地 IDE / Claude 配置
.claude/ .claude/
# Next.js 自动生成(dev/build 间路径翻转,build 每次重生成)
apps/pac-web/next-env.d.ts
...@@ -60,13 +60,18 @@ SELECT count(*) AS multi_active FROM (SELECT subject_id,host_id,tenant_id FROM p ...@@ -60,13 +60,18 @@ SELECT count(*) AS multi_active FROM (SELECT subject_id,host_id,tenant_id FROM p
## 二、日级增量 ## 二、日级增量
`SyncIncrementalSchedulerService` 的 `@Cron`(`PAC_INCREMENTAL_CRON`)随服务启动注册,走与全量同一套 `importDirectory`: `SyncIncrementalSchedulerService` 启动时**为每个 `auto_sync: true` 的 host 各注册一个 CronJob**,走与全量同一套 `importDirectory`。cron 时间取自该 host `manifest.incremental_cron`(按 manifest `timezone` 解释),缺省回退全局 `PAC_INCREMENTAL_CRON` —— 各 host 在自己上游刷新窗口后跑,不再挤一个全局时刻:
```yaml
# data/<host>/manifest.yaml
incremental_cron: '15 8 * * *' # 该 host 数据源当日刷新之后
``` ```
每天 08:15(PAC_INCREMENTAL_CRON,须设在数据源当日刷新之后)
```
到点(每 host 各自 cron)
importDirectory({ incremental: true }) importDirectory({ incremental: true })
- 并发锁:sync_logs partial UNIQUE (host_id) WHERE status='running' - 并发锁:sync_logs partial UNIQUE (host_id) WHERE status='running' AND direction='pull'
同 host 已在跑 → 跳过,等下次 cron 同 host 已在跑 → 跳过,等下次 cron
- 读上次 cursor_after → SQL WHERE updated_date > (cursor − 回看窗 48h) - 读上次 cursor_after → SQL WHERE updated_date > (cursor − 回看窗 48h)
(回看重叠靠 source_event_id 去重;增量数据量小,通常 1 批) (回看重叠靠 source_event_id 去重;增量数据量小,通常 1 批)
......
...@@ -22,7 +22,19 @@ PAC 的运行态监控分四块:数据源滞后、每日健康日报、队列健 ...@@ -22,7 +22,19 @@ PAC 的运行态监控分四块:数据源滞后、每日健康日报、队列健
🔴 > 48h critical log + 告警推送(严重滞后,事件可能漏召) 🔴 > 48h critical log + 告警推送(严重滞后,事件可能漏召)
``` ```
阈值由 `PAC_LAG_WARN_HOURS` / `PAC_LAG_ERROR_HOURS` 控制。未配 `ALERT_WEBHOOK_URL` 时仅落 log,值班查 `journalctl -u pac-service | grep dw-lag`。 阈值默认由 `PAC_LAG_WARN_HOURS` / `PAC_LAG_ERROR_HOURS` 控制,**每个 host 可在 `manifest.monitoring` 覆盖**(上游节奏不同,一刀切会误报/漏报):
```yaml
# data/<host>/manifest.yaml
monitoring:
dw_lag_warn_hours: 24
dw_lag_error_hours: 48
push_lag_error_hours: 26 # push host:超此小时数无推送 → 告警(实时推设小,批量推设大)
```
未配 `ALERT_WEBHOOK_URL` 时仅落 log,值班查 `journalctl -u pac-service | grep dw-lag`。
**push 断流监控**:同一 `DwLagMonitorService` 对**曾推过**的 host 检查最近一次 push 距今时长,超 `push_lag_error_hours`(默认 26h)→ critical 告警;未上线 push 的 host 不检查。
--- ---
......
...@@ -42,6 +42,12 @@ host_name: jvs-dw ...@@ -42,6 +42,12 @@ host_name: jvs-dw
display_name: 瑞尔集团 DW(单 host 多 brand) display_name: 瑞尔集团 DW(单 host 多 brand)
auto_sync: true # 纳入每日自动增量同步(scheduler 自动发现;取代 PAC_INCREMENTAL_HOSTS env) auto_sync: true # 纳入每日自动增量同步(scheduler 自动发现;取代 PAC_INCREMENTAL_HOSTS env)
# 每宿主运维配置(多宿主:上游节奏/SLA 各异,不用全局 env 一刀切)。
incremental_cron: '15 8 * * *' # 瑞尔 DW 每日 08:00(沪)落库,其后跑;按下方 timezone 解释
monitoring:
dw_lag_warn_hours: 24 # 增量游标滞后 >24h 提醒
dw_lag_error_hours: 48 # >48h 告警(事件可能漏召)
# ── 集团模型(tenant=集团 slug;品牌降为 source_unit 命名空间)── # ── 集团模型(tenant=集团 slug;品牌降为 source_unit 命名空间)──
# tenant=集团:稳定 slug(各环境一致,不再写死 DW UUID);所有行归此集团。 # tenant=集团:稳定 slug(各环境一致,不再写死 DW UUID);所有行归此集团。
# 品牌(瑞尔/瑞泰)= patients.source_unit,经 identity_namespace_field 从 brand 列填, # 品牌(瑞尔/瑞泰)= patients.source_unit,经 identity_namespace_field 从 brand 列填,
......
...@@ -65,6 +65,7 @@ ...@@ -65,6 +65,7 @@
"@nestjs/swagger": "^11.4.2", "@nestjs/swagger": "^11.4.2",
"@nestjs/websockets": "^11.1.24", "@nestjs/websockets": "^11.1.24",
"@pac/types": "workspace:*", "@pac/types": "workspace:*",
"cron": "^4.4.0",
"@pac/utils": "workspace:*", "@pac/utils": "workspace:*",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@scalar/nestjs-api-reference": "^1.1.14", "@scalar/nestjs-api-reference": "^1.1.14",
......
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
EXTERNAL_TREATMENT_VISIT_NEG_RE, EXTERNAL_TREATMENT_VISIT_NEG_RE,
NO_RESTORATION_GAP_EXAM_PATTERNS, NO_RESTORATION_GAP_EXAM_PATTERNS,
TREATMENT_REFUSAL_SUBTYPE_PATTERNS, TREATMENT_REFUSAL_SUBTYPE_PATTERNS,
TREATMENT_REFUSAL_IMAGING_EXCLUDE_RE,
RESTORATION_INELIGIBLE_DX_NAMES, RESTORATION_INELIGIBLE_DX_NAMES,
STRUCTURAL_DX_CODE_LIST, STRUCTURAL_DX_CODE_LIST,
type DxTreatmentRule, type DxTreatmentRule,
...@@ -139,6 +140,11 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -139,6 +140,11 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
const afterDxFragRtx = afterDxFor('rtx'); const afterDxFragRtx = afterDxFor('rtx');
const noRestorMsgRe = NO_RESTORATION_GAP_EXAM_PATTERNS.join('|'); const noRestorMsgRe = NO_RESTORATION_GAP_EXAM_PATTERNS.join('|');
const refusalRe = TREATMENT_REFUSAL_SUBTYPE_PATTERNS.join('|'); const refusalRe = TREATMENT_REFUSAL_SUBTYPE_PATTERNS.join('|');
/// 判 refusal 前先抹掉"拒绝拍片"类影像拒绝(≠ 拒绝治疗),避免误压召回。
/// regexp_replace 而非直接 !~:混合记录("拒绝拍片…拒绝正畸")抹掉影像拒绝后仍保留真治疗拒绝。
const refusalImagingRe = TREATMENT_REFUSAL_IMAGING_EXCLUDE_RE;
const refusalSubtypeMatch = (alias: string): Prisma.Sql =>
Prisma.sql`regexp_replace(${Prisma.raw(alias)}.content->>'subtype', ${refusalImagingRe}, '', 'g') ~ ${refusalRe}`;
/// 外院已治疗(回访记录 result)患者级排除:POS 紧邻命中 ∧ 非 NEG 意图 ∧ 回访日 ≥ 本次诊断 → 排除。 /// 外院已治疗(回访记录 result)患者级排除:POS 紧邻命中 ∧ 非 NEG 意图 ∧ 回访日 ≥ 本次诊断 → 排除。
/// 患者级(不看牙位/类目);时间方向同治疗排除(新诊断更晚 → 不排)。只读 patient_return_visits.result, /// 患者级(不看牙位/类目);时间方向同治疗排除(新诊断更晚 → 不排)。只读 patient_return_visits.result,
/// 不影响已治疗事实。一线反馈②(李姝妤 回访"已在外院矫正中")。 /// 不影响已治疗事实。一线反馈②(李姝妤 回访"已在外院矫正中")。
...@@ -276,7 +282,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -276,7 +282,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
CROSS JOIN unnest(${toothArrSql(Prisma.sql`rfx.content->>'tooth_position'`)}) AS rft CROSS JOIN unnest(${toothArrSql(Prisma.sql`rfx.content->>'tooth_position'`)}) AS rft
WHERE rfx.patient_id = p.id WHERE rfx.patient_id = p.id
AND rfx.type = 'treatment_record' AND rfx.status IN ('active', 'fulfilled') AND rfx.type = 'treatment_record' AND rfx.status IN ('active', 'fulfilled')
AND rfx.content->>'subtype' ~ ${Prisma.sql`${refusalRe}`} AND ${refusalSubtypeMatch('rfx')}
AND rfx.content->>'category' = ANY(${resolverCats}::text[]) AND rfx.content->>'category' = ANY(${resolverCats}::text[])
${refusalAfterDx} ${refusalAfterDx}
UNION UNION
...@@ -341,7 +347,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -341,7 +347,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
WHERE rfx.patient_id = p.id WHERE rfx.patient_id = p.id
AND rfx.type = 'treatment_record' AND rfx.type = 'treatment_record'
AND rfx.status IN ('active', 'fulfilled') AND rfx.status IN ('active', 'fulfilled')
AND rfx.content->>'subtype' ~ ${Prisma.sql`${refusalRe}`} AND ${refusalSubtypeMatch('rfx')}
AND rfx.content->>'category' = ANY(${resolverCats}::text[]) AND rfx.content->>'category' = ANY(${resolverCats}::text[])
${refusalAfterDx} ${refusalAfterDx}
) )
......
...@@ -495,6 +495,38 @@ export class ColdImportService { ...@@ -495,6 +495,38 @@ export class ColdImportService {
} }
} }
/**
* 每宿主运维配置(调度 cron + 监控阈值),从 manifest 读。多宿主:各 host 上游节奏/SLA 不同,
* 这些不该用全局 env 一刀切。缺失字段由调用方回退全局默认。无 manifest → 全 null。
*/
getHostOpsConfig(hostName: string): {
incrementalCron: string | null;
timezone: string | null;
dwLagWarnHours: number | null;
dwLagErrorHours: number | null;
pushLagErrorHours: number | null;
} {
const empty = {
incrementalCron: null,
timezone: null,
dwLagWarnHours: null,
dwLagErrorHours: null,
pushLagErrorHours: null,
};
try {
const m = this.readManifest(this.resolveHostDir(hostName));
return {
incrementalCron: m.incremental_cron ?? null,
timezone: m.timezone ?? null,
dwLagWarnHours: m.monitoring?.dw_lag_warn_hours ?? null,
dwLagErrorHours: m.monitoring?.dw_lag_error_hours ?? null,
pushLagErrorHours: m.monitoring?.push_lag_error_hours ?? null,
};
} catch {
return empty;
}
}
/// host → 配置目录(= sync.service.resolveDataDir 同约定:env PAC_INCREMENTAL_DATA_DIR ?? cwd/data,再拼 hostName) /// host → 配置目录(= sync.service.resolveDataDir 同约定:env PAC_INCREMENTAL_DATA_DIR ?? cwd/data,再拼 hostName)
private resolveHostDir(hostName: string): string { private resolveHostDir(hostName: string): string {
const base = process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(process.cwd(), 'data'); const base = process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(process.cwd(), 'data');
......
...@@ -114,6 +114,24 @@ export const ColdImportManifestSchema = z ...@@ -114,6 +114,24 @@ export const ColdImportManifestSchema = z
///(接入新 host 忘了改 env 导致静默不同步的坑)。env 仍可作显式 override(见 scheduler)。 ///(接入新 host 忘了改 env 导致静默不同步的坑)。env 仍可作显式 override(见 scheduler)。
auto_sync: z.boolean().optional().default(false), auto_sync: z.boolean().optional().default(false),
/// **每宿主增量 cron**(可选)—— 上游 DW 刷新窗口因 host 而异,该 host 在自己 DW 就绪后跑。
/// 缺省回退全局 `PAC_INCREMENTAL_CRON`。cron 按本 manifest 的 `timezone` 解释(不是服务器 UTC)。
/// 例:'30 2 * * *'(DW 02:00 刷新后)。仅 auto_sync=true 时生效。
incremental_cron: z.string().min(1).optional(),
/// **每宿主监控阈值**(可选)—— 不同 host 推送/刷新节奏不同,一刀切阈值要么误报要么漏报。
/// 缺省回退全局 env(见 dw-lag-monitor)。
monitoring: z
.object({
/// DW 增量游标滞后告警(pull host):warn / error 小时阈值
dw_lag_warn_hours: z.number().positive().optional(),
dw_lag_error_hours: z.number().positive().optional(),
/// push 断流告警(push host):超过此小时数无 push → critical。
/// 实时推的 host 设小(如 3),每晚批量推的设大(如 26)。
push_lag_error_hours: z.number().positive().optional(),
})
.optional(),
/// **单 tenant 场景**:静态 tenant_id,所有行共用一个 tenant /// **单 tenant 场景**:静态 tenant_id,所有行共用一个 tenant
tenant_id: z.string().min(1).optional(), tenant_id: z.string().min(1).optional(),
......
...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; ...@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AlertService } from '../common/alerting/alert.service'; import { AlertService } from '../common/alerting/alert.service';
import { ColdImportService } from '../modules/sync/cold-import/cold-import.service';
/** /**
* DwLagMonitorService — 每小时检查 DW 增量数据滞后(W4 末) * DwLagMonitorService — 每小时检查 DW 增量数据滞后(W4 末)
...@@ -25,6 +26,7 @@ export class DwLagMonitorService { ...@@ -25,6 +26,7 @@ export class DwLagMonitorService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly alerter: AlertService, private readonly alerter: AlertService,
private readonly coldImport: ColdImportService,
) {} ) {}
/// DW 数据滞后告警 cron — env 驱动 /// DW 数据滞后告警 cron — env 驱动
...@@ -35,12 +37,16 @@ export class DwLagMonitorService { ...@@ -35,12 +37,16 @@ export class DwLagMonitorService {
name: 'dw-lag-monitor', name: 'dw-lag-monitor',
}) })
async checkLag(): Promise<void> { async checkLag(): Promise<void> {
const warnH = Number(process.env.PAC_LAG_WARN_HOURS ?? '24'); // 全局默认(env);每宿主可在 manifest.monitoring 覆盖(上游节奏不同,一刀切会误报/漏报)
const errorH = Number(process.env.PAC_LAG_ERROR_HOURS ?? '48'); const gWarnH = Number(process.env.PAC_LAG_WARN_HOURS ?? '24');
const gErrorH = Number(process.env.PAC_LAG_ERROR_HOURS ?? '48');
const now = Date.now(); const now = Date.now();
const hosts = await this.prisma.host.findMany({ select: { id: true, name: true } }); const hosts = await this.prisma.host.findMany({ select: { id: true, name: true } });
for (const host of hosts) { for (const host of hosts) {
const ops = this.coldImport.getHostOpsConfig(host.name);
const warnH = ops.dwLagWarnHours ?? gWarnH;
const errorH = ops.dwLagErrorHours ?? gErrorH;
const last = await this.prisma.syncLog.findFirst({ const last = await this.prisma.syncLog.findFirst({
where: { where: {
hostId: host.id, hostId: host.id,
...@@ -105,8 +111,10 @@ export class DwLagMonitorService { ...@@ -105,8 +111,10 @@ export class DwLagMonitorService {
hosts: Array<{ id: string; name: string }>, hosts: Array<{ id: string; name: string }>,
now: number, now: number,
): Promise<void> { ): Promise<void> {
const errorH = Number(process.env.PAC_PUSH_LAG_ERROR_HOURS ?? '26'); const gErrorH = Number(process.env.PAC_PUSH_LAG_ERROR_HOURS ?? '26');
for (const host of hosts) { for (const host of hosts) {
// 每宿主阈值:实时推的 host 设小(断几小时就该报),批量推的设大;缺省回退全局
const errorH = this.coldImport.getHostOpsConfig(host.name).pushLagErrorHours ?? gErrorH;
const lastPush = await this.prisma.syncLog.findFirst({ const lastPush = await this.prisma.syncLog.findFirst({
where: { hostId: host.id, direction: 'push' }, where: { hostId: host.id, direction: 'push' },
orderBy: { startedAt: 'desc' }, orderBy: { startedAt: 'desc' },
......
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import * as path from 'path'; import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
...@@ -11,27 +12,24 @@ import { PersonaService } from '../modules/persona/persona.service'; ...@@ -11,27 +12,24 @@ import { PersonaService } from '../modules/persona/persona.service';
import { PlanEngineService } from '../modules/plan/engine/plan-engine.service'; import { PlanEngineService } from '../modules/plan/engine/plan-engine.service';
/** /**
* SyncIncrementalSchedulerService — W4 末:DW 直连增量自动跑(每天 02:30) * SyncIncrementalSchedulerService — DW 直连增量自动跑(**每宿主独立 cron**)
* *
* 上游:DW 团队每日凌晨 ~02:00 完成全量刷新 * 每天在各 host 自己的 DW 刷新窗口后触发:
* 我们 02:30 触发: * 1. ColdImportService.importDirectory({ incremental: true })(游标增量 + stub 补齐)
* 1. ColdImportService.importDirectory({ incremental: true }) * 2. Persona 重算(本次 sync 写入的 distinct patient_id)
* - 读 sync_logs 上次 cursor_after → SQL 注入 WHERE updated_date > '...' * 3. org-tree 预热 + Plan 重算(per tenant)
* - 反向拉主档(C 方案)+ stub auto-create(A 方案)→ 无数据丢失
* - 写新 cursor 到 sync_logs
* 2. Persona 重算(affected patient — 本次 sync 写入的 distinct patient_id)
* 3. Plan 重算(per tenant,scenario SQL + 6 因子打分)
* *
* 多 host:默认自动发现 data/<host>/manifest.yaml 中 `auto_sync: true` 的 host; * **多 host 调度**(启动时动态注册,取代单一全局 @Cron):
* PAC_INCREMENTAL_HOSTS env(逗号分隔)若设则作显式 override(escape hatch)。 * - 发现 data/<host>/manifest.yaml 中 auto_sync=true 的 host;
* 每个 host 期望 data/<host>/manifest.yaml 存在 * - 每个 host 用**自己 manifest 的 incremental_cron + timezone**注册独立 CronJob
* —— 上游 DW 刷新窗口因 host 而异,各自在就绪后跑,不再挤在一个全局时刻;
* - manifest 没配 cron → 回退全局 PAC_INCREMENTAL_CRON(按 host manifest 时区解释)。
* - PAC_INCREMENTAL_HOSTS env(逗号分隔)仍作显式 override 逃生口(用全局 cron)。
* *
* 跑失败: * 跑失败:cursor 不前进 → 下次自动 catchup;log ERROR 不抛。
* - cursor 不前进(ColdImportService 内 transaction)→ 下次自动 catchup
* - log ERROR + 不抛(让 cron 下次继续)
*/ */
@Injectable() @Injectable()
export class SyncIncrementalSchedulerService { export class SyncIncrementalSchedulerService implements OnModuleInit {
private readonly logger = new Logger(SyncIncrementalSchedulerService.name); private readonly logger = new Logger(SyncIncrementalSchedulerService.name);
constructor( constructor(
...@@ -40,56 +38,69 @@ export class SyncIncrementalSchedulerService { ...@@ -40,56 +38,69 @@ export class SyncIncrementalSchedulerService {
private readonly orgTree: OrgTreeService, private readonly orgTree: OrgTreeService,
private readonly persona: PersonaService, private readonly persona: PersonaService,
private readonly planEngine: PlanEngineService, private readonly planEngine: PlanEngineService,
private readonly scheduler: SchedulerRegistry,
) {} ) {}
/// DW 增量同步 cron — env 驱动 onModuleInit(): void {
/// PAC_INCREMENTAL_CRON: const globalCron = process.env.PAC_INCREMENTAL_CRON;
/// 不设 → 不跑(local 默认,避免开发期自动消耗 DW 流量) const dataDir = this.dataDir();
/// '30 2 * * *' → 生产推荐(DW 02:00 全量刷新后) // env override(逗号分隔)优先;否则自动发现 auto_sync=true 的 host
/// '30 3 * * *' → staging 错峰
/// 详见 .env.example
@Cron(process.env.PAC_INCREMENTAL_CRON || '0 0 31 12 *' /* never — env not set */, {
name: 'sync-incremental-daily',
// cron 表达式按北京时间解释(容器为 UTC;不加则实际 +8h —— 03:30 会变 11:30)
timeZone: 'Asia/Shanghai',
})
async runDaily(): Promise<void> {
const dataDir = process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(__dirname, '../../data');
// host 来源:env 显式 override(逗号分隔)优先;未设 → 自动发现 manifest.auto_sync=true。
// 自动发现取代旧 `PAC_INCREMENTAL_HOSTS=jvs-dw` 默认值,接入新 host 只改其 manifest,不碰 env(防 drift)。
const envHosts = (process.env.PAC_INCREMENTAL_HOSTS ?? '') const envHosts = (process.env.PAC_INCREMENTAL_HOSTS ?? '')
.split(',') .split(',')
.map((h) => h.trim()) .map((h) => h.trim())
.filter(Boolean); .filter(Boolean);
const hosts = envHosts.length > 0 ? envHosts : this.coldImport.discoverAutoSyncHostDirs(dataDir); const hosts = envHosts.length > 0 ? envHosts : this.coldImport.discoverAutoSyncHostDirs(dataDir);
const source = envHosts.length > 0 ? 'env-override' : 'manifest auto_sync';
if (hosts.length === 0) { if (hosts.length === 0) {
this.logger.warn( this.logger.log('sync-incremental: 无 auto_sync host,不注册 cron');
`sync-incremental: 无 host 可同步(env PAC_INCREMENTAL_HOSTS 未设,且 ${dataDir} 下无 manifest.auto_sync=true);skip`,
);
return; return;
} }
this.logger.log(
`sync-incremental: START hosts=[${hosts.join(',')}] (source=${source}) dataDir=${dataDir}`,
);
for (const host of hosts) { for (const host of hosts) {
try { const ops = this.coldImport.getHostOpsConfig(host);
await this.runOne(path.join(dataDir, host)); // 每宿主 cron:manifest.incremental_cron 优先,否则全局 env;都没有 → 不注册(该 host 不自动同步)
} catch (err) { const cronExpr = ops.incrementalCron ?? globalCron;
if (err instanceof SyncAlreadyRunningError) { if (!cronExpr) {
// 并发拦截 — 已有手动跑或上次 cron 还没完。下次 cron 自然 retry。 this.logger.warn(
this.logger.warn( `sync-incremental: host=${host} 无 cron(manifest.incremental_cron 与 PAC_INCREMENTAL_CRON 都未设),跳过注册`,
`sync-incremental: host=${host} skip(并发锁拦截):${err.message}`, );
); continue;
} else { }
this.logger.error( const timeZone = ops.timezone ?? 'Asia/Shanghai';
`sync-incremental: host=${host} failed: ${(err as Error).message}`, const jobName = `sync-incremental:${host}`;
); const job = new CronJob(
} cronExpr,
// 不抛 — 下个 host 继续;cursor 没前进自然下次 catchup () => {
void this.runHostSafe(host);
},
null,
false,
timeZone,
);
this.scheduler.addCronJob(jobName, job as unknown as Parameters<SchedulerRegistry['addCronJob']>[1]);
job.start();
this.logger.log(
`sync-incremental: 注册 host=${host} cron='${cronExpr}' tz=${timeZone}` +
`${ops.incrementalCron ? '' : '(全局默认)'}`,
);
}
}
private dataDir(): string {
return process.env.PAC_INCREMENTAL_DATA_DIR ?? path.resolve(__dirname, '../../data');
}
/// 单 host 跑一轮(cron 回调用,吞异常不影响该 host 下次 / 别的 host)
private async runHostSafe(host: string): Promise<void> {
try {
await this.runOne(path.join(this.dataDir(), host));
} catch (err) {
if (err instanceof SyncAlreadyRunningError) {
this.logger.warn(`sync-incremental: host=${host} skip(并发锁拦截):${err.message}`);
} else {
this.logger.error(`sync-incremental: host=${host} failed: ${(err as Error).message}`);
} }
} }
this.logger.log('sync-incremental: ALL DONE');
} }
/// 跑单个 host 的完整链路(可手动调,debug 用) /// 跑单个 host 的完整链路(可手动调,debug 用)
......
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
...@@ -316,6 +316,13 @@ export function reconcileDiagnosisCode(code: string | null, nameZh: string | nul ...@@ -316,6 +316,13 @@ export function reconcileDiagnosisCode(code: string | null, nameZh: string | nul
/// 全库 treatment_record subtype 命中 1225 患者(正畸434/牙周267/种植103…)。 /// 全库 treatment_record subtype 命中 1225 患者(正畸434/牙周267/种植103…)。
export const TREATMENT_REFUSAL_SUBTYPE_PATTERNS = ['无意愿', '不愿', '拒绝'] as const; export const TREATMENT_REFUSAL_SUBTYPE_PATTERNS = ['无意愿', '不愿', '拒绝'] as const;
/// 影像拒绝(拒绝拍片 / 拒绝拍全景片 / 拒绝影像…)= 拒绝【拍片】≠ 拒绝【治疗】。
/// 判定 refusal 前先把这类影像拒绝短语抹掉再匹配 —— 否则"拒绝拍片但做了洁治"会被 `拒绝` 误当成
/// "拒绝该类治疗"从而错误压制召回(2026-07 数据核查:review 235 无害 + perio/种植/正畸等约 30 人真误伤)。
/// `拒绝` + ≤6 个非标点字符 + 影像名词(片/影像/X光/CBCT/全景/放射/曲面断层)。
export const TREATMENT_REFUSAL_IMAGING_EXCLUDE_RE =
'拒绝[^,。;;、]{0,6}(片|影像|X光|CBCT|全景|放射|曲面断层)';
export const NO_RESTORATION_GAP_EXAM_PATTERNS = [ export const NO_RESTORATION_GAP_EXAM_PATTERNS = [
'间隙关闭', '间隙关闭',
'间隙已关闭', '间隙已关闭',
......
...@@ -190,6 +190,9 @@ importers: ...@@ -190,6 +190,9 @@ importers:
class-validator: class-validator:
specifier: ^0.15.1 specifier: ^0.15.1
version: 0.15.1 version: 0.15.1
cron:
specifier: ^4.4.0
version: 4.4.0
csv-parse: csv-parse:
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1 version: 6.2.1
...@@ -12054,8 +12057,8 @@ snapshots: ...@@ -12054,8 +12057,8 @@ snapshots:
'@next/eslint-plugin-next': 16.2.4 '@next/eslint-plugin-next': 16.2.4
eslint: 9.39.4(jiti@2.7.0) eslint: 9.39.4(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0))
eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0))
...@@ -12077,7 +12080,7 @@ snapshots: ...@@ -12077,7 +12080,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.3 debug: 4.4.3
...@@ -12088,22 +12091,22 @@ snapshots: ...@@ -12088,22 +12091,22 @@ snapshots:
tinyglobby: 0.2.16 tinyglobby: 0.2.16
unrs-resolver: 1.11.1 unrs-resolver: 1.11.1
optionalDependencies: optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
eslint: 9.39.4(jiti@2.7.0) eslint: 9.39.4(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
...@@ -12114,7 +12117,7 @@ snapshots: ...@@ -12114,7 +12117,7 @@ snapshots:
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.39.4(jiti@2.7.0) eslint: 9.39.4(jiti@2.7.0)
eslint-import-resolver-node: 0.3.10 eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))
hasown: 2.0.4 hasown: 2.0.4
is-core-module: 2.16.2 is-core-module: 2.16.2
is-glob: 4.0.3 is-glob: 4.0.3
......
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