Compare commits
No commits in common. "9d1a3b2374f239a280e7d66c0c331fd9a05731b4" and "9fea4efe0f05bec54ac0b4e9c4086947a3c486d7" have entirely different histories.
9d1a3b2374
...
9fea4efe0f
@ -1,7 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE `ltc_agency` ADD COLUMN `docking_tier` VARCHAR(10) NOT NULL DEFAULT 'B',
|
|
||||||
ADD COLUMN `new_national_notify` BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN `qc_threshold` INTEGER NOT NULL DEFAULT 24;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE `svc_org` ADD COLUMN `photo_tier` VARCHAR(10) NOT NULL DEFAULT 'free';
|
|
||||||
@ -318,9 +318,6 @@ model Agency {
|
|||||||
createdBy String? @map("created_by") @db.VarChar(36)
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
qcThreshold Int @default(24) @map("qc_threshold")
|
|
||||||
dockingTier String @default("B") @map("docking_tier") @db.VarChar(10)
|
|
||||||
newNationalNotify Boolean @default(true) @map("new_national_notify")
|
|
||||||
|
|
||||||
@@map("ltc_agency")
|
@@map("ltc_agency")
|
||||||
}
|
}
|
||||||
@ -410,7 +407,6 @@ model SvcOrg {
|
|||||||
price String? @map("price") @db.VarChar(50)
|
price String? @map("price") @db.VarChar(50)
|
||||||
priceEffective DateTime? @map("price_effective")
|
priceEffective DateTime? @map("price_effective")
|
||||||
isNewNational Boolean @default(false) @map("is_new_national")
|
isNewNational Boolean @default(false) @map("is_new_national")
|
||||||
photoTier String @default("free") @map("photo_tier") @db.VarChar(10)
|
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
createdBy String? @map("created_by") @db.VarChar(36)
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { validate } from 'class-validator';
|
|
||||||
import { AgencyService } from './agency.service.js';
|
import { AgencyService } from './agency.service.js';
|
||||||
import { UpdateAgencyDto } from './dto.js';
|
|
||||||
import type { PrismaService } from '../prisma/prisma.service.js';
|
import type { PrismaService } from '../prisma/prisma.service.js';
|
||||||
import { runWithRequestContext } from '../common/context/request-context.js';
|
import { runWithRequestContext } from '../common/context/request-context.js';
|
||||||
import type { RequestContext } from '../common/context/request-context.js';
|
import type { RequestContext } from '../common/context/request-context.js';
|
||||||
@ -39,123 +36,4 @@ 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 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无 agency 行时 PATCH 自动创建(最小默认值 + dto 字段,locCode 置空)', async () => {
|
|
||||||
const findUnique = vi.fn().mockResolvedValue(null);
|
|
||||||
const create = vi.fn().mockResolvedValue({ code: 'T001', orgName: '新机构' });
|
|
||||||
const update = vi.fn();
|
|
||||||
const prisma = { client: { agency: { findUnique, create, update } } };
|
|
||||||
const service = new AgencyService(prisma as unknown as PrismaService);
|
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () =>
|
|
||||||
service.update({ orgName: '新机构', qcThreshold: 48, dockingTier: 'A', newNationalNotify: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
code: 'T001',
|
|
||||||
orgName: '新机构',
|
|
||||||
orgType: '',
|
|
||||||
creditCode: '',
|
|
||||||
locCode: '',
|
|
||||||
locName: '',
|
|
||||||
svcSource: '',
|
|
||||||
address: '',
|
|
||||||
contactName: '',
|
|
||||||
contactPhone: '',
|
|
||||||
qcThreshold: 48,
|
|
||||||
dockingTier: 'A',
|
|
||||||
newNationalNotify: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无 agency 行且 dto 为空时,create 落最小默认值(24/B/true)', async () => {
|
|
||||||
const findUnique = vi.fn().mockResolvedValue(null);
|
|
||||||
const create = vi.fn().mockResolvedValue({ code: 'T001' });
|
|
||||||
const update = vi.fn();
|
|
||||||
const prisma = { client: { agency: { findUnique, create, update } } };
|
|
||||||
const service = new AgencyService(prisma as unknown as PrismaService);
|
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () => service.update({}));
|
|
||||||
|
|
||||||
expect(create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
code: 'T001',
|
|
||||||
orgName: '',
|
|
||||||
orgType: '',
|
|
||||||
creditCode: '',
|
|
||||||
locCode: '',
|
|
||||||
locName: '',
|
|
||||||
svcSource: '',
|
|
||||||
address: '',
|
|
||||||
contactName: '',
|
|
||||||
contactPhone: '',
|
|
||||||
qcThreshold: 24,
|
|
||||||
dockingTier: 'B',
|
|
||||||
newNationalNotify: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('R7:dto={qcThreshold:null} 过滤 null,创建用默认 24 而非 null', async () => {
|
|
||||||
const findUnique = vi.fn().mockResolvedValue(null);
|
|
||||||
const create = vi.fn().mockResolvedValue({ code: 'T001' });
|
|
||||||
const update = vi.fn();
|
|
||||||
const prisma = { client: { agency: { findUnique, create, update } } };
|
|
||||||
const service = new AgencyService(prisma as unknown as PrismaService);
|
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () => service.update({ qcThreshold: null } as never));
|
|
||||||
|
|
||||||
expect(create).toHaveBeenCalledWith({
|
|
||||||
data: {
|
|
||||||
code: 'T001',
|
|
||||||
orgName: '',
|
|
||||||
orgType: '',
|
|
||||||
creditCode: '',
|
|
||||||
locCode: '',
|
|
||||||
locName: '',
|
|
||||||
svcSource: '',
|
|
||||||
address: '',
|
|
||||||
contactName: '',
|
|
||||||
contactPhone: '',
|
|
||||||
qcThreshold: 24,
|
|
||||||
dockingTier: 'B',
|
|
||||||
newNationalNotify: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('UpdateAgencyDto.dockingTier', () => {
|
|
||||||
it('只允许 A/B/C', async () => {
|
|
||||||
const dto = plainToInstance(UpdateAgencyDto, { dockingTier: 'X' });
|
|
||||||
const errors = await validate(dto);
|
|
||||||
expect(errors.some((e) => e.property === 'dockingTier')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['A', 'B', 'C'])('合法值 %s 通过', async (tier) => {
|
|
||||||
const dto = plainToInstance(UpdateAgencyDto, { dockingTier: tier });
|
|
||||||
const errors = await validate(dto);
|
|
||||||
expect(errors).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,36 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { 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 { BusinessException } from '../common/business-exception.js';
|
||||||
import { UpdateAgencyDto } from './dto.js';
|
import { UpdateAgencyDto } from './dto.js';
|
||||||
|
|
||||||
/** 无 agency 行时(R5:无 provisioning 路径)创建行的最小默认值。 */
|
|
||||||
function agencyDefaults(code: string): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
orgName: '',
|
|
||||||
orgType: '',
|
|
||||||
creditCode: '',
|
|
||||||
locCode: '',
|
|
||||||
locName: '',
|
|
||||||
svcSource: '',
|
|
||||||
address: '',
|
|
||||||
contactName: '',
|
|
||||||
contactPhone: '',
|
|
||||||
qcThreshold: 24,
|
|
||||||
dockingTier: 'B',
|
|
||||||
newNationalNotify: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** R7:只保留 dto 中有值的字段,过滤 null/undefined,避免 null 覆盖 DB 默认值。 */
|
|
||||||
function definedFields(dto: UpdateAgencyDto): Record<string, unknown> {
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const [key, value] of Object.entries(dto)) {
|
|
||||||
if (value !== null && value !== undefined) out[key] = value;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 机构(=租户)信息读写。Agency 是 TENANT_FREE 模型,主键 code = 当前租户 code,
|
* 机构(=租户)信息读写。Agency 是 TENANT_FREE 模型,主键 code = 当前租户 code,
|
||||||
* 因此读写一律按 `ctx.tenantCode` 定位,而非 BaseCrudService 的 `findById(id)`。
|
* 因此读写一律按 `ctx.tenantCode` 定位,而非 BaseCrudService 的 `findById(id)`。
|
||||||
@ -45,21 +19,13 @@ export class AgencyService {
|
|||||||
return this.prisma.client.agency.findUnique({ where: { code: ctx.tenantCode } });
|
return this.prisma.client.agency.findUnique({ where: { code: ctx.tenantCode } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 更新当前机构信息;写操作同样按 code = ctx.tenantCode 定位,禁止按 id。 */
|
||||||
* 更新当前机构信息(upsert);写操作同样按 code = ctx.tenantCode 定位,禁止按 id。
|
|
||||||
* R5:agency 无 provisioning 路径(空表 + 无 create),故无行时先建最小默认值行再落 dto 字段。
|
|
||||||
* 参保地 locCode/locName 只读,不随 PATCH 更新(建行时置空串,dto 不含这两字段)。
|
|
||||||
*/
|
|
||||||
async update(dto: UpdateAgencyDto) {
|
async update(dto: UpdateAgencyDto) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
const existing = await this.getCurrent();
|
const existing = await this.getCurrent();
|
||||||
const data = definedFields(dto);
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return this.prisma.client.agency.create({
|
throw new BusinessException(ErrorCodes.ORG_NOT_FOUND, '机构不存在', HttpStatus.NOT_FOUND);
|
||||||
data: { ...agencyDefaults(ctx.tenantCode), ...data } as any,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return this.prisma.client.agency.update({ where: { code: ctx.tenantCode }, data });
|
return this.prisma.client.agency.update({ where: { code: ctx.tenantCode }, data: dto });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsBoolean, IsDateString, IsIn, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
import { IsDateString, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新机构信息。仅暴露业务可编辑字段:
|
* 更新机构信息。仅暴露业务可编辑字段:
|
||||||
@ -58,20 +58,4 @@ export class UpdateAgencyDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
signDate?: string;
|
signDate?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '质控时限(小时)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
qcThreshold?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '对接档位(A/B/C,C 显示「待接口发布」)', enum: ['A', 'B', 'C'] })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['A', 'B', 'C'])
|
|
||||||
dockingTier?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: '国家新增项通知开关' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
newNationalNotify?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,7 +36,6 @@ 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';
|
||||||
|
|
||||||
@ -70,7 +69,6 @@ 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, UpdatePhotoTierDto } from './dto.js';
|
import { ConfigSvcOrgDto } from './dto.js';
|
||||||
import { CatalogService } from './catalog.service.js';
|
import { CatalogService } from './catalog.service.js';
|
||||||
|
|
||||||
@Controller('svc')
|
@Controller('svc')
|
||||||
@ -27,13 +27,6 @@ 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,90 +110,3 @@ 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,12 +1,9 @@
|
|||||||
import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
|
import { 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, UpdatePhotoTierDto } from './dto.js';
|
import { ConfigSvcOrgDto } from './dto.js';
|
||||||
|
|
||||||
/** 拍照分档合法取值(photo_tier 列枚举)。 */
|
|
||||||
const PHOTO_TIERS = ['must', 'free', 'optional'] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。
|
* 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。
|
||||||
@ -63,36 +60,4 @@ 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,15 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import {
|
|
||||||
ArrayNotEmpty,
|
|
||||||
IsArray,
|
|
||||||
IsBoolean,
|
|
||||||
IsIn,
|
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之
|
* 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之
|
||||||
@ -43,26 +33,3 @@ 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[];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -131,8 +131,6 @@ export const APIS: { code: string; name: string; method: string; path: string; p
|
|||||||
{ code: Permissions.qc.correct, name: '补正', method: 'POST', path: '/api/v1/qc/correct', parent: 'qc' },
|
{ code: Permissions.qc.correct, name: '补正', method: 'POST', path: '/api/v1/qc/correct', parent: 'qc' },
|
||||||
{ code: Permissions.qc.batchPass, name: '批量放行', method: 'POST', path: '/api/v1/qc/batch-pass', parent: 'qc' },
|
{ code: Permissions.qc.batchPass, name: '批量放行', method: 'POST', path: '/api/v1/qc/batch-pass', parent: 'qc' },
|
||||||
{ code: Permissions.abnormal.handle, name: '异常处理', method: 'POST', path: '/api/v1/qc/abnormal/:id/handle', parent: 'qc' },
|
{ code: Permissions.abnormal.handle, name: '异常处理', method: 'POST', path: '/api/v1/qc/abnormal/:id/handle', parent: 'qc' },
|
||||||
// 服务机构端·文书导出(C5)
|
|
||||||
{ code: Permissions.export.export, name: '文书导出', method: 'GET', path: '/api/v1/document/export', parent: 'document' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
||||||
@ -179,7 +177,6 @@ export const BUSINESS_ROLES: { code: string; name: string; permissions: string[]
|
|||||||
Permissions.qc.batchPass,
|
Permissions.qc.batchPass,
|
||||||
Permissions.abnormal.list,
|
Permissions.abnormal.list,
|
||||||
Permissions.abnormal.handle,
|
Permissions.abnormal.handle,
|
||||||
Permissions.export.export,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ code: 'leader', name: '组长', permissions: [Permissions.staff.list, Permissions.svcOrg.list] },
|
{ code: 'leader', name: '组长', permissions: [Permissions.staff.list, Permissions.svcOrg.list] },
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
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' } });
|
|
||||||
|
|
||||||
// 水印:机构名 + 时间 + 操作人(username)+ R4 文案
|
|
||||||
expect(res.watermark).toMatchObject({
|
|
||||||
orgName: '测试机构',
|
|
||||||
operator: 'admin',
|
|
||||||
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' } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
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.username,
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -166,36 +166,6 @@ describe('PlanService', () => {
|
|||||||
expect(planCreate).not.toHaveBeenCalled();
|
expect(planCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('R6:svc_org.photoTier=must 且客户端不传 → plan_item.photoTier=must', async () => {
|
|
||||||
const svcOrgFindFirst = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1', photoTier: 'must' });
|
|
||||||
const planItemCreate = vi.fn().mockResolvedValue({ id: 'pi1' });
|
|
||||||
const { service } = makeService({ svcOrgFindFirst, planItemCreate });
|
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () => service.create({ ...baseDto, items: [{ svcId: 'svc1' }] }));
|
|
||||||
|
|
||||||
expect(planItemCreate).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({ svcId: 'svc1', photoTier: 'must' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('R6:客户端 photoTier 覆盖 svc_org 默认(可加严)', async () => {
|
|
||||||
const svcOrgFindFirst = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ id: 'svc1', srcType: 'national', srcId: 'nat1', photoTier: 'free' });
|
|
||||||
const planItemCreate = vi.fn().mockResolvedValue({ id: 'pi1' });
|
|
||||||
const { service } = makeService({ svcOrgFindFirst, planItemCreate });
|
|
||||||
|
|
||||||
await runWithRequestContext(ctx, () =>
|
|
||||||
service.create({ ...baseDto, items: [{ svcId: 'svc1', photoTier: 'must' }] }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(planItemCreate).toHaveBeenCalledWith({
|
|
||||||
data: expect.objectContaining({ svcId: 'svc1', photoTier: 'must' }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('elder 不存在 → ELDER_NOT_FOUND', async () => {
|
it('elder 不存在 → ELDER_NOT_FOUND', async () => {
|
||||||
const elderFindFirst = vi.fn().mockResolvedValue(null);
|
const elderFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
const { service, planCreate } = makeService({ elderFindFirst });
|
const { service, planCreate } = makeService({ elderFindFirst });
|
||||||
|
|||||||
@ -126,24 +126,18 @@ export class PlanService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验每个 svc_id 落本机构 L4,并解析 svc 名称/代码快照 + photo_tier 默认源。 */
|
/** 校验每个 svc_id 落本机构 L4,并解析 svc 名称/代码快照。 */
|
||||||
private async resolvePlanItems(items: PlanItemInputDto[]): Promise<ResolvedPlanItem[]> {
|
private async resolvePlanItems(items: PlanItemInputDto[]): Promise<ResolvedPlanItem[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
const svcOrg = await this.requireSvcOrg(item.svcId);
|
const svcOrg = await this.requireSvcOrg(item.svcId);
|
||||||
const snapshot = await this.resolveSvcSnapshot(svcOrg);
|
const snapshot = await this.resolveSvcSnapshot(svcOrg);
|
||||||
return {
|
return { ...item, svcName: snapshot.svcName, svcCode: snapshot.svcCode };
|
||||||
...item,
|
|
||||||
svcName: snapshot.svcName,
|
|
||||||
svcCode: snapshot.svcCode,
|
|
||||||
// R6:photo_tier 溯源 svc_org(客户端 DTO 仍可覆盖加严)。
|
|
||||||
photoTier: item.photoTier ?? svcOrg.photoTier ?? 'free',
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requireSvcOrg(svcId: string): Promise<{ srcType: string; srcId: string; photoTier: string }> {
|
private async requireSvcOrg(svcId: string): Promise<{ srcType: string; srcId: string }> {
|
||||||
const svcOrg = await this.prisma.client.svcOrg.findFirst({ where: { id: svcId } });
|
const svcOrg = await this.prisma.client.svcOrg.findFirst({ where: { id: svcId } });
|
||||||
if (!svcOrg) {
|
if (!svcOrg) {
|
||||||
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
|
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user