diff --git a/docs/superpowers/plans/2026-10-15-服务机构端-C4质控闸门.md b/docs/superpowers/plans/2026-10-15-服务机构端-C4质控闸门.md new file mode 100644 index 0000000..826f40c --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-服务机构端-C4质控闸门.md @@ -0,0 +1,218 @@ +# 服务机构端 C4 质控闸门 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 C3 骨架上落地「质控闸门」:qc 表 + 补正表、全量质控(定时任务)、放行/退回(4001 自审自放行)、五类主动推送聚合、异常联动改计划、补正、admin 质控中心页。 + +**Architecture:** 纯新增。后端新增 qc 模块(依赖 record/abnormal/plan/order/elder),前端质控中心页。复用 C3 的 record(submitted)、abnormal、C2 的 plan.change、C1 的 elder。 + +**Tech Stack:** NestJS 12 + Prisma 6 + MySQL 8(utf8mb4_general_ci)、Vue 3 + Vite + Pinia + UnoCSS。 + +**Spec:** `openspec/changes/svc-org-c4-qc/`(proposal + specs + design + tasks);设计背景 `docs/superpowers/specs/2026-10-15-服务机构端-C4质控闸门-design.md`。 + +## Global Constraints + +- 机构=租户:业务表带 `tenant_code`、UUID v7、`@map` snake_case;新表 `utf8mb4_general_ci`。 +- 表命名:`qc`(无前缀,质控记录)、`ho_record_correction`(补正属留痕域 ho_)。 +- 不动脚手架文件;业务代码落 `src/qc/`、`src/pages/ltc/qc/`。 +- 放行前自检按序:四要素(1004) → 必拍(3001) → 幂等 → 自审自放行(4001)。 +- 五类推送 = pending-list 跨表聚合(非 qc 落库);每类带消除条件。 +- 补正 = 新增说明不改原记录;不可补正签到时间/定位/照片;≥3 次进质控重点。 +- 权限码 qc.*;错误码域 46;规则号 1004/3001/4001 写 message。 +- ⚠️ 迁移链损坏:`migrate dev` 不可用,用 `migrate diff --from-schema-datasource` + 手工迁移目录 + `migrate deploy`。 + +## Review Focus + +1. **自审自放行**:`record.created_by === ctx.userId`(放行人=录入人)→ 4001 拒绝(不因角色并集放宽)。 +2. **放行自检顺序**:四要素 → 必拍 → 幂等 → 自审,任一不过不写 pass_time。 +3. **补正不改原值**:correct 只写 correction + 生成 recheck qc,不更新 record 的签到时间/定位/照片/项目。 +4. **五类推送消除条件**:每类处理完后从 pending-list 消失(否则列表只长不消)。 +5. **定时任务跨租户**:@Cron 无请求上下文,用 `prisma.raw`(无租户注入)跨租户扫描,否则只扫到单租户。 + +--- + +### Task 1: Prisma 模型 + 迁移 + +**Files:** +- Modify: `sources/ec-nest/prisma/schema.prisma` +- Modify: `sources/ec-nest/src/prisma/prisma.service.ts` + +**Interfaces:** +- Produces: `Qc`/`RecordCorrection` 模型。 + +- [ ] **Step 1: 追加 2 个模型** + +```prisma +/// 质控记录 +model Qc { + id String @id @default(uuid(7)) @db.VarChar(36) + tenantCode String @map("tenant_code") @db.VarChar(64) + recordId String @map("record_id") @db.VarChar(36) + qcType String @default("normal") @map("qc_type") @db.VarChar(20) + qcResult String @default("pending") @map("qc_result") @db.VarChar(20) + qcNote String? @map("qc_note") @db.VarChar(200) + qcStaffId String? @map("qc_staff_id") @db.VarChar(36) + qcTime DateTime? @map("qc_time") + passTime DateTime? @map("pass_time") + firstQcId String? @map("first_qc_id") @db.VarChar(36) + createdAt DateTime @default(now()) @map("created_at") + createdBy String? @map("created_by") @db.VarChar(36) + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@index([tenantCode, recordId]) + @@index([tenantCode, qcResult]) + @@map("qc") +} + +/// 补正说明(留痕域 ho_) +model RecordCorrection { + id String @id @default(uuid(7)) @db.VarChar(36) + tenantCode String @map("tenant_code") @db.VarChar(64) + recordId String @map("record_id") @db.VarChar(36) + qcId String? @map("qc_id") @db.VarChar(36) + correctionNote String @map("correction_note") @db.VarChar(200) + correctedBy String @map("corrected_by") @db.VarChar(36) + correctedAt DateTime @default(now()) @map("corrected_at") + + @@index([recordId]) + @@map("ho_record_correction") +} +``` + +- [ ] **Step 2: SOFT_DELETE 加 `Qc`**(prisma.service.ts 第 8 行集合加 `'Qc'`;`RecordCorrection` 子表不入) + +- [ ] **Step 3: 迁移(utf8mb4_general_ci)** + +Run: `cd sources/ec-nest && pnpm exec prisma migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script > /tmp/c4.sql`;手工落盘迁移目录 `20261015xxxxxx_c4_qc/migration.sql`,`utf8mb4_unicode_ci` 全改 `utf8mb4_general_ci`;`pnpm exec prisma migrate deploy && pnpm exec prisma generate`。 + +- [ ] **Step 4: Commit** + +```bash +git -C sources/ec-nest add prisma/schema.prisma prisma/migrations src/prisma/prisma.service.ts +git -C sources/ec-nest commit -m "feat(c4): qc + record_correction 数据模型" +``` + +--- + +### Task 2: 权限码 + 错误码 + APIS + +**Files:** `permissions.ts`、`error-codes.ts`、`tenant-template.ts`(APIS) + +- [ ] **Step 1:** permissions.ts 追加: + +```ts + qc: { + pendingList: 'qc:pending-list', detail: 'qc:detail', pass: 'qc:pass', return: 'qc:return', + exceptionLink: 'qc:exception-link', correct: 'qc:correct', batchPass: 'qc:batch-pass', + }, +``` + +- [ ] **Step 2:** error-codes.ts 补域 46: + +```ts + // 服务机构端·质控(46) + QC_NOT_FOUND: 46001, + QC_ALREADY_HANDLED: 46002, + QC_SELF_REVIEW: 46003, // 自审自放行(4001) + QC_ELEMENT_MISSING: 46004, // 缺要素(1004) + QC_NOTE_REQUIRED: 46005, + QC_BATCH_UNSAFE: 46006, + QC_RECORD_NOT_SUBMITTED: 46007, +``` + +- [ ] **Step 3:** APIS 登记(parent 'qc'): + +```ts + { code: Permissions.qc.pendingList, name: '待质控列表', method: 'GET', path: '/api/v1/qc/pending/list', parent: 'qc' }, + { code: Permissions.qc.detail, name: '质控详情', method: 'GET', path: '/api/v1/qc/:id', parent: 'qc' }, + { code: Permissions.qc.pass, name: '放行', method: 'POST', path: '/api/v1/qc/:id/pass', parent: 'qc' }, + { code: Permissions.qc.return, name: '退回', method: 'POST', path: '/api/v1/qc/:id/return', parent: 'qc' }, + { code: Permissions.qc.exceptionLink, name: '异常联动改计划', method: 'POST', path: '/api/v1/qc/exception/link', parent: 'qc' }, + { code: Permissions.qc.correct, name: '补正', method: 'POST', path: '/api/v1/qc/correct', parent: 'qc' }, + { code: Permissions.qc.batchPass, name: '批量放行', method: 'POST', path: '/api/v1/qc/batch-pass', parent: 'qc' }, +``` + +- [ ] **Step 4:** tsc + Commit + +```bash +git -C sources/ec-nest add src/common/permissions.ts src/common/error-codes.ts src/common/tenant-template.ts +git -C sources/ec-nest commit -m "feat(c4): qc 权限码/错误码/APIS" +``` + +--- + +### Task 3: qc 模块 + +**Files:** 新建 `src/qc/{qc.module,qc.controller,qc.service,dto,qc.service.spec}.ts`;注册 module(本任务直接改 app.module.ts,本 change 单任务串行)。 + +**Interfaces:** +- Consumes: `PrismaService`(client + raw)、`getRequestContext()`、`Permissions.qc.*`、`ErrorCodes.QC_*`、record/abnormal/plan/order/elder 模型。 +- Produces: `QcService`(generatePending/pendingList/detail/pass/return/exceptionLink/correct/batchPass + @Cron 定时任务)。 + +- [ ] **Step 1: 写失败测试**(qc.service.spec.ts):定时任务生成 pending qc;放行自检四步(1004/3001/幂等/4001);退回 qc_note 必填;补正不改原值 + 生成 recheck;批量放行排除异常记录;五类推送消除条件。 + +- [ ] **Step 2: 跑测试确认失败** + +- [ ] **Step 3: 实现 qc.service** + +关键逻辑: +- **定时任务**:`@Cron('0 * * * *')` `generatePendingQc()`——用 `prisma.raw` 跨租户扫描 `record_status='submitted'` 且无 qc 的 record → 为每条 create qc(tenantCode 取 record.tenantCode、qcType='normal'、qcResult='pending')。 +- **pendingList(q)**:聚合五类(跨表查询): + 1. 异常待处理:`recordAbnormal.count({ handleStatus:'open' })`(消除条件=已处理)。 + 2. 逾期未做:order `timeSlotEnd < now-24h` 且无 record 且 orderStatus 非 cancelled/not_met/done。 + 3. 住院仍有派单:elder `treatStatus='hosp_pause'` 且有 orderStatus ∈ {to_assign, assigned, serving} 的 order。 + 4. 计划未制定:elder `benefit_start ≤ now-5工作日` 且无 plan_status=active 的 plan。 + 5. 留痕待放行:`qc.count({ qcResult:'pending' })` + 待放行队列(pending qc 列表,含 record 摘要 + 已等待时长)。 +- **pass(id)**:`requireQc` → `qcResult !== 'pending'` → QC_ALREADY_HANDLED(幂等);`record.findUnique` → ① 四要素(checkin_time/checkout_time/duration_min/assignee_id 任一缺 → QC_ELEMENT_MISSING 1004)→ ② 必拍已拍(遍历 record_item photoRequired && done && !isRefusedPhoto 无 photo → 3001,复用 C3 口径)→ ④ `record.createdBy === ctx.userId` → QC_SELF_REVIEW 4001;通过 → qc.update({ qcResult:'passed', qcStaffId, qcTime:now, passTime:now })。 +- **return(id, dto)**:`dto.qcNote` 空 → QC_NOTE_REQUIRED;qc.update({ qcResult:'returned', qcNote, qcStaffId, qcTime:now })。 +- **detail(id)**:qc + 关联 record(全文/项目/照片/异常/定位)+ 补正历史(correction 列表)。 +- **exceptionLink(dto)**:`abnormal.findUnique` → 复用 `planService.change`(传 elderId + changeFromPlanId + items 含加项)生成新 plan → `abnormal.update({ linkPlanChange:true })` → 返回新 plan。 +- **correct(dto)**:`record.findUnique`(record_status 须 submitted)→ `correction.create({ recordId, qcId, correctionNote, correctedBy: ctx.userId })` → 生成 recheck qc:`qc.findFirst({ recordId, qcResult:'returned', orderBy 最新 })` → `qc.create({ recordId, qcType:'recheck', qcResult:'pending', firstQcId: 首次qcId })`。(不改 record 任何证据字段。) +- **batchPass(dto)**:对每条 record 校验「有 abnormal 关联 / 有 exec_result∈{undo_not_done,undo_refused} / 有 extra_flag / duration<5min / 有 correction」→ 命中任一则 QC_BATCH_UNSAFE;否则逐条 pass。 + +- [ ] **Step 4: 实现 controller + dto + module**(`@Controller('qc')`;写操作 `@AuditLog`;`@Cron` 在 service;注册 module 到 app.module.ts)。**路由序**:静态路径(`pending/list`、`correct`、`batch-pass`、`exception/link`)必须声明在 `GET /:id` 之前(否则 `:id` 吞掉静态段)。 + +- [ ] **Step 5: 测试通过 + tsc + Commit** + +```bash +git -C sources/ec-nest add src/qc src/app.module.ts +git -C sources/ec-nest commit -m "feat(c4): 质控模块(全量质控/放行退回/五类推送/异常联动/补正)" +``` + +--- + +### Task 4: 前端 + +**Files:** `src/api/qc.ts`;`src/pages/ltc/qc/index.vue`(替换占位)。 + +- [ ] **Step 1: api 封装**(`http` + 类型对齐后端 DTO;pendingList/detail/pass/return/exceptionLink/correct/batchPass)。 + +- [ ] **Step 2: 质控中心页**(`defineOptions({name:'qc'})`):三区: + - 待我处理(五类推送聚合:每类计数 + 点击跳对应队列)。 + - 待放行队列(record 列表 → 逐条放行/退回,退回选原因下拉;批量放行按钮,后端拦不可批量项)。 + - 异常处理台(abnormal 列表 → 处理/转派/关闭 + 处理意见 + 「联动改计划」入口)。 + +- [ ] **Step 3: type-check + Commit** + +```bash +git -C sources/ec-admin add src/api/qc.ts src/pages/ltc/qc +git -C sources/ec-admin commit -m "feat(c4): 质控中心页" +``` + +--- + +### Task 5: 端到端验证 + +- [ ] **Step 1:** 起后端 + 登录,冒烟:submitted 留痕 → 定时任务(或手动触发)生成 qc → 放行(4001 自审拒/通过)→ 退回 → 补正 → recheck → 放行。 +- [ ] **Step 2:** 五类推送:异常/逾期/住院/计划/待放行各出现与消除。 +- [ ] **Step 3:** 异常联动改计划生成新 plan + link_plan_change;审计落库。停后端清理。 + +--- + +## Self-Review Notes + +- Spec 覆盖:data-model 2 实体→T1;api-contract→T3;backend/qc→T3;frontend/ad/qc→T4。 +- Review Focus 5 条均有测试(T3)。 +- 类型一致:`Qc`/`RecordCorrection` 模型名、`Permissions.qc.*`、`ErrorCodes.QC_*` 在 T1/T2/T3 一致。 +- 定时任务用 `prisma.raw` 跨租户(Review Focus #5)。