touyun-elderly-care/docs/superpowers/plans/2026-10-15-服务机构端-C3履约留痕.md
liuyun 398eae800f docs: 服务机构端 C3 履约留痕实施计划
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-15 00:16:16 +08:00

20 KiB
Raw Permalink Blame History

服务机构端 C3 履约留痕 实施计划

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: 在 C2 骨架上落地「履约留痕」:护理员签到/签退/项目执行/拍照/异常上报(ho_record 四表)+ 垫付台账与结转(is_prepaid)+ admin 工单留痕/异常列表两页。

Architecture: 纯新增。后端新增 record/abnormal 模块,前端工单留痕页 + 异常列表页 + 服务对象页结转入口。复用 C2 的 order 八态、plan/plan_item、staff、MinIO StorageService。

Tech Stack: NestJS 12 + Prisma 6 + MySQL 8(utf8mb4_general_ci)、Vue 3 + Vite + Pinia + UnoCSS。

Spec: openspec/changes/svc-org-c3-record/(proposal + specs + design + tasks);设计背景 docs/superpowers/specs/2026-10-15-服务机构端-C3履约留痕-design.md。

Global Constraints

  • 机构=租户:业务表带 tenant_code、UUID v7、@map snake_case;新表 utf8mb4_general_ci(迁移 SQL 手改)。
  • 表命名:ho_record/ho_record_item/ho_record_photo/ho_record_abnormal(前缀 ho);settlement/report 无前缀预留空表。禁用 checkin/clockin/punch。
  • 不动脚手架文件;业务代码落 src/record/、src/abnormal/、src/pages/ltc/record/。
  • 签到/签退时间、时长一律服务器取(接口不接受客户端传入 checkin_time/checkout_time/duration_min)。
  • exec_result 枚举 done/undo_not_done/undo_refused(禁用 not_done)。
  • 必拍未拍 → 签退硬拒 3001(+拒拍出路);定位失败 3002 不阻塞;垫付 3004 仅标记;扑空 3003 转 not_met。
  • L3:留痕必须关联有效工单;L1 例外:waiting_period 老人留痕 is_prepaid=1、billable=0。
  • 权限码 record./abnormal.;错误码域 45;规则号写 message(循 C2 R3 口径)。
  • ⚠️ 迁移链已损坏:migrate dev 不可用,用 migrate diff --from-schema-datasource + 手工迁移目录 + migrate deploy。

Review Focus

  1. 服务器取时:checkin_time/checkout_time/duration_min 必须服务端生成,客户端传入被忽略(防补录时间)。
  2. 必拍未拍边界:photo_required 且 exec_result=done 的项目必须有照片(或 is_refused_photo=1),否则签退 3001 硬拒。
  3. 垫付判定时点:record 创建时 elder.treat_status === waiting_period → is_prepaid=1、billable=0;active → 0/1。
  4. 结转不改原始时间戳:carryover 复制出的新 record 签到时间/定位/照片仍是原始值,仅 source_prepaid_of 标记。
  5. 扑空 vs 逾期未做:扑空有到访时间+定位+原因(order.not_met_* + record_abnormal),不产生 record、不计费;逾期未做=到点无记录。

Task 1: Prisma 模型 + 迁移

Files:

  • Modify: sources/ec-nest/prisma/schema.prisma
  • Modify: sources/ec-nest/src/prisma/prisma.service.ts

Interfaces:

  • Produces: Record/RecordItem/RecordPhoto/RecordAbnormal/Settlement/Report 模型。

  • Step 1: 追加 6 个模型(schema.prisma 末尾)

