Commit 83f62942 by luoqi

fix(plan): 客服姓名改从回访表取,不再读 mock 花名册

产品指出:姓名本来就在 `patient_return_visits.task_director_name` 里。
原实现去读了 `data/jvs-dw/users.json` —— 那份是 mock 登录用的**派生物、只在 dev 存在**,
拿它当生产功能的姓名源等于让线上依赖一个开发态文件。而它本身就是从这张回访表派生的,
兜不住任何回访表查不到的人。

## 但不能直接取:id→姓名是**时间性的**

本地实测 `task_director_id` 有 101 个、`task_director_name` 100% 有值,
可**同一个 id 会对到多个姓名**:

```
4090 → 李宇琦 601 条  2021-06 ~ 2024-12   ← 现任
       赵惠    32 条  2021-01 ~ 2021-07
       杨柳     4 条  / 刘博学 1 条
```

DW 侧这个 id 被换过人。取错会让界面写着"赵惠"、实际派给了"李宇琦" ——
比不显示姓名更糟。按**最近一次**取之后干净了:101 个 id → 97 个名(剩下是真重名)。

⇒ 用 `DISTINCT ON` 而不是 groupBy:Prisma 的 groupBy 表达不了
「取 max(时间) 那一行的另一列」。顺带把原来的两次查询(名册 + 姓名)合成一次。

## 名册外被点名的人也要有名字

主管点名的客服可能只是**本诊所近 12 月**没回访(在别的诊所、或更早),
回访表里查得到。不兜这层,他点的人在确认单上就是一片空白。
加一次跨诊所、不限时间窗的兜底查询(只在确有人未解析时才发)。

实测:`4090` → 李宇琦(inRoster=false 如实标着);真不存在的 `99999` 仍是 null,不编。

## 批次跟踪的两处姓名也接上同一个源

`AssignmentBrief.createdByName` 与 `agentStats[].name` 原来硬编码 null(留了 P4.3 的 TODO),
现在同源解析。️ 这里是**读时解析不是快照**:批次跟踪看的是"这个 id 现在是谁",
换人极罕见且不会发生在一个批次的几天窗口内,不为它再立一列。

## 顺带修一条把 URL 写错的注释

端点实际是 `GET /pac/v1/plans/assignments/agents`(控制器前缀 `plans/assignments`),
注释里写成了 `/plans/agents` —— 那个路径归 PlanController,会被它的裸 `@Get(':id')`
当成 planId='agents',报出来是 Prisma 的 uuid 解析错,看不出是路由问题。实测踩过。

