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

353 lines
20 KiB
Markdown
Raw 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.

# 服务机构端 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 末尾)
```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**
```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(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 追加:
```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:
```ts
// 服务机构端·履约留痕/异常(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'):
```ts
{ 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
```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(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**
```bash
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**
```bash
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**
```bash
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。