Commit 14ec24ec by luoqi

feat(web): 确认单显示患者姓名 + 时效改 1-7 天下拉 + 撤掉「铺平」标

产品走查三条:
① 患者明细只有 `#912e5dae` —— 主管展开客服看的就是"这 20 个人是谁",
   给一串十六进制等于让他猜(T14)。ProposedItem 加 patientName / medicalRecordNumber,
   服务端把 dedicatedCsOf 合并成 patientsOf 一次取齐(同一批 id 同一张表,分两次纯属多一个来回)。
   ️ 身份在**落人之后**贴上:placeAgents 是纯算法,让它认识"患者叫什么"只会更难测。
② 时效 3/5/7 三档 → **1-7 天下拉**(批次级 + 按客服两处同一个控件)。
   档位是我们拍的,主管对自己团队的节奏有判断(「明天就要」= 1 天)。
   顺带确认:时效**早已落到每条召回计划**(assignment_expires_at 逐行写),
   不是只挂在批次头上 —— 卡片上补了一句说明。
③ 汇总行的「铺平 N」标撤掉(看不懂)。明细里只标「专属」,没标的自然就是不是他的老客户 ——
   同一个信息换成主管的语言。️ assignStrategy 仍逐条落库,T20 反推不受影响
   (T15 要的是"可见",不是"必须以这三个字出现在汇总行")。

