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