Commit b3334df7 by luoqi

feat(中间表): 分页改滚动加载 + 姓名/病历号/医生/客服 四个筛选(服务端过滤)

翻页按钮换成滚到底继续加载(与批次列表同一套:scroll 事件 + inFlight ref 守卫 +
按 planId 去重, 不用 IntersectionObserver —— 零高度哨兵不回调、内置浏览器面板里
整个不工作,那条路没法在我们的验证环境里证伪)。

筛选四个:姓名/病历号(防抖 300ms)、医生(主治**或**上次接诊任一命中,候选走
/plans/doctors)、专属客服(候选走本诊所名册,另有「无专属」一档)。
🔴 全部在**服务端**过滤:滚动加载只加载了前几页,放前端过滤就成了"在已加载的那几页里搜",
   第 7 页的张三搜不到还不报错。

🔴 **筛选只影响"看",不影响"分"**:确定按钮上永远写整格人数(「确定,交给助手(420 人)」),
   筛选条旁边再明说一遍。这是"筛到 12 人却分出去 420"这个误解唯一的拦截点。
   要让筛选参与圈人是另一件事(医生/客服本就是画像维度,得一并传给助手), 别偷偷用 filtered。

🔴 途中真炸过一次,注释钉住了:`input.agentUserId === NO_OWNER` 这种写法,在服务进程
   抱着旧 @pac/types dist 时 `NO_OWNER` 是 undefined,而不传筛选时 agentUserId 也是
   undefined → 恒等成立 → **每次查询都被悄悄加上"只看无主的"**,420 人的格子只回 20 行,
   而 count 仍是 420(它不走这段)。改成先判"传没传"再判哨兵。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 07e5cabf
