ec-nest/src/elder/elder.service.ts
2026-10-21 14:59:22 +08:00

248 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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