/// 履约留痕(主)
model Record {
  id                 String    @id @default(uuid(7)) @db.VarChar(36)
  tenantCode         String    @map("tenant_code") @db.VarChar(64)
  orderId            String    @map("order_id") @db.VarChar(36)
  elderId            String    @map("elder_id") @db.VarChar(36)
  assigneeId         String    @map("assignee_id") @db.VarChar(36)
  checkinTime        DateTime? @map("checkin_time")
  checkoutTime       DateTime? @map("checkout_time")
  durationMin        Int?      @map("duration_min")
  checkinLat         Decimal?  @map("checkin_lat") @db.Decimal(10, 6)
  checkinLng         Decimal?  @map("checkin_lng") @db.Decimal(10, 6)
  locateStatus       String    @default("ok") @map("locate_status") @db.VarChar(20)
  recordStatus       String    @default("serving") @map("record_status") @db.VarChar(20)
  voiceFile          String?   @map("voice_file") @db.VarChar(200)
  offlineFlag        Boolean   @default(false) @map("offline_flag")
  clientMd5          String?   @map("client_md5") @db.VarChar(32)
  submitTime         DateTime? @map("submit_time")
  actualDurationTier String?   @map("actual_duration_tier") @db.VarChar(10)
  bizLine            String    @default("ltci") @map("biz_line") @db.VarChar(20)
  isPrepaid          Boolean   @default(false) @map("is_prepaid")
  billable           Boolean   @default(true) @map("billable")
  sourcePrepaidOf    String?   @map("source_prepaid_of") @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")

  @@unique([tenantCode, clientMd5])
  @@index([tenantCode, orderId])
  @@index([tenantCode, elderId])
  @@index([tenantCode, assigneeId, checkinTime])
  @@map("ho_record")
}

/// 留痕项目明细
model RecordItem {
  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)
  svcId           String  @map("svc_id") @db.VarChar(36)
  svcNameSnapshot String  @map("svc_name_snapshot") @db.VarChar(100)
  execResult      String  @map("exec_result") @db.VarChar(20)
  undoReason      String? @map("undo_reason") @db.VarChar(200)
  extraFlag       Boolean @default(false) @map("extra_flag")
  photoRequired   Boolean @default(false) @map("photo_required")
  isRefusedPhoto  Boolean @default(false) @map("is_refused_photo")
  refuseReason    String? @map("refuse_reason") @db.VarChar(200)

  @@index([recordId])
  @@map("ho_record_item")
}

/// 留痕照片
model RecordPhoto {
  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)
  svcId           String?  @map("svc_id") @db.VarChar(36)
  filePath        String   @map("file_path") @db.VarChar(255)
  fileSize        Int      @map("file_size")
  visibleToFamily Boolean  @default(false) @map("visible_to_family")
  uploadBatch     String?  @map("upload_batch") @db.VarChar(36)
  keepUntil       DateTime? @map("keep_until")
  createdAt       DateTime @default(now()) @map("created_at")

  @@index([recordId])
  @@map("ho_record_photo")
}

/// 异常上报
model RecordAbnormal {
  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)
  orderId        String?  @map("order_id") @db.VarChar(36)
  elderId        String   @map("elder_id") @db.VarChar(36)
  reporterId     String   @map("reporter_id") @db.VarChar(36)
  abnormalType   String   @map("abnormal_type") @db.VarChar(20)
  abnormalDesc   String?  @map("abnormal_desc") @db.Text
  voiceFile      String?  @map("voice_file") @db.VarChar(200)
  hasPhoto       Boolean  @default(false) @map("has_photo")
  visitTime      DateTime? @map("visit_time")
  visitLat       Decimal? @map("visit_lat") @db.Decimal(10, 6)
  visitLng       Decimal? @map("visit_lng") @db.Decimal(10, 6)
  notifyStatus   String   @default("pending") @map("notify_status") @db.VarChar(20)
  handleStatus   String   @default("open") @map("handle_status") @db.VarChar(20)
  handleResult   String?  @map("handle_result") @db.VarChar(200)
  linkPlanChange Boolean  @default(false) @map("link_plan_change")
  createdAt      DateTime @default(now()) @map("created_at")
  createdBy      String?  @map("created_by") @db.VarChar(36)

  @@index([tenantCode, elderId])
  @@index([tenantCode, notifyStatus])
  @@map("ho_record_abnormal")
}