本地实测:展开康慧捧 20 条全带姓名+病历号+专属标;薛玫 20 条全是铺来的,无标。
989 tests green。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 39cd460a
......@@ -215,8 +215,18 @@ export class AssignmentProposalService {
// ⚠️ 专属客服表**作参数传下去**,不挂实例字段:service 是单例,
// 两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
// (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
const dedicated = await this.dedicatedCsOf(chosen.map((c) => c.patientId));
const items = placeAgents(chosen, agents, dedicated, capOf);
const { dedicated, info } = await this.patientsOf(chosen.map((c) => c.patientId));
const raw = placeAgents(chosen, agents, dedicated, capOf);
// ⭐ 身份(姓名/病历号)在**落人之后**贴上 —— placeAgents 是纯算法,
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
const items = {
unplaced: raw.unplaced,
placed: raw.placed.map((it) => ({
...it,
patientName: info.get(it.patientId)?.name ?? null,
medicalRecordNumber: info.get(it.patientId)?.mrn ?? null,
})),
};
const byAgent = new Map<string, ProposedItem[]>();
for (const it of items.placed) {
......@@ -404,23 +414,41 @@ export class AssignmentProposalService {
return Number(rows[0]?.n ?? 0);
}
private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
/**
* 一次取齐**专属客服**与**患者身份**(姓名 / 病历号)。
*
* ⭐ 合并成一次查询而不是两次:同一批 id、同一张表,分两次纯属多一个来回。
* ⚠️ 姓名要跟着确认单下发 —— 主管展开客服看的是"这 20 个人是谁",
* 给一串 `#912e5dae` 等于让他对着十六进制猜。
*/
private async patientsOf(patientIds: string[]): Promise<{
dedicated: Map<string, string>;
info: Map<string, { name: string | null; mrn: string | null }>;
}> {
const ids = [...new Set(patientIds)];
if (ids.length === 0) return new Map();
if (ids.length === 0) return { dedicated: new Map(), info: new Map() };
const rows = await this.prisma.patient.findMany({
where: { id: { in: ids } },
select: { id: true, preferences: true },
select: { id: true, preferences: true, name: true, medicalRecordNumber: true },
});
const out = new Map<string, string>();
const dedicated = new Map<string, string>();
const info = new Map<string, { name: string | null; mrn: string | null }>();
for (const r of rows) {
const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
if (typeof id === 'string' && id) out.set(r.id, id);
if (typeof id === 'string' && id) dedicated.set(r.id, id);
info.set(r.id, { name: r.name ?? null, mrn: r.medicalRecordNumber ?? null });
}
return out;
return { dedicated, info };
}
}
/**
* 落人算法的产出 —— ⛔ **不含患者姓名**:那是身份信息,与"分给谁"这件事无关,
* 由 propose() 在算完之后贴上(见那里的注释)。
*/
type PlacedItem = Omit<ProposedItem, 'patientName' | 'medicalRecordNumber'>;
/**
* ② 落人:**专属优先 → 无主补空 → 有主改派**,三趟共用同一本水位账。
*
* ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
......@@ -455,7 +483,7 @@ export function placeAgents(
* ⚠️ 它**压过一切**(含专属):主管说了只给 5 条,第 6 个她的专属患者会被改派给别人。
*/
maxOf: (userId: string) => number = () => Infinity,
): { placed: ProposedItem[]; unplaced: number } {
): { placed: PlacedItem[]; unplaced: number } {
const rosterIds = new Set(agents.map((a) => a.userId));
/// ⭐ 水位 = 该客服**分配后**手上有多少 = 在手 + 本批已给。三趟共用同一本账 ——
/// 分开算的话后面几趟会把已经灌高的人当成"还很空",专属大户被再灌一轮。
......@@ -477,7 +505,7 @@ export function placeAgents(
const waterline =
agents.length > 0 ? Math.max(1, Math.ceil((inHandSum + chosen.length) / agents.length)) : 0;
const placed: ProposedItem[] = [];
const placed: PlacedItem[] = [];
const free: typeof chosen = []; // 无主 / 专属已离岗
const held: typeof chosen = []; // 有主,但专属这轮的份额已满
let unplaced = 0;
......
......@@ -51,6 +51,41 @@ function mergeOverrides(
return out;
}
/**
* 时效选择 —— 1~7 天下拉。
*
* ⭐ 用原生 `<select>`:卡片跑在 400px 的助手窗里,7 个档位铺成按钮会换行占两行,
* 而这是个"设一次就不动"的控件,不值得占那么多地方。
* ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
*/
function DaySelect({
value,
disabled,
onChange,
}: {
value: number;
disabled?: boolean;
onChange: (days: number) => void;
}) {
return (
<select
value={value}
disabled={disabled}
onChange={(e) => onChange(Number(e.target.value))}
className={cn(
'rounded border border-slate-200 bg-white px-1.5 py-0.5 text-[11px] text-slate-700 outline-none',
'focus:border-brand-400 disabled:opacity-60',
)}
>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
);
}
export function AssignmentConfirmSheet({
requestId,
sheet,
......@@ -194,12 +229,11 @@ export function AssignmentConfirmSheet({
<span className="ml-1 text-[10.5px] text-slate-400">(在手 {a.inHandBefore})</span>
)}
</span>
{/* ⭐ T15:溢出转铺平必须**显式可见** —— 主管要看见发生了什么,而不是只看到结果 */}
{a.spread > 0 && (
<span className="flex-none rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600">
铺平 {a.spread}
</span>
)}
{/* ⛔ 这里原来有个「铺平 N」标 —— 撤掉(2026-08-03 走查:主管看不懂)。
"铺平"是内部算法词,不是业务语言;而且它要表达的事(这几条不是他的老客户)
在展开的患者列表里用「专属」标已经说清楚了 —— 有标的是他的人,没标的不是。
⚠️ 数据没丢:assignStrategy 仍逐条落库,T20 反推照常(T15 要的是"可见",
不是"必须以这个词出现在汇总行")。 */}
{/* ⭐ 精调过的人必须一眼看得出 —— 一条上个月的临时精调如果静默沿用,没人会发现 */}
{(a.overridden || expiryByAgent[a.userId] != null) && (
<span className="flex-none rounded bg-violet-50 px-1.5 py-0.5 text-[10px] text-violet-700 ring-1 ring-inset ring-violet-200">
......@@ -217,14 +251,10 @@ export function AssignmentConfirmSheet({
</span>
<span className="text-slate-300">·</span>
<span>时效</span>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => {
const eff = expiryByAgent[a.userId] ?? expiresInDays;
return (
<button
key={d}
type="button"
<DaySelect
value={expiryByAgent[a.userId] ?? expiresInDays}
disabled={done}
onClick={() =>
onChange={(d) =>
setExpiryByAgent((s) => {
const n = { ...s };
// 选回批次时效 = 取消精调(⛔ 不要留一条"恰好等于批次"的精调,
......@@ -234,18 +264,7 @@ export function AssignmentConfirmSheet({
return n;
})
}
className={cn(
'rounded border px-1.5 py-0.5 transition-colors',
eff === d
? 'border-brand-500 bg-brand-50 font-medium text-brand-700'
: 'border-slate-200 bg-white text-slate-500 hover:bg-slate-50',
done && 'opacity-60',
)}
>
{d}
</button>
);
})}
/>
{expiryByAgent[a.userId] != null && (
<span className="text-violet-600">单独设定</span>
)}
......@@ -253,12 +272,27 @@ export function AssignmentConfirmSheet({
<ul className="space-y-0.5 text-[11px] text-slate-500">
{mine.slice(0, 30).map((i) => (
<li key={i.planId} className="flex items-center gap-1.5">
<span className="truncate">#{i.patientId.slice(0, 8)}</span>
<span className="text-[10px] text-slate-400">
{ASSIGN_STRATEGY_META[i.assignStrategy as AssignStrategy]?.groupZh}
{/* ⭐ 姓名优先;宿主没给姓名才退回 id 前缀(⛔ 不显示完整 uuid,卡片只有 400px) */}
<span className="truncate text-slate-700">
{i.patientName ?? `#${i.patientId.slice(0, 8)}`}
</span>
{i.medicalRecordNumber && (
<span className="flex-none font-mono text-[10px] text-slate-400">
{i.medicalRecordNumber}
</span>
)}
{/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂(2026-08-03 走查)。
没有标的就是"不是他的老客户",反而更好读。 */}
{ASSIGN_STRATEGY_META[i.assignStrategy as AssignStrategy]?.isSpread ===
false && (
<span className="flex-none rounded bg-brand-50 px-1 text-[10px] text-brand-700">
专属
</span>
)}
{i.selectionMode === 'explore' && (
<span className="rounded bg-violet-100 px-1 text-[10px] text-violet-600">探索</span>
<span className="flex-none rounded bg-violet-100 px-1 text-[10px] text-violet-600">
探索
</span>
)}
</li>
))}
......@@ -288,23 +322,9 @@ export function AssignmentConfirmSheet({
<div className="space-y-1.5 border-t border-slate-100 px-3 py-2">
<div className="flex items-center gap-2">
<span className="flex-none text-slate-500">时效</span>
{ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
<button
key={d}
type="button"
disabled={done}
onClick={() => setExpiresInDays(d)}
className={cn(
'rounded border px-2 py-0.5 text-[11px] transition-colors',
expiresInDays === d
? 'border-brand-500 bg-brand-50 font-medium text-brand-700'
: 'border-slate-200 text-slate-500 hover:bg-slate-50',
done && 'opacity-60',
)}
>
{d}
</button>
))}
{/* ⭐ 1-7 天下拉,不是 3/5/7 三档 —— 档位是我们拍的,主管对自己团队的节奏有判断 */}
<DaySelect value={expiresInDays} disabled={done} onChange={setExpiresInDays} />
<span className="text-[10px] text-slate-400">每条召回计划各自记这个到期</span>
{/* ⭐ T14:这个初值哪来的必须当场说清 —— 沿用上次 / 首次默认,信任度不同 */}
<span className="text-[10px] text-slate-400">
{sheet.basis === 'inherited' && sheet.basisFrom
......
......@@ -124,6 +124,14 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
⚠️ 改派**不是"抢客户"**`spread_overflow` 的语义是「关系还在,只是这轮没轮到」。
⛔ 助手不许说成"系统重新分配了归属"。
⚠️ **但「铺平」这个词不进主管界面**(2026-08-03 走查:主管看不懂)。
它是内部算法词。确认单上只标「**专属**」,没标的自然就是"不是他的老客户" ——
同一个信息,用主管的语言表达。⛔ 数据不受影响:`assignStrategy` 仍逐条落库,T20 反推照常。
(T15 要求的是"溢出转铺平必须可见",不是"必须以这三个字出现在汇总行"。)
**确认单的患者明细必须给姓名 + 病历号,⛔ 不许只给 id 前缀。**
主管展开客服看的就是"这 20 个人是谁",给一串 `#912e5dae` 等于让他对着十六进制猜(T14)。
> **弯路一**:曾把「客服不能接别人的专属患者」做成**硬约束**(给不下就 unplaced)。
> 结果在上面那份数据下,「满」和「平」不可能同时成立 ——
> 严格齐平的上限是每人 9 条、一批只有 153 人,而那 755 个患者要几十批才轮得完。
......@@ -137,7 +145,12 @@ v1 **轻量**:不核销、不接宿主福利数据,福利就是**话术勾
| | 改什么 | 会不会换人群 | 入口 |
|---|---|---|---|
| `maxThisBatch` | **这一批**最多给他几条(0 = 这轮不给) | 会(少的那部分由别人接走,总数不变;但名单变了) | 回对话说一句,助手重出确认单 |
| `expiresInDays` | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改 |
| `expiresInDays` | 他名下每条任务的到期 | 不会 | 卡片上展开那一行直接改(1-7 天下拉) |
⚠️ 时效**落到每一条召回计划**`followup_plans.assignment_expires_at` 逐行写),
不是只在批次头上挂一个 —— 所以按客服精调、乃至将来按单条精调,都不需要动表结构。
⚠️ 卡片上给**全部 1-7 天**而不是 3/5/7 三档:档位是我们拍的,
而主管对自己团队的节奏有判断(「明天就要」= 1 天)。⛔ 别用三个拍的档位限制他。
`maxThisBatch` **不是"容量"** —— 它是一次性名额,不是这个人的上限。
精调与基数一样会被沿用,所以卡片上必须标「精调」并在 basisNote 里点名 ——
......
......@@ -108,8 +108,15 @@ export const BATCH_SIZE_PER_AGENT_FIRST = 20;
/// **首次**分配的时效起点(之后沿用上一次)。同批次人数,是基数不是常量。
export const ASSIGNMENT_EXPIRES_DAYS_DEFAULT = 3;
/// 卡片上给的时效档位(时效不改人群 → 卡片可直接改;改人数会换人群 → 回对话让助手重出单)
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [3, 5, 7];
/**
* 卡片上时效可选的天数。**给全部 1-7 天**,不是 3/5/7 三档。
*
* ⭐ 档位是我们拍的,而主管对自己团队的节奏有判断(「明天就要结果」= 1 天;「这周内」= 5 天)——
* ⛔ 别用三个我们拍的档位去限制他。
* ⚠️ 到 7 为止不是业务上限:契约允许 90 天,真要更长在对话里说个数,助手照传。
* ⚠️ 时效**不改人群**,所以留在卡片上;改人数会换一批人,那必须回对话重出单。
*/
export const ASSIGNMENT_EXPIRES_DAYS_PRESETS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
export const AgentInfoSchema = z.object({
userId: z.string(),
......@@ -264,6 +271,14 @@ export type AssignmentDetailResponse = z.infer<typeof AssignmentDetailResponseSc
export const ProposedItemSchema = z.object({
planId: z.string(),
patientId: z.string(),
/**
* ⭐ 患者姓名 —— **必须带**。主管展开客服看的是"这 20 个人是谁",
* 光给 `#912e5dae` 这种 id 前缀等于让他对着一串十六进制猜(T14:证据要看得懂)。
* ⚠️ 可能为 null(宿主没给姓名),前端退回 id 前缀。
*/
patientName: z.string().nullable(),
/// 病历号(宿主不一定给:jvs-dw 91% / friday 63%),缺就不占位
medicalRecordNumber: z.string().nullable(),
assigneeUserId: z.string(),
assignStrategy: AssignStrategySchema,
selectionMode: z.enum(['rank', 'explore']),
......
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