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),
},
......
'use client';
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { ANCHOR_MODE_META, TEMPERATURE_META, type AnchorModeValue, type TemperatureValue } from '@pac/types';
import { assignmentsApi, type CohortPage } from '@/components/plans/assignments-api';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, X } from 'lucide-react';
import {
ANCHOR_MODE_META,
NO_OWNER,
TEMPERATURE_META,
type AnchorModeValue,
type TemperatureValue,
} from '@pac/types';
import { assignmentsApi, type CohortPatientRow } from '@/components/plans/assignments-api';
import { plansApi } from '@/components/plans/plans-api';
import { cn, formatGender } from '@/lib/utils';
/**
* 「点了一格,先看看这批人是谁」—— 矩阵与助手之间的**中间一步**(2026-08-11 产品定)。
*
* ⭐ 存在的理由:主管点的是一个数字(「充填 · 3 年以上 4,159」),而他要为这批人负责。
* ⭐ 存在的理由:主管点的是一个数字(「充填 · 半年到 1 年 420」),而他要为这批人负责。
* 在交给助手之前先摊开"这些人是谁",比事后在确认单上一个个挑出来便宜得多。
*
* 🔴 **这张表与确认单必须是同一批人、同一个顺序**:数据走
* `GET /plans/assignments/cohort`,服务端用的是**出确认单那段同样的** SQL 与排序键。
* ⛔ 前端不许再排一次序、不许本地过滤 —— 那会让"我在这看到的前 20 个"跟
* "助手挑走的前 20 个"对不上,而且两边都不报错(T14)。
* ⛔ 前端不许再排一次序 —— 那会让"我在这看到的前 20 个"跟"助手挑走的前 20 个"对不上,
* 而且两边都不报错(T14)。
*
* ⚠️ **分页取**,不是一次拉全:最大的格子四千多人,一次拉回来 DOM 也撑不住,
* 而主管本来也不会逐行看完 —— 他要的是"抽查几页 + 知道总数对不对"。
* 🔴 **搜索/筛选只影响"看",⛔ 不影响"分"**:点确定交出去的永远是**整格**的人。
* 这是本组件最容易被误解的一点(筛到 12 人 → 点确定 → 分下去 420 人),
* 所以按钮上**必须**始终写着整格的人数,筛选状态下还要显式说明。
* ⚠️ 要让筛选参与圈人,那是另一件事:得把条件也传给助手(医生/客服本来就是画像维度),
* ⛔ 别在这里偷偷把 filtered 当成人群 —— 那会分错人且无法追溯。
*/
export function CohortPreview({
clinicId,
......@@ -34,33 +44,119 @@ export function CohortPreview({
/// 确定 —— 带上「确定」按钮的视口矩形,数据流粒子从那里起飞(⛔ 不能事后再查,窗已经关了)
onConfirm: (from: { x: number; y: number; w: number; h: number }) => void;
}) {
const PAGE_SIZE = 50;
const [items, setItems] = useState<CohortPatientRow[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [filtered, setFiltered] = useState<number | null>(null);
const [page, setPage] = useState(1);
const [data, setData] = useState<CohortPage | null>(null);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const okRef = useRef<HTMLButtonElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const PAGE_SIZE = 50;
// ── 三个「看」的条件 ──────────────────────────────────
/// 输入框里的原文 vs 真正发出去的词 —— 分开是为了防抖(每敲一个字发一次请求会打爆后端)
const [kwInput, setKwInput] = useState('');
const [keyword, setKeyword] = useState('');
const [doctor, setDoctor] = useState('');
const [agent, setAgent] = useState('');
useEffect(() => {
const t = window.setTimeout(() => setKeyword(kwInput.trim()), 300);
return () => window.clearTimeout(t);
}, [kwInput]);
/// 候选名单:医生走 `/plans/doctors`(服务端缓存 6h),客服走本诊所名册 —— 与筛选面板同源
const [doctors, setDoctors] = useState<string[]>([]);
const [agents, setAgents] = useState<Array<{ userId: string; name: string | null }>>([]);
useEffect(() => {
let alive = true;
void plansApi.doctors().then((r) => alive && setDoctors(r.doctors)).catch(() => undefined);
void assignmentsApi
.agents(clinicId)
.then((r) => alive && setAgents(r.agents.map((a) => ({ userId: a.userId, name: a.name }))))
.catch(() => undefined);
return () => {
alive = false;
};
}, [clinicId]);
/**
* 🔴 **在途守卫必须是 ref,⛔ 不能靠 `loading` 这个 state**(与批次列表同一个坑):
* scroll 事件连发时 `loading` 还是上一帧的快照,挡不住第二、第三次触发 ——
* 结果是同一页被追加两遍,React 报重复 key,界面上真的画两行。
*/
const inFlight = useRef(false);
const load = useCallback(
async (p: number, replace: boolean) => {
if (inFlight.current) return;
inFlight.current = true;
setLoading(true);
assignmentsApi
.cohort({
try {
const r = await assignmentsApi.cohort({
clinicId,
potentialTreatment: cell.treatment,
temperature: cell.temperature,
anchor: anchorMode,
page,
...(keyword ? { keyword } : {}),
...(doctor ? { doctor } : {}),
...(agent ? { agentUserId: agent } : {}),
page: p,
pageSize: PAGE_SIZE,
})
.then((r) => alive && (setData(r), setError(null)))
.catch((e) => alive && setError(e instanceof Error ? e.message : '取患者失败'))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
});
setTotal(r.total);
setFiltered(r.filtered);
setError(null);
setDone(r.items.length < PAGE_SIZE);
setItems((prev) => {
if (replace) return r.items;
// ⚠️ 按 planId 去重:offset 分页在边界上仍可能重(池子被别人动过一条就会错位)
const seen = new Set(prev.map((x) => x.planId));
return [...prev, ...r.items.filter((x) => !seen.has(x.planId))];
});
} catch (e) {
setError(e instanceof Error ? e.message : '取患者失败');
} finally {
inFlight.current = false;
setLoading(false);
}
},
[clinicId, cell.treatment, cell.temperature, anchorMode, keyword, doctor, agent],
);
/// 条件一变就回到第一页并清空 —— ⛔ 不能只追加:那会把新条件的结果接在旧结果后面
useEffect(() => {
setItems([]);
setPage(1);
setDone(false);
scroller.current?.scrollTo({ top: 0 });
void load(1, true);
}, [load]);
/**
* 滚到底继续加载 —— **监听滚动容器的 scroll**,⛔ 不用 IntersectionObserver。
* 理由与批次列表那处相同:零高度哨兵 IO 不回调,而且在内置浏览器面板里 IO 整个不工作,
* 那条路**没法在我们的验证环境里证伪**。
*/
useEffect(() => {
const el = scroller.current;
if (!el || done) return;
const onScroll = () => {
if (inFlight.current) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 160) {
setPage((p) => {
const next = p + 1;
void load(next, false);
return next;
});
}
};
}, [clinicId, cell.treatment, cell.temperature, anchorMode, page]);
el.addEventListener('scroll', onScroll, { passive: true });
onScroll(); // 首屏没撑满时也要能继续拉
return () => el.removeEventListener('scroll', onScroll);
}, [done, load, items.length]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onCancel();
......@@ -68,8 +164,9 @@ export function CohortPreview({
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
const total = data?.total ?? cell.count;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const cellTotal = total ?? cell.count;
const hasFilter = Boolean(keyword || doctor || agent);
const shown = filtered ?? cellTotal;
const tag = ANCHOR_MODE_META[anchorMode].batchTag;
return (
......@@ -86,15 +183,15 @@ export function CohortPreview({
{cell.treatmentZh} · {TEMPERATURE_META[cell.temperature].zh}
{tag ? `(${tag})` : ''}
</span>
{/* ⚠️ 总数取**服务端**的 total,⛔ 不用格子上那个数:两者应当一致,
不一致时要让主管看见的是**真会被分的那个**(格子可能是几秒前的快照)。 */}
<span className="nums text-[12px] text-slate-500">{total}</span>
{data && total !== cell.count && (
{/* ⚠️ 总数取**服务端**的 total,⛔ 不用格子上那个数:格子可能是几秒前的快照 */}
<span className="nums text-[12px] text-slate-500">{cellTotal}</span>
{hasFilter && (
<span className="nums text-[12px] text-brand-700">· 筛选后 {shown}</span>
)}
{total != null && total !== cell.count && (
<span className="text-[11px] text-amber-700">(格子上显示 {cell.count},池子刚变过)</span>
)}
<span className="ml-auto text-[11px] text-slate-400">
按分配顺序排列,助手会从上往下取
</span>
<span className="ml-auto text-[11px] text-slate-400">按分配顺序排列,助手从上往下取</span>
<button
type="button"
onClick={onCancel}
......@@ -105,15 +202,79 @@ export function CohortPreview({
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{/* 筛选条 —— ⚠️ 全部走**服务端**过滤:滚动加载只加载了前几页,
放前端过滤就成了"在已加载的那几页里搜",第 7 页的张三搜不到还不报错。 */}
<div className="flex flex-none flex-wrap items-center gap-2 border-b bg-slate-50/60 px-4 py-2">
<span className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
<input
value={kwInput}
onChange={(e) => setKwInput(e.target.value)}
placeholder="姓名 / 病历号"
className="h-7 w-48 rounded-md border bg-white pl-7 pr-2 text-[12px] outline-none placeholder:text-slate-300 focus:border-brand-300"
/>
</span>
<select
value={doctor}
onChange={(e) => setDoctor(e.target.value)}
className="h-7 max-w-[11rem] rounded-md border bg-white px-1.5 text-[12px] text-slate-700 outline-none focus:border-brand-300"
>
{/* ⚠️ 医生只有一个下拉:主治与上次接诊**任一命中**即可 ——
主管说「薛玫的患者」时不分这两者,拆成两个筛选反而逼他猜。 */}
<option value="">全部医生</option>
{doctors.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<select
value={agent}
onChange={(e) => setAgent(e.target.value)}
className="h-7 max-w-[11rem] rounded-md border bg-white px-1.5 text-[12px] text-slate-700 outline-none focus:border-brand-300"
>
<option value="">全部客服</option>
{/* ⭐「无专属」是主管真会用的一档:他常想先看"没主的还剩多少" */}
<option value={NO_OWNER}>无专属客服</option>
{agents.map((a) => (
<option key={a.userId} value={a.userId}>
{a.name ?? a.userId}
</option>
))}
</select>
{hasFilter && (
<button
type="button"
onClick={() => {
setKwInput('');
setKeyword('');
setDoctor('');
setAgent('');
}}
className="text-[11.5px] text-brand-700 hover:underline"
>
清空筛选
</button>
)}
{/* 🔴 这句必须在,而且必须在筛选条旁边 —— 见组件头注释 */}
<span className="ml-auto text-[11px] text-slate-400">
筛选只影响这张表,交给助手的仍是整格 {cellTotal}
</span>
</div>
<div ref={scroller} className="min-h-0 flex-1 overflow-auto">
{error ? (
<p className="py-10 text-center text-[12px] text-rose-600">{error}</p>
) : !data ? (
) : items.length === 0 && loading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-slate-100" />
))}
</div>
) : items.length === 0 ? (
<p className="py-10 text-center text-[12px] text-slate-400">
{hasFilter ? '这批人里没有符合筛选条件的' : '这一格没有人'}
</p>
) : (
<table className="w-full border-collapse text-[12.5px] text-slate-700">
<thead>
......@@ -133,12 +294,10 @@ export function CohortPreview({
)}
</tr>
</thead>
<tbody className={cn(loading && 'opacity-50')}>
{data.items.map((r, i) => (
<tbody>
{items.map((r, i) => (
<tr key={r.planId} className="border-t hover:bg-slate-50">
<td className="nums px-2.5 py-1.5 text-right text-[11px] text-slate-400">
{(data.page - 1) * data.pageSize + i + 1}
</td>
<td className="nums px-2.5 py-1.5 text-right text-[11px] text-slate-400">{i + 1}</td>
<td className="px-2.5 py-1.5">
<span className="font-medium text-slate-900">
{r.patientName ?? `#${r.patientId.slice(0, 8)}`}
......@@ -161,31 +320,26 @@ export function CohortPreview({
</tbody>
</table>
)}
{/* 滚动加载的进度条 —— ⛔ 不要静默:主管要知道"还没到底"而不是"就这些人" */}
{items.length > 0 && (
<div className="flex items-center gap-2 px-4 pb-2 pt-2.5">
<span className="h-px flex-1 bg-slate-100" />
<span className="nums text-[10.5px] text-slate-400">
{loading
? '正在加载…'
: done
? `已显示全部 ${items.length} 人`
: `已显示 ${items.length} / ${shown} 人 · 往下滚动继续加载`}
</span>
<span className="h-px flex-1 bg-slate-100" />
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 border-t px-4 py-2.5">
{/* 翻页 —— ⚠️ 页码要给全(第 3 / 84 页):主管抽查时要能回跳,这也是这里
用 offset 而不是游标的原因(见服务端 cohortPage 注释)。 */}
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
className="rounded-md border px-2.5 py-1 text-[12px] text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
上一页
</button>
<span className="nums text-[11.5px] text-slate-500">
{data?.page ?? page} / {pages}
<span className="text-[11px] text-slate-400">
这一步只是过目,点确定后助手才会出确认单(那时还能再改)
</span>
<button
type="button"
disabled={page >= pages || loading}
onClick={() => setPage((p) => Math.min(pages, p + 1))}
className="rounded-md border px-2.5 py-1 text-[12px] text-slate-600 hover:bg-slate-50 disabled:opacity-40"
>
下一页
</button>
<span className="ml-auto flex items-center gap-2">
<button
type="button"
......@@ -198,17 +352,18 @@ export function CohortPreview({
ref={okRef}
type="button"
onClick={() => {
const el = okRef.current;
const b = el?.getBoundingClientRect();
const b = okRef.current?.getBoundingClientRect();
onConfirm(
b
? { x: b.left, y: b.top, w: b.width, h: b.height }
: { x: window.innerWidth / 2, y: window.innerHeight / 2, w: 0, h: 0 },
);
}}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-[12.5px] font-medium text-white hover:bg-brand-700"
className="nums rounded-lg bg-brand-600 px-3 py-1.5 text-[12.5px] font-medium text-white hover:bg-brand-700"
>
确定,交给助手
{/* 🔴 人数**永远是整格的** —— 筛选状态下尤其:这是"筛到 12 人却分出去 420"
这个误解唯一的拦截点 */}
确定,交给助手({cellTotal} 人)
</button>
</span>
</div>
......
......@@ -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