/// 结算表(二期预留,建空表不写业务数据)
model Settlement {
  id               String   @id @default(uuid(7)) @db.VarChar(36)
  tenantCode       String   @map("tenant_code") @db.VarChar(64)
  settlePeriod     String   @map("settle_period") @db.VarChar(20)
  amountTotal      Decimal? @map("amount_total") @db.Decimal(12, 2)
  amountPassed     Decimal? @map("amount_passed") @db.Decimal(12, 2)
  amountRejected   Decimal? @map("amount_rejected") @db.Decimal(12, 2)
  amountFund       Decimal? @map("amount_fund") @db.Decimal(12, 2)
  amountSelfPay    Decimal? @map("amount_self_pay") @db.Decimal(12, 2)
  submitDate       DateTime? @map("submit_date") @db.Date
  payDate          DateTime? @map("pay_date") @db.Date
  exceptionReason  String?  @map("exception_reason") @db.VarChar(200)
  handleDeadline   DateTime? @map("handle_deadline") @db.Date
  createdAt        DateTime @default(now()) @map("created_at")

  @@map("settlement")
}

/// 上报表(二期预留,建空表不写业务数据)
model Report {
  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)
  orderId          String   @map("order_id") @db.VarChar(36)
  reportStatus     String   @default("pending") @map("report_status") @db.VarChar(20)
  reportTime       DateTime? @map("report_time")
  officialNo       String?  @map("official_no") @db.VarChar(50)
  locCode          String   @map("loc_code") @db.VarChar(12)
  natCodeSnapshot  Json?    @map("nat_code_snapshot")
  adapterResult    String?  @map("adapter_result") @db.VarChar(200)
  createdAt        DateTime @default(now()) @map("created_at")

  @@map("report")
}
  • Step 2: SOFT_DELETE 只加 Record(prisma.service.ts 第 8 行集合加 'Record';子表 item/photo/abnormal 循 ElderStatusLog 先例不入,settlement/report 预留不入)

  • 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/c3.sql;手工落盘为迁移目录 20261015xxxxxx_c3_record/migration.sql,把 utf8mb4_unicode_ci 全改 utf8mb4_general_ci;pnpm exec prisma migrate deploy && pnpm exec prisma generate。(migrate dev 因迁移链损坏不可用。)

  • 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(c3): record/item/photo/abnormal + settlement/report 数据模型"

Task 2: 权限码 + 错误码 + APIS

Files: permissions.ts、error-codes.ts、tenant-template.ts(APIS + DICTS 补扑空分类)

  • Step 1: permissions.ts 追加:
  record: {
    checkin: 'record:checkin', itemSave: 'record:item-save', photoUpload: 'record:photo-upload',
    checkout: 'record:checkout', offlineSync: 'record:offline-sync', detail: 'record:detail',
    list: 'record:list', prepaidList: 'record:prepaid-list', prepaidCarryover: 'record:prepaid-carryover',
  },
  abnormal: { report: 'abnormal:report', list: 'abnormal:list' },
  • Step 2: error-codes.ts 补域 45:
  // 服务机构端·履约留痕/异常(45)
  RECORD_NOT_FOUND: 45001,
  RECORD_ORDER_INVALID: 45002,
  RECORD_STATUS_INVALID: 45003,
  RECORD_PHOTO_REQUIRED: 45004, // 必拍未拍(3001)
  RECORD_MD5_DUP: 45005,
  ABNORMAL_NOT_FOUND: 45006,
  PREPAID_CARRYOVER_INVALID: 45007,
  • Step 3: APIS 登记(parent 用 'record' 或 'qc'/'elder'):
  { code: Permissions.record.checkin, name: '签到', method: 'POST', path: '/api/v1/record/checkin', parent: 'record' },
  { code: Permissions.record.itemSave, name: '保存项目', method: 'POST', path: '/api/v1/record/item/save', parent: 'record' },
  { code: Permissions.record.photoUpload, name: '上传照片', method: 'POST', path: '/api/v1/record/photo/upload', parent: 'record' },
  { code: Permissions.record.checkout, name: '签退', method: 'POST', path: '/api/v1/record/checkout', parent: 'record' },
  { code: Permissions.record.offlineSync, name: '离线补传', method: 'POST', path: '/api/v1/record/offline/sync', parent: 'record' },
  { code: Permissions.record.detail, name: '留痕详情', method: 'GET', path: '/api/v1/record/:id', parent: 'record' },
  { code: Permissions.record.list, name: '留痕列表', method: 'GET', path: '/api/v1/record', parent: 'record' },
  { code: Permissions.record.prepaidList, name: '垫付台账', method: 'GET', path: '/api/v1/record/prepaid-list', parent: 'record' },
  { code: Permissions.record.prepaidCarryover, name: '结转', method: 'POST', path: '/api/v1/record/prepaid/carryover', parent: 'record' },
  { code: Permissions.abnormal.report, name: '异常上报', method: 'POST', path: '/api/v1/abnormal', parent: 'record' },
  { code: Permissions.abnormal.list, name: '异常列表', method: 'GET', path: '/api/v1/abnormal', parent: 'record' },
  • Step 4: DICTS abnormal_type 若未含「未遇(扑空)」则补一项 { label: '未遇(扑空)', value: 'not_met', colorType: 'info', sort: 10 }(builtIn)。

  • Step 5: 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(c3): record/abnormal 权限码/错误码/APIS"