877 单测通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ad8475f7
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AGENT_CAPACITY_RANGE, type AgentInfo, type ListAgentsResponse } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { loadMockUsers } from '../auth/mock-users';
/**
* AgentRosterService —— 「这个诊所现在有哪些客服、各自手上压了多少」。
......@@ -41,22 +41,37 @@ export class AgentRosterService {
since.setMonth(since.getMonth() - months);
// ── 名册:该诊所近 N 月有回访记录的人 ──────────────────────
const roster = await this.prisma.patientReturnVisit.groupBy({
by: ['taskDirectorId'],
where: {
hostId: scope.hostId,
tenantId: scope.tenantId,
clinicId,
taskDirectorId: { not: null },
// ⛔ 不是 taskDate,见类注释
sourceCreatedAt: { gte: since },
},
_count: { _all: true },
_max: { sourceCreatedAt: true },
});
// ⭐ **姓名直接来自回访表**(`task_director_name`,摄入的生产数据,本地实测 100% 有值)——
// 不读 mock 登录用的 users.json:那份是**派生物**、只在 dev 存在,拿它当姓名源
// 等于让生产功能依赖一个开发态文件。
//
// ⚠️ 但 id→姓名是**时间性的**,必须取"最近一次"那个名:
// 本地实测 task_director_id=4090 在 2021 上半年是赵惠/杨柳/刘博学,
// 2021-06 起一直是李宇琦(601 条)—— DW 侧这个 id 被换过人。
// 取错会让主管把单派给"李宇琦",而界面上写着"赵惠" —— 比不显示姓名更糟。
// 按最近一次取之后,101 个 id → 97 个名(剩下的是真重名,正常)。
// ⇒ 用 DISTINCT ON,不是 groupBy:Prisma 的 groupBy 表达不了
// "取 max(时间) 那一行的另一列"。
const roster = await this.prisma.$queryRaw<
Array<{ id: string; name: string | null; visits: number; lastAt: Date }>
>(Prisma.sql`
SELECT DISTINCT ON (rv.task_director_id)
rv.task_director_id AS "id",
rv.task_director_name AS "name",
COUNT(*) OVER (PARTITION BY rv.task_director_id)::int AS "visits",
MAX(rv.source_created_at) OVER (PARTITION BY rv.task_director_id) AS "lastAt"
FROM patient_return_visits rv
WHERE rv.host_id = ${scope.hostId}::uuid
AND rv.tenant_id = ${scope.tenantId}
AND rv.clinic_id = ${clinicId}
AND rv.task_director_id IS NOT NULL
-- ⛔ 不是 task_date,见类注释
AND rv.source_created_at >= ${since}
ORDER BY rv.task_director_id, rv.source_created_at DESC
`);
const ids = new Set<string>();
for (const r of roster) if (r.taskDirectorId) ids.add(r.taskDirectorId);
for (const r of roster) ids.add(r.id);
for (const id of opts.extraUserIds ?? []) ids.add(id);
if (ids.size === 0) return { clinicId, rosterMonths: months, agents: [], rosterNote: emptyNote(months) };
......@@ -96,19 +111,30 @@ export class AgentRosterService {
const inHandHere = new Map<string, number>();
for (const l of loadHere) if (l.assigneeUserId) inHandHere.set(l.assigneeUserId, l._count._all);
const rosterById = new Map(roster.filter((r) => r.taskDirectorId).map((r) => [r.taskDirectorId!, r]));
const nameById = mockNameIndex();
const rosterById = new Map(roster.map((r) => [r.id, r]));
// ⭐ 名册外被点名的人也要有名字:他们只是**本诊所近 N 月**没回访,
// 不代表回访表里查不到(可能在别的诊所、或更早的时间)。
// 不兜这一层,主管点名的人在确认单上就是一片空白 —— 那比不让他点更让人困惑。
// 只在确实有人没解析出来时才发这一次查询。
const unresolved = [...ids].filter((id) => !rosterById.has(id));
const fallbackNames = unresolved.length
? await this.namesAnywhere(scope, unresolved)
: new Map<string, string>();
const agents: AgentInfo[] = [...ids].map((userId) => {
const r = rosterById.get(userId);
const inHand = inHandAll.get(userId) ?? 0;
return {
userId,
name: nameById.get(userId) ?? null,
// ⚠️ 主管显式点名、但名册里没有的人(实测 11 人只做召回不做回访)拿不到姓名 —— 返回 null。
// ⛔ 别去 mock 花名册兜底:那份是从**同一张回访表**派生的,同样查不到,
// 只会给人"有兜底"的错觉。前端按 null 显示 id 前 8 位即可。
name: r?.name ?? fallbackNames.get(userId) ?? null,
inHand,
inHandThisClinic: inHandHere.get(userId) ?? 0,
recentVisits: r?._count._all ?? 0,
lastVisitAt: r?._max.sourceCreatedAt?.toISOString() ?? null,
recentVisits: r?.visits ?? 0,
lastVisitAt: r?.lastAt?.toISOString() ?? null,
/// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
inRoster: r != null,
// ⛔ **不返回 remaining**。容量「20-50」是一个**区间默认值**,不是这个人的真实上限;
......@@ -135,20 +161,30 @@ export class AgentRosterService {
`名册外的客服也可以指定。`,
};
}
/**
* 跨诊所、不限时间窗地查姓名 —— 只给「名册外被点名的人」兜底。
* ⚠️ 同样取**最近一次**(id→姓名是时间性的,见 list 里的长注释)。
*/
private async namesAnywhere(
scope: TenantScopeContext,
userIds: string[],
): Promise<Map<string, string>> {
const rows = await this.prisma.$queryRaw<Array<{ id: string; name: string | null }>>(Prisma.sql`
SELECT DISTINCT ON (rv.task_director_id)
rv.task_director_id AS "id", rv.task_director_name AS "name"
FROM patient_return_visits rv
WHERE rv.host_id = ${scope.hostId}::uuid
AND rv.tenant_id = ${scope.tenantId}
AND rv.task_director_id IN (${Prisma.join(userIds)})
ORDER BY rv.task_director_id, rv.source_created_at DESC
`);
const out = new Map<string, string>();
for (const r of rows) if (r.name) out.set(r.id, r.name);
return out;
}
}
function emptyNote(months: number): string {
return `该诊所近 ${months} 个月无回访记录,名册为空 —— 可能是新开诊所或回访数据未接入;请主管直接指定客服。`;
}
/**
* userId → 姓名。当前唯一来源是 mock 花名册(`data/<host>/users.json`,派生自回访表)。
* ⚠️ PAC **没有 users 表**,而前端 `dictionary.users` 只覆盖当前登录人 ——
* 所以姓名必须由**服务端**解析后随 payload 下发,不能指望前端自己翻。
* 将来接了真 users 表,只换这个函数。
*/
function mockNameIndex(): Map<string, string> {
const m = new Map<string, string>();
for (const u of loadMockUsers()) m.set(u.externalId, u.name);
return m;
}
......@@ -42,8 +42,11 @@ export class AssignmentController {
* 分配问「还能吃多少」、跟踪问「压了多少」,是同一份数据的两种读法;
* 拆开必然口径漂移(一个算 assigned、一个算 assigned+active),而漂了不报错。
*
* ⚠️ 路由声明必须在 `@Get(':id')` **之前** —— 否则 'agents' 会被当成 assignmentId
* (Nest 按声明顺序匹配)。同一个坑本文件的 module 注册顺序也踩过一次。
* 完整路径 **`GET /pac/v1/plans/assignments/agents`**(控制器前缀是 `plans/assignments`)。
* ⚠️ 别写成 `/plans/agents` —— 那个路径归 PlanController,会被它的裸 `@Get(':id')`
* 当成 planId='agents',报出来是 Prisma 的 uuid 解析错,看不出是路由问题(实测踩过)。
* ⚠️ 本方法必须声明在下面 `@Get(':id')` **之前**:同一控制器内 Nest 按声明顺序匹配,
* 放后面 'agents' 会被当成 assignmentId。
*/
@Get('agents')
@RequirePermission(Permission.PLAN_DISPATCH)
......
......@@ -350,12 +350,13 @@ export class PlanAssignmentService {
if (heads.length === 0) return { items: [] };
const stats = await this.statsByAssignment(heads.map((h) => h.id));
const names = await this.resolveNames(scope, heads.map((h) => h.createdBy));
return {
items: heads.map((h) => ({
id: h.id,
clinicId: h.clinicId,
createdBy: h.createdBy,
createdByName: null, // P4.3 补服务端姓名解析后填
createdByName: names.get(h.createdBy) ?? null,
criteria: (h.criteria ?? {}) as Record<string, unknown>,
benefitText: readBenefitText(h.attributes),
status: h.status,
......@@ -446,11 +447,14 @@ export class PlanAssignmentService {
})
: [];
const names = await this.resolveNames(scope, [head.createdBy, ...byAgent.keys()]);
for (const a of byAgent.values()) a.name = names.get(a.userId) ?? null;
return {
id: head.id,
clinicId: head.clinicId,
createdBy: head.createdBy,
createdByName: null,
createdByName: names.get(head.createdBy) ?? null,
criteria: (head.criteria ?? {}) as Record<string, unknown>,
benefitText: readBenefitText(head.attributes),
status: head.status,
......@@ -590,6 +594,40 @@ export class PlanAssignmentService {
return out;
}
/**
* userId → 姓名。**唯一来源是回访表 `task_director_name`**(摄入的生产数据)。
*
* ⚠️ 必须取**最近一次**的名:id→姓名是时间性的,本地实测同一个 id 在不同时期
* 对应不同的人(DW 侧换过人)。取到旧名会让界面写着"赵惠"、实际派给了"李宇琦",
* 比不显示姓名更糟。
*
* ⚠️ 为什么服务端解析而不是前端翻:PAC **没有 users 表**,前端的
* `dictionary.users` 只覆盖当前登录人 —— 详情页「承接人」现在显示的就是一串 uuid。
* 凡是返回 userId 的 payload 都该顺带把姓名带上。
*
* ⚠️ 这里是**读时解析**,不是快照:批次跟踪看的是"这个 id 现在是谁"。
* 换人极罕见且不会发生在一个批次的几天窗口内,故不为它再立一列。
*/
private async resolveNames(
scope: TenantScopeContext,
userIds: string[],
): Promise<Map<string, string>> {
const ids = [...new Set(userIds)].filter(Boolean);
if (ids.length === 0) return new Map();
const rows = await this.prisma.$queryRaw<Array<{ id: string; name: string | null }>>(Prisma.sql`
SELECT DISTINCT ON (rv.task_director_id)
rv.task_director_id AS "id", rv.task_director_name AS "name"
FROM patient_return_visits rv
WHERE rv.host_id = ${scope.hostId}::uuid
AND rv.tenant_id = ${scope.tenantId}
AND rv.task_director_id IN (${Prisma.join(ids)})
ORDER BY rv.task_director_id, rv.source_created_at DESC
`);
const out = new Map<string, string>();
for (const r of rows) if (r.name) out.set(r.id, r.name);
return out;
}
/** host 时区(缺省 Asia/Shanghai —— 现有宿主全在东八区,manifest 里也是必填项) */
private async hostTimezone(hostId: string): Promise<string> {
const host = await this.prisma.host.findUnique({
......
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