......@@ -9,6 +9,7 @@ import {
type AssignmentProposal,
type ProposedItem,
AnchorMode,
NO_OWNER,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
......@@ -641,6 +642,18 @@ export class AssignmentProposalService {
temperature?: TemperatureValue;
anchorMode?: AnchorModeValue;
personaTags?: string;
/**
* 🔴 下面三个是**看**的条件,⛔ 不是圈人条件 —— 它们只收窄这张表显示什么,
* 交给助手的仍然是整格的人(`total`)。
* 这条区分必须在界面上说清楚(见 CohortPreview 的按钮文案):
* 主管筛到 12 人然后点确定、结果分下去 420 人,是这功能最容易出的误解。
*/
/// 姓名 / 病历号 模糊查(在这批人里找某个人)
keyword?: string;
/// 医生:主治**或**上次就诊任一命中(候选来自 /plans/doctors,精确匹配)
doctor?: string;
/// 专属客服 userId;`__none__` = 只看无主的
agentUserId?: string;
page?: number;
pageSize?: number;
},
......@@ -661,8 +674,61 @@ export class AssignmentProposalService {
const page = Math.max(input.page ?? 1, 1);
const offset = (page - 1) * pageSize;
const [{ total }, rows] = await Promise.all([
/**
* 「看」的过滤 —— 与 `cohortWhereSql`(圈人)**分开拼**,刻意的:
* 一眼能看出哪些条件决定"分谁"、哪些只决定"这张表显示谁"。
* 🔴 必须在**服务端**过滤:分页是滚动加载的,放前端过滤就只在"已加载的那几页"里搜 ——
* 第 7 页的张三搜不到,而且不报错(看起来就像"这批里没有他")。
* ⚠️ 姓名/病历号用 ILIKE + 转义 %_,⛔ 别把用户输入直接塞进 pattern。
* ⚠️ 医生两列任一命中:主管说"薛玫的患者"时不区分主治还是上次接诊,
* 分两个筛选反而逼他猜(而且他多半两个都想要)。
*/
const esc = (v: string) => v.replace(/([%_\\])/g, '\\$1');
const kw = input.keyword?.trim();
const viewFilter = Prisma.sql`
${
kw
? Prisma.sql`AND (p.name ILIKE ${`%${esc(kw)}%`} ESCAPE '\\'
OR p.medical_record_number ILIKE ${`%${esc(kw)}%`} ESCAPE '\\')`
: Prisma.empty
}
${
input.doctor
? Prisma.sql`AND EXISTS (SELECT 1 FROM patient_profiles vpp
WHERE vpp.patient_id = fp.patient_id
AND ${input.doctor} IN (vpp.preferred_doctor, vpp.last_visit_doctor))`
: Prisma.empty
}
${
/**
* 🔴 **先判"传没传",再判"是不是哨兵"** —— ⛔ 别写成 `x === NO_OWNER ? … : x ? … : 空`。
* 2026-08-11 实测:服务进程抱着旧的 `@pac/types` dist 时 `NO_OWNER` 是 `undefined`,
* 而没传筛选时 `input.agentUserId` 也是 `undefined` → 恒等成立 →
* **每一次查询都被悄悄加上"只看无主的"**,420 人的格子只回 20 行,
* count 还是 420(它不走这段)—— 表和总数对不上,且不报任何错。
* 这么写之后,没传就是没传,常量再怎么坏也影响不到"不筛"这条路。
*/
!input.agentUserId
? Prisma.empty
: input.agentUserId === NO_OWNER
? Prisma.sql`AND p.preferences #>> '{dedicatedCs,id}' IS NULL`
: Prisma.sql`AND p.preferences #>> '{dedicatedCs,id}' = ${input.agentUserId}`
}`;
const hasView = Boolean(kw || input.doctor || input.agentUserId);
const [{ total }, filteredRows, rows] = await Promise.all([
// ⭐ 整格的人数(**不含**看的过滤)—— 这才是点确定后真正交出去的量
this.countCandidates(scope, criteria, now).then((c) => ({ total: c.total })),
hasView
? this.prisma.$queryRaw<Array<{ n: bigint | number }>>(
Prisma.sql`
SELECT count(DISTINCT fp.patient_id) AS n
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)} ${viewFilter}
`,
)
: Promise.resolve(null),
this.prisma.$queryRaw<
Array<{ planId: string; patientId: string; priorityScore: number; assignmentId: string | null }>
>(
......@@ -671,12 +737,14 @@ export class AssignmentProposalService {
fp.priority_score AS "priorityScore", fp.assignment_id AS "assignmentId"
FROM followup_plans fp
JOIN patients p ON p.id = fp.patient_id
WHERE ${cohortWhereSql(scope, criteria, now)}
WHERE ${cohortWhereSql(scope, criteria, now)} ${viewFilter}
${SELECTION_ORDER}
LIMIT ${pageSize} OFFSET ${offset}
`,
),
]);
/// 筛选后的条数;没筛就等于整格 —— 前端据此显示「共 420 人 · 筛选后 12」
const filtered = filteredRows ? Number(filteredRows[0]?.n ?? 0) : total;
// 身份走**同一个** patientsOf —— 与确认单的患者行逐字段同源(姓名/病历号/性别/年龄/两个医生)
const { info, dedicated } = await this.patientsOf(rows.map((r) => r.patientId));
......@@ -686,6 +754,7 @@ export class AssignmentProposalService {
return {
clinicId,
total,
filtered,
page,
pageSize,
items: rows.map((r) => {
......
......@@ -193,6 +193,10 @@ export class AssignmentController {
@Query('temperature') temperature?: string,
@Query('anchor') anchor?: string,
@Query('personaTags') personaTags?: string,
/// ↓ 三个「看」的过滤:只收窄这张表,⛔ 不改变交给助手的人群(见 service 注释)
@Query('keyword') keyword?: string,
@Query('doctor') doctor?: string,
@Query('agentUserId') agentUserId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
......@@ -206,6 +210,9 @@ export class AssignmentController {
...(temperature ? { temperature: temperature as never } : {}),
anchorMode: parseAnchorMode(anchor),
...(personaTags ? { personaTags } : {}),
...(keyword ? { keyword } : {}),
...(doctor ? { doctor } : {}),
...(agentUserId ? { agentUserId } : {}),
...(page ? { page: Number(page) } : {}),
...(pageSize ? { pageSize: Number(pageSize) } : {}),
});
......
......@@ -40,7 +40,10 @@ export interface CohortPatientRow {
export interface CohortPage {
clinicId: string;
/** 整格的人数(**不含**搜索/筛选)—— 点确定真正交出去的量 */
total: number;
/** 加了搜索/筛选之后还剩多少;没筛时 = total */
filtered: number;
page: number;
pageSize: number;
items: CohortPatientRow[];
......@@ -139,6 +142,10 @@ export const assignmentsApi = {
temperature?: string;
anchor?: AnchorModeValue;
personaTags?: string;
/// ↓ 三个「看」的条件:只收窄这张表,⛔ 不改变交给助手的人群
keyword?: string;
doctor?: string;
agentUserId?: string;
page?: number;
pageSize?: number;
}) =>
......@@ -149,6 +156,9 @@ export const assignmentsApi = {
temperature: q.temperature,
anchor: q.anchor,
personaTags: q.personaTags,
keyword: q.keyword,
doctor: q.doctor,
agentUserId: q.agentUserId,
page: q.page == null ? undefined : String(q.page),
pageSize: q.pageSize == null ? undefined : String(q.pageSize),
},
......
......@@ -110,10 +110,16 @@ export const COLD_TEMPERATURES: readonly TemperatureValue[] = [
* · `last_visit` = 患者**最后一次到诊** → 「这人多久没露面」(接通率 / 关系温度)
*
* 🔴 `diagnosis` 有一个**已量化**的系统偏差:复诊时医生常不重复写同一诊断
* (本地实测:诊断后又来过的机会里 **41.3%** 一次都没再写,期间平均又来了 6 次;
* 这些机会的诊断日与末诊日**中位相差 526 天** = 矩阵上差 1–2 档)。
* 而且各医生记录习惯差一倍(重复记录率 20.7% ~ 43.2%)——
* (**测试服朝阳全量** 45,190 个机会:诊断后又来过的 19,605 个里 **49.1%** 一次都没再写,
* 期间平均又来 4.7 次;诊断日与末诊日**中位相差 490 天** = 矩阵上差 1–2 档)。
* 而且各医生记录习惯差一倍 ——
* ⚠️ 所以 `diagnosis` 档位里含着"这个医生爱不爱重复写"的成分,⛔ 别把它读成临床新鲜度。
* ⚠️⚠️ 量这类比例**必须按日期比,⛔ 不能按时间戳**:同次就诊里 emr 与 diagnosis 的
* `occurred_at` 差几毫秒,用 `>` 会把"本次就诊"算成"又来过一次"(测试服上算出 99.98%,
* 真值 43%),而这个错不报错。
* ⚠️ **⛔ 别拿本地库量两版差异**:本地只有 5,825 人、最早末诊 2024-06(全是近两年来过的),
* 「按末诊·3 年以上」在本地恒空 —— 那是采样偏差,不是业务发现。测试服上那一档仍有
* 3,592 格(占池子一半以上),诊断 3 年+ 的格位里 89% 末诊也是 3 年+。
*
* ⛔ **不要改用「最早那条诊断」**去求所谓真实年龄:那会重新引入反单调
* (上周刚查出的龋齿,因为身上有颗两年前的旧龋,整个人被判成冷)—— 见文件头 ①。
......@@ -152,6 +158,13 @@ export const ANCHOR_MODE_META: Record<
},
};
/**
* 「无专属客服」的筛选哨兵值 —— 前后端共用。
* ⚠️ 用一个不可能与 userId 相撞的串,⛔ 别用空串:空串在查询串里跟"没传这个参数"分不开,
* 而"只看无主的"与"不按客服筛"是两个不同的意思。
*/
export const NO_OWNER = '__none__';
/** 入参解析:非法值一律回落默认(⛔ 别抛错 —— 查询串是用户可改的) */
export function parseAnchorMode(v: unknown): AnchorModeValue {
return v === AnchorMode.LAST_VISIT ? AnchorMode.LAST_VISIT : AnchorMode.DIAGNOSIS;
......
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