Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
9f7ec1a4
Commit
9f7ec1a4
authored
Sep 21, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
merge: 修确认分配 500(全 null 数组类型推断)→ main
parents
af0db174
5fcd4afe
Pipeline
#3693
failed in 0 seconds
Changes
2
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
113 additions
and
3 deletions
+113
-3
apps/pac-service/src/modules/plan/plan-assignment.service.ts
+24
-3
apps/pac-service/tests/null-array-pg-cast.spec.ts
+89
-0
No files found.
apps/pac-service/src/modules/plan/plan-assignment.service.ts
View file @
9f7ec1a4
...
...
@@ -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
}
::t
ext[]::t
imestamptz[],
${
aPriorityScore
}
::double precision[],
${
aSourceConfidence
}
::double precision[],
${
aSelectionMode
}
::text[]
...
...
apps/pac-service/tests/null-array-pg-cast.spec.ts
0 → 100644
View file @
9f7ec1a4
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()'
);
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment