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

18 KiB
Raw Blame History

服务机构端 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 末尾)

/// 老人档案
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 行集合)
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
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;缺则补)

  elder: { list: 'elder:list', detail: 'elder:detail', create: 'elder:create', statusChange: 'elder:status-change' },
  • Step 2: 追加 elder 错误码(error-codes.ts,域 43)
  // 服务机构端·老人档案(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')
  { 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

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

cd sources/ec-nest && pnpm add minio

.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)
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
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
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 场景)

// 关键用例(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
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
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)

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

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 间一致。