feat(c1): elder 待遇档案模块(建档/状态机/掩码/上传)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
465057cce0
commit
1308675f80
@ -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: [
|
||||
|
||||
164
src/elder/dto.ts
Normal file
164
src/elder/dto.ts
Normal 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;
|
||||
}
|
||||
51
src/elder/elder.controller.ts
Normal file
51
src/elder/elder.controller.ts
Normal 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
12
src/elder/elder.module.ts
Normal 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 {}
|
||||
236
src/elder/elder.service.spec.ts
Normal file
236
src/elder/elder.service.spec.ts
Normal 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
203
src/elder/elder.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user