248 lines
9.9 KiB
TypeScript
248 lines
9.9 KiB
TypeScript
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 { decryptIdCard, encrypt, hash } from '../common/id-card-cipher.js';
|
||
import { ChangeStatusDto, CreateElderDto, IRREVERSIBLE_TREAT_STATUSES, 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';
|
||
}
|
||
|
||
/** YYYY-MM-DD → UTC 零点 Date(Prisma @db.Date 按 UTC instant 取日期,须用 UTC 零点避免时区回退一天);空值返回 undefined。 */
|
||
export function toDateOnly(v?: string): Date | undefined {
|
||
if (!v) return undefined;
|
||
return new Date(`${v}T00:00:00Z`);
|
||
}
|
||
|
||
/**
|
||
* 老人档案(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>) => this.presentIdCard(e, false)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 建档:校验身份证(18 位+校验位)→ 同机构防重 → 等级与副本绑定 →
|
||
* 出生日期由身份证带出 → 初始状态由待遇有效期决定。
|
||
*/
|
||
async createElder(dto: CreateElderDto) {
|
||
const idCardNo = dto.idCardNo.trim();
|
||
const birthDate = deriveBirthDate(idCardNo);
|
||
this.assertBenefitRange(dto.benefitStart, dto.benefitEnd);
|
||
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);
|
||
const { idCardNo: _idCardNo, ...dtoRest } = dto;
|
||
try {
|
||
const created = await super.create({
|
||
...dtoRest,
|
||
idCardNoHash: hash(idCardNo),
|
||
idCardNoEnc: encrypt(idCardNo),
|
||
birthDate: toDateOnly(birthDate),
|
||
treatStatus,
|
||
statusSource: 'manual',
|
||
benefitStart: toDateOnly(dto.benefitStart),
|
||
benefitEnd: toDateOnly(dto.benefitEnd),
|
||
assessDate: toDateOnly(dto.assessDate),
|
||
insurVerifyDate: toDateOnly(dto.insurVerifyDate),
|
||
hospStart: toDateOnly(dto.hospStart),
|
||
hospEnd: toDateOnly(dto.hospEnd),
|
||
});
|
||
return this.presentIdCard(created, false);
|
||
} catch (err) {
|
||
// 建档失败时清理已上传的评估结论副本(避免 MinIO 孤儿对象)。
|
||
if (dto.assessEvidenceFile) {
|
||
await this.storage.delete(dto.assessEvidenceFile).catch(() => undefined);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** 详情: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 this.presentIdCard(elder, true);
|
||
}
|
||
return this.presentIdCard(elder, false);
|
||
}
|
||
|
||
/** 变更待遇状态: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);
|
||
if (IRREVERSIBLE_TREAT_STATUSES.includes(elder.treatStatus)) {
|
||
throw new BadRequestException('当前待遇状态不可再变更');
|
||
}
|
||
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' || r.roleCode === 'tenant_super_admin');
|
||
}
|
||
|
||
private async assertIdCardUnique(idCardNo: string) {
|
||
const existing = await this.db().findFirst({ where: { idCardNoHash: hash(idCardNo) } });
|
||
if (existing) {
|
||
throw new BusinessException(ErrorCodes.ELDER_ID_CARD_EXISTS, '已有档案', HttpStatus.CONFLICT);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 将存储层记录转成对外返回:剥离 idCardNoEnc / idCardNoHash,解密后按授权级别输出 idCardNo。
|
||
* plain=true(admin/qc 明文查看)返回明文,否则返回掩码。
|
||
*/
|
||
private presentIdCard(record: Record<string, any>, plain: boolean): Record<string, any> {
|
||
const { idCardNoEnc, idCardNoHash: _hash, ...rest } = record;
|
||
const idCardNo = decryptIdCard(idCardNoEnc);
|
||
return { ...rest, idCardNo: plain ? idCardNo : maskIdCard(idCardNo) };
|
||
}
|
||
|
||
/** 待遇有效期须「止 > 起」。 */
|
||
private assertBenefitRange(benefitStart?: string, benefitEnd?: string) {
|
||
if (benefitStart && benefitEnd && benefitEnd <= benefitStart) {
|
||
throw new BadRequestException('待遇有效期止须晚于待遇有效期起');
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|