Commit 9f7ec1a4 by luoqi

merge: 修确认分配 500(全 null 数组类型推断)→ main

parents af0db174 5fcd4afe
Pipeline #3693 failed in 0 seconds
......@@ -368,7 +368,11 @@ export class PlanAssignmentService {
const aExpires: Date[] = [];
const aStrategy: string[] = [];
const aDedicatedCs: (string | null)[] = [];
const aDedicatedCsLastVisit: (Date | null)[] = [];
/**
* ⚠️ 存 **ISO 文本**而不是 Date —— 见下方 `::text[]::timestamptz[]` 的长注释。
* 一句话:整个数组全是 null 时,Prisma 推断不出元素类型,会退化成 integer[]。
*/
const aDedicatedCsLastVisit: (string | null)[] = [];
const aPriorityScore: number[] = [];
const aSourceConfidence: (number | null)[] = [];
const aSelectionMode: string[] = [];
......@@ -383,12 +387,29 @@ export class PlanAssignmentService {
);
aStrategy.push(item.assignStrategy);
aDedicatedCs.push(dcs);
aDedicatedCsLastVisit.push(dcs ? (lastVisitByCs.get(dcs) ?? null) : null);
aDedicatedCsLastVisit.push(dcs ? (lastVisitByCs.get(dcs)?.toISOString() ?? null) : null);
aPriorityScore.push(row.priorityScore);
aSourceConfidence.push(confidenceByPlan.get(row.id) ?? null);
aSelectionMode.push(item.selectionMode ?? 'rank');
}
/**
* 🔴 `aDedicatedCsLastVisit` 走 `::text[]::timestamptz[]`,**别改回 `::timestamptz[]`** ——
* 那样写在「本批所有患者都没有专属客服」时必 500(2026-09-21 FRIDAY 宿主实证)。
*
* 机理:Prisma 从**值**推断数组元素类型。整个数组全是 null 时推不出来,退化成
* `integer[]`,撞上显式 `::timestamptz[]` → PG 报
* `42846 cannot cast type integer[] to timestamp with time zone[]`。
* ⚠️ 空数组 `[]` 反而没事(压根走不到转换);**只有「非空且全 null」才炸**。
*
* 实测哪些目标类型会塌(2026-09-21 测试机):
* ::timestamptz[] ❌ ::uuid[] ❌
* ::text[] ✅ ::double precision[] ✅ ::int[] ✅
* ⇒ 同段里 `aDedicatedCs`(::text[])、`aSourceConfidence`(::double precision[])
* 虽然也可空,但 integer 到这些类型有转换路径,**不受影响**,别顺手一起改。
*
* ⛔ 别把这段注释写进下面的模板串里 —— 那里面的 `${…}` 会被当插值求值(踩过)。
*/
const appliedRows = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
UPDATE followup_plans AS fp SET
status = 'assigned',
......@@ -413,7 +434,7 @@ export class PlanAssignmentService {
${aExpires}::timestamptz[],
${aStrategy}::text[],
${aDedicatedCs}::text[],
${aDedicatedCsLastVisit}::timestamptz[],
${aDedicatedCsLastVisit}::text[]::timestamptz[],
${aPriorityScore}::double precision[],
${aSourceConfidence}::double precision[],
${aSelectionMode}::text[]
......
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
/**
* 「可空数组 + 显式 PG 类型转换」的地雷守卫。
*
* ═══ 这条规则从哪来 ═══════════════════════════════════════════════
* 2026-09-21 FRIDAY 宿主接入后,点「确认分配」必 500:
* 42846 cannot cast type integer[] to timestamp with time zone[]
*
* 机理:Prisma 从**值**推断数组元素类型。整个数组**非空且全是 null** 时推不出来,
* 退化成 `integer[]`,撞上显式 `::timestamptz[]` 就报 42846。
* ⚠️ 空数组 `[]` 反而安全(走不到转换);**只有「非空且全 null」才炸**。
*
* 为什么瑞尔一直没事、FRIDAY 一上来就炸:那一列是「该患者专属客服的最近到诊时间」,
* 瑞尔有回访历史 → 多数非 null;FRIDAY 是新宿主 → 整批全 null。
* ⇒ **这不是 FRIDAY 特有的**,瑞尔只要碰上「这批人恰好都没有专属客服」一样炸。
*
* ═══ 只有这两种目标类型会塌(2026-09-21 测试机实测)═══════════════
* ::timestamptz[] ❌ ::uuid[] ❌
* ::text[] ✅ ::double precision[] ✅ ::int[] ✅
* integer 到 text/float/int 有转换路径,到 timestamptz/uuid 没有。
* ⇒ ⛔ 别把同段里的 `::text[]` / `::double precision[]` 参数也"顺手修了",它们没病。
*
* ═══ 断言什么 ═════════════════════════════════════════════════════
* 凡是 `${x}::timestamptz[]` 或 `${x}::uuid[]`,若 `x` 的声明类型里有 `| null`,
* 就是地雷 —— 必须先走 `::text[]` 再整体转(见 plan-assignment.service.ts 的长注释)。
* ⚠️ 只锁「声明可空 ⇒ 不许裸转」这一条,⛔ 不锁具体写法/行号/措辞。
*/
const SRC = join(__dirname, '../src');
function walk(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (p.endsWith('.ts')) out.push(p);
}
return out;
}
/** `${ident}::timestamptz[]` / `${ident}::uuid[]` —— 但排除已经过 text[] 中转的写法 */
const RISKY = /\$\{(\w+)\}::(timestamptz|uuid)\[\]/g;
describe('可空数组不许裸转 timestamptz[] / uuid[]', () => {
const files = walk(SRC);
it('仓库里没有「声明可空却裸转」的参数', () => {
const offenders: string[] = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
for (const m of src.matchAll(RISKY)) {
const ident = m[1];
const target = m[2];
// 找同文件里该变量的声明
const decl = new RegExp(`const\\s+${ident}\\s*:\\s*([^=]+)=`).exec(src)?.[1] ?? '';
if (/\|\s*null/.test(decl)) {
offenders.push(
`${file.replace(SRC, 'src')}: \${${ident}}::${target}[] —— 声明为 ${decl.trim()},` +
` 全 null 时会退化成 integer[] 并报 42846。改成 ::text[]::${target}[] 并传 ISO 文本/字符串。`,
);
}
}
}
expect(offenders).toEqual([]);
});
it('🔴 那个出过事的参数确实走了 text[] 中转', () => {
const f = readFileSync(join(SRC, 'modules/plan/plan-assignment.service.ts'), 'utf8');
expect(f).toContain('${aDedicatedCsLastVisit}::text[]::timestamptz[]');
expect(f).not.toMatch(/\$\{aDedicatedCsLastVisit\}::timestamptz\[\]/);
});
it('⛔ 没病的那两个没被顺手改坏(text[] / double precision[] 保持原样)', () => {
const f = readFileSync(join(SRC, 'modules/plan/plan-assignment.service.ts'), 'utf8');
expect(f).toContain('${aDedicatedCs}::text[]');
expect(f).toContain('${aSourceConfidence}::double precision[]');
});
it('数组里存的是 ISO 文本而不是 Date(否则 text[] 那步会塞进 Date 对象)', () => {
const f = readFileSync(join(SRC, 'modules/plan/plan-assignment.service.ts'), 'utf8');
expect(f).toMatch(/aDedicatedCsLastVisit:\s*\(string \| null\)\[\]/);
// ⚠️ 必须锁**这一行**的 toISOString —— 只写 toContain('toISOString()') 没牙:
// 文件别处也有它,把这行的删掉照样绿(2026-09-21 变异测试当场抓到)。
const push = /aDedicatedCsLastVisit\.push\([^;]*\);/.exec(f)?.[0] ?? '';
expect(push).toContain('toISOString()');
});
});
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