Task 3: record 模块(履约留痕 + 垫付结转)

Files: 新建 src/record/{record.module,record.controller,record.service,dto,record.service.spec}.ts;注册 module。

Interfaces:

  • Consumes: PrismaService.client、getRequestContext()、Permissions.record.*、ErrorCodes.RECORD_*、order(八态)、elder(treat_status)、staff、StorageService(MinIO)、AuditLogsModule。

  • Produces: RecordService(checkin/itemSave/photoUpload/checkout/offlineSync/detail/list/prepaidList/prepaidCarryover)。

  • Step 1: 写失败测试(record.service.spec.ts):签到 L3(order 无效拒绝)+ 服务器取时(客户端传入被忽略)+ 定位失败不阻塞;签退必拍 3001 + 垫付 is_prepaid;exec_result 枚举;结转不改时间戳;离线 client_md5 去重。

  • Step 2: 跑测试确认失败

  • Step 3: 实现 record.service

关键逻辑:

  • checkin(dto):order.findUnique(orderId) → 校验存在 + orderStatus ∈ {assigned, serving} + elderId/assigneeId 匹配(L3);checkinTime = new Date()(忽略客户端传入);locateStatus 取 dto(ok/failed/denied);elder.findUnique → isPrepaid = elder.treatStatus === 'waiting_period'、billable = !isPrepaid;record.create({ recordStatus: 'serving', ... })。

  • itemSave(dto):record 存在且 serving;每个 item svcId 落本机构 L4(svcOrg.findFirst);execResult ∈ {done, undo_not_done, undo_refused};整表覆盖(先删后写,循 elder-svc PUT 先例)。

  • photoUpload(dto, file):record 存在;file.size ≤ 500KB(否则拒);StorageService.upload → record_photo.create({ filePath, fileSize, visibleToFamily: false, keepUntil: now+7d })。注意 StorageService.buildKey 硬编码 elder/ 前缀——给 buildKey 加可选 prefix 参数(默认 'elder'),record 照片传 record/(改 src/storage/storage.service.ts,不改 elder 现有调用)。

  • checkout(dto):record 存在且 serving;遍历 record_item 找 photoRequired && execResult==='done' && !isRefusedPhoto 且无对应 record_photo → throw RECORD_PHOTO_REQUIRED(message 含 3001);checkoutTime = new Date()、durationMin = round((checkout-checkin)/60000)、recordStatus='submitted'、submitTime=now。

  • offlineSync(dto):dto.clientMd5 必填;record.findFirst({ clientMd5 }) 命中则幂等返回已存在;否则建完整 record(checkin/checkout/items/photos 一次性)。

  • detail(id)/list(q):详情含 item/photo/abnormal;列表按 elder/assignee/日期/状态筛选。

  • prepaidList(elderId):is_prepaid=true 的记录列表。

  • prepaidCarryover(dto):校验所有 recordIds is_prepaid=true 且同 elder;对每条:record.create({ ...原字段, isPrepaid:false, billable:true, sourcePrepaidOf:原id, checkinTime/checkoutTime/checkinLat/checkinLng 原样 }) + 复制 item/photo;原记录不改。

  • Step 4: 实现 controller + dto + module(@Controller('record');写操作 @AuditLog;checkin_time/checkout_time/duration_min 不在 DTO,防客户端传入;注册 module)

  • Step 5: 测试通过 + tsc + Commit

