touyun-elderly-care/docs/superpowers/plans/2026-10-14-服务机构端-C1待遇档案.md
liuyun bd4d5c5744 docs: 服务机构端 C1 待遇档案实施计划
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 14:23:29 +08:00

395 lines
18 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.

# 服务机构端 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<string>`(返回对象 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<string> {
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<string, unknown>) => http.post<Elder>('/elder', data)
export const getElder = (id: string) => http.get<Elder>(`/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 间一致。