feat(c1): elder 待遇档案模块(建档/状态机/掩码/上传)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-14 17:19:07 +08:00
parent 465057cce0
commit 1308675f80
6 changed files with 668 additions and 0 deletions

View File

@ -29,6 +29,7 @@ import { AgencyModule } from './agency/agency.module.js';
import { StaffModule } from './staff/staff.module.js'; import { StaffModule } from './staff/staff.module.js';
import { CatalogModule } from './catalog/catalog.module.js'; import { CatalogModule } from './catalog/catalog.module.js';
import { StorageModule } from './storage/storage.module.js'; import { StorageModule } from './storage/storage.module.js';
import { ElderModule } from './elder/elder.module.js';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller.js';
import { AppService } from './app.service.js'; import { AppService } from './app.service.js';
@ -55,6 +56,7 @@ import { AppService } from './app.service.js';
StaffModule, StaffModule,
CatalogModule, CatalogModule,
StorageModule, StorageModule,
ElderModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

164
src/elder/dto.ts Normal file
View File

@ -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;
}

View File

@ -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);
}
}

12
src/elder/elder.module.ts Normal file
View File

@ -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 {}

View File

@ -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<typeof vi.fn>;
elderFindUnique?: ReturnType<typeof vi.fn>;
elderFindMany?: ReturnType<typeof vi.fn>;
elderCount?: ReturnType<typeof vi.fn>;
elderCreate?: ReturnType<typeof vi.fn>;
elderUpdate?: ReturnType<typeof vi.fn>;
userRoleFindMany?: ReturnType<typeof vi.fn>;
statusLogCreate?: ReturnType<typeof vi.fn>;
auditRecord?: ReturnType<typeof vi.fn>;
} = {},
) {
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' });
});
});

203
src/elder/elder.service.ts Normal file
View File

@ -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<string, unknown> = {};
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<string, unknown>) => ({ ...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<boolean> {
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<Record<string, any>> {
const existing = await this.findById(id);
if (!existing) {
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
}
return existing;
}
}