From 16937fcf175367fdb35f1ce19c75e5d21469a2fb Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 07:36:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(c5):=20=E6=8B=8D=E7=85=A7=E5=88=86?= =?UTF-8?q?=E6=A1=A3=E6=89=B9=E9=87=8F=E6=94=B9=20+=20=E6=9C=BA=E6=9E=84?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20=E6=96=87=E4=B9=A6=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/agency/agency.service.spec.ts | 16 ++ src/agency/dto.ts | 18 ++- src/app.module.ts | 2 + src/catalog/catalog.controller.ts | 9 +- src/catalog/catalog.service.spec.ts | 87 ++++++++++ src/catalog/catalog.service.ts | 39 ++++- src/catalog/dto.ts | 35 +++- src/document/document.controller.ts | 17 ++ src/document/document.module.ts | 9 ++ src/document/document.service.spec.ts | 220 ++++++++++++++++++++++++++ src/document/document.service.ts | 151 ++++++++++++++++++ src/document/dto.ts | 20 +++ 12 files changed, 618 insertions(+), 5 deletions(-) create mode 100644 src/document/document.controller.ts create mode 100644 src/document/document.module.ts create mode 100644 src/document/document.service.spec.ts create mode 100644 src/document/document.service.ts create mode 100644 src/document/dto.ts diff --git a/src/agency/agency.service.spec.ts b/src/agency/agency.service.spec.ts index f52e178..b9b4c38 100644 --- a/src/agency/agency.service.spec.ts +++ b/src/agency/agency.service.spec.ts @@ -36,4 +36,20 @@ describe('AgencyService', () => { expect(update).toHaveBeenCalledWith({ where: { code: 'T001' }, data: { orgName: '新名称' } }); }); + + it('update 落 qcThreshold/dockingTier/newNationalNotify 三字段', async () => { + const findUnique = vi.fn().mockResolvedValue({ code: 'T001' }); + const update = vi.fn().mockResolvedValue({ code: 'T001' }); + const prisma = { client: { agency: { findUnique, update } } }; + const service = new AgencyService(prisma as unknown as PrismaService); + + await runWithRequestContext(ctx, () => + service.update({ qcThreshold: 48, dockingTier: 'A', newNationalNotify: false }), + ); + + expect(update).toHaveBeenCalledWith({ + where: { code: 'T001' }, + data: { qcThreshold: 48, dockingTier: 'A', newNationalNotify: false }, + }); + }); }); diff --git a/src/agency/dto.ts b/src/agency/dto.ts index 6cb6cae..d1d0a03 100644 --- a/src/agency/dto.ts +++ b/src/agency/dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, Min } from 'class-validator'; /** * 更新机构信息。仅暴露业务可编辑字段: @@ -58,4 +58,20 @@ export class UpdateAgencyDto { @IsOptional() @IsDateString() signDate?: string; + + @ApiPropertyOptional({ description: '质控时限(小时)' }) + @IsOptional() + @IsInt() + @Min(0) + qcThreshold?: number; + + @ApiPropertyOptional({ description: '对接档位' }) + @IsOptional() + @IsString() + dockingTier?: string; + + @ApiPropertyOptional({ description: '国家新增项通知开关' }) + @IsOptional() + @IsBoolean() + newNationalNotify?: boolean; } diff --git a/src/app.module.ts b/src/app.module.ts index db01224..3730bdc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,6 +36,7 @@ import { OrderModule } from './order/order.module.js'; import { RecordModule } from './record/record.module.js'; import { AbnormalModule } from './abnormal/abnormal.module.js'; import { QcModule } from './qc/qc.module.js'; +import { DocumentModule } from './document/document.module.js'; import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; @@ -69,6 +70,7 @@ import { AppService } from './app.service.js'; RecordModule, AbnormalModule, QcModule, + DocumentModule, ], controllers: [AppController], providers: [ diff --git a/src/catalog/catalog.controller.ts b/src/catalog/catalog.controller.ts index 63045b5..d72f518 100644 --- a/src/catalog/catalog.controller.ts +++ b/src/catalog/catalog.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; -import { ConfigSvcOrgDto } from './dto.js'; +import { ConfigSvcOrgDto, UpdatePhotoTierDto } from './dto.js'; import { CatalogService } from './catalog.service.js'; @Controller('svc') @@ -27,6 +27,13 @@ export class CatalogController { return this.service.config(dto); } + @Post('org/photo-tier/batch') + @RequirePermissions(Permissions.svcOrg.config) + @AuditLog('svc-org', 'photo-tier-batch') + updatePhotoTier(@Body() dto: UpdatePhotoTierDto) { + return this.service.updatePhotoTier(dto); + } + @Get('org/new-national') @RequirePermissions(Permissions.svcOrg.newNational) newNational() { diff --git a/src/catalog/catalog.service.spec.ts b/src/catalog/catalog.service.spec.ts index c80d0a1..b740256 100644 --- a/src/catalog/catalog.service.spec.ts +++ b/src/catalog/catalog.service.spec.ts @@ -110,3 +110,90 @@ describe('CatalogService', () => { ).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } }); }); }); + +describe('CatalogService.updatePhotoTier', () => { + it('逐条校验本机构 + 更新 photoTier,无放宽时不返回 warnings', async () => { + const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'o1', tenantCode: 'T001', photoTier: 'free' }); + const svcOrgUpdate = vi.fn().mockResolvedValue({ id: 'o1', photoTier: 'must' }); + const { service } = makeService({ svcOrgFindFirst, svcOrgUpdate }); + + const res = await runWithRequestContext(ctx, () => + service.updatePhotoTier({ + items: [ + { id: 'o1', photoTier: 'must' }, + { id: 'o2', photoTier: 'optional' }, + ], + } as never), + ); + + expect(svcOrgFindFirst).toHaveBeenCalledTimes(2); + expect(svcOrgFindFirst).toHaveBeenCalledWith({ where: { id: 'o1', tenantCode: 'T001' } }); + expect(svcOrgUpdate).toHaveBeenCalledTimes(2); + expect(svcOrgUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { photoTier: 'must' } }); + expect(svcOrgUpdate).toHaveBeenCalledWith({ where: { id: 'o2' }, data: { photoTier: 'optional' } }); + expect(res.updated).toBe(2); + expect(res.warnings).toEqual([]); + expect(res.message).toBeUndefined(); + }); + + it('must→free/optional 收集 warnings 提示放宽', async () => { + const svcOrgFindFirst = vi + .fn() + .mockResolvedValueOnce({ id: 'o1', tenantCode: 'T001', photoTier: 'must' }) + .mockResolvedValueOnce({ id: 'o2', tenantCode: 'T001', photoTier: 'must' }); + const svcOrgUpdate = vi.fn().mockResolvedValue({}); + const { service } = makeService({ svcOrgFindFirst, svcOrgUpdate }); + + const res = await runWithRequestContext(ctx, () => + service.updatePhotoTier({ + items: [ + { id: 'o1', photoTier: 'free' }, + { id: 'o2', photoTier: 'optional' }, + ], + } as never), + ); + + expect(res.warnings).toEqual([ + { id: 'o1', from: 'must', to: 'free' }, + { id: 'o2', from: 'must', to: 'optional' }, + ]); + expect(res.message).toBe('已放宽 2 项,请确认'); + }); + + it('must→must(保持必拍)不算放宽', async () => { + const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'o1', tenantCode: 'T001', photoTier: 'must' }); + const svcOrgUpdate = vi.fn().mockResolvedValue({}); + const { service } = makeService({ svcOrgFindFirst, svcOrgUpdate }); + + const res = await runWithRequestContext(ctx, () => + service.updatePhotoTier({ items: [{ id: 'o1', photoTier: 'must' }] } as never), + ); + + expect(res.warnings).toEqual([]); + }); + + it('目标 svc_org 非本机构 → SVC_ORG_NOT_FOUND', async () => { + const svcOrgFindFirst = vi.fn().mockResolvedValue(null); + const { service, svcOrgUpdate } = makeService({ svcOrgFindFirst }); + + await expect( + runWithRequestContext(ctx, () => + service.updatePhotoTier({ items: [{ id: 'missing', photoTier: 'must' }] } as never), + ), + ).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } }); + + expect(svcOrgUpdate).not.toHaveBeenCalled(); + }); + + it('非法 photoTier → 拒绝', async () => { + const { service, svcOrgUpdate } = makeService(); + + await expect( + runWithRequestContext(ctx, () => + service.updatePhotoTier({ items: [{ id: 'o1', photoTier: 'must_have' }] } as never), + ), + ).rejects.toThrowError('photo_tier'); + + expect(svcOrgUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/catalog/catalog.service.ts b/src/catalog/catalog.service.ts index df6b49d..dfadd96 100644 --- a/src/catalog/catalog.service.ts +++ b/src/catalog/catalog.service.ts @@ -1,9 +1,12 @@ -import { HttpStatus, Injectable } from '@nestjs/common'; +import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service.js'; import { getRequestContext } from '../common/context/request-context.js'; import { ErrorCodes } from '../common/error-codes.js'; import { BusinessException } from '../common/business-exception.js'; -import { ConfigSvcOrgDto } from './dto.js'; +import { ConfigSvcOrgDto, UpdatePhotoTierDto } from './dto.js'; + +/** 拍照分档合法取值(photo_tier 列枚举)。 */ +const PHOTO_TIERS = ['must', 'free', 'optional'] as const; /** * 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。 @@ -60,4 +63,36 @@ export class CatalogService { throw new BusinessException(ErrorCodes.SVC_LOC_IMMUTABLE, '仅可调整启用状态与价格', HttpStatus.BAD_REQUEST); } } + + /** + * 拍照分档批量更新:逐条校验 id 落本机构 svc_org 后 update photoTier。 + * must→free/optional 视为「放宽」,收集到 warnings 返回(提示「已放宽 N 项,请确认」),不静默。 + */ + async updatePhotoTier(dto: UpdatePhotoTierDto) { + const ctx = getRequestContext()!; + const warnings: { id: string; from: string; to: string }[] = []; + for (const item of dto.items) { + if (!(PHOTO_TIERS as readonly string[]).includes(item.photoTier)) { + throw new BadRequestException('photo_tier 非法(须 must/free/optional)'); + } + const existing = await this.prisma.client.svcOrg.findFirst({ + where: { id: item.id, tenantCode: ctx.tenantCode }, + }); + if (!existing) { + throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND); + } + if (existing.photoTier === 'must' && item.photoTier !== 'must') { + warnings.push({ id: item.id, from: existing.photoTier, to: item.photoTier }); + } + await this.prisma.client.svcOrg.update({ + where: { id: item.id }, + data: { photoTier: item.photoTier }, + }); + } + return { + updated: dto.items.length, + warnings, + message: warnings.length > 0 ? `已放宽 ${warnings.length} 项,请确认` : undefined, + }; + } } diff --git a/src/catalog/dto.ts b/src/catalog/dto.ts index 4ca1066..6c16d55 100644 --- a/src/catalog/dto.ts +++ b/src/catalog/dto.ts @@ -1,5 +1,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; /** * 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之 @@ -33,3 +43,26 @@ export class ConfigSvcOrgDto { @IsOptional() srcId?: string; } + +/** 拍照分档批量更新项:机构只能调 photoTier(must/free/optional),id 定位目标 svc_org 行。 */ +export class PhotoTierItemDto { + @ApiProperty({ description: '机构项目库记录 ID(由 /svc/org/list 返回)' }) + @IsString() + @IsNotEmpty() + id!: string; + + @ApiProperty({ description: '拍照分档', enum: ['must', 'free', 'optional'] }) + @IsString() + @IsIn(['must', 'free', 'optional']) + photoTier!: string; +} + +/** 拍照分档批量更新入参。 */ +export class UpdatePhotoTierDto { + @ApiProperty({ description: '批量更新项', type: [PhotoTierItemDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => PhotoTierItemDto) + items!: PhotoTierItemDto[]; +} diff --git a/src/document/document.controller.ts b/src/document/document.controller.ts new file mode 100644 index 0000000..20930ee --- /dev/null +++ b/src/document/document.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { Permissions } from '../common/permissions.js'; +import { AuditLog, RequirePermissions } from '../auth/decorators.js'; +import { ExportDocumentQuery } from './dto.js'; +import { DocumentService } from './document.service.js'; + +@Controller('document') +export class DocumentController { + constructor(private readonly service: DocumentService) {} + + @Get('export') + @RequirePermissions(Permissions.export.export) + @AuditLog('document', 'export') + export(@Query() q: ExportDocumentQuery) { + return this.service.export(q.elderId, q.startDate, q.endDate); + } +} diff --git a/src/document/document.module.ts b/src/document/document.module.ts new file mode 100644 index 0000000..5284921 --- /dev/null +++ b/src/document/document.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DocumentController } from './document.controller.js'; +import { DocumentService } from './document.service.js'; + +@Module({ + controllers: [DocumentController], + providers: [DocumentService], +}) +export class DocumentModule {} diff --git a/src/document/document.service.spec.ts b/src/document/document.service.spec.ts new file mode 100644 index 0000000..96dc8c1 --- /dev/null +++ b/src/document/document.service.spec.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest'; +import { DocumentService, EXPORT_NOTICE } from './document.service.js'; +import type { PrismaService } from '../prisma/prisma.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: 'self', + orgCode: null, + orgCodes: [], +}; + +function makeService( + overrides: { + elderFindUnique?: ReturnType; + agencyFindUnique?: ReturnType; + planFindMany?: ReturnType; + planItemFindMany?: ReturnType; + recordFindMany?: ReturnType; + recordItemFindMany?: ReturnType; + recordAbnormalFindMany?: ReturnType; + } = {}, +) { + const elderFindUnique = overrides.elderFindUnique ?? vi.fn().mockResolvedValue(null); + const agencyFindUnique = overrides.agencyFindUnique ?? vi.fn().mockResolvedValue({ code: 'T001', orgName: '测试机构' }); + const planFindMany = overrides.planFindMany ?? vi.fn().mockResolvedValue([]); + const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]); + const recordFindMany = overrides.recordFindMany ?? vi.fn().mockResolvedValue([]); + const recordItemFindMany = overrides.recordItemFindMany ?? vi.fn().mockResolvedValue([]); + const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]); + + const prisma = { + client: { + elder: { findUnique: elderFindUnique }, + agency: { findUnique: agencyFindUnique }, + plan: { findMany: planFindMany }, + planItem: { findMany: planItemFindMany }, + record: { findMany: recordFindMany }, + recordItem: { findMany: recordItemFindMany }, + recordAbnormal: { findMany: recordAbnormalFindMany }, + }, + }; + const service = new DocumentService(prisma as unknown as PrismaService); + return { + service, + elderFindUnique, + agencyFindUnique, + planFindMany, + planItemFindMany, + recordFindMany, + recordItemFindMany, + recordAbnormalFindMany, + }; +} + +describe('DocumentService.export', () => { + it('老人不存在 → ELDER_NOT_FOUND', async () => { + const { service, recordFindMany } = makeService(); + + await expect( + runWithRequestContext(ctx, () => service.export('missing')), + ).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_NOT_FOUND } }); + + expect(recordFindMany).not.toHaveBeenCalled(); + }); + + it('组装一人一档:掩码身份证 + 日期转 YYYY-MM-DD + 计划/记录/异常 + 水印', async () => { + const elder = { + id: 'e1', + name: '张三', + gender: '1', + birthDate: new Date('1950-01-01T00:00:00Z'), + idCardNo: '530102195001010011', + phone: '13800000000', + address: '昆明市', + insurNo: 'I001', + careLevel: '重度', + treatStatus: 'active', + benefitStart: new Date('2026-01-01T00:00:00Z'), + benefitEnd: null, + }; + const plans = [ + { + id: 'p1', + elderId: 'e1', + planPeriodStart: new Date('2026-10-01T00:00:00Z'), + planPeriodEnd: new Date('2026-10-31T00:00:00Z'), + careLevel: '重度', + careMode: 'home_org', + planStatus: 'active', + }, + ]; + const planItems = [ + { id: 'pi1', planId: 'p1', svcId: 'svc1', svcNameSnapshot: '协助翻身', freqType: 'weekly', freqValue: 3, durationMin: 30, photoTier: 'must' }, + ]; + const records = [ + { + id: 'r1', + elderId: 'e1', + checkinTime: new Date('2026-10-15T02:00:00Z'), + checkoutTime: new Date('2026-10-15T03:00:00Z'), + durationMin: 60, + recordStatus: 'submitted', + }, + ]; + const recordItems = [ + { id: 'ri1', recordId: 'r1', svcId: 'svc1', svcNameSnapshot: '协助翻身', execResult: 'done', undoReason: null, extraFlag: false }, + ]; + const abnormals = [ + { id: 'a1', elderId: 'e1', abnormalType: '跌倒', abnormalDesc: '轻微', handleStatus: 'open' }, + ]; + + const { + service, + elderFindUnique, + agencyFindUnique, + planFindMany, + planItemFindMany, + recordFindMany, + recordItemFindMany, + recordAbnormalFindMany, + } = makeService({ + elderFindUnique: vi.fn().mockResolvedValue(elder), + planFindMany: vi.fn().mockResolvedValue(plans), + planItemFindMany: vi.fn().mockResolvedValue(planItems), + recordFindMany: vi.fn().mockResolvedValue(records), + recordItemFindMany: vi.fn().mockResolvedValue(recordItems), + recordAbnormalFindMany: vi.fn().mockResolvedValue(abnormals), + }); + + const res = await runWithRequestContext(ctx, () => service.export('e1', '2026-10-01', '2026-10-31')); + + // 定位与范围 + expect(elderFindUnique).toHaveBeenCalledWith({ where: { id: 'e1' } }); + expect(agencyFindUnique).toHaveBeenCalledWith({ where: { code: 'T001' } }); + expect(planFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' }, orderBy: { createdAt: 'desc' } }); + expect(planItemFindMany).toHaveBeenCalledWith({ where: { planId: { in: ['p1'] } } }); + expect(recordFindMany).toHaveBeenCalledWith({ + where: { + elderId: 'e1', + checkinTime: { + gte: new Date('2026-10-01T00:00:00+08:00'), + lte: new Date('2026-10-31T23:59:59.999+08:00'), + }, + }, + orderBy: { checkinTime: 'desc' }, + }); + expect(recordItemFindMany).toHaveBeenCalledWith({ where: { recordId: { in: ['r1'] } } }); + expect(recordAbnormalFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' } }); + + // 水印:机构名 + 时间 + 操作人 + R4 文案 + expect(res.watermark).toMatchObject({ + orgName: '测试机构', + operator: 'u1', + notice: EXPORT_NOTICE, + }); + expect(res.watermark.exportTime).toEqual(expect.any(String)); + + // 老人信息:身份证掩码 + 日期转 YYYY-MM-DD + expect(res.elder).toEqual({ + name: '张三', + gender: '1', + birthDate: '1950-01-01', + idCardNo: '5301**********0011', + phone: '13800000000', + address: '昆明市', + insurNo: 'I001', + careLevel: '重度', + treatStatus: 'active', + benefitStart: '2026-01-01', + benefitEnd: null, + }); + + // 计划 + 计划项 + expect(res.plans).toEqual([ + { + id: 'p1', + planPeriodStart: '2026-10-01', + planPeriodEnd: '2026-10-31', + careLevel: '重度', + careMode: 'home_org', + planStatus: 'active', + items: [{ svcId: 'svc1', svcName: '协助翻身', freqType: 'weekly', freqValue: 3, durationMin: 30, photoTier: 'must' }], + }, + ]); + + // 记录 + 记录项(checkin/checkout 转 ISO) + expect(res.records).toEqual([ + { + id: 'r1', + checkinTime: new Date('2026-10-15T02:00:00Z').toISOString(), + checkoutTime: new Date('2026-10-15T03:00:00Z').toISOString(), + durationMin: 60, + recordStatus: 'submitted', + items: [{ svcId: 'svc1', svcName: '协助翻身', execResult: 'done', undoReason: null, extraFlag: false }], + }, + ]); + + // 异常 + expect(res.abnormals).toEqual([{ id: 'a1', type: '跌倒', desc: '轻微', handleStatus: 'open' }]); + }); + + it('无日期范围时不加 checkinTime 条件', async () => { + const elder = { id: 'e1', name: '张三', gender: '1', birthDate: new Date('1950-01-01T00:00:00Z'), idCardNo: '530102195001010011', phone: null, address: '昆明市', insurNo: 'I001', careLevel: '重度', treatStatus: 'active', benefitStart: null, benefitEnd: null }; + const recordFindMany = vi.fn().mockResolvedValue([]); + const { service } = makeService({ + elderFindUnique: vi.fn().mockResolvedValue(elder), + recordFindMany, + }); + + await runWithRequestContext(ctx, () => service.export('e1')); + + expect(recordFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' }, orderBy: { checkinTime: 'desc' } }); + }); +}); diff --git a/src/document/document.service.ts b/src/document/document.service.ts new file mode 100644 index 0000000..737cfdd --- /dev/null +++ b/src/document/document.service.ts @@ -0,0 +1,151 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +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 { maskIdCard } from '../elder/elder.service.js'; + +/** 导出水印固定文案(R4:只用「日常留痕,随时可导出」,禁用迎检/应对巡查等词)。 */ +export const EXPORT_NOTICE = '日常留痕,随时可导出'; + +/** @db.Date 字段 → YYYY-MM-DD(按 UTC 取日期,与 toDateOnly 的 UTC 零点存储口径一致)。 */ +function fmtDate(v: unknown): string | null { + if (!v) return null; + const d = v instanceof Date ? v : new Date(v as string | number); + if (Number.isNaN(d.getTime())) return null; + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; +} + +/** 日期时间字段 → ISO 字符串。 */ +function fmtDateTime(v: unknown): string | null { + if (!v) return null; + const d = v instanceof Date ? v : new Date(v as string | number); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); +} + +/** YYYY-MM-DD → 起/止边界(与 record.list 的服务日期口径一致:+08:00);否则按 ISO 原样解析。 */ +function toRangeDate(s: string, endOfDay: boolean): Date { + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + return new Date(`${s}T${endOfDay ? '23:59:59.999' : '00:00:00'}+08:00`); + } + return new Date(s); +} + +/** + * 文书导出:一人一档结构化 JSON(结构化导出,前端组装文件,不引重型依赖)。 + * + * - elder 基本信息(idCardNo 掩码、@db.Date 转 YYYY-MM-DD); + * - plan + plan_item(项目名快照); + * - record(checkinTime 范围)+ record_item(exec_result); + * - record_abnormal; + * - watermark 头(机构名 + 时间 + 操作人 + 固定文案)。 + */ +@Injectable() +export class DocumentService { + constructor(private readonly prisma: PrismaService) {} + + async export(elderId: string, startDate?: string, endDate?: string) { + const ctx = getRequestContext()!; + const elder = await this.prisma.client.elder.findUnique({ where: { id: elderId } }); + if (!elder) { + throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND); + } + + const [agency, plans, abnormals] = await Promise.all([ + this.prisma.client.agency.findUnique({ where: { code: ctx.tenantCode } }), + this.prisma.client.plan.findMany({ where: { elderId }, orderBy: { createdAt: 'desc' } }), + this.prisma.client.recordAbnormal.findMany({ where: { elderId } }), + ]); + + const planIds = plans.map((p) => p.id); + const planItems = planIds.length + ? await this.prisma.client.planItem.findMany({ where: { planId: { in: planIds } } }) + : []; + const planItemsByPlan = new Map(); + for (const item of planItems) { + const list = planItemsByPlan.get(item.planId) ?? []; + list.push(item); + planItemsByPlan.set(item.planId, list); + } + + const recordWhere: Record = { elderId }; + const checkinTime: Record = {}; + if (startDate) checkinTime.gte = toRangeDate(startDate, false); + if (endDate) checkinTime.lte = toRangeDate(endDate, true); + if (Object.keys(checkinTime).length > 0) recordWhere.checkinTime = checkinTime; + const records = await this.prisma.client.record.findMany({ + where: recordWhere, + orderBy: { checkinTime: 'desc' }, + }); + + const recordIds = records.map((r) => r.id); + const recordItems = recordIds.length + ? await this.prisma.client.recordItem.findMany({ where: { recordId: { in: recordIds } } }) + : []; + const recordItemsByRecord = new Map(); + for (const item of recordItems) { + const list = recordItemsByRecord.get(item.recordId) ?? []; + list.push(item); + recordItemsByRecord.set(item.recordId, list); + } + + return { + watermark: { + orgName: agency?.orgName ?? '', + exportTime: new Date().toISOString(), + operator: ctx.userId, + notice: EXPORT_NOTICE, + }, + elder: { + name: elder.name, + gender: elder.gender, + birthDate: fmtDate(elder.birthDate), + idCardNo: maskIdCard(String(elder.idCardNo ?? '')), + phone: elder.phone, + address: elder.address, + insurNo: elder.insurNo, + careLevel: elder.careLevel, + treatStatus: elder.treatStatus, + benefitStart: fmtDate(elder.benefitStart), + benefitEnd: fmtDate(elder.benefitEnd), + }, + plans: plans.map((p) => ({ + id: p.id, + planPeriodStart: fmtDate(p.planPeriodStart), + planPeriodEnd: fmtDate(p.planPeriodEnd), + careLevel: p.careLevel, + careMode: p.careMode, + planStatus: p.planStatus, + items: (planItemsByPlan.get(p.id) ?? []).map((it) => ({ + svcId: it.svcId, + svcName: it.svcNameSnapshot, + freqType: it.freqType, + freqValue: it.freqValue, + durationMin: it.durationMin, + photoTier: it.photoTier, + })), + })), + records: records.map((r) => ({ + id: r.id, + checkinTime: fmtDateTime(r.checkinTime), + checkoutTime: fmtDateTime(r.checkoutTime), + durationMin: r.durationMin, + recordStatus: r.recordStatus, + items: (recordItemsByRecord.get(r.id) ?? []).map((it) => ({ + svcId: it.svcId, + svcName: it.svcNameSnapshot, + execResult: it.execResult, + undoReason: it.undoReason, + extraFlag: it.extraFlag, + })), + })), + abnormals: abnormals.map((a) => ({ + id: a.id, + type: a.abnormalType, + desc: a.abnormalDesc, + handleStatus: a.handleStatus, + })), + }; + } +} diff --git a/src/document/dto.ts b/src/document/dto.ts new file mode 100644 index 0000000..91974b4 --- /dev/null +++ b/src/document/dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +/** 文书导出查询参数。 */ +export class ExportDocumentQuery { + @ApiProperty({ description: '老人档案 ID' }) + @IsString() + @IsNotEmpty() + elderId!: string; + + @ApiPropertyOptional({ description: '留痕起始日期(YYYY-MM-DD 或 ISO 日期时间)' }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ description: '留痕结束日期(YYYY-MM-DD 或 ISO 日期时间)' }) + @IsOptional() + @IsDateString() + endDate?: string; +}