feat(c5): 拍照分档批量改 + 机构配置 + 文书导出
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9a0ffc6fc7
commit
16937fcf17
@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
17
src/document/document.controller.ts
Normal file
17
src/document/document.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/document/document.module.ts
Normal file
9
src/document/document.module.ts
Normal 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 {}
|
||||||
220
src/document/document.service.spec.ts
Normal file
220
src/document/document.service.spec.ts
Normal 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' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
151
src/document/document.service.ts
Normal file
151
src/document/document.service.ts
Normal 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
20
src/document/dto.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user