From bd4d5c57447d61c114475ea58706e6278e08ae1e Mon Sep 17 00:00:00 2001 From: liuyun Date: Wed, 14 Oct 2026 14:23:29 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9C=8D=E5=8A=A1=E6=9C=BA=E6=9E=84?= =?UTF-8?q?=E7=AB=AF=20C1=20=E5=BE=85=E9=81=87=E6=A1=A3=E6=A1=88=E5=AE=9E?= =?UTF-8?q?=E6=96=BD=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-10-14-服务机构端-C1待遇档案.md | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-服务机构端-C1待遇档案.md diff --git a/docs/superpowers/plans/2026-10-14-服务机构端-C1待遇档案.md b/docs/superpowers/plans/2026-10-14-服务机构端-C1待遇档案.md new file mode 100644 index 0000000..65a7ce7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-服务机构端-C1待遇档案.md @@ -0,0 +1,394 @@ +# 服务机构端 C1 待遇档案 实施计划 + +> **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:** 在 C0 骨架上落地「待遇档案」域:老人档案(elder)+ 十态状态机 + 状态日志(elder_status_log)+ 评估结论副本上传(MinIO)+ 身份证掩码/防重。 + +**Architecture:** 纯新增,不侵入脚手架。后端新增 elder 模块 + storage 模块(MinIO),前端「服务对象」页面。复用 C0 的多租户过滤、RBAC、审计、目录、菜单。 + +**Tech Stack:** NestJS 12 + Prisma 6 + MySQL 8(utf8mb4_general_ci)、Vue 3 + Vite + Pinia + UnoCSS、MinIO(minio npm 包)。 + +**Spec:** `openspec/changes/svc-org-c1-elder/`(proposal + specs + design + tasks);设计背景 `docs/superpowers/specs/2026-10-14-服务机构端-C1待遇档案-design.md`。 + +## Global Constraints + +- 机构=租户:业务表带 `tenant_code`,继承 PrismaService 多租户过滤;主键 UUID v7;列 snake_case(camelCase + `@map`);软删 `deleted_at`。 +- 字符集硬要求:新表 `utf8mb4_general_ci`(迁移 SQL 手改,不得残留 `utf8mb4_unicode_ci`)。 +- 等级唯一来源:建档不接受前端传 `assess_level/care_level`;`assess_level/care_level` 与 `assess_evidence_file` 绑定(有副本才能填等级)。 +- 身份证掩码在服务端(`maskIdCard`),列表不返回明文;明文仅 admin/qc + 落审计。 +- `id_card_no` 存储层加密后置(存明文 + TODO),掩码/查看留痕本期做。 +- 不动脚手架现有文件;业务代码落新目录(`src/elder/`、`src/storage/`、`src/pages/ltc/elder/`)。 +- 权限码 `elder:list/create/detail/status-change`(C0 已预留);错误码域 43。 + +## Review Focus + +1. 等级绑定绕过:有等级但无 `assess_evidence_file`(或反之)→ 建档必须被拒。 +2. 身份证掩码泄露:`/elder` 列表返回明文 id_card_no → 必须返回掩码。 +3. 明文查看无留痕:admin/qc 调 `/elder/:id` 拿明文却没落审计 → 必须落审计。 +4. 状态变更缺 `change_reason` → 必须拒绝,且不写日志。 +5. 建档初始状态判定错:等待期(benefit_start 未来)被置成 active → 必须置 waiting_period。 + +--- + +### Task 1: Prisma 模型 + 迁移 + +**Files:** +- Modify: `sources/ec-nest/prisma/schema.prisma`(追加 Elder、ElderStatusLog) +- Modify: `sources/ec-nest/src/prisma/prisma.service.ts`(SOFT_DELETE 加 `Elder`) + +**Interfaces:** +- Produces: Prisma 模型 `Elder`、`ElderStatusLog`。 + +- [ ] **Step 1: 追加 Elder + ElderStatusLog 模型**(schema.prisma 末尾) + +```prisma +/// 老人档案 +model Elder { + id String @id @default(uuid(7)) @db.VarChar(36) + tenantCode String @map("tenant_code") @db.VarChar(64) + locCode String @map("loc_code") @db.VarChar(12) + prevLocCode String? @map("prev_loc_code") @db.VarChar(12) + transferConfirmAt DateTime? @map("transfer_confirm_at") + name String @db.VarChar(30) + gender String @db.Char(1) + birthDate DateTime @map("birth_date") @db.Date + idCardNo String @map("id_card_no") @db.VarChar(255) + phone String? @db.VarChar(15) + address String @db.VarChar(200) + guardianName String? @map("guardian_name") @db.VarChar(30) + guardianPhone String? @map("guardian_phone") @db.VarChar(15) + insurNo String @map("insur_no") @db.VarChar(30) + insurType String @map("insur_type") @db.VarChar(10) + insurYears Int? @map("insur_years") + insurVerifyStatus String? @map("insur_verify_status") @db.VarChar(20) + insurVerifyDate DateTime? @map("insur_verify_date") @db.Date + assessLevel String? @map("assess_level") @db.VarChar(20) + careLevel String? @map("care_level") @db.VarChar(20) + assessDate DateTime? @map("assess_date") @db.Date + assessOrg String? @map("assess_org") @db.VarChar(100) + assessEvidenceFile String? @map("assess_evidence_file") @db.VarChar(255) + benefitStart DateTime? @map("benefit_start") @db.Date + benefitEnd DateTime? @map("benefit_end") @db.Date + treatStatus String @default("pending_file") @map("treat_status") @db.VarChar(20) + statusSource String @default("manual") @map("status_source") @db.VarChar(10) + statusChangeDate DateTime? @map("status_change_date") @db.Date + hospStart DateTime? @map("hosp_start") @db.Date + hospEnd DateTime? @map("hosp_end") @db.Date + photoTierOverride Json? @map("photo_tier_override") + needAssess Json? @map("need_assess") + note String? @db.Text + 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") + + @@unique([tenantCode, idCardNo]) + @@index([tenantCode, name]) + @@map("sys_elder") +} + +/// 待遇状态变更日志 +model ElderStatusLog { + id String @id @default(uuid(7)) @db.VarChar(36) + elderId String @map("elder_id") @db.VarChar(36) + tenantCode String @map("tenant_code") @db.VarChar(64) + fromStatus String @map("from_status") @db.VarChar(20) + toStatus String @map("to_status") @db.VarChar(20) + changeReason String @map("change_reason") @db.VarChar(200) + source String @default("manual") @db.VarChar(10) + operatorId String @map("operator_id") @db.VarChar(36) + evidenceFile String? @map("evidence_file") @db.VarChar(255) + changedAt DateTime @default(now()) @map("changed_at") + + @@index([elderId]) + @@map("sys_elder_status_log") +} +``` + +- [ ] **Step 2: SOFT_DELETE 加 Elder**(prisma.service.ts 第 8 行集合) + +```ts +const SOFT_DELETE = new Set([..., 'Elder', 'SvcOrg']); +``` + +- [ ] **Step 3: 迁移(utf8mb4_general_ci)** + +Run: `cd sources/ec-nest && pnpm exec prisma migrate dev --create-only --name c1_elder`;编辑生成的 `*_c1_elder/migration.sql` 把 `utf8mb4_unicode_ci` 全改为 `utf8mb4_general_ci`;再 `pnpm exec prisma migrate deploy && pnpm exec prisma generate`。 + +- [ ] **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(c1): elder + elder_status_log 数据模型" +``` + +--- + +### Task 2: 权限码 + 错误码 + APIS 资源 + +**Files:** +- Modify: `sources/ec-nest/src/common/permissions.ts` +- Modify: `sources/ec-nest/src/common/error-codes.ts` +- Modify: `sources/ec-nest/src/common/tenant-template.ts`(APIS) + +**Interfaces:** +- Produces: `Permissions.elder.*`、`ErrorCodes.ELDER_*`、6 条 APIS 资源。 + +- [ ] **Step 1: 确认/补 permissions.ts 的 elder 权限码**(C0 已预留,确保存在 `elder.list/create/detail/status-change`;缺则补) + +```ts + elder: { list: 'elder:list', detail: 'elder:detail', create: 'elder:create', statusChange: 'elder:status-change' }, +``` + +- [ ] **Step 2: 追加 elder 错误码**(error-codes.ts,域 43) + +```ts + // 服务机构端·老人档案(43) + ELDER_NOT_FOUND: 43001, + ELDER_ID_CARD_EXISTS: 43002, + ELDER_STATUS_REASON_REQUIRED: 43003, + ELDER_LEVEL_REQUIRES_EVIDENCE: 43004, +``` + +- [ ] **Step 3: APIS 登记 6 条 elder 接口**(tenant-template.ts APIS 数组,parent='elder') + +```ts + { code: Permissions.elder.list, name: '老人列表', method: 'GET', path: '/api/v1/elder', parent: 'elder' }, + { code: Permissions.elder.create, name: '创建老人', method: 'POST', path: '/api/v1/elder', parent: 'elder' }, + { code: Permissions.elder.detail, name: '老人详情', method: 'GET', path: '/api/v1/elder/:id', parent: 'elder' }, + { code: Permissions.elder.statusChange, name: '变更待遇状态', method: 'POST', path: '/api/v1/elder/:id/status', parent: 'elder' }, + { code: Permissions.elder.detail, name: '状态日志', method: 'GET', path: '/api/v1/elder/:id/status/log', parent: 'elder' }, + { code: Permissions.elder.create, name: '上传评估结论', method: 'POST', path: '/api/v1/elder/assess-evidence', parent: 'elder' }, +``` + +- [ ] **Step 4: tsc 校验 + Commit** + +Run: `cd sources/ec-nest && pnpm exec tsc --noEmit` +```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(c1): elder 权限码/错误码/接口资源" +``` + +--- + +### Task 3: MinIO 存储模块 + +**Files:** +- Create: `sources/ec-nest/src/storage/storage.module.ts`、`storage.service.ts` +- Modify: `sources/ec-nest/src/app.module.ts`、`sources/ec-nest/.env`、`package.json`(依赖) + +**Interfaces:** +- Produces: `StorageService.upload(buffer: Buffer, key: string): Promise`(返回对象 key);`StorageService.buildKey(tenantCode, filename): string`。 + +- [ ] **Step 1: 装依赖 + 配 .env** + +```bash +cd sources/ec-nest && pnpm add minio +``` +`.env` 追加: +```env +MINIO_ENDPOINT="localhost" +MINIO_PORT=9000 +MINIO_ACCESS_KEY="minioadmin" +MINIO_SECRET_KEY="minioadmin" +MINIO_BUCKET="carelink" +``` + +- [ ] **Step 2: 写失败测试**(`src/storage/storage.service.spec.ts`——无参构造,测试时替换内部 client 为 mock,避免真实连接 MinIO) + +```ts +import { describe, it, expect, vi } from 'vitest'; +import { StorageService } from './storage.service.js'; + +describe('StorageService', () => { + it('upload 写桶并返回 key', async () => { + const putObject = vi.fn().mockResolvedValue({ etag: 'x' }); + const svc = new StorageService(); + (svc as any).client = { putObject }; + (svc as any).bucket = 'carelink'; + const key = await svc.upload(Buffer.from('x'), 'elder/evidence/a.jpg'); + expect(putObject).toHaveBeenCalledWith('carelink', 'elder/evidence/a.jpg', expect.any(Buffer), expect.any(Number)); + expect(key).toBe('elder/evidence/a.jpg'); + }); + + it('buildKey 带租户前缀', () => { + const svc = new StorageService(); + const key = svc.buildKey('T001', 'a.jpg'); + expect(key).toMatch(/^elder\/T001\/\d+-a\.jpg$/); + }); +}); +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `cd sources/ec-nest && pnpm test -- storage` +Expected: FAIL(StorageService 不存在) + +- [ ] **Step 4: 实现 storage.service.ts + module** + +```ts +import { Injectable } from '@nestjs/common'; +import { Client } from 'minio'; + +@Injectable() +export class StorageService { + private readonly client: Client; + private readonly bucket: string; + + constructor() { + // new Client() 只是构造客户端对象、不建立连接;putObject 时才连。 + // 测试里直接替换 (svc as any).client / .bucket 为 mock。 + this.client = new Client({ + endPoint: process.env.MINIO_ENDPOINT ?? 'localhost', + port: Number(process.env.MINIO_PORT ?? 9000), + useSSL: false, + accessKey: process.env.MINIO_ACCESS_KEY ?? 'minioadmin', + secretKey: process.env.MINIO_SECRET_KEY ?? 'minioadmin', + }); + this.bucket = process.env.MINIO_BUCKET ?? 'carelink'; + } + + buildKey(tenantCode: string, filename: string): string { + return `elder/${tenantCode}/${Date.now()}-${filename}`; + } + + async upload(buffer: Buffer, key: string): Promise { + await this.client.putObject(this.bucket, key, buffer, buffer.length); + return key; + } +} +``` + +(`StorageModule` 声明 provider 并 `exports`;`app.module.ts` imports 注册。) + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd sources/ec-nest && pnpm test -- storage` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git -C sources/ec-nest add src/storage src/app.module.ts .env package.json pnpm-lock.yaml +git -C sources/ec-nest commit -m "feat(c1): MinIO 存储模块" +``` + +--- + +### Task 4: elder 后端模块 + +**Files:** +- Create: `sources/ec-nest/src/elder/elder.module.ts`、`elder.controller.ts`、`elder.service.ts`、`dto.ts`、`elder.service.spec.ts` +- Modify: `sources/ec-nest/src/app.module.ts` + +**Interfaces:** +- Consumes: `StorageService.upload/buildKey`(Task 3)、`PrismaService.client`、`getRequestContext()`、`Permissions.elder.*`、`ErrorCodes.ELDER_*`。 +- Produces: `maskIdCard(idCardNo: string): string`;`ElderService` 六个接口(list/create/detail/changeStatus/statusLog/uploadEvidence)。 + +- [ ] **Step 1: 写失败测试**(`elder.service.spec.ts`,覆盖 5 个 Review Focus 场景) + +```ts +// 关键用例(mock PrismaService + StorageService + ctx): +// 1. create 时 assess_level 有值但 assessEvidenceFile 为空 → 抛 ELDER_LEVEL_REQUIRES_EVIDENCE +// 2. create 时 benefit_start 未来 → treatStatus = 'waiting_period' +// 3. changeStatus 缺 changeReason → 抛 ELDER_STATUS_REASON_REQUIRED +// 4. list 返回的 idCardNo 是掩码(前4+*+后4) +// 5. detail 明文分支:非 admin/qc 不返回明文;admin/qc 返回明文 + 写审计 +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cd sources/ec-nest && pnpm test -- elder` +Expected: FAIL + +- [ ] **Step 3: 实现 maskIdCard + ElderService** + +```ts +export function maskIdCard(id: string): string { + if (!id) return ''; + if (id.length === 18) return `${id.slice(0, 4)}**********${id.slice(-4)}`; + if (id.length === 15) return `${id.slice(0, 3)}********${id.slice(-4)}`; + return id; +} + +// 建档:18 位 + 校验位校验(GB11643 加权算法)、出生日期由身份证带出、 +// 同机构 id_card_no 唯一(命中抛 ELDER_ID_CARD_EXISTS)、 +// 等级与副本绑定(assess_level/care_level 有值则 assessEvidenceFile 必填,否则 ELDER_LEVEL_REQUIRES_EVIDENCE)、 +// 初始状态:benefit_start<=today<=benefit_end ? active : waiting_period。 +// changeStatus:change_reason 必填;写 ElderStatusLog(from/to/reason/operator/changedAt)。 +// list:分页 + 掩码 id_card_no。detail:admin/qc 明文 + 审计,否则掩码。 +// uploadEvidence:multipart 读 buffer → StorageService.buildKey + upload → 返回 { key }。 +``` + +- [ ] **Step 4: 实现 controller + dto + module**(`@Controller('elder')`;写操作 `@AuditLog('elder','xxx')`;`app.module.ts` 注册 `ElderModule`) + +- [ ] **Step 5: 跑测试确认通过 + tsc** + +Run: `cd sources/ec-nest && pnpm test -- elder && pnpm exec tsc --noEmit` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git -C sources/ec-nest add src/elder src/app.module.ts +git -C sources/ec-nest commit -m "feat(c1): elder 待遇档案模块(建档/状态机/掩码/上传)" +``` + +--- + +### Task 5: 前端服务对象页面 + +**Files:** +- Create: `sources/ec-admin/src/api/elder.ts` +- Modify: `sources/ec-admin/src/pages/ltc/elder/index.vue`(替换占位页) +- Create: `sources/ec-admin/src/pages/ltc/elder/components/elder-form.vue`(建档表单)、`elder-detail.vue`(详情+状态变更) + +**Interfaces:** +- Consumes: `http`(`@/api/http`)、`ty-crud`、`ty-form`、`showDialog`、`useDict`。 +- Produces: `/ltc/elder` 列表 + 建档弹窗 + 详情弹窗。 + +- [ ] **Step 1: api 封装**(`src/api/elder.ts`) + +```ts +import { http } from '@/api/http' +export interface Elder { id: string; name: string; gender: string; idCardNo: string; assessLevel?: string; careLevel?: string; treatStatus: string; birthDate?: number } +export const listElders = (params: { page: number; pageSize: number; treatStatus?: string; name?: string }) => + http.get<{ list: Elder[]; total: number }>('/elder', { params }) +export const createElder = (data: Record) => http.post('/elder', data) +export const getElder = (id: string) => http.get(`/elder/${id}`) +export const changeElderStatus = (id: string, data: { toStatus: string; changeReason: string }) => http.post(`/elder/${id}/status`, data) +export const getStatusLog = (id: string) => http.get(`/elder/${id}/status/log`) +export const uploadEvidence = (file: File) => { const fd = new FormData(); fd.append('file', file); return http.post<{ key: string }>('/elder/assess-evidence', fd) } +``` + +- [ ] **Step 2: 列表页**(`ltc/elder/index.vue`,`defineOptions({name:'elder'})`)用 `ty-crud`:列=姓名/护理等级(带「(最高级)」)/状态(dictCode)/下次服务占位/操作;状态筛选用 `ty-search-form`;「查看」打开详情弹窗、「建档」打开建档弹窗。 + +- [ ] **Step 3: 建档表单**(`elder-form.vue`)用 `ty-form` JSON Schema:name/gender/birth_date(身份证带出)/id_card_no/phone/address/insur_no/insur_type/insur_verify_status/assess_* 区块。等级区块:上传评估结论副本(`uploadEvidence`)+ 录入 assess_org/assess_date/assess_level/care_level(**有副本才能填等级**,前端联动校验)。 + +- [ ] **Step 4: 详情弹窗**(`elder-detail.vue`):完整信息 + 状态变更(下拉十态 + 原因必填)+ 状态日志时间线。 + +- [ ] **Step 5: type-check + Commit** + +Run: `cd sources/ec-admin && pnpm type-check` +```bash +git -C sources/ec-admin add src/api/elder.ts src/pages/ltc/elder +git -C sources/ec-admin commit -m "feat(c1): 服务对象列表/建档/详情/状态变更" +``` + +--- + +### Task 6: 端到端验证 + +- [ ] **Step 1:** 起后端 + 登录 `touyun`,冒烟:建档(带评估结论上传)→ 列表(掩码)→ 详情(qc 明文 + 审计)→ 状态变更(原因必填)→ 状态日志。 +- [ ] **Step 2:** 等级绑定:无副本填等级被拒;有等级无副本被拒。 +- [ ] **Step 3:** 身份证防重:同机构重复建档被拦(ELDER_ID_CARD_EXISTS)。 +- [ ] **Step 4:** 停后端、清理端口。 + +--- + +## Self-Review Notes + +- Spec 覆盖:data-model 两个实体→T1;api-contract 掩码/接口→T4;backend/elder 五条 requirement→T4;frontend/ad/elder 三条→T5。 +- Review Focus 5 条均有对应测试(T4 Step 1/3)。 +- 类型一致:`maskIdCard`、`StorageService.upload/buildKey`、`Permissions.elder.*`、`ErrorCodes.ELDER_*` 在 T2/T3/T4 间一致。