diff --git a/src/app.module.ts b/src/app.module.ts index 46728a0..36ee2ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { AgencyModule } from './agency/agency.module.js'; import { StaffModule } from './staff/staff.module.js'; import { CatalogModule } from './catalog/catalog.module.js'; import { StorageModule } from './storage/storage.module.js'; +import { ElderModule } from './elder/elder.module.js'; import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; @@ -55,6 +56,7 @@ import { AppService } from './app.service.js'; StaffModule, CatalogModule, StorageModule, + ElderModule, ], controllers: [AppController], providers: [ diff --git a/src/elder/dto.ts b/src/elder/dto.ts new file mode 100644 index 0000000..ad6fbcf --- /dev/null +++ b/src/elder/dto.ts @@ -0,0 +1,164 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { PageQuery } from '../common/page.dto.js'; + +/** + * 老人档案(elder)DTO。字段字典以 shared/data-model 为准(ltc_elder)。 + * - 不含 tenantCode/createdBy/createdAt/updatedAt/deletedAt(系统字段,服务端注入); + * - birth_date 由身份证 7-14 位带出,不接受前端传入; + * - treat_status/status_source 由服务端定,不接受前端传入; + * - id_card_no 本期存明文(加密机制由后续安全 change 统一落地,见 elder.service.ts TODO)。 + */ +export class CreateElderDto { + @ApiProperty({ description: '参保地/统筹区 code(loc_code)' }) + @IsString() + @IsNotEmpty() + locCode!: string; + + @ApiProperty({ description: '姓名' }) + @IsString() + @IsNotEmpty() + @MaxLength(30) + name!: string; + + @ApiProperty({ description: '性别:M/F' }) + @IsString() + @IsNotEmpty() + @IsIn(['M', 'F']) + gender!: string; + + @ApiProperty({ description: '身份证号(18 位,服务端做 GB11643 校验位校验)' }) + @IsString() + @IsNotEmpty() + idCardNo!: string; + + @ApiPropertyOptional({ description: '联系电话' }) + @IsOptional() + @IsString() + phone?: string; + + @ApiProperty({ description: '居住地址' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + address!: string; + + @ApiPropertyOptional({ description: '紧急联系人' }) + @IsOptional() + @IsString() + guardianName?: string; + + @ApiPropertyOptional({ description: '紧急联系人电话' }) + @IsOptional() + @IsString() + guardianPhone?: string; + + @ApiProperty({ description: '医保参保号' }) + @IsString() + @IsNotEmpty() + insurNo!: string; + + @ApiProperty({ description: '参保类型:employee/resident' }) + @IsString() + @IsNotEmpty() + insurType!: string; + + @ApiPropertyOptional({ description: '缴费年限' }) + @IsOptional() + @IsInt() + insurYears?: number; + + @ApiPropertyOptional({ description: '参保核验状态:verified/unverified' }) + @IsOptional() + @IsString() + insurVerifyStatus?: string; + + @ApiPropertyOptional({ description: '参保核验日期(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + insurVerifyDate?: string; + + @ApiPropertyOptional({ description: '失能等级(sev_1/sev_2/sev_3),有值则须上传评估结论副本' }) + @IsOptional() + @IsString() + assessLevel?: string; + + @ApiPropertyOptional({ description: '护理等级(care_1/care_2/care_3),有值则须上传评估结论副本' }) + @IsOptional() + @IsString() + careLevel?: string; + + @ApiPropertyOptional({ description: '评估结论日期(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + assessDate?: string; + + @ApiPropertyOptional({ description: '评估机构名称' }) + @IsOptional() + @IsString() + assessOrg?: string; + + @ApiPropertyOptional({ description: '评估结论副本 MinIO key(有等级则必填)' }) + @IsOptional() + @IsString() + assessEvidenceFile?: string; + + @ApiPropertyOptional({ description: '待遇有效期起(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + benefitStart?: string; + + @ApiPropertyOptional({ description: '待遇有效期止(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + benefitEnd?: string; + + @ApiPropertyOptional({ description: '住院起(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + hospStart?: string; + + @ApiPropertyOptional({ description: '住院止(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + hospEnd?: string; + + @ApiPropertyOptional({ description: '备注' }) + @IsOptional() + @IsString() + note?: string; +} + +export class ListElderQuery extends PageQuery { + @ApiPropertyOptional({ description: '姓名(模糊)' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: '待遇状态' }) + @IsOptional() + @IsString() + treatStatus?: string; + + @ApiPropertyOptional({ description: '失能/护理等级(匹配 assess_level 或 care_level)' }) + @IsOptional() + @IsString() + level?: string; +} + +export class ChangeStatusDto { + @ApiProperty({ description: '目标待遇状态(十态之一)' }) + @IsString() + @IsNotEmpty() + toStatus!: string; + + @ApiProperty({ description: '变更原因(必填)' }) + @IsString() + @IsNotEmpty() + changeReason!: string; + + @ApiPropertyOptional({ description: '佐证材料 MinIO key' }) + @IsOptional() + @IsString() + evidenceFile?: string; +} diff --git a/src/elder/elder.controller.ts b/src/elder/elder.controller.ts new file mode 100644 index 0000000..5ac4854 --- /dev/null +++ b/src/elder/elder.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Param, Post, Query, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; +import { ChangeStatusDto, CreateElderDto, ListElderQuery } from './dto.js'; +import { ElderService } from './elder.service.js'; + +@Controller('elder') +export class ElderController { + constructor(private readonly service: ElderService) {} + + @Get() + @RequirePermissions(Permissions.elder.list) + list(@Query() q: ListElderQuery) { + return this.service.listElder(q); + } + + @Post() + @RequirePermissions(Permissions.elder.create) + @AuditLog('elder', 'create') + create(@Body() dto: CreateElderDto) { + return this.service.createElder(dto); + } + + @Get(':id') + @RequirePermissions(Permissions.elder.detail) + detail(@Param('id') id: string) { + return this.service.detail(id); + } + + @Post(':id/status') + @RequirePermissions(Permissions.elder.statusChange) + @AuditLog('elder', 'status-change') + changeStatus(@Param('id') id: string, @Body() dto: ChangeStatusDto) { + return this.service.changeStatus(id, dto); + } + + @Get(':id/status/log') + @RequirePermissions(Permissions.elder.detail) + statusLog(@Param('id') id: string) { + return this.service.statusLog(id); + } + + @Post('assess-evidence') + @RequirePermissions(Permissions.elder.create) + @AuditLog('elder', 'upload-evidence') + @UseInterceptors(FileInterceptor('file')) + uploadEvidence(@UploadedFile() file: { buffer: Buffer; originalname: string }) { + return this.service.uploadEvidence(file); + } +} diff --git a/src/elder/elder.module.ts b/src/elder/elder.module.ts new file mode 100644 index 0000000..958c08d --- /dev/null +++ b/src/elder/elder.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ElderController } from './elder.controller.js'; +import { ElderService } from './elder.service.js'; +import { StorageModule } from '../storage/storage.module.js'; +import { AuditLogsModule } from '../audit-logs/audit-logs.module.js'; + +@Module({ + imports: [StorageModule, AuditLogsModule], + controllers: [ElderController], + providers: [ElderService], +}) +export class ElderModule {} diff --git a/src/elder/elder.service.spec.ts b/src/elder/elder.service.spec.ts new file mode 100644 index 0000000..fd5a574 --- /dev/null +++ b/src/elder/elder.service.spec.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ElderService, maskIdCard } from './elder.service.js'; +import type { PrismaService } from '../prisma/prisma.service.js'; +import type { StorageService } from '../storage/storage.service.js'; +import type { AuditLogsService } from '../audit-logs/audit-logs.service.js'; +import { ErrorCodes } from '../common/error-codes.js'; +import { runWithRequestContext } from '../common/context/request-context.js'; +import type { RequestContext } from '../common/context/request-context.js'; + +const ctx: RequestContext = { + tenantCode: 'T001', + userId: 'u1', + username: 'admin', + super: false, + dataScope: 'all', + orgCode: null, + orgCodes: [], +}; + +/** 通过 GB11643 校验位校验的合法身份证(出生日期 1990-01-01)。 */ +const VALID_ID_CARD = '530102199001011231'; + +const baseDto = { + locCode: '530100', + name: '张三', + gender: 'M', + idCardNo: VALID_ID_CARD, + phone: '13800000000', + address: '昆明市五华区', + insurNo: 'INS001', + insurType: 'employee', +}; + +function makeService( + overrides: { + elderFindFirst?: ReturnType; + elderFindUnique?: ReturnType; + elderFindMany?: ReturnType; + elderCount?: ReturnType; + elderCreate?: ReturnType; + elderUpdate?: ReturnType; + userRoleFindMany?: ReturnType; + statusLogCreate?: ReturnType; + auditRecord?: ReturnType; + } = {}, +) { + const elderFindFirst = overrides.elderFindFirst ?? vi.fn().mockResolvedValue(null); + const elderFindUnique = overrides.elderFindUnique ?? vi.fn().mockResolvedValue(null); + const elderFindMany = overrides.elderFindMany ?? vi.fn().mockResolvedValue([]); + const elderCount = overrides.elderCount ?? vi.fn().mockResolvedValue(0); + const elderCreate = overrides.elderCreate ?? vi.fn().mockResolvedValue({ id: 'e1', ...baseDto }); + const elderUpdate = overrides.elderUpdate ?? vi.fn().mockResolvedValue({ id: 'e1' }); + const userRoleFindMany = overrides.userRoleFindMany ?? vi.fn().mockResolvedValue([]); + const statusLogCreate = overrides.statusLogCreate ?? vi.fn().mockResolvedValue({ id: 'sl1' }); + const auditRecord = overrides.auditRecord ?? vi.fn().mockResolvedValue(undefined); + + const prisma = { + client: { + elder: { + findFirst: elderFindFirst, + findUnique: elderFindUnique, + findMany: elderFindMany, + count: elderCount, + create: elderCreate, + update: elderUpdate, + }, + elderStatusLog: { create: statusLogCreate, findMany: vi.fn().mockResolvedValue([]) }, + userRole: { findMany: userRoleFindMany }, + }, + }; + const storage = { + buildKey: vi.fn().mockReturnValue('elder/T001/1-a.jpg'), + upload: vi.fn().mockResolvedValue('elder/T001/1-a.jpg'), + }; + const auditLogs = { record: auditRecord }; + const service = new ElderService( + prisma as unknown as PrismaService, + storage as unknown as StorageService, + auditLogs as unknown as AuditLogsService, + ); + return { + service, + elderFindFirst, + elderFindUnique, + elderFindMany, + elderCount, + elderCreate, + elderUpdate, + userRoleFindMany, + statusLogCreate, + auditRecord, + storage, + }; +} + +describe('maskIdCard', () => { + it('18 位 → 前4 + 10个* + 后4', () => { + expect(maskIdCard('530102199001011231')).toBe('5301**********1231'); + }); + it('15 位 → 前3 + 8个* + 后4', () => { + expect(maskIdCard('530102900101123')).toBe('530********1123'); + }); + it('空串与非常见长度原样返回', () => { + expect(maskIdCard('')).toBe(''); + expect(maskIdCard('123')).toBe('123'); + }); +}); + +describe('ElderService', () => { + it('create 有等级无评估结论副本 → 抛 ELDER_LEVEL_REQUIRES_EVIDENCE', async () => { + const { service, elderCreate } = makeService(); + + await expect( + runWithRequestContext(ctx, () => service.createElder({ ...baseDto, assessLevel: 'sev_1' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_LEVEL_REQUIRES_EVIDENCE } }); + + expect(elderCreate).not.toHaveBeenCalled(); + }); + + it('create benefit_start 未来 → treatStatus = waiting_period', async () => { + const { service, elderCreate } = makeService(); + + await runWithRequestContext(ctx, () => + service.createElder({ ...baseDto, benefitStart: '2099-01-01', benefitEnd: '2099-12-31' }), + ); + + expect(elderCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ treatStatus: 'waiting_period', statusSource: 'manual' }), + }); + }); + + it('create 待遇有效期覆盖今天 → treatStatus = active,且出生日期由身份证带出', async () => { + const { service, elderCreate } = makeService(); + + await runWithRequestContext(ctx, () => + service.createElder({ ...baseDto, benefitStart: '2000-01-01', benefitEnd: '2099-12-31' }), + ); + + expect(elderCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ treatStatus: 'active', birthDate: '1990-01-01' }), + }); + }); + + it('同机构重复 id_card_no → 抛 ELDER_ID_CARD_EXISTS', async () => { + const elderFindFirst = vi.fn().mockResolvedValue({ id: 'e-other', idCardNo: VALID_ID_CARD }); + const elderCreate = vi.fn(); + const { service } = makeService({ elderFindFirst, elderCreate }); + + await expect( + runWithRequestContext(ctx, () => service.createElder(baseDto)), + ).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_ID_CARD_EXISTS } }); + + expect(elderFindFirst).toHaveBeenCalledWith({ where: { idCardNo: VALID_ID_CARD } }); + expect(elderCreate).not.toHaveBeenCalled(); + }); + + it('changeStatus 缺 changeReason → 抛 ELDER_STATUS_REASON_REQUIRED', async () => { + const { service, elderUpdate } = makeService(); + + await expect( + runWithRequestContext(ctx, () => service.changeStatus('e1', { toStatus: 'active', changeReason: '' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_STATUS_REASON_REQUIRED } }); + + expect(elderUpdate).not.toHaveBeenCalled(); + }); + + it('changeStatus 写状态日志(from/to/reason/operator)', async () => { + const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', treatStatus: 'waiting_period', name: '张三' }); + const { service, statusLogCreate } = makeService({ elderFindUnique }); + + const result = await runWithRequestContext(ctx, () => + service.changeStatus('e1', { toStatus: 'active', changeReason: '待遇生效' }), + ); + + expect(result).toEqual({ id: 'e1', fromStatus: 'waiting_period', toStatus: 'active' }); + expect(statusLogCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + elderId: 'e1', + tenantCode: 'T001', + fromStatus: 'waiting_period', + toStatus: 'active', + changeReason: '待遇生效', + operatorId: 'u1', + }), + }); + }); + + it('list 返回掩码 id_card_no', async () => { + const elderFindMany = vi.fn().mockResolvedValue([ + { id: 'e1', idCardNo: VALID_ID_CARD, name: '张三', treatStatus: 'active' }, + ]); + const elderCount = vi.fn().mockResolvedValue(1); + const { service } = makeService({ elderFindMany, elderCount }); + + const res = await runWithRequestContext(ctx, () => service.listElder({ page: 1, pageSize: 20 })); + + expect(res.list[0].idCardNo).toBe('5301**********1231'); + expect(res.total).toBe(1); + }); + + it('detail 非 admin/qc 不返回明文', async () => { + const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三' }); + const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'nurse' }]); + const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany }); + + const res = await runWithRequestContext(ctx, () => service.detail('e1')); + + expect(res.idCardNo).toBe('5301**********1231'); + expect(auditRecord).not.toHaveBeenCalled(); + }); + + it('detail admin/qc 返回明文并写审计', async () => { + const elderFindUnique = vi.fn().mockResolvedValue({ id: 'e1', idCardNo: VALID_ID_CARD, name: '张三' }); + const userRoleFindMany = vi.fn().mockResolvedValue([{ roleCode: 'admin' }]); + const { service, auditRecord } = makeService({ elderFindUnique, userRoleFindMany }); + + const res = await runWithRequestContext(ctx, () => service.detail('e1')); + + expect(res.idCardNo).toBe(VALID_ID_CARD); + expect(auditRecord).toHaveBeenCalledWith( + expect.objectContaining({ module: 'elder', action: 'view-id-card', userId: 'u1' }), + ); + }); + + it('uploadEvidence 读 buffer → buildKey + upload → 返回 { key }', async () => { + const { service, storage } = makeService(); + + const res = await runWithRequestContext(ctx, () => + service.uploadEvidence({ buffer: Buffer.from('x'), originalname: 'a.jpg' }), + ); + + expect(storage.buildKey).toHaveBeenCalledWith('T001', 'a.jpg'); + expect(storage.upload).toHaveBeenCalledWith(expect.any(Buffer), 'elder/T001/1-a.jpg'); + expect(res).toEqual({ key: 'elder/T001/1-a.jpg' }); + }); +}); diff --git a/src/elder/elder.service.ts b/src/elder/elder.service.ts new file mode 100644 index 0000000..f8126c6 --- /dev/null +++ b/src/elder/elder.service.ts @@ -0,0 +1,203 @@ +import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; +import { BaseCrudService } from '../common/base-crud.service.js'; +import { PrismaService } from '../prisma/prisma.service.js'; +import { getRequestContext } from '../common/context/request-context.js'; +import { BusinessException } from '../common/business-exception.js'; +import { ErrorCodes } from '../common/error-codes.js'; +import { StorageService } from '../storage/storage.service.js'; +import { AuditLogsService } from '../audit-logs/audit-logs.service.js'; +import { ChangeStatusDto, CreateElderDto, ListElderQuery } from './dto.js'; + +/** + * 身份证掩码:18 位 → 前4 + 10个* + 后4;15 位 → 前3 + 8个* + 后4;其余原样。 + * 列表/非授权详情返回掩码;明文仅 admin/qc(或平台超管)可见且落审计。 + */ +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; +} + +/** GB11643-1999 加权因子与校验码映射。 */ +const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; +const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; + +/** 身份证 18 位 + 校验位校验,通过返回出生日期(YYYY-MM-DD)。 */ +function deriveBirthDate(idCardNo: string): string { + if (!/^\d{17}[\dXx]$/.test(idCardNo)) { + throw new BadRequestException('身份证号格式不正确(须 18 位,末位可为 X)'); + } + const sum = ID_WEIGHTS.reduce((acc, w, i) => acc + w * Number(idCardNo[i]), 0); + if (ID_CHECK_CODES[sum % 11] !== idCardNo[17].toUpperCase()) { + throw new BadRequestException('身份证号校验位错误'); + } + return `${idCardNo.slice(6, 10)}-${idCardNo.slice(10, 12)}-${idCardNo.slice(12, 14)}`; +} + +/** 本地今天(YYYY-MM-DD,字符串比较即日期比较)。 */ +function todayStr(): string { + const d = new Date(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${m}-${day}`; +} + +/** 建档初始状态:待遇有效期覆盖今天 → active;否则 waiting_period。 */ +function resolveInitialStatus(benefitStart?: string, benefitEnd?: string): string { + if (benefitStart && benefitEnd) { + const today = todayStr(); + if (benefitStart <= today && today <= benefitEnd) return 'active'; + } + return 'waiting_period'; +} + +/** + * 老人档案(elder)业务服务。elder 是租户级软删模型,读写经 Prisma 扩展自动注入 + * tenantCode / deletedAt 过滤;创建时 tenantCode/createdBy 由 BaseCrudService 注入。 + * elder_status_log 为 append-only 租户级模型(无软删),tenantCode 由本 service 显式写入。 + */ +@Injectable() +export class ElderService extends BaseCrudService { + constructor( + prisma: PrismaService, + private readonly storage: StorageService, + private readonly auditLogs: AuditLogsService, + ) { + super(prisma, 'elder'); + } + + /** 分页列表:状态/等级/姓名筛选;身份证服务端掩码。 */ + async listElder(q: ListElderQuery) { + const where: Record = {}; + if (q.name) where.name = { contains: q.name }; + if (q.treatStatus) where.treatStatus = q.treatStatus; + if (q.level) where.OR = [{ assessLevel: q.level }, { careLevel: q.level }]; + const page = await this.paginate(q.page, q.pageSize, where); + return { + ...page, + list: page.list.map((e: Record) => ({ ...e, idCardNo: maskIdCard(String(e.idCardNo ?? '')) })), + }; + } + + /** + * 建档:校验身份证(18 位+校验位)→ 同机构防重 → 等级与副本绑定 → + * 出生日期由身份证带出 → 初始状态由待遇有效期决定。 + */ + async createElder(dto: CreateElderDto) { + const idCardNo = dto.idCardNo.trim(); + const birthDate = deriveBirthDate(idCardNo); + await this.assertIdCardUnique(idCardNo); + if ((dto.assessLevel || dto.careLevel) && !dto.assessEvidenceFile) { + throw new BusinessException( + ErrorCodes.ELDER_LEVEL_REQUIRES_EVIDENCE, + '填写等级须上传评估结论副本', + HttpStatus.BAD_REQUEST, + ); + } + const treatStatus = resolveInitialStatus(dto.benefitStart, dto.benefitEnd); + // TODO(id_card_no 加密): 本期直接存明文,加密机制由后续 security change 统一落地。 + return super.create({ + ...dto, + idCardNo, + birthDate, + treatStatus, + statusSource: 'manual', + }); + } + + /** 详情:admin/qc(或平台超管)返回身份证明文并显式写审计;否则掩码。 */ + async detail(id: string) { + const elder = await this.requireExists(id); + if (await this.canViewPlainIdCard()) { + const ctx = getRequestContext(); + await this.auditLogs.record({ + tenantCode: ctx?.tenantCode ?? '', + userId: ctx?.userId ?? null, + username: ctx?.username ?? null, + module: 'elder', + action: 'view-id-card', + status: 'success', + params: JSON.stringify({ elderId: id, elderName: elder.name }), + }); + return elder; + } + return { ...elder, idCardNo: maskIdCard(String(elder.idCardNo ?? '')) }; + } + + /** 变更待遇状态:changeReason 必填;写 elder_status_log(from/to/reason/operator/changedAt)。 */ + async changeStatus(id: string, dto: ChangeStatusDto) { + if (!dto.changeReason) { + throw new BusinessException( + ErrorCodes.ELDER_STATUS_REASON_REQUIRED, + '变更状态须填写原因', + HttpStatus.BAD_REQUEST, + ); + } + const elder = await this.requireExists(id); + const ctx = getRequestContext(); + const fromStatus = elder.treatStatus; + await this.update(id, { + treatStatus: dto.toStatus, + statusSource: 'manual', + statusChangeDate: new Date(), + }); + await this.prisma.client.elderStatusLog.create({ + data: { + elderId: id, + tenantCode: ctx?.tenantCode ?? '', + fromStatus, + toStatus: dto.toStatus, + changeReason: dto.changeReason, + source: 'manual', + operatorId: ctx?.userId ?? '', + evidenceFile: dto.evidenceFile ?? null, + }, + }); + return { id, fromStatus, toStatus: dto.toStatus }; + } + + /** 状态变更历史(按时间倒序)。 */ + statusLog(id: string) { + const ctx = getRequestContext(); + return this.prisma.client.elderStatusLog.findMany({ + where: { elderId: id, tenantCode: ctx?.tenantCode }, + orderBy: { changedAt: 'desc' }, + }); + } + + /** 上传评估结论副本:读 buffer → StorageService.buildKey + upload → 返回 { key }。 */ + async uploadEvidence(file: { buffer: Buffer; originalname: string }) { + const ctx = getRequestContext(); + const key = this.storage.buildKey(ctx?.tenantCode ?? '', file.originalname); + await this.storage.upload(file.buffer, key); + return { key }; + } + + /** admin/qc 角色(或平台超管)可查看身份证明文。 */ + private async canViewPlainIdCard(): Promise { + const ctx = getRequestContext(); + if (!ctx) return false; + if (ctx.super) return true; + const rows = await this.prisma.client.userRole.findMany({ + where: { tenantCode: ctx.tenantCode, userId: ctx.userId }, + select: { roleCode: true }, + }); + return rows.some((r) => r.roleCode === 'admin' || r.roleCode === 'qc'); + } + + private async assertIdCardUnique(idCardNo: string) { + const existing = await this.db().findFirst({ where: { idCardNo } }); + if (existing) { + throw new BusinessException(ErrorCodes.ELDER_ID_CARD_EXISTS, '已有档案', HttpStatus.CONFLICT); + } + } + + private async requireExists(id: string): Promise> { + const existing = await this.findById(id); + if (!existing) { + throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND); + } + return existing; + } +}