Commit b7901c93 by luoqi

Merge downstream Beijing business date fix into main

parents 1793c1bc 2e5e22eb
Pipeline #3829 failed in 0 seconds
import { businessDate } from '@pac/utils/business-date';
/** /**
* script-facts — 确定性渲染 helper(**不含 PII**):智能日期 + 牙位俗称。 * script-facts — 确定性渲染 helper(**不含 PII**):智能日期 + 牙位俗称。
* *
...@@ -14,10 +15,11 @@ export function smartDateDisplay(visit: Date | string | null | undefined, now: D ...@@ -14,10 +15,11 @@ export function smartDateDisplay(visit: Date | string | null | undefined, now: D
if (!visit) return null; if (!visit) return null;
const d = visit instanceof Date ? visit : new Date(visit); const d = visit instanceof Date ? visit : new Date(visit);
if (Number.isNaN(d.getTime())) return null; if (Number.isNaN(d.getTime())) return null;
const m = d.getMonth() + 1; const [year, month, day] = businessDate(visit).split('-').map(Number);
if (d.getFullYear() === now.getFullYear()) return `${m}月${d.getDate()}号`; const thisYear = Number(businessDate(now).slice(0, 4));
if (d.getFullYear() === now.getFullYear() - 1) return `去年${m}月`; if (year === thisYear) return `${month}月${day}号`;
return `${d.getFullYear()}年${m}月`; if (year === thisYear - 1) return `去年${month}月`;
return `${year}年${month}月`;
} }
......
import { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
...@@ -628,7 +629,7 @@ export class PlanScriptOrchestrator { ...@@ -628,7 +629,7 @@ export class PlanScriptOrchestrator {
// ⚠️ 与矩阵锚点同口径 `COALESCE(occurred_at, planned_for)` —— 建议类信号(*_RECOMMENDED) // ⚠️ 与矩阵锚点同口径 `COALESCE(occurred_at, planned_for)` —— 建议类信号(*_RECOMMENDED)
// 常常只有 plannedFor,只读 occurredAt 会得到 null,话术就没了日期而矩阵有,又是一处不一致。 // 常常只有 plannedFor,只读 occurredAt 会得到 null,话术就没了日期而矩阵有,又是一处不一致。
const anchorAt = lead.occurredAt ?? lead.plannedFor; const anchorAt = lead.occurredAt ?? lead.plannedFor;
const date = anchorAt ? anchorAt.toISOString().slice(0, 10) : null; const date = anchorAt ? businessDate(anchorAt) : null;
const medicalRecord = buildMedicalRecord(emrForDiagnosis(lead), facts); const medicalRecord = buildMedicalRecord(emrForDiagnosis(lead), facts);
return { doctor, date, medicalRecord }; return { doctor, date, medicalRecord };
}; };
...@@ -927,7 +928,7 @@ function buildMedicalRecord( ...@@ -927,7 +928,7 @@ function buildMedicalRecord(
.filter((t) => t.categoryLabel) .filter((t) => t.categoryLabel)
: []; : [];
return { return {
date: emr.occurredAt ? emr.occurredAt.toISOString().slice(0, 10) : null, date: emr.occurredAt ? businessDate(emr.occurredAt) : null,
doctorName: str(c.doctor_name), doctorName: str(c.doctor_name),
chiefComplaint: str(c.illness_desc), chiefComplaint: str(c.illness_desc),
presentIllness: str(c.pre_illness), presentIllness: str(c.pre_illness),
...@@ -1116,7 +1117,7 @@ function summarizeRecentTreatments( ...@@ -1116,7 +1117,7 @@ function summarizeRecentTreatments(
subtype: (c?.subtype as string | undefined)?.trim() || null, subtype: (c?.subtype as string | undefined)?.trim() || null,
doctorName: (c?.doctor_name as string | undefined)?.trim() || null, doctorName: (c?.doctor_name as string | undefined)?.trim() || null,
date: latest.occurredAt date: latest.occurredAt
? `${latest.occurredAt.getFullYear()}.${String(latest.occurredAt.getMonth() + 1).padStart(2, '0')}` ? fmtYearMonth(latest.occurredAt)
: null, : null,
}; };
}); });
......
import { businessDaysBetween, dateOnly } from '@pac/utils/business-date';
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
...@@ -292,7 +293,7 @@ export class RecallBriefOrchestrator { ...@@ -292,7 +293,7 @@ export class RecallBriefOrchestrator {
// ⛔ 只要**已发生**的:未来的预约回访说成历史就是编(见 input.types 的注释) // ⛔ 只要**已发生**的:未来的预约回访说成历史就是编(见 input.types 的注释)
.filter((v) => v.taskDate != null && v.taskDate.getTime() <= nowMs) .filter((v) => v.taskDate != null && v.taskDate.getTime() <= nowMs)
.map((v) => ({ .map((v) => ({
atText: `${daysText(Math.floor((nowMs - v.taskDate!.getTime()) / 86_400_000))}前`, atText: `${daysText(businessDaysBetween(new Date(nowMs), dateOnly(v.taskDate!)))}前`,
type: v.type?.trim() || null, type: v.type?.trim() || null,
/** /**
* ⭐ **这次回访是干什么的**(follow_content)—— 主题:半年洁牙提醒 / 种植潜客 / 术后复查… * ⭐ **这次回访是干什么的**(follow_content)—— 主题:半年洁牙提醒 / 种植潜客 / 术后复查…
......
import { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { maskName } from '@pac/utils'; import { maskName } from '@pac/utils';
...@@ -76,7 +77,7 @@ export class RecallSummaryOrchestrator { ...@@ -76,7 +77,7 @@ export class RecallSummaryOrchestrator {
// ⭐ 未来排程判定放这里(程序算),不交给 LLM —— 回访表含未来排程, // ⭐ 未来排程判定放这里(程序算),不交给 LLM —— 回访表含未来排程,
// 模型没有"今天"就分不清"还没到期"和"漏做了"(2026-07 生产事故)。 // 模型没有"今天"就分不清"还没到期"和"漏做了"(2026-07 生产事故)。
// 口径:按**日期**比(taskDate 是 @db.Date,无时分秒),taskDate > today 即未到期。 // 口径:按**日期**比(taskDate 是 @db.Date,无时分秒),taskDate > today 即未到期。
const today = new Date().toISOString().slice(0, 10); const today = businessDate(new Date());
const dated = visits.map((v) => ({ const dated = visits.map((v) => ({
...v, ...v,
......
...@@ -236,7 +236,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces { ...@@ -236,7 +236,7 @@ export function buildGapCore(input: GapCoreInput): GapCorePieces {
WHERE rv.patient_id = p.id WHERE rv.patient_id = p.id
AND rv.result ~ ${Prisma.sql`${EXTERNAL_TREATMENT_VISIT_POS_RE}`} AND rv.result ~ ${Prisma.sql`${EXTERNAL_TREATMENT_VISIT_POS_RE}`}
AND rv.result !~ ${Prisma.sql`${EXTERNAL_TREATMENT_VISIT_NEG_RE}`} AND rv.result !~ ${Prisma.sql`${EXTERNAL_TREATMENT_VISIT_NEG_RE}`}
AND rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)::date AND rv.task_date >= (COALESCE(sig.occurred_at, sig.planned_for) AT TIME ZONE 'Asia/Shanghai')::date
)`; )`;
/// 拒绝记录时间方向:planned 记录 occurred_at 为空 → 用 COALESCE(occurred_at, planned_for) /// 拒绝记录时间方向:planned 记录 occurred_at 为空 → 用 COALESCE(occurred_at, planned_for)
const refusalAfterDx = rule.excludeIfEverTreated const refusalAfterDx = rule.excludeIfEverTreated
...@@ -709,7 +709,7 @@ export function buildGapSetBased(input: GapCoreInput): GapSetBasedPieces | null ...@@ -709,7 +709,7 @@ export function buildGapSetBased(input: GapCoreInput): GapSetBasedPieces | null
WHERE rv.patient_id = p.id WHERE rv.patient_id = p.id
AND rv.result ~ ${EXTERNAL_TREATMENT_VISIT_POS_RE} AND rv.result ~ ${EXTERNAL_TREATMENT_VISIT_POS_RE}
AND rv.result !~ ${EXTERNAL_TREATMENT_VISIT_NEG_RE} AND rv.result !~ ${EXTERNAL_TREATMENT_VISIT_NEG_RE}
AND rv.task_date >= COALESCE(sig.occurred_at, sig.planned_for)::date AND rv.task_date >= (COALESCE(sig.occurred_at, sig.planned_for) AT TIME ZONE 'Asia/Shanghai')::date
)`; )`;
const refusalRe = TREATMENT_REFUSAL_SUBTYPE_PATTERNS.join('|'); const refusalRe = TREATMENT_REFUSAL_SUBTYPE_PATTERNS.join('|');
......
import { businessDate, birthdayBoundary, businessDayStart } from '@pac/utils/business-date';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
CONTRAINDICATION_DOMAIN_ORDER, CONTRAINDICATION_DOMAIN_ORDER,
...@@ -63,8 +64,7 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor { ...@@ -63,8 +64,7 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor {
// ── ① 年龄禁忌(不依赖任何 fact,证据是 patient.birthDate)── // ── ① 年龄禁忌(不依赖任何 fact,证据是 patient.birthDate)──
const age = ctx.patient.birthDate ? ageYearsAt(ctx.patient.birthDate, ctx.now) : null; const age = ctx.patient.birthDate ? ageYearsAt(ctx.patient.birthDate, ctx.now) : null;
if (age !== null && age >= 0 && age <= 18) { if (age !== null && age >= 0 && age <= 18) {
const lift = new Date(ctx.patient.birthDate!); const lift = birthdayBoundary(ctx.patient.birthDate!, ContraindicationFeatureExtractor.IMPLANT_MIN_AGE);
lift.setFullYear(lift.getFullYear() + ContraindicationFeatureExtractor.IMPLANT_MIN_AGE);
detail.push({ detail.push({
code: 'implant_age', code: 'implant_age',
label: '骨骼发育未完全', label: '骨骼发育未完全',
...@@ -73,7 +73,7 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor { ...@@ -73,7 +73,7 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor {
reason: `年龄${age}岁`, reason: `年龄${age}岁`,
phrase: null, phrase: null,
source: null, source: null,
validUntil: lift.toISOString().slice(0, 10), validUntil: businessDate(lift),
}); });
} }
...@@ -115,8 +115,8 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor { ...@@ -115,8 +115,8 @@ export class ContraindicationFeatureExtractor implements FeatureExtractor {
]; ];
for (const s of collectSignals(ctx, new Set())) { for (const s of collectSignals(ctx, new Set())) {
if (s.negated !== false) continue; if (s.negated !== false) continue;
if (s.validUntil) dates.push(new Date(`${s.validUntil}T00:00:00Z`)); if (s.validUntil) dates.push(businessDayStart(s.validUntil));
if (s.reviewAfter) dates.push(new Date(`${s.reviewAfter}T00:00:00Z`)); if (s.reviewAfter) dates.push(businessDayStart(s.reviewAfter));
} }
return earliestFuture(ctx.now, ...dates); return earliestFuture(ctx.now, ...dates);
} }
...@@ -219,7 +219,7 @@ function newer(a: SourcedSignal, b: SourcedSignal): boolean { ...@@ -219,7 +219,7 @@ function newer(a: SourcedSignal, b: SourcedSignal): boolean {
} }
function iso(d: Date): string { function iso(d: Date): string {
return d.toISOString().slice(0, 10); return businessDate(d);
} }
/** 命中的治疗域,按风险高低固定序 —— 前端与话术共用同一个顺序,免得两处各排各的 */ /** 命中的治疗域,按风险高低固定序 —— 前端与话术共用同一个顺序,免得两处各排各的 */
......
import { businessDate } from '@pac/utils/business-date';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PersonaFeatureKey, FactType } from '@pac/types'; import { PersonaFeatureKey, FactType } from '@pac/types';
import type { import type {
...@@ -59,7 +60,7 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor { ...@@ -59,7 +60,7 @@ export class DiscountAnchorFeatureExtractor implements FeatureExtractor {
if (!Number.isFinite(minRatio)) return null; // 从无折扣 → 无锚点 if (!Number.isFinite(minRatio)) return null; // 从无折扣 → 无锚点
const zhe = (minRatio * 10).toFixed(1); // 0.85 → 8.5折 const zhe = (minRatio * 10).toFixed(1); // 0.85 → 8.5折
const dateStr = anchorAt ? anchorAt.toISOString().slice(0, 10) : null; const dateStr = anchorAt ? businessDate(anchorAt) : null;
const projStr = project ? ` · ${project}` : ''; const projStr = project ? ` · ${project}` : '';
return { return {
key: this.key, key: this.key,
......
import { businessAge } from '@pac/utils/business-date';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
EXTRACTION_NAME_KEYWORDS, EXTRACTION_NAME_KEYWORDS,
...@@ -52,9 +53,7 @@ const AGE_GATES = [3, 13, 19, 41] as const; ...@@ -52,9 +53,7 @@ const AGE_GATES = [3, 13, 19, 41] as const;
/// 周岁(单一源,potential_treatment / urgency_level 共用) /// 周岁(单一源,potential_treatment / urgency_level 共用)
export function ageYearsAt(birth: Date | null, now: Date): number | null { export function ageYearsAt(birth: Date | null, now: Date): number | null {
if (!birth) return null; if (!birth) return null;
let age = now.getFullYear() - birth.getFullYear(); const age = businessAge(birth, now);
const m = now.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && now.getDate() < birth.getDate())) age--;
return age >= 0 && age <= 120 ? age : null; return age >= 0 && age <= 120 ? age : null;
} }
......
import { birthdayBoundary } from '@pac/utils/business-date';
/** /**
* 「下一次会因为时间流逝而改档」的时刻 —— 各 extractor 的 nextBoundaryAt 用这几个原语拼。 * 「下一次会因为时间流逝而改档」的时刻 —— 各 extractor 的 nextBoundaryAt 用这几个原语拼。
* *
...@@ -33,7 +34,7 @@ export function earliestFuture(now: Date, ...candidates: Array<Date | null | und ...@@ -33,7 +34,7 @@ export function earliestFuture(now: Date, ...candidates: Array<Date | null | und
* 患者满 `ages` 中下一个年龄的那天(即生日)。 * 患者满 `ages` 中下一个年龄的那天(即生日)。
* *
* 与 ageYearsAt 同语义:生日当天算作已满。2/29 出生者用 setFullYear 会滚到 3/1, * 与 ageYearsAt 同语义:生日当天算作已满。2/29 出生者用 setFullYear 会滚到 3/1,
* 早一天触发重算 —— 宁可早一天,不可漏。 * 生日当天北京零点触发重算。
*/ */
export function nextAgeBoundary( export function nextAgeBoundary(
birth: Date | null, birth: Date | null,
...@@ -43,8 +44,7 @@ export function nextAgeBoundary( ...@@ -43,8 +44,7 @@ export function nextAgeBoundary(
if (!birth) return null; if (!birth) return null;
const sorted = [...ages].sort((a, b) => a - b); const sorted = [...ages].sort((a, b) => a - b);
for (const age of sorted) { for (const age of sorted) {
const at = new Date(birth); const at = birthdayBoundary(birth, age);
at.setFullYear(at.getFullYear() + age);
if (at.getTime() > now.getTime()) return at; if (at.getTime() > now.getTime()) return at;
} }
return null; // 全部年龄档都已跨过 return null; // 全部年龄档都已跨过
......
import { businessDate } from '@pac/utils/business-date';
import { FactKind, FactType } from '@pac/types'; import { FactKind, FactType } from '@pac/types';
import type { ActiveFact, FeatureExtractorContext } from './feature.interface'; import type { ActiveFact, FeatureExtractorContext } from './feature.interface';
...@@ -34,7 +35,7 @@ export function visitSpanOf(facts: ActiveFact[]): VisitSpan { ...@@ -34,7 +35,7 @@ export function visitSpanOf(facts: ActiveFact[]): VisitSpan {
let lastFactId: string | null = null; let lastFactId: string | null = null;
for (const f of facts) { for (const f of facts) {
if (!f.occurredAt) continue; if (!f.occurredAt) continue;
days.add(f.occurredAt.toISOString().slice(0, 10)); days.add(businessDate(f.occurredAt));
if (!first || f.occurredAt < first) first = f.occurredAt; if (!first || f.occurredAt < first) first = f.occurredAt;
if (!last || f.occurredAt > last) { if (!last || f.occurredAt > last) {
last = f.occurredAt; last = f.occurredAt;
......
import { businessDate, businessDateValue } from '@pac/utils/business-date';
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
// Prisma 作为值用(Prisma.DbNull:就地刷新时把 data 显式写回 SQL NULL) // Prisma 作为值用(Prisma.DbNull:就地刷新时把 data 显式写回 SQL NULL)
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
...@@ -86,13 +87,13 @@ export class PersonaService { ...@@ -86,13 +87,13 @@ export class PersonaService {
// 注:rfm / lifecycle_stage / urgency_level 仍用窄口径 visitFactsOf,那是它们各自的历史口径, // 注:rfm / lifecycle_stage / urgency_level 仍用窄口径 visitFactsOf,那是它们各自的历史口径,
// 不在本次改动范围(但同一批患者会因此少算就诊次数,值得另开一件事核实)。 // 不在本次改动范围(但同一批患者会因此少算就诊次数,值得另开一件事核实)。
const span = visitSpanOf([...visitFactsOf(ctx), ...emrs]); const span = visitSpanOf([...visitFactsOf(ctx), ...emrs]);
const lastVisitAt = span.last ? new Date(span.last.toISOString().slice(0, 10)) : null; const lastVisitAt = span.last ? businessDateValue(span.last) : null;
let lastVisitDoctor: string | null = null; let lastVisitDoctor: string | null = null;
if (span.last) { if (span.last) {
const day = span.last.toISOString().slice(0, 10); const day = businessDate(span.last);
for (const f of emrs) { for (const f of emrs) {
if (!f.occurredAt || f.occurredAt.toISOString().slice(0, 10) !== day) continue; if (!f.occurredAt || businessDate(f.occurredAt) !== day) continue;
const n = doctorNameOf(f); const n = doctorNameOf(f);
if (n) { if (n) {
lastVisitDoctor = n; lastVisitDoctor = n;
......
import { businessDate } from '@pac/utils/business-date';
import { attachImageLinks } from '../facts/image-links'; import { attachImageLinks } from '../facts/image-links';
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
...@@ -577,8 +578,8 @@ function serializeProfile( ...@@ -577,8 +578,8 @@ function serializeProfile(
paymentCount: payments.length, paymentCount: payments.length,
rechargeCount: recharges.length, rechargeCount: recharges.length,
refundCount: refunds.length, refundCount: refunds.length,
firstVisit: firstEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null, firstVisit: firstEncounter?.occurredAt ? businessDate(firstEncounter.occurredAt) : null,
lastVisit: latestEncounter?.occurredAt?.toISOString().slice(0, 10) ?? null, lastVisit: latestEncounter?.occurredAt ? businessDate(latestEncounter.occurredAt) : null,
daysSinceLatestVisit, daysSinceLatestVisit,
doctors, doctors,
contacts, contacts,
......
import { businessDate } from '@pac/utils/business-date';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { import {
...@@ -499,7 +500,7 @@ function findDoctorAdvice( ...@@ -499,7 +500,7 @@ function findDoctorAdvice(
if (!dx) return null; if (!dx) return null;
const dc = dx.content as Record<string, unknown> | null; const dc = dx.content as Record<string, unknown> | null;
const srcId = dc?.source_encounter_external_id ? String(dc.source_encounter_external_id) : ''; const srcId = dc?.source_encounter_external_id ? String(dc.source_encounter_external_id) : '';
const dxDay = effectiveTime(dx)?.toISOString().slice(0, 10) ?? ''; const dxDay = effectiveTime(dx) ? businessDate(effectiveTime(dx)!) : '';
let dateFallback: string | null = null; let dateFallback: string | null = null;
for (const e of emrFacts) { for (const e of emrFacts) {
const ec = e.content as Record<string, unknown> | null; const ec = e.content as Record<string, unknown> | null;
...@@ -508,7 +509,7 @@ function findDoctorAdvice( ...@@ -508,7 +509,7 @@ function findDoctorAdvice(
// 精确:同 source 文档 id // 精确:同 source 文档 id
if (srcId && String(ec?.emr_external_id ?? '') === srcId) return advice; if (srcId && String(ec?.emr_external_id ?? '') === srcId) return advice;
// 兜底:同日期(记下,循环结束没精确命中再用) // 兜底:同日期(记下,循环结束没精确命中再用)
if (dxDay && effectiveTime(e)?.toISOString().slice(0, 10) === dxDay) dateFallback ??= advice; if (dxDay && effectiveTime(e) && businessDate(effectiveTime(e)!) === dxDay) dateFallback ??= advice;
} }
return dateFallback; return dateFallback;
} }
......
import { businessDate } from '@pac/utils/business-date';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { currentFactHeadSql } from '../../../facts/current-fact'; import { currentFactHeadSql } from '../../../facts/current-fact';
...@@ -403,7 +404,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -403,7 +404,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
p.id AS patient_id, p.id AS patient_id,
p.external_id AS patient_external_id, p.external_id AS patient_external_id,
-- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认) -- 建议治疗的年龄适配用(只影响"建议做什么",不参与召回筛选/排除;无生日 → NULL 走默认)
date_part('year', age(${scope.now}::timestamptz, p.birth_date))::int AS patient_age, date_part('year', age(${businessDate(scope.now)}::date, p.birth_date))::int AS patient_age,
sig.id AS signal_fact_id, sig.id AS signal_fact_id,
sig.type AS signal_type, sig.type AS signal_type,
sig.content->>'code' AS signal_code, sig.content->>'code' AS signal_code,
...@@ -507,7 +508,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin { ...@@ -507,7 +508,7 @@ export class TreatmentInitiationRecallScenario implements PlanScenarioPlugin {
-- 就说这人没有潜在治疗 —— 那是两套口径。 -- 就说这人没有潜在治疗 —— 那是两套口径。
SELECT 1 FROM patient_return_visits rv SELECT 1 FROM patient_return_visits rv
WHERE rv.patient_id = p.id WHERE rv.patient_id = p.id
AND rv.task_date > ${scope.now}::date AND rv.task_date > ${businessDate(scope.now)}::date
)`; )`;
const sb = gap.setBased; const sb = gap.setBased;
......
...@@ -33,7 +33,7 @@ import { ...@@ -33,7 +33,7 @@ import {
*/ */
/** 患者周岁 —— 与 `ageYearsAt` 同语义(Postgres 的 age() 是生日感知的完整年数) */ /** 患者周岁 —— 与 `ageYearsAt` 同语义(Postgres 的 age() 是生日感知的完整年数) */
export const AGE_YEARS_SQL = Prisma.sql`date_part('year', age(NOW(), p.birth_date))::int`; export const AGE_YEARS_SQL = Prisma.sql`date_part('year', age((NOW() AT TIME ZONE 'Asia/Shanghai')::date, p.birth_date))::int`;
/** 同上,给**外层别名是 `p`** 的关联片段用(两处别名一致,留两个常量只为可读) */ /** 同上,给**外层别名是 `p`** 的关联片段用(两处别名一致,留两个常量只为可读) */
export const AGE_YEARS_SQL_P = AGE_YEARS_SQL; export const AGE_YEARS_SQL_P = AGE_YEARS_SQL;
......
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { businessDate } from '@pac/utils/business-date';
const run = process.env.PAC_BUSINESS_DATE_POSTGRES === '1' ? test : test.skip;
run.each(['UTC', 'Asia/Shanghai', 'America/Los_Angeles'])('真实 PostgreSQL 日期判断不随会话时区 %s 变化', timezone => {
// 直接抽取实际业务代码使用的 SQL 表达式,避免测试另写一份正确逻辑却漏改查询。
const source = readFileSync(join(__dirname, '../src/modules/clinical-gap/potential-treatment-gap.sql.ts'), 'utf8');
const expr = source.match(/rv\.task_date >= (\(COALESCE\(sig\.occurred_at, sig\.planned_for\) AT TIME ZONE 'Asia\/Shanghai'\)::date)/g)!;
expect(expr).toHaveLength(2); // legacy 与 set-based 两条查询都要修正
expect(new Set(expr).size).toBe(1);
const now = businessDate(new Date('2026-10-17T16:01:00Z'));
const sql = `BEGIN READ ONLY; SET LOCAL TIME ZONE '${timezone}';
SELECT (${expr[0]})::int, (rv.task_date > '${now}'::date)::int,
extract(year from age('${now}'::date, date '2007-10-18'))::int
FROM (VALUES (timestamptz '2026-10-17 16:00:00+00', NULL::timestamptz)) sig(occurred_at,planned_for)
CROSS JOIN (VALUES (date '2026-10-17'), (date '2026-10-18'), (date '2026-10-19')) rv(task_date)
ORDER BY rv.task_date; ROLLBACK;`;
const out = execFileSync('docker', ['exec', '-i', 'pac-postgres', 'sh', '-c', 'psql -X -q -A -t -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'], { input: sql, encoding: 'utf8' });
expect(out.trim().split('\n')).toEqual(['0|0|19','1|0|19','1|1|19']);
});
import { execFileSync } from 'node:child_process';
import { businessDate, businessDateValue, dateOnly, businessDayStart, businessAge, businessDaysBetween, birthdayBoundary } from '@pac/utils/business-date';
import { fmtDate, fmtYearMonth, fmtYearMonthDay, calcAge } from '@pac/utils';
import { visitRecencyRange, FactType } from '@pac/types';
import { visitSpanOf } from '../src/modules/persona/features/visit-facts';
import { PersonaService } from '../src/modules/persona/persona.service';
import { smartDateDisplay } from '../src/modules/ai/calls/draft-plan-script/shared/script-facts';
import { normalizeDatetime } from '../src/modules/sync/assembler/field-mapper';
import type { ActiveFact, FeatureExtractorContext } from '../src/modules/persona/features/feature.interface';
const midnight = new Date('2026-10-17T16:00:00Z');
const fact = (id: string, at: string, type = 'encounter_record', doctor?: string) => ({
id, type, kind: 'actual', status: 'active', subjectId: id, clinicId: null,
occurredAt: new Date(at), plannedFor: null, content: doctor ? { doctor_name: doctor } : {},
}) as ActiveFact;
describe('北京业务日期:时刻与纯日期分开', () => {
test.each([
['2026-10-17T15:59:59.999Z', '2026-10-17'],
['2026-10-17T16:00:00Z', '2026-10-18'],
['2026-12-31T16:00:00Z', '2027-01-01'],
['2024-02-28T16:00:00Z', '2024-02-29'],
['2026-10-18', '2026-10-18'],
])('%s → %s', (input, expected) => expect(businessDate(input)).toBe(expected));
test('摄入零点锚保持不变,下游按北京日展示与存储 DATE', () => {
const instant = new Date(normalizeDatetime('2026-10-18', 'Asia/Shanghai'));
expect(instant.toISOString()).toBe('2026-10-17T16:00:00.000Z');
expect(fmtDate(instant)).toBe('2026-10-18');
expect(businessDateValue(instant).toISOString()).toBe('2026-10-18T00:00:00.000Z');
expect(dateOnly(new Date('2026-10-18T00:00:00Z'))).toBe('2026-10-18');
expect(businessDayStart('2026-10-18')).toEqual(instant);
});
test('展示与话术跨月跨年仍是宿主日期', () => {
const time = new Date('2026-12-31T16:00:00Z');
expect(fmtYearMonth(time)).toBe('2027.01');
expect(fmtYearMonthDay(time)).toBe('2027.01.01');
expect(smartDateDisplay(time, time)).toBe('1月1号');
expect(smartDateDisplay('2026-12-31', time)).toBe('去年12月');
});
test('生日当天北京零点变年龄,纯生日不移日;闰日顺延规则保持', () => {
const birth = new Date('2007-10-18');
expect(businessAge(birth, new Date('2026-10-17T15:59:59Z'))).toBe(18);
expect(calcAge(birth, midnight)).toBe(19);
expect(calcAge('2007-10-18T00:00:00Z', midnight)).toBe(19);
expect(birthdayBoundary(birth, 19)).toEqual(midnight);
expect(birthdayBoundary('2004-02-29', 19).toISOString()).toBe('2023-02-28T16:00:00.000Z');
});
test('回访日与今天按自然日比较,不按 DATE 的 UTC 载体时间计算', () => {
expect(businessDaysBetween(midnight, '2026-10-18')).toBe(0);
expect(businessDaysBetween(midnight, '2026-10-17')).toBe(1);
expect(businessDaysBetween(midnight, '2026-10-19')).toBe(-1);
});
test('到诊日期筛选整天稳定,90天边界不会在一天内漂移', () => {
const a = visitRecencyRange('0_3m', midnight)!;
const b = visitRecencyRange('0_3m', new Date('2026-10-18T15:59:59Z'))!;
expect(a).toEqual(b);
expect(a.gte!.toISOString()).toBe('2026-07-20T00:00:00.000Z');
expect(visitRecencyRange('3_6m', midnight)!.lt).toEqual(a.gte);
});
test.each(['UTC', 'Asia/Shanghai', 'America/Los_Angeles'])('进程时区 %s 不改变日期、年龄和格式化', TZ => {
const modulePath = require.resolve('@pac/utils/business-date');
const out = execFileSync(process.execPath, ['-e', `const x=require(${JSON.stringify(modulePath)}); console.log(JSON.stringify([x.businessDate(new Date('2026-10-17T16:00:00Z')),x.businessAge('2007-10-18',new Date('2026-10-17T16:00:00Z')),x.dateOnly(new Date('2026-10-18T00:00:00Z'))]));`], { env: { ...process.env, TZ }, encoding: 'utf8' });
expect(JSON.parse(out)).toEqual(['2026-10-18', 19, '2026-10-18']);
});
});
describe('下游真实消费者', () => {
test('北京同日零点和上午计一次;UTC同日但北京跨日计两次', () => {
expect(visitSpanOf([fact('a', '2026-10-17T16:00Z'), fact('b', '2026-10-18T02:00Z')]).dayCount).toBe(1);
expect(visitSpanOf([fact('a', '2026-10-17T15:59Z'), fact('b', '2026-10-17T16:00Z')]).dayCount).toBe(2);
});
test('刷新上次到诊 DATE 及医生:同北京日、不同UTC日也能关联', async () => {
const upsert = jest.fn().mockResolvedValue({});
const prisma = { patientProfile: { findUnique: jest.fn().mockResolvedValue(null), upsert } };
const svc = new PersonaService(prisma as never, {} as never, {} as never);
const ctx = { factsByType: new Map([
[FactType.ENCOUNTER_RECORD, [fact('visit', '2026-10-18T02:00Z')]],
[FactType.EMR_RECORD, [fact('emr', '2026-10-17T16:00Z', 'emr_record', '同日医生')]],
]) } as FeatureExtractorContext;
const refresh = svc as unknown as { refreshVisitDerivedFields(id: string, ctx: FeatureExtractorContext): Promise<void> };
await refresh.refreshVisitDerivedFields('p', ctx);
expect(upsert.mock.calls[0]![0].create).toMatchObject({ lastVisitAt: new Date('2026-10-18'), lastVisitDoctor: '同日医生' });
});
});
...@@ -199,14 +199,14 @@ describe('⑦ nextBoundaryAt —— 到期当晚自动重算,标签自己消失' ...@@ -199,14 +199,14 @@ describe('⑦ nextBoundaryAt —— 到期当晚自动重算,标签自己消失'
it('报出哺乳期的失效日', () => { it('报出哺乳期的失效日', () => {
// 缺了它,这个标签会一直挂到她下次恰好有新病历为止(水位闸只在事实变了时放行) // 缺了它,这个标签会一直挂到她下次恰好有新病历为止(水位闸只在事实变了时放行)
const at = extractor.nextBoundaryAt(withFacts([emr('哺乳期', '2026-05-01')])); const at = extractor.nextBoundaryAt(withFacts([emr('哺乳期', '2026-05-01')]));
expect(at?.toISOString().slice(0, 10)).toBe('2026-10-28'); // 2026-05-01 + 180d expect(at?.toISOString()).toBe('2026-10-27T16:00:00.000Z'); // 2026-05-01 + 180d
}); });
it('多个到期日取最早的那个', () => { it('多个到期日取最早的那个', () => {
const at = extractor.nextBoundaryAt( const at = extractor.nextBoundaryAt(
withFacts([emr('哺乳期', '2026-05-01'), emr('现怀孕7月余', '2026-06-01')]), withFacts([emr('哺乳期', '2026-05-01'), emr('现怀孕7月余', '2026-06-01')]),
); );
expect(at?.toISOString().slice(0, 10)).toBe('2026-10-28'); // 哺乳 10-28 早于妊娠 2027-03-08 expect(at?.toISOString()).toBe('2026-10-27T16:00:00.000Z'); // 哺乳 10-28 早于妊娠 2027-03-08
}); });
it('全终身型 + 成年 → 无边界(再等下去也不会变)', () => { it('全终身型 + 成年 → 无边界(再等下去也不会变)', () => {
......
...@@ -51,7 +51,7 @@ describe('时间边界原语', () => { ...@@ -51,7 +51,7 @@ describe('时间边界原语', () => {
const birth = D('1980-09-01T00:00:00Z'); // 2026-07-26 时 45 岁 const birth = D('1980-09-01T00:00:00Z'); // 2026-07-26 时 45 岁
// 年龄档下界 …31,46,55…;下一个是 46 → 2026-09-01 生日 // 年龄档下界 …31,46,55…;下一个是 46 → 2026-09-01 生日
expect(nextAgeBoundary(birth, NOW, [0, 3, 7, 12, 18, 26, 31, 46, 55])?.toISOString()).toBe( expect(nextAgeBoundary(birth, NOW, [0, 3, 7, 12, 18, 26, 31, 46, 55])?.toISOString()).toBe(
'2026-09-01T00:00:00.000Z', '2026-08-31T16:00:00.000Z',
); );
}); });
...@@ -144,13 +144,13 @@ describe('⭐ 红线:会随时间改档的特征必须声明 nextBoundaryAt', () ...@@ -144,13 +144,13 @@ describe('⭐ 红线:会随时间改档的特征必须声明 nextBoundaryAt', ()
describe('特征报出的边界值', () => { describe('特征报出的边界值', () => {
test('⭐ age_bracket:45 岁 → 下一个生日(满 46 进中老年)', async () => { test('⭐ age_bracket:45 岁 → 下一个生日(满 46 进中老年)', async () => {
const ex = (await extractors()).find((e) => e.key === 'age_bracket')!; const ex = (await extractors()).find((e) => e.key === 'age_bracket')!;
expect(ex.nextBoundaryAt!(ctxOf())?.toISOString()).toBe('2026-09-01T00:00:00.000Z'); expect(ex.nextBoundaryAt!(ctxOf())?.toISOString()).toBe('2026-08-31T16:00:00.000Z');
}); });
test('⭐ contraindication:未成年 → 满 19 岁那天;成年 → null', async () => { test('⭐ contraindication:未成年 → 满 19 岁那天;成年 → null', async () => {
const ex = (await extractors()).find((e) => e.key === 'contraindication')!; const ex = (await extractors()).find((e) => e.key === 'contraindication')!;
const minor = ctxOf({ patient: { ...ctxOf().patient, birthDate: D('2010-03-05T00:00:00Z') } }); const minor = ctxOf({ patient: { ...ctxOf().patient, birthDate: D('2010-03-05T00:00:00Z') } });
expect(ex.nextBoundaryAt!(minor)?.toISOString()).toBe('2029-03-05T00:00:00.000Z'); expect(ex.nextBoundaryAt!(minor)?.toISOString()).toBe('2029-03-04T16:00:00.000Z');
expect(ex.nextBoundaryAt!(ctxOf())).toBeNull(); // 45 岁 expect(ex.nextBoundaryAt!(ctxOf())).toBeNull(); // 45 岁
}); });
......
...@@ -145,7 +145,7 @@ describe('⭐ 分层纪律:⑤g 属于「运营时机」层,不得下沉到召 ...@@ -145,7 +145,7 @@ describe('⭐ 分层纪律:⑤g 属于「运营时机」层,不得下沉到召
test('gapWhere 里**保留**外院已治疗闸 —— 它读 rv.result(临床事实),跟 ⑤g 分工不同', () => { test('gapWhere 里**保留**外院已治疗闸 —— 它读 rv.result(临床事实),跟 ⑤g 分工不同', () => {
expect(gapWhereText).toContain('patient_return_visits'); expect(gapWhereText).toContain('patient_return_visits');
expect(gapWhereText).toContain('rv.result'); expect(gapWhereText).toContain('rv.result');
expect(gapWhereText).toMatch(/rv\.task_date\s*>=\s*COALESCE\(sig\.occurred_at/); expect(gapWhereText).toMatch(/rv\.task_date\s*>=\s*\(COALESCE\(sig\.occurred_at/);
}); });
test('画像 selector 跑出来的 SQL 不含未来回访闸', async () => { test('画像 selector 跑出来的 SQL 不含未来回访闸', async () => {
......
'use client'; 'use client';
import { businessDate } from '@pac/utils/business-date';
import { type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Chip } from './shared'; import { Chip } from './shared';
...@@ -336,7 +338,7 @@ function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) { ...@@ -336,7 +338,7 @@ function ReturnVisitsList({ visits }: { visits: ReturnVisitItem[] }) {
if (visits.length === 0) { if (visits.length === 0) {
return <div className="text-center py-12 text-sm text-slate-400">无历史联系记录</div>; return <div className="text-center py-12 text-sm text-slate-400">无历史联系记录</div>;
} }
const today = new Date().toISOString().slice(0, 10); const today = businessDate(new Date());
const latestPast = visits.findIndex((v) => v.taskDate != null && v.taskDate <= today); const latestPast = visits.findIndex((v) => v.taskDate != null && v.taskDate <= today);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
......
const clinicDate = new Intl.DateTimeFormat('en-CA', { import { businessDate } from '@pac/utils/business-date';
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
});
/** 当前宿主均使用中国诊所日历日;不能截取入库 UTC 日期,也不能依赖浏览器时区。 */ /** 原始纯日期保留;事件时刻按北京业务日期展示,不依赖浏览器时区。 */
export function formatEmrDate(value: string | null | undefined): string { export function formatEmrDate(value: string | null | undefined): string {
if (!value) return '—'; if (!value) return '—';
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; try { return businessDate(value); } catch { return '—'; }
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
const parts = clinicDate.formatToParts(date);
return ['year', 'month', 'day'].map((key) => parts.find((part) => part.type === key)!.value).join('-');
} }
'use client'; 'use client';
import { businessDate, BUSINESS_TIME_ZONE } from '@pac/utils/business-date';
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { import {
Stethoscope, Stethoscope,
...@@ -222,7 +224,7 @@ function StatsSummary({ facts }: { facts: AdaptedFact[] }) { ...@@ -222,7 +224,7 @@ function StatsSummary({ facts }: { facts: AdaptedFact[] }) {
const lastT = allTimes.length ? Math.max(...allTimes) : null; const lastT = allTimes.length ? Math.max(...allTimes) : null;
const spanDays = firstT && lastT ? Math.round((lastT - firstT) / 86400_000) : null; const spanDays = firstT && lastT ? Math.round((lastT - firstT) / 86400_000) : null;
const fmtDate = (t: number | null) => const fmtDate = (t: number | null) =>
t ? new Date(t).toLocaleDateString('zh-CN').replace(/\//g, '.') : '—'; t ? businessDate(new Date(t)).replace(/-/g, '.') : '—';
return ( return (
<div className="rounded-lg border bg-slate-50/60 p-2.5"> <div className="rounded-lg border bg-slate-50/60 p-2.5">
...@@ -314,7 +316,7 @@ function TimelineRow({ fact, hostActionContext, sourceUnit }: { ...@@ -314,7 +316,7 @@ function TimelineRow({ fact, hostActionContext, sourceUnit }: {
// planned 事实(预约/计划治疗)显示 plannedFor;加"约"前缀以区分已发生 // planned 事实(预约/计划治疗)显示 plannedFor;加"约"前缀以区分已发生
const tIso = fact.occurredAt ?? fact.plannedFor; const tIso = fact.occurredAt ?? fact.plannedFor;
const dateStr = tIso const dateStr = tIso
? (fact.occurredAt ? '' : '约 ') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.') ? (fact.occurredAt ? '' : '约 ') + businessDate(tIso).replace(/-/g, '.')
: '—'; : '—';
// 计划治疗(treatment_record kind=planned)≠ 已做的治疗:单独标"治疗计划" + 虚线空心点 + "计划"徽标, // 计划治疗(treatment_record kind=planned)≠ 已做的治疗:单独标"治疗计划" + 虚线空心点 + "计划"徽标,
...@@ -615,7 +617,7 @@ function hhmm(iso: string): string { ...@@ -615,7 +617,7 @@ function hhmm(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
return Number.isNaN(d.getTime()) return Number.isNaN(d.getTime())
? '' ? ''
: d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }); : d.toLocaleTimeString('zh-CN', { timeZone: BUSINESS_TIME_ZONE, hour: '2-digit', minute: '2-digit', hour12: false });
} }
function truncate(s: string, n: number): string { function truncate(s: string, n: number): string {
......
'use client'; 'use client';
import { businessDate } from '@pac/utils/business-date';
import { useState } from 'react'; import { useState } from 'react';
import { import {
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
...@@ -293,7 +295,7 @@ export function OutcomeForm({ ...@@ -293,7 +295,7 @@ export function OutcomeForm({
value={schedDate ? fromYMD(schedDate) : undefined} value={schedDate ? fromYMD(schedDate) : undefined}
// 选日期时,时间缺省给 00:00(默认 0 时 0 分) // 选日期时,时间缺省给 00:00(默认 0 时 0 分)
onChange={(d) => setScheduledNextAt(d ? `${toYMD(d)}T${schedTime || '00:00'}` : '')} onChange={(d) => setScheduledNextAt(d ? `${toYMD(d)}T${schedTime || '00:00'}` : '')}
min={new Date(new Date().setHours(0, 0, 0, 0))} min={fromYMD(businessDate(new Date()))}
placeholder="选择回访日期" placeholder="选择回访日期"
/> />
</div> </div>
......
'use client'; 'use client';
import { businessDate, BUSINESS_TIME_ZONE } from '@pac/utils/business-date';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
...@@ -432,7 +434,7 @@ export function PlanDetailApp({ ...@@ -432,7 +434,7 @@ export function PlanDetailApp({
// 表单直接给 enum key(不再经中译映射),原样提交 // 表单直接给 enum key(不再经中译映射),原样提交
abandonReasons: formData.abandonReasons.length > 0 ? formData.abandonReasons : undefined, abandonReasons: formData.abandonReasons.length > 0 ? formData.abandonReasons : undefined,
scheduledNextAt: formData.scheduledNextAt scheduledNextAt: formData.scheduledNextAt
? new Date(formData.scheduledNextAt).toISOString() ? new Date(`${formData.scheduledNextAt}+08:00`).toISOString()
: undefined, : undefined,
}); });
...@@ -1369,7 +1371,7 @@ function TopBar({ ...@@ -1369,7 +1371,7 @@ function TopBar({
{plan.updatedAt && fmtRel && ( {plan.updatedAt && fmtRel && (
<span <span
className="hidden md:inline text-[11px] text-slate-400" className="hidden md:inline text-[11px] text-slate-400"
title={`本召回计划最近重算于 ${plan.updatedAt.toLocaleString('zh-CN')}`} title={`本召回计划最近重算于 ${plan.updatedAt.toLocaleString('zh-CN', { timeZone: BUSINESS_TIME_ZONE })}`}
> >
更新于 {fmtRel(plan.updatedAt)} 更新于 {fmtRel(plan.updatedAt)}
</span> </span>
...@@ -1611,7 +1613,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) { ...@@ -1611,7 +1613,7 @@ function ExpiryBadge({ expiresAt }: { expiresAt: Date | null }) {
: 'bg-slate-50 text-slate-500 ring-slate-200'; : 'bg-slate-50 text-slate-500 ring-slate-200';
return ( return (
<span <span
title={`${expiresAt.toLocaleString('zh-CN')} 之前打完;超过之后记为超期,单子仍归你`} title={`${expiresAt.toLocaleString('zh-CN', { timeZone: BUSINESS_TIME_ZONE })} 之前打完;超过之后记为超期,单子仍归你`}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium ring-1 ring-inset ${tone}`} className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11.5px] font-medium ring-1 ring-inset ${tone}`}
> >
{label} {label}
...@@ -2574,7 +2576,8 @@ function SuggestionCard({ ...@@ -2574,7 +2576,8 @@ function SuggestionCard({
/// 日期 → "M月D日"(使用者习惯,不用 7/9 这种易歧义格式) /// 日期 → "M月D日"(使用者习惯,不用 7/9 这种易歧义格式)
function mdLabel(d: Date): string { function mdLabel(d: Date): string {
return `${d.getMonth() + 1}月${d.getDate()}日`; const [, m, day] = businessDate(d).split('-').map(Number);
return `${m}月${day}日`;
} }
/// snooze 到期时间 → 人话。永久哨兵(>~3 年)显示"长期暂缓",否则显示日期 + 剩余天数。 /// snooze 到期时间 → 人话。永久哨兵(>~3 年)显示"长期暂缓",否则显示日期 + 剩余天数。
......
'use client'; 'use client';
import { businessDate } from '@pac/utils/business-date';
import { lookupDxTreatment, NO_RESTORATION_GAP_EXAM_PATTERNS } from '@pac/types'; import { lookupDxTreatment, NO_RESTORATION_GAP_EXAM_PATTERNS } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AdaptedFact } from './adapt-data'; import type { AdaptedFact } from './adapt-data';
...@@ -179,7 +181,7 @@ function ToothFactRow({ fact }: { fact: AdaptedFact }) { ...@@ -179,7 +181,7 @@ function ToothFactRow({ fact }: { fact: AdaptedFact }) {
const tIso = fact.occurredAt ?? fact.plannedFor; const tIso = fact.occurredAt ?? fact.plannedFor;
const planned = !fact.occurredAt && !!fact.plannedFor; const planned = !fact.occurredAt && !!fact.plannedFor;
const dateStr = tIso const dateStr = tIso
? (planned ? '约 ' : '') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.') ? (planned ? '约 ' : '') + businessDate(tIso).replace(/-/g, '.')
: '—'; : '—';
const { badge, badgeTone, text } = factLabel(fact); const { badge, badgeTone, text } = factLabel(fact);
return ( return (
......
'use client'; 'use client';
import { businessDate, BUSINESS_TIME_ZONE } from '@pac/utils/business-date';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
...@@ -31,6 +33,7 @@ const GATE_ZH: Record<string, string> = { ...@@ -31,6 +33,7 @@ const GATE_ZH: Record<string, string> = {
function fmt(d: string | null): string { function fmt(d: string | null): string {
if (!d) return '—'; if (!d) return '—';
return new Date(d).toLocaleString('zh-CN', { return new Date(d).toLocaleString('zh-CN', {
timeZone: BUSINESS_TIME_ZONE,
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
...@@ -41,7 +44,7 @@ function fmt(d: string | null): string { ...@@ -41,7 +44,7 @@ function fmt(d: string | null): string {
function fmtDay(d: string | null): string { function fmtDay(d: string | null): string {
if (!d) return '—'; if (!d) return '—';
return new Date(d).toLocaleDateString('zh-CN'); return businessDate(d);
} }
const UUID_RE = const UUID_RE =
......
'use client'; 'use client';
import { businessDate, BUSINESS_TIME_ZONE } from '@pac/utils/business-date';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { AssignmentBrief, AssignmentDetailResponse } from '@pac/types'; import type { AssignmentBrief, AssignmentDetailResponse } from '@pac/types';
...@@ -65,8 +67,9 @@ const CHANNEL_ZH: Record<string, string> = { ...@@ -65,8 +67,9 @@ const CHANNEL_ZH: Record<string, string> = {
*/ */
function fmtWhen(iso: string) { function fmtWhen(iso: string) {
const d = new Date(iso); const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, '0'); const [, month, day] = businessDate(d).split('-').map(Number);
return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}`; const time = new Intl.DateTimeFormat('en-GB', { timeZone: BUSINESS_TIME_ZONE, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(d);
return `${month}/${day} ${time}`;
} }
/** 「跑了几天」—— 主管比批次之前必须先看年龄(跑三个月的天然比跑三天的好看) */ /** 「跑了几天」—— 主管比批次之前必须先看年龄(跑三个月的天然比跑三天的好看) */
...@@ -196,7 +199,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) { ...@@ -196,7 +199,7 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
for (const b of shown) { for (const b of shown) {
if (runIds.has(b.id)) continue; if (runIds.has(b.id)) continue;
const d = new Date(b.createdAt); const d = new Date(b.createdAt);
const key = `${d.getMonth() + 1} 月`; const key = `${Number(businessDate(d).slice(5, 7))} 月`;
if (key !== lastKey) { if (key !== lastKey) {
out.push({ key, note: '已经结束,只作回顾', past: true, rows: [] }); out.push({ key, note: '已经结束,只作回顾', past: true, rows: [] });
lastKey = key; lastKey = key;
......
# 下游北京业务日期修复验收(2026-10-18)
## 边界与改动
- 时刻继续以 UTC 保存;下游按 `Asia/Shanghai` 解释业务日期。
- 生日、回访任务日、`@db.Date` 保持纯日期语义,不将数据库的 UTC 零点载体当成事件时刻。
- 修复上次到诊派生日期、同日就诊计数、同日病历/医嘱关联、未来回访排除、外院治疗回访日期比较、周岁及生日边界、AI 日期话术、患者详情与批次日期展示。
- 到诊分档按北京自然日稳定划分;原有天数阈值不变。下次回访表单按北京时间提交。
- 摄入、源时区转换、事实字段、数据库结构、召回阈值与按实际时长计算的冷却窗口均未修改。
## 本地验证
- 后端完整回归:145 套、2429 项通过;3 套、24 项按原有条件跳过。
- 前端:5 套、77 项通过;前后端 TypeScript 检查通过。
- 实际 PostgreSQL 在 UTC、北京、洛杉矶会话时区分别验证:同日/前日/次日回访、未来回访、生日年龄均一致;事务只读且回滚。
- Node 三种进程时区验证一致;覆盖北京零点、跨月年、闰日、纯日期、摄入零点锚、到诊 DATE 派生及同日医生关联。
- 两处旧禁忌边界测试改为完整时刻断言:北京 2026-10-28 零点对应 `2026-10-27T16:00:00Z`。
## 存量与发布范围
- 读取时计算的日期随发布生效;已持久化的上次到诊、画像特征和已生成话术不会因代码发布全部自动改写。
- 历史画像须走已有患者定向强制重算;未运行全库重算、未重摄入事实。部署测试后用真实患者定向核验。
- 本次顺序:功能分支 → test → 测试发布与验证 → 同一功能分支独立合入 main。生产不发布。
...@@ -27,7 +27,8 @@ ...@@ -27,7 +27,8 @@
"clean": "rm -rf dist .turbo" "clean": "rm -rf dist .turbo"
}, },
"dependencies": { "dependencies": {
"zod": "^4.4.3" "zod": "^4.4.3",
"@pac/utils": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.9.3" "typescript": "^5.9.3"
......
import { businessDateValue } from '@pac/utils/business-date';
/** /**
* 上次到诊的时间分档 —— 圈人筛选的「上次时间」维度取值。 * 上次到诊的时间分档 —— 圈人筛选的「上次时间」维度取值。
* *
...@@ -27,7 +28,7 @@ export function visitRecencyRange( ...@@ -27,7 +28,7 @@ export function visitRecencyRange(
bucket: string, bucket: string,
now: Date, now: Date,
): { gte?: Date; lt?: Date } | null { ): { gte?: Date; lt?: Date } | null {
const daysAgo = (d: number) => new Date(now.getTime() - d * 86400_000); const daysAgo = (d: number) => new Date(businessDateValue(now).getTime() - d * 86400_000);
switch (bucket) { switch (bucket) {
case '0_3m': case '0_3m':
return { gte: daysAgo(90) }; return { gte: daysAgo(90) };
......
...@@ -20,6 +20,10 @@ ...@@ -20,6 +20,10 @@
"./telemetry": { "./telemetry": {
"types": "./dist/telemetry.d.ts", "types": "./dist/telemetry.d.ts",
"default": "./dist/telemetry.js" "default": "./dist/telemetry.js"
},
"./business-date": {
"types": "./dist/business-date.d.ts",
"default": "./dist/business-date.js"
} }
}, },
"files": [ "files": [
...@@ -35,5 +39,12 @@ ...@@ -35,5 +39,12 @@
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
},
"typesVersions": {
"*": {
"business-date": [
"dist/business-date.d.ts"
]
}
} }
} }
/** PAC 当前宿主的业务日历。只用于下游解释;不改写摄入时刻。 */
export const BUSINESS_TIME_ZONE = 'Asia/Shanghai';
const DAY_MS = 86_400_000;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: BUSINESS_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit',
});
/** 时刻 → 北京业务日期;YYYY-MM-DD 已是纯日期,保留原值。 */
export function businessDate(value: Date | string): string {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
const date = typeof value === 'string' ? new Date(value) : value;
const parts = formatter.formatToParts(date);
return ['year', 'month', 'day'].map(key => parts.find(p => p.type === key)!.value).join('-');
}
/** @db.Date / 生日等纯日期:Date 是数据库的 UTC 零点载体,不是一个待转换的事件时刻。 */
export function dateOnly(value: Date | string): string {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
return (value instanceof Date ? value : new Date(value)).toISOString().slice(0, 10);
}
/** 纯日期在北京的起始时刻,用于到期边界;不要拿它写 @db.Date。 */
export function businessDayStart(day: string): Date {
return new Date(`${day}T00:00:00+08:00`);
}
/** 业务日期写回 @db.Date / 作为日期查询参数的载体。 */
export function businessDateValue(value: Date | string): Date {
return new Date(`${businessDate(value)}T00:00:00Z`);
}
/** 两个业务日之间相差的自然日数;不用于冷却期等实际时长规则。 */
export function businessDaysBetween(later: Date | string, earlier: Date | string): number {
return Math.round((businessDateValue(later).getTime() - businessDateValue(earlier).getTime()) / DAY_MS);
}
/** 生日仍是纯日期,周岁从北京生日当天开始。 */
export function businessAge(birth: Date | string, now: Date = new Date()): number {
const b = dateOnly(birth), today = businessDate(now);
return Number(today.slice(0, 4)) - Number(b.slice(0, 4)) - (today.slice(5) < b.slice(5) ? 1 : 0);
}
/** 满 age 岁的北京生日零点;2/29 在非闰年沿用滚到 3/1 的规则。 */
export function birthdayBoundary(birth: Date | string, age: number): Date {
const d = new Date(`${dateOnly(birth)}T00:00:00Z`);
d.setUTCFullYear(d.getUTCFullYear() + age);
return businessDayStart(dateOnly(d));
}
import { businessAge, businessDate } from './business-date';
/** /**
* 浏览器 + Node 双端可用的格式化工具(零 node 依赖)。 * 浏览器 + Node 双端可用的格式化工具(零 node 依赖)。
* *
...@@ -43,27 +45,24 @@ export function fmtRel(d: Date | string, now: Date = new Date()): string { ...@@ -43,27 +45,24 @@ export function fmtRel(d: Date | string, now: Date = new Date()): string {
} }
/** /**
* `YYYY-MM-DD`(UTC slice) * `YYYY-MM-DD`(北京业务日期;纯日期不转换)
*/ */
export function fmtDate(d: Date | string): string { export function fmtDate(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d; return businessDate(d);
return target.toISOString().slice(0, 10);
} }
/** /**
* `YYYY.MM`(year + month,主要用于治疗链节点标签) * `YYYY.MM`(year + month,主要用于治疗链节点标签)
*/ */
export function fmtYearMonth(d: Date | string): string { export function fmtYearMonth(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d; return businessDate(d).slice(0, 7).replace('-', '.');
return `${target.getFullYear()}.${String(target.getMonth() + 1).padStart(2, '0')}`;
} }
/** /**
* `YYYY.MM.DD`(year + month + day,治疗链节点 / fact 时间轴精确日期) * `YYYY.MM.DD`(year + month + day,治疗链节点 / fact 时间轴精确日期)
*/ */
export function fmtYearMonthDay(d: Date | string): string { export function fmtYearMonthDay(d: Date | string): string {
const target = typeof d === 'string' ? new Date(d) : d; return businessDate(d).replace(/-/g, '.');
return `${target.getFullYear()}.${String(target.getMonth() + 1).padStart(2, '0')}.${String(target.getDate()).padStart(2, '0')}`;
} }
/** /**
...@@ -71,9 +70,5 @@ export function fmtYearMonthDay(d: Date | string): string { ...@@ -71,9 +70,5 @@ export function fmtYearMonthDay(d: Date | string): string {
* @param now 参考时间(默认当下)— 便于测试注入 * @param now 参考时间(默认当下)— 便于测试注入
*/ */
export function calcAge(birthDate: Date | string, now: Date = new Date()): number { export function calcAge(birthDate: Date | string, now: Date = new Date()): number {
const b = typeof birthDate === 'string' ? new Date(birthDate) : birthDate; return businessAge(birthDate, now);
let age = now.getFullYear() - b.getFullYear();
const m = now.getMonth() - b.getMonth();
if (m < 0 || (m === 0 && now.getDate() < b.getDate())) age--;
return age;
} }
...@@ -767,6 +767,9 @@ importers: ...@@ -767,6 +767,9 @@ importers:
packages/types: packages/types:
dependencies: dependencies:
'@pac/utils':
specifier: workspace:*
version: link:../utils
zod: zod:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.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