docs: 服务机构端 C2 派单工单实施计划

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 21:54:56 +08:00
parent bec32cf223
commit 1caea9cb4c

View File

@ -0,0 +1,315 @@
# 服务机构端 C2 派单工单 实施计划
> **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:** 在 C1 骨架上落地「派单工单」:服务计划(svc_plan/svc_plan_item)+ 八态工单(svc_order)+ 老人个人项目库(ltc_elder_svc)+ 后台排班派单页。
**Architecture:** 纯新增。后端新增 plan/order 模块(elder-svc 并入 plan),前端排班派单页 + 服务对象页扩展。复用 C1 的 elder 状态机、svc_org 目录、staff 护理员。
**Tech Stack:** NestJS 12 + Prisma 6 + MySQL 8(utf8mb4_general_ci)、Vue 3 + Vite + Pinia + UnoCSS。
**Spec:** `openspec/changes/svc-org-c2-plan-order/`(proposal + specs + design + tasks);设计背景 `docs/superpowers/specs/2026-10-14-服务机构端-C2派单工单-design.md`。
## Global Constraints
- 机构=租户:业务表带 `tenant_code`、UUID v7、`@map` snake_case、软删 `deleted_at`;新表 `utf8mb4_general_ci`(迁移 SQL 手改)。
- 表命名:`svc_plan`/`svc_plan_item`/`svc_order`(服务域)、`ltc_elder_svc`(老人域)。
- 不动脚手架文件;业务代码落 `src/plan/`、`src/order/`、`src/pages/ltc/schedule/`。
- 计划变更 MUST 新建 + `change_from_plan_id` 指向旧,不原地改。
- 派单 L1:`elder.treat_status ∈ {active, waiting_period}`;时间冲突 2001 硬拦。
- 权限码 plan.*/order.*(C0 预留);错误码域 44。
## Review Focus
1. 时间冲突边界:两工单恰好首尾相接(09:00-10:00 与 10:00-11:00)不算重叠;重叠判据用严格 `<`。
2. L1 例外方向:waiting_period 允许派单、其他非 active 态(如 benefit_expired/hosp_pause)拒绝。
3. 项数校验边界:附加项 ≤5;居家 care_mode 按周次/月时长、不按项数。
4. 计划变更留旧版:change 后旧计划 changed、新计划 active,历史工单 plan_id 不变。
5. 个人项目库越界:PUT 的 svc_id 非本机构 L4 → 拒绝。
---
### Task 1: Prisma 模型 + 迁移
**Files:**
- Modify: `sources/ec-nest/prisma/schema.prisma`
- Modify: `sources/ec-nest/src/prisma/prisma.service.ts`
**Interfaces:**
- Produces: `Plan`/`PlanItem`/`Order`/`ElderSvc` 模型。
- [ ] **Step 1: 追加 4 个模型**(schema.prisma 末尾)
```prisma
/// 服务计划头
model Plan {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
elderId String @map("elder_id") @db.VarChar(36)
planPeriodStart DateTime @map("plan_period_start") @db.Date
planPeriodEnd DateTime @map("plan_period_end") @db.Date
careLevel String @map("care_level") @db.VarChar(20)
careMode String @map("care_mode") @db.VarChar(20)
itemCount Int @default(0) @map("item_count")
itemCountRule String? @map("item_count_rule") @db.VarChar(100)
extraItemCount Int @default(0) @map("extra_item_count")
weekFreq Int? @map("week_freq")
monthHours Int? @map("month_hours")
planStatus String @default("draft") @map("plan_status") @db.VarChar(20)
confirmElder Boolean @default(false) @map("confirm_elder")
confirmGuardian Boolean @default(false) @map("confirm_guardian")
confirmTime DateTime? @map("confirm_time")
planBasis Json? @map("plan_basis")
changeFromPlanId String? @map("change_from_plan_id") @db.VarChar(36)
bizLine String @default("ltci") @map("biz_line") @db.VarChar(20)
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, elderId])
@@map("svc_plan")
}
/// 服务计划项
model PlanItem {
id String @id @default(uuid(7)) @db.VarChar(36)
planId String @map("plan_id") @db.VarChar(36)
svcId String @map("svc_id") @db.VarChar(36)
svcNameSnapshot String @map("svc_name_snapshot") @db.VarChar(100)
svcCodeSnapshot String? @map("svc_code_snapshot") @db.VarChar(20)
freqType String? @map("freq_type") @db.VarChar(20)
freqValue Int? @map("freq_value")
durationMin Int? @map("duration_min")
photoTier String @default("free") @map("photo_tier") @db.VarChar(10)
cautionNote String? @map("caution_note") @db.Text
isMedical Boolean @default(false) @map("is_medical")
needCert Boolean @default(false) @map("need_cert")
@@index([planId])
@@map("svc_plan_item")
}
/// 工单
model Order {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
elderId String @map("elder_id") @db.VarChar(36)
planId String @map("plan_id") @db.VarChar(36)
serviceDate DateTime @map("service_date") @db.Date
timeSlotStart DateTime @map("time_slot_start")
timeSlotEnd DateTime @map("time_slot_end")
assigneeId String? @map("assignee_id") @db.VarChar(36)
assignTime DateTime? @map("assign_time")
assignMode String? @map("assign_mode") @db.VarChar(20)
orderStatus String @default("to_assign") @map("order_status") @db.VarChar(20)
itemList Json @map("item_list")
needPhoto Boolean @default(false) @map("need_photo")
cancelReason String? @map("cancel_reason") @db.VarChar(200)
rescheduleLog Json? @map("reschedule_log")
qualificationCheck String? @map("qualification_check") @db.VarChar(20)
billable Boolean @default(true) @map("billable")
priceSnapshot Json? @map("price_snapshot")
settlePeriod String? @map("settle_period") @db.VarChar(20)
notMetReason String? @map("not_met_reason") @db.VarChar(50)
notMetAt DateTime? @map("not_met_at")
bizLine String @default("ltci") @map("biz_line") @db.VarChar(20)
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, assigneeId, serviceDate])
@@index([tenantCode, elderId])
@@map("svc_order")
}
/// 老人个人项目库
model ElderSvc {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
elderId String @map("elder_id") @db.VarChar(36)
svcId String @map("svc_id") @db.VarChar(36)
svcNameSnapshot String @map("svc_name_snapshot") @db.VarChar(100)
svcCodeSnapshot String? @map("svc_code_snapshot") @db.VarChar(20)
careMode String @map("care_mode") @db.VarChar(20)
isActive Boolean @default(true) @map("is_active")
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, elderId])
@@map("ltc_elder_svc")
}
```
- [ ] **Step 2: SOFT_DELETE 加四模型**(prisma.service.ts 第 8 行集合加 `'Plan', 'PlanItem', 'Order', 'ElderSvc'`)
- [ ] **Step 3: 迁移(utf8mb4_general_ci)**
Run: `cd sources/ec-nest && pnpm exec prisma migrate dev --create-only --name c2_plan_order`;编辑迁移 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(c2): plan/plan_item/order/elder_svc 数据模型"
```
---
### Task 2: 权限码 + 错误码 + APIS
**Files:** `permissions.ts`、`error-codes.ts`、`tenant-template.ts`(APIS)
- [ ] **Step 1:** 确认 `plan: { create, change, detail, confirm }`、`order: { generate, assign, reassign, cancel, detail, today }` 在 permissions.ts(C0 预留),缺则补。
- [ ] **Step 2:** error-codes.ts 补域 44:
```ts
// 服务机构端·计划/工单(44)
PLAN_NOT_FOUND: 44001,
PLAN_ITEM_RULE: 44002,
ORDER_NOT_FOUND: 44003,
ORDER_CONFLICT: 44004,
ORDER_STATUS_INVALID: 44005,
```
- [ ] **Step 3:** APIS 登记(parent 用菜单 code 'schedule' 或 'elder'):
```ts
{ code: Permissions.plan.create, name: '制定计划', method: 'POST', path: '/api/v1/plan', parent: 'elder' },
{ code: Permissions.plan.change, name: '变更计划', method: 'POST', path: '/api/v1/plan/change', parent: 'elder' },
{ code: Permissions.plan.detail, name: '计划详情', method: 'GET', path: '/api/v1/plan/:id', parent: 'elder' },
{ code: Permissions.order.generate, name: '生成工单', method: 'POST', path: '/api/v1/order/generate', parent: 'schedule' },
{ code: Permissions.order.assign, name: '派单', method: 'POST', path: '/api/v1/order/assign', parent: 'schedule' },
{ code: Permissions.order.reassign, name: '调单', method: 'POST', path: '/api/v1/order/:id/reassign', parent: 'schedule' },
{ code: Permissions.order.cancel, name: '取消工单', method: 'POST', path: '/api/v1/order/:id/cancel', parent: 'schedule' },
{ code: Permissions.order.detail, name: '工单详情', method: 'GET', path: '/api/v1/order/:id', parent: 'schedule' },
{ code: Permissions.order.today, name: '今日任务', method: 'GET', path: '/api/v1/order/today', parent: 'schedule' },
{ code: Permissions.elder.svcSave, name: '个人项目库', method: 'PUT', path: '/api/v1/elder-svc/:elderId', parent: 'elder' },
```
- [ ] **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(c2): plan/order 权限码/错误码/APIS"
```
---
### Task 3: plan + elder-svc 模块
**Files:** 新建 `src/plan/{plan.module,plan.controller,plan.service,plan-rule.ts,dto,plan.service.spec}.ts`、`src/elder-svc/{elder-svc.module,elder-svc.controller,elder-svc.service,dto,elder-svc.service.spec}.ts`;注册两个 module。
**Interfaces:**
- Consumes: `PrismaService.client`、`getRequestContext()`、`Permissions.plan.*`、`ErrorCodes.PLAN_*`、`svc_org`(L4)。
- Produces: `plan-rule.ts` 导出 `ITEM_COUNT_RULE`(昆明项数规则常量)、`checkItemCount(careMode, careLevel, itemCount, extraCount)`。
- [ ] **Step 1: 写失败测试**(plan.service.spec.ts):制定计划(L4 校验 + 项数 + item_count 快照)、变更留旧版(旧转 changed + change_from_plan_id)、项数不满足返回 PLAN_ITEM_RULE。
- [ ] **Step 2: 跑测试确认失败**
- [ ] **Step 3: 实现 plan-rule.ts**(昆明项数规则常量)
```ts
export const ITEM_COUNT_RULE = {
inst_medical: { care_1: 18, care_2: 15, care_3: 13 },
inst_nursing: { care_1: 18, care_2: 15, care_3: 13 },
home_individual: { care_1: 15, care_2: 13, care_3: 10 },
home_org: null, // 居家机构上门:按周次/月时长,不按项数
} as const;
export const EXTRA_MAX = 5;
export function checkItemCount(careMode: string, careLevel: string, itemCount: number, extraCount: number): { ok: boolean; hint: string } {
const rule = (ITEM_COUNT_RULE as Record<string, Record<string, number> | null>)[careMode];
if (rule === null || rule === undefined) return { ok: true, hint: '州市项数规则待属地实施细则明确' };
const min = rule[careLevel];
if (min === undefined) return { ok: true, hint: '' };
if (itemCount < min) return { ok: false, hint: `当前护理方式/等级需 ≥${min} 项` };
if (extraCount > EXTRA_MAX) return { ok: false, hint: `附加项最多 ${EXTRA_MAX} 项` };
return { ok: true, hint: '' };
}
```
- [ ] **Step 4: 实现 plan.service**(制定计划:L4 校验 + checkItemCount + item_count/item_count_rule 快照;变更:新建 + change_from_plan_id + 旧转 changed;确认;按老人查)+ elder-svc.service(PUT 整表覆盖:svc_id 落本机构 L4、care_mode 变更重制旧库留档;带入)
- [ ] **Step 5: 实现 controller + dto + module**(`@Controller('plan')`、`@Controller('elder-svc')`;写操作 `@AuditLog`;注册 module)
- [ ] **Step 6: 测试通过 + tsc + Commit**
```bash
git -C sources/ec-nest add src/plan src/elder-svc src/app.module.ts
git -C sources/ec-nest commit -m "feat(c2): 服务计划 + 老人个人项目库模块"
```
---
### Task 4: order 模块
**Files:** 新建 `src/order/{order.module,order.controller,order.service,dto,order.service.spec}.ts`;注册 module。
**Interfaces:**
- Consumes: `PrismaService`、`getRequestContext()`、`Permissions.order.*`、`ErrorCodes.ORDER_*`、elder 状态、staff。
- Produces: `isTimeOverlap(aStart, aEnd, bStart, bEnd)`、`OrderService`(generate/assign/reassign/cancel/detail/today/batchRecommend)。
- [ ] **Step 1: 写失败测试**(order.service.spec.ts):派单 L1(waiting_period 允许、benefit_expired 拒绝 1001)、时间冲突 2001(首尾相接不算重叠)、调单写 reschedule_log、取消需 reason。
- [ ] **Step 2: 跑测试确认失败**
- [ ] **Step 3: 实现时间冲突检测**
```ts
export function isTimeOverlap(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
return aStart < bEnd && bStart < aEnd; // 严格 <,首尾相接不算重叠
}
```
- [ ] **Step 4: 实现 order.service**:generate(L2 需 active 计划 + 从 plan_item 生成 item_list 快照 + need_photo);assign(L1 校验 treat_status ∈ {active, waiting_period} + 当班 + isTimeOverlap 冲突 2001 + 资质 1006 提示);reassign(reschedule_log);cancel(cancel_reason 必填);detail;today(本人);batchRecommend(权重:同资质→历史服务过→距离近→负荷低,返回推荐+理由,不自动派)。
- [ ] **Step 5: 实现 controller + dto + module**(`@Controller('order')`;写操作 `@AuditLog`;注册 module)
- [ ] **Step 6: 测试通过 + tsc + Commit**
```bash
git -C sources/ec-nest add src/order src/app.module.ts
git -C sources/ec-nest commit -m "feat(c2): 工单模块(派单/调单/冲突/批量推荐)"
```
---
### Task 5: 前端
**Files:** `src/api/plan.ts`、`src/api/order.ts`、`src/api/elder-svc.ts`;`src/pages/ltc/schedule/index.vue`(替换占位);`src/pages/ltc/elder/` 扩展(个人项目库 + 制定计划)。
- [ ] **Step 1: api 封装**(`http` + 类型对齐后端 DTO)。
- [ ] **Step 2: 排班派单页**(`defineOptions({name:'schedule'})`):日视图(时间轴表格/栅格,纵轴护理员、横轴时段)、工单卡片、调单面板(≤3步:点卡片→选护理员→确认)、冲突红框/空缺橙框、批量推荐列表(需逐条确认)。
- [ ] **Step 3: 服务对象页扩展**:个人项目库区块(勾选 L4 项目、可空)+ 制定计划弹窗(从个人项目库带入、项数校验提示)。
- [ ] **Step 4: type-check + Commit**
```bash
git -C sources/ec-admin add src/api/plan.ts src/api/order.ts src/api/elder-svc.ts src/pages/ltc/schedule src/pages/ltc/elder
git -C sources/ec-admin commit -m "feat(c2): 排班派单页 + 服务对象计划/个人项目库"
```
---
### Task 6: 端到端验证
- [ ] **Step 1:** 起后端 + 登录,冒烟:制定计划(项数校验)→ 生成工单 → 派单 → 调单 → 取消 → 冲突硬拦。
- [ ] **Step 2:** L1 例外:waiting_period 可派、benefit_expired 拒。
- [ ] **Step 3:** 个人项目库带入计划。
- [ ] **Step 4:** 审计落库(派单/调单/取消/计划变更)。停后端清理。
---
## Self-Review Notes
- Spec 覆盖:data-model 4 实体→T1;api-contract→T3/T4;backend/plan→T3;backend/order→T4;frontend/ad/schedule→T5。
- Review Focus 5 条均有测试(T3/T4)。
- 类型一致:`ITEM_COUNT_RULE`/`checkItemCount`/`isTimeOverlap`/`Permissions.plan.*`/`Permissions.order.*` 在 T2/T3/T4 一致。