touyun-elderly-care/docs/superpowers/plans/2026-10-14-服务机构端-C2派单工单.md
liuyun 1caea9cb4c docs: 服务机构端 C2 派单工单实施计划
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 21:54:56 +08:00

316 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 服务机构端 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 一致。