Commit 59378564 by luoqi

fix(团队负载): 「分到 N 条一条都没动」把主管自己撤回去的也算进去了

实测:姜茜「这 7 天分到 53 条,一条都还没动」,拆开是
  20(8/4 17:31 那批,**已撤销**)+ 20 + 13。

头一个 20 是主管自己在撤销窗口里收回去的,她根本没机会碰。
摆在「分到…一条都没动」里,等于拿主管自己的操作去指责客服。

⇒ assign 统计排掉落了 `auto_release/revoked` 的那些。

️ 判据是**逐条 + 同批次**, 不是"整批 status=revoked 就全扣":
   · 撤销刻意不收已被客服打开过(view 事件)的单 —— 那些仍在他手上、仍该算他的
   · 同一条单可能先在 A 批被撤、后在 B 批正常分给他,B 批那次不能跟着被扣
️ 到期回池(assignment_expired)**照样算** —— 单子在他手上放到过期,
   正是「没动」要表达的东西, 别一起排掉。

测试机实测(同一条 SQL 直查):李银兰 62→42、张悦 61→41、康慧捧 58→40、
刘艳阳 55→35,各减掉自己在撤销批里的那一份;不在该批的(钱俏虹 397)不动。

回归锁 SQL 文本 —— 口径写在原生查询里,$queryRaw 一 mock 就是"喂什么返什么",
行为测试证明不了任何事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 16e2134b
Pipeline #3558 failed in 0 seconds
...@@ -960,16 +960,37 @@ export class PlanAssignmentService { ...@@ -960,16 +960,37 @@ export class PlanAssignmentService {
* 窗口内「分到了多少」—— 退回率那个"没动"要用它减。 * 窗口内「分到了多少」—— 退回率那个"没动"要用它减。
* ⚠️ 走账本的 assign 事件(历史事实),⛔ 不数 followup_plans.assignment_id: * ⚠️ 走账本的 assign 事件(历史事实),⛔ 不数 followup_plans.assignment_id:
* 那一列会被下一次分配覆盖。 * 那一列会被下一次分配覆盖。
*
* 🔴 **被主管撤回去的不算**(2026-08-10 产品发现)。
*
* ── 实测 ────────────────────────────────────────────────────
* 姜茜「这 7 天分到 53 条,一条都还没动」,拆开是 20(**已撤销那批**)+ 20 + 13。
* 那 20 条是主管自己在撤销窗口里收回去的,她根本没机会碰 ——
* 摆在「分到…一条都没动」里等于**拿主管自己的操作去指责客服**。
*
* ⚠️ 判据是**逐条**的,⛔ 不是"整批 status=revoked 就全扣":
* 撤销刻意**不收**已经被客服打开过(view 事件)的单 —— 那些仍在他手上、仍该算他的。
* 所以只排掉真的落了 `auto_release/revoked` 的那些,且要求**同一批次**
* (同一条单可能先在 A 批被撤、后在 B 批正常分给他,B 批那次不能跟着被扣掉)。
* ⚠️ 到期回池(`assignment_expired`)**照样算** —— 那是单子在他手上放到过期,
* 正是「没动」要表达的东西,⛔ 别一起排掉。
*/ */
const assigned = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql` const assigned = await this.prisma.$queryRaw<Array<{ uid: string; n: bigint }>>(Prisma.sql`
SELECT assignee_user_id AS uid, count(DISTINCT plan_id) AS n SELECT e.assignee_user_id AS uid, count(DISTINCT e.plan_id) AS n
FROM plan_event_logs FROM plan_event_logs e
WHERE host_id = ${scope.hostId}::uuid WHERE e.host_id = ${scope.hostId}::uuid
AND tenant_id = ${scope.tenantId} AND e.tenant_id = ${scope.tenantId}
AND event = ${PlanEventType.ASSIGN} AND e.event = ${PlanEventType.ASSIGN}
AND created_at >= ${since} AND e.created_at >= ${since}
AND assignee_user_id IN (${Prisma.join(ids)}) AND e.assignee_user_id IN (${Prisma.join(ids)})
GROUP BY assignee_user_id`); AND NOT EXISTS (
SELECT 1 FROM plan_event_logs r
WHERE r.plan_id = e.plan_id
AND r.assignment_id IS NOT DISTINCT FROM e.assignment_id
AND r.event = ${PlanEventType.AUTO_RELEASE}
AND r.reason = ${PlanEventReason.REVOKED}
)
GROUP BY e.assignee_user_id`);
const num = (rows: Array<{ uid: string; n: bigint }>) => const num = (rows: Array<{ uid: string; n: bigint }>) =>
new Map(rows.map((r) => [r.uid, Number(r.n)])); new Map(rows.map((r) => [r.uid, Number(r.n)]));
......
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { PlanEventReason, PlanEventType, ReleaseReason } from '@pac/types'; import { PlanEventReason, PlanEventType, ReleaseReason } from '@pac/types';
import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service'; import { PlanAssignmentService } from '../src/modules/plan/plan-assignment.service';
...@@ -230,3 +232,41 @@ describe('批次详情 —— 出池的人必须留在批次里', () => { ...@@ -230,3 +232,41 @@ describe('批次详情 —— 出池的人必须留在批次里', () => {
expect(d.progress.note).toContain('先看年龄'); expect(d.progress.note).toContain('先看年龄');
}); });
}); });
/**
* 「团队现在什么状态」里那句**分到多少 / 一条都没动**的口径(2026-08-10 产品发现)。
*
* ── 实测 ────────────────────────────────────────────────────────
* 姜茜「这 7 天分到 53 条,一条都还没动」。拆开是 20 + 20 + 13,
* 其中头一个 20 来自一批**已撤销**的分配 —— 主管自己在撤销窗口里收回去的,
* 她根本没机会碰。摆在「分到…一条都没动」里,等于**拿主管自己的操作去指责客服**。
*
* ⚠️ 这条只能锁 SQL 文本:口径写在原生查询里,而 `$queryRaw` 一 mock 就是"喂什么返什么",
* 行为测试证明不了任何事(与 mcp-clinic-scope 那几条同一类)。
*/
describe('团队负载 —— 「分到」要排掉被主管撤回去的', () => {
const SVC = readFileSync(
join(__dirname, '../src/modules/plan/plan-assignment.service.ts'),
'utf8',
);
// 只看 workload() 里那段 assign 统计,⛔ 别误伤 detail 那边同名的查询
const ASSIGNED_SQL =
/窗口内「分到了多少」[\s\S]*?GROUP BY e\.assignee_user_id`\)/.exec(SVC)?.[0] ?? '';
test('⭐⭐ 撤销回收的那些必须排掉', () => {
expect(ASSIGNED_SQL).toMatch(/NOT EXISTS/);
expect(ASSIGNED_SQL).toMatch(/PlanEventReason\.REVOKED/);
});
test('🔴 判据是**逐条 + 同批次**,⛔ 不是"整批 status=revoked 就全扣"', () => {
// 撤销刻意不收已被客服打开过的单 —— 那些仍在他手上、仍该算他的
expect(ASSIGNED_SQL).toMatch(/r\.plan_id = e\.plan_id/);
// 同一条单可能先在 A 批被撤、后在 B 批正常分给他,B 批那次不能跟着被扣
expect(ASSIGNED_SQL).toMatch(/r\.assignment_id IS NOT DISTINCT FROM e\.assignment_id/);
expect(ASSIGNED_SQL).not.toMatch(/plan_assignments/); // ⛔ 别去 join 批次状态
});
test('⛔ 到期回池照样算 —— 那正是「没动」要表达的东西', () => {
expect(ASSIGNED_SQL).not.toMatch(/ASSIGNMENT_EXPIRED/);
});
});
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