git -C sources/ec-nest add src/record src/app.module.ts
git -C sources/ec-nest commit -m "feat(c3): 履约留痕模块(签到/签退/项目/照片/离线/垫付结转)"

Task 4: abnormal 模块(异常 + 扑空)

Files: 新建 src/abnormal/{abnormal.module,abnormal.controller,abnormal.service,dto,abnormal.service.spec}.ts;注册 module。

Interfaces:

  • Consumes: PrismaService、Permissions.abnormal.*、ErrorCodes.ABNORMAL_*、order(八态)、elder。

  • Produces: AbnormalService(report/list)+ 扑空(not_met)流转。

  • Step 1: 写失败测试(abnormal.service.spec.ts):异常上报分类必选其余可选;扑空(abnormal_type=not_met)→ order 转 not_met + 不产生 record + 到访时间/定位/原因必留;列表筛选。

  • Step 2: 跑测试确认失败

  • Step 3: 实现 abnormal.service

关键逻辑:

  • report(dto):abnormal_type 必填(其余可选);写 record_abnormal(record_id 或 order_id 可空)。

  • 扑空分支:dto.abnormalType === 'not_met' 时 → order.update({ orderStatus:'not_met', notMetReason: dto.abnormalDesc, notMetAt: dto.visitTime ?? now })(visit_time/visit_lat/visit_lng/原因必留)→ 不产生 record。

  • list(q):按 abnormal_type/notify_status/handle_status/elder 筛选。

  • Step 4: 实现 controller + dto + module(@Controller('abnormal');@AuditLog;注册 module)

  • Step 5: 测试通过 + tsc + Commit

git -C sources/ec-nest add src/abnormal src/app.module.ts
git -C sources/ec-nest commit -m "feat(c3): 异常上报模块(9宫格/扑空转 not_met)"

Task 5: 前端

Files: src/api/record.ts、src/api/abnormal.ts;src/pages/ltc/record/index.vue(替换占位);src/pages/ltc/qc/index.vue(异常列表,或独立);src/pages/ltc/elder/ 扩展(垫付台账 + 结转入口)。

  • Step 1: api 封装(http + 类型对齐后端 DTO;record 列表/详情/签到/签退/项目/照片/离线/垫付台账/结转 + abnormal 上报/列表)。

  • Step 2: 工单留痕页(defineOptions({name:'record'})):列表(筛选老人/护理员/日期/状态)+ 详情(签到签退时间/时长/项目 exec_result/照片/异常/定位/垫付标记)。复用 ty-search-form + ty-table + showDialog。

  • Step 3: 异常列表页:分类/状态/关联老人 + 语音/照片入口。

  • Step 4: 服务对象页扩展:垫付台账区块(is_prepaid 记录)+ 「结转垫付期服务」按钮(勾选 → prepaidCarryover)。

  • Step 5: type-check + Commit

git -C sources/ec-admin add src/api/record.ts src/api/abnormal.ts src/pages/ltc/record src/pages/ltc/qc src/pages/ltc/elder
git -C sources/ec-admin commit -m "feat(c3): 工单留痕页 + 异常列表 + 服务对象垫付结转"

Task 6: 端到端验证

  • Step 1: 起后端 + 登录,冒烟:签到 → 项目 → 照片 → 签退(必拍 3001 硬拒)→ 详情。
  • Step 2: waiting_period 老人签到/签退 → is_prepaid=1、billable=0。
  • Step 3: 扑空:abnormal_type=not_met → 工单转 not_met + 不产生 record。
  • Step 4: 结转:勾选垫付记录 → 正式记录生成,原始时间戳不变。
  • Step 5: 离线补传 client_md5 去重幂等;审计落库。停后端清理。

Self-Review Notes

  • Spec 覆盖:data-model 6 实体→T1;api-contract→T3/T4;backend/record→T3;backend/abnormal→T4;frontend/ad/record→T5。
  • Review Focus 5 条均有测试(T3/T4)。
  • 类型一致:Record/RecordItem/RecordPhoto/RecordAbnormal/Settlement/Report 模型名、Permissions.record.*/abnormal.*、ErrorCodes.RECORD_* 在 T1/T2/T3/T4 一致。
  • 命名纪律:exec_result=undo_not_done;表名 ho_*;禁用 checkin/clockin/punch。