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: '新名称' } });
|
||||
});
|
||||
|
||||
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 { 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;
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
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