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

16 KiB
Raw Permalink Blame History

服务机构端 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 末尾)

/// 服务计划头
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
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:

  // 服务机构端·计划/工单(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'):
  { 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
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(昆明项数规则常量)

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

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: 实现时间冲突检测

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

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

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 一致。