diff --git a/docs/superpowers/plans/2026-10-15-服务机构端-C3履约留痕.md b/docs/superpowers/plans/2026-10-15-服务机构端-C3履约留痕.md new file mode 100644 index 0000000..820af9b --- /dev/null +++ b/docs/superpowers/plans/2026-10-15-服务机构端-C3履约留痕.md @@ -0,0 +1,352 @@ +# 服务机构端 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。