20 KiB
服务机构端 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、@mapsnake_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
- 服务器取时:checkin_time/checkout_time/duration_min 必须服务端生成,客户端传入被忽略(防补录时间)。
- 必拍未拍边界:photo_required 且 exec_result=done 的项目必须有照片(或 is_refused_photo=1),否则签退 3001 硬拒。
- 垫付判定时点:record 创建时 elder.treat_status === waiting_period → is_prepaid=1、billable=0;active → 0/1。
- 结转不改原始时间戳:carryover 复制出的新 record 签到时间/定位/照片仍是原始值,仅 source_prepaid_of 标记。
- 扑空 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;每个 itemsvcId落本机构 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):校验所有 recordIdsis_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。