feat(c5): 拍照分档批量改 + 机构配置 + 文书导出

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 07:36:58 +08:00
parent 9a0ffc6fc7
commit 16937fcf17
12 changed files with 618 additions and 5 deletions

View File

@ -36,4 +36,20 @@ describe('AgencyService', () => {
expect(update).toHaveBeenCalledWith({ where: { code: 'T001' }, data: { orgName: '新名称' } }); 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 },
});
});
}); });

View File

@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; 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() @IsOptional()
@IsDateString() @IsDateString()
signDate?: string; signDate?: string;
@ApiPropertyOptional({ description: '质控时限(小时)' })
@IsOptional()
@IsInt()
@Min(0)
qcThreshold?: number;
@ApiPropertyOptional({ description: '对接档位' })
@IsOptional()
@IsString()
dockingTier?: string;
@ApiPropertyOptional({ description: '国家新增项通知开关' })
@IsOptional()
@IsBoolean()
newNationalNotify?: boolean;
} }

View File

@ -36,6 +36,7 @@ import { OrderModule } from './order/order.module.js';
import { RecordModule } from './record/record.module.js'; import { RecordModule } from './record/record.module.js';
import { AbnormalModule } from './abnormal/abnormal.module.js'; import { AbnormalModule } from './abnormal/abnormal.module.js';
import { QcModule } from './qc/qc.module.js'; import { QcModule } from './qc/qc.module.js';
import { DocumentModule } from './document/document.module.js';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller.js';
import { AppService } from './app.service.js'; import { AppService } from './app.service.js';
@ -69,6 +70,7 @@ import { AppService } from './app.service.js';
RecordModule, RecordModule,
AbnormalModule, AbnormalModule,
QcModule, QcModule,
DocumentModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js'; import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.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'; import { CatalogService } from './catalog.service.js';
@Controller('svc') @Controller('svc')
@ -27,6 +27,13 @@ export class CatalogController {
return this.service.config(dto); 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') @Get('org/new-national')
@RequirePermissions(Permissions.svcOrg.newNational) @RequirePermissions(Permissions.svcOrg.newNational)
newNational() { newNational() {

View File

@ -110,3 +110,90 @@ describe('CatalogService', () => {
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } }); ).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();
});
});

View File

@ -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 { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js'; import { getRequestContext } from '../common/context/request-context.js';
import { ErrorCodes } from '../common/error-codes.js'; import { ErrorCodes } from '../common/error-codes.js';
import { BusinessException } from '../common/business-exception.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 机构项目库)。 * 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。
@ -60,4 +63,36 @@ export class CatalogService {
throw new BusinessException(ErrorCodes.SVC_LOC_IMMUTABLE, '仅可调整启用状态与价格', HttpStatus.BAD_REQUEST); 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,
};
}
} }

View File

@ -1,5 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 五条纪律之 * 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之
@ -33,3 +43,26 @@ export class ConfigSvcOrgDto {
@IsOptional() @IsOptional()
srcId?: string; 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[];
}

View File

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

View File

@ -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 {}

View File

@ -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<typeof vi.fn>;
agencyFindUnique?: ReturnType<typeof vi.fn>;
planFindMany?: ReturnType<typeof vi.fn>;
planItemFindMany?: ReturnType<typeof vi.fn>;
recordFindMany?: ReturnType<typeof vi.fn>;
recordItemFindMany?: ReturnType<typeof vi.fn>;
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
} = {},
) {
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' } });
});
});

View File

@ -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<string, any[]>();
for (const item of planItems) {
const list = planItemsByPlan.get(item.planId) ?? [];
list.push(item);
planItemsByPlan.set(item.planId, list);
}
const recordWhere: Record<string, unknown> = { elderId };
const checkinTime: Record<string, Date> = {};
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<string, any[]>();
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,
})),
};
}
}

20
src/document/dto.ts Normal file
View File

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