Compare commits

...

7 Commits

Author SHA1 Message Date
liuyun
9d1a3b2374 fix(c5): 终审 R6-R9 溯源/null过滤/操作人/权限
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 08:04:06 +08:00
liuyun
5cba51abb8 fix(c5): agency.update 改 upsert 补齐 provisioning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:57:36 +08:00
liuyun
b09015151f fix(c5): 补 document/export 权限 seed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:49:52 +08:00
liuyun
94f0fb2bb5 fix(c5): dockingTier 枚举约束 A/B/C
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:40:56 +08:00
liuyun
16937fcf17 feat(c5): 拍照分档批量改 + 机构配置 + 文书导出
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:36:58 +08:00
liuyun
9a0ffc6fc7 style(c5): agency 新增字段保持纯增量(还原既有行对齐)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:31:24 +08:00
liuyun
11955c4f55 feat(c5): svc_org photo_tier + agency 机构配置字段
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-15 07:30:08 +08:00
18 changed files with 817 additions and 14 deletions

View File

@ -0,0 +1,7 @@
-- 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';

View File

@ -318,6 +318,9 @@ 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")
} }
@ -407,6 +410,7 @@ 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")

View File

@ -1,5 +1,8 @@
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';
@ -36,4 +39,123 @@ 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([]);
});
}); });

View File

@ -1,10 +1,36 @@
import { HttpStatus, Injectable } from '@nestjs/common'; import { 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)`。
@ -19,13 +45,21 @@ 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) {
throw new BusinessException(ErrorCodes.ORG_NOT_FOUND, '机构不存在', HttpStatus.NOT_FOUND); return this.prisma.client.agency.create({
data: { ...agencyDefaults(ctx.tenantCode), ...data } as any,
});
} }
return this.prisma.client.agency.update({ where: { code: ctx.tenantCode }, data: dto }); return this.prisma.client.agency.update({ where: { code: ctx.tenantCode }, data });
} }
} }

View File

@ -1,5 +1,5 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsInt, IsOptional, IsString, Min } from 'class-validator'; import { IsBoolean, IsDateString, IsIn, 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: '对接档位(A/B/C,C 显示「待接口发布」)', enum: ['A', 'B', 'C'] })
@IsOptional()
@IsIn(['A', 'B', 'C'])
dockingTier?: string;
@ApiPropertyOptional({ description: '国家新增项通知开关' })
@IsOptional()
@IsBoolean()
newNationalNotify?: boolean;
} }

View File

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

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js'; import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { ConfigSvcOrgDto } from './dto.js'; import { ConfigSvcOrgDto, UpdatePhotoTierDto } from './dto.js';
import { CatalogService } from './catalog.service.js'; import { CatalogService } from './catalog.service.js';
@Controller('svc') @Controller('svc')
@ -27,6 +27,13 @@ export class CatalogController {
return this.service.config(dto); return this.service.config(dto);
} }
@Post('org/photo-tier/batch')
@RequirePermissions(Permissions.svcOrg.config)
@AuditLog('svc-org', 'photo-tier-batch')
updatePhotoTier(@Body() dto: UpdatePhotoTierDto) {
return this.service.updatePhotoTier(dto);
}
@Get('org/new-national') @Get('org/new-national')
@RequirePermissions(Permissions.svcOrg.newNational) @RequirePermissions(Permissions.svcOrg.newNational)
newNational() { newNational() {

View File

@ -110,3 +110,90 @@ describe('CatalogService', () => {
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } }); ).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } });
}); });
}); });
describe('CatalogService.updatePhotoTier', () => {
it('逐条校验本机构 + 更新 photoTier,无放宽时不返回 warnings', async () => {
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'o1', tenantCode: 'T001', photoTier: 'free' });
const svcOrgUpdate = vi.fn().mockResolvedValue({ id: 'o1', photoTier: 'must' });
const { service } = makeService({ svcOrgFindFirst, svcOrgUpdate });
const res = await runWithRequestContext(ctx, () =>
service.updatePhotoTier({
items: [
{ id: 'o1', photoTier: 'must' },
{ id: 'o2', photoTier: 'optional' },
],
} as never),
);
expect(svcOrgFindFirst).toHaveBeenCalledTimes(2);
expect(svcOrgFindFirst).toHaveBeenCalledWith({ where: { id: 'o1', tenantCode: 'T001' } });
expect(svcOrgUpdate).toHaveBeenCalledTimes(2);
expect(svcOrgUpdate).toHaveBeenCalledWith({ where: { id: 'o1' }, data: { photoTier: 'must' } });
expect(svcOrgUpdate).toHaveBeenCalledWith({ where: { id: 'o2' }, data: { photoTier: 'optional' } });
expect(res.updated).toBe(2);
expect(res.warnings).toEqual([]);
expect(res.message).toBeUndefined();
});
it('must→free/optional 收集 warnings 提示放宽', async () => {
const svcOrgFindFirst = vi
.fn()
.mockResolvedValueOnce({ id: 'o1', tenantCode: 'T001', photoTier: 'must' })
.mockResolvedValueOnce({ id: 'o2', tenantCode: 'T001', photoTier: 'must' });
const svcOrgUpdate = vi.fn().mockResolvedValue({});
const { service } = makeService({ svcOrgFindFirst, svcOrgUpdate });
const res = await runWithRequestContext(ctx, () =>
service.updatePhotoTier({
items: [
{ id: 'o1', photoTier: 'free' },
{ id: 'o2', photoTier: 'optional' },
],
} as never),
);
expect(res.warnings).toEqual([
{ id: 'o1', from: 'must', to: 'free' },
{ id: 'o2', from: 'must', to: 'optional' },
]);
expect(res.message).toBe('已放宽 2 项,请确认');
});
it('must→must(保持必拍)不算放宽', async () => {
const svcOrgFindFirst = vi.fn().mockResolvedValue({ id: 'o1', tenantCode: 'T001', photoTier: 'must' });
const svcOrgUpdate = vi.fn().mockResolvedValue({});
const { service } = makeService({ svcOrgFindFirst, svcOrgUpdate });
const res = await runWithRequestContext(ctx, () =>
service.updatePhotoTier({ items: [{ id: 'o1', photoTier: 'must' }] } as never),
);
expect(res.warnings).toEqual([]);
});
it('目标 svc_org 非本机构 → SVC_ORG_NOT_FOUND', async () => {
const svcOrgFindFirst = vi.fn().mockResolvedValue(null);
const { service, svcOrgUpdate } = makeService({ svcOrgFindFirst });
await expect(
runWithRequestContext(ctx, () =>
service.updatePhotoTier({ items: [{ id: 'missing', photoTier: 'must' }] } as never),
),
).rejects.toMatchObject({ response: { code: ErrorCodes.SVC_ORG_NOT_FOUND } });
expect(svcOrgUpdate).not.toHaveBeenCalled();
});
it('非法 photoTier → 拒绝', async () => {
const { service, svcOrgUpdate } = makeService();
await expect(
runWithRequestContext(ctx, () =>
service.updatePhotoTier({ items: [{ id: 'o1', photoTier: 'must_have' }] } as never),
),
).rejects.toThrowError('photo_tier');
expect(svcOrgUpdate).not.toHaveBeenCalled();
});
});

View File

@ -1,9 +1,12 @@
import { HttpStatus, Injectable } from '@nestjs/common'; import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js'; import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js'; import { getRequestContext } from '../common/context/request-context.js';
import { ErrorCodes } from '../common/error-codes.js'; import { ErrorCodes } from '../common/error-codes.js';
import { BusinessException } from '../common/business-exception.js'; import { BusinessException } from '../common/business-exception.js';
import { ConfigSvcOrgDto } from './dto.js'; import { ConfigSvcOrgDto, UpdatePhotoTierDto } from './dto.js';
/** 拍照分档合法取值(photo_tier 列枚举)。 */
const PHOTO_TIERS = ['must', 'free', 'optional'] as const;
/** /**
* 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。 * 服务目录四层(L1 国家基准库 / L2 属地目录 / L3 映射(机构不可见) / L4 机构项目库)。
@ -60,4 +63,36 @@ export class CatalogService {
throw new BusinessException(ErrorCodes.SVC_LOC_IMMUTABLE, '仅可调整启用状态与价格', HttpStatus.BAD_REQUEST); throw new BusinessException(ErrorCodes.SVC_LOC_IMMUTABLE, '仅可调整启用状态与价格', HttpStatus.BAD_REQUEST);
} }
} }
/**
* 拍照分档批量更新:逐条校验 id 落本机构 svc_org 后 update photoTier。
* must→free/optional 视为「放宽」,收集到 warnings 返回(提示「已放宽 N 项,请确认」),不静默。
*/
async updatePhotoTier(dto: UpdatePhotoTierDto) {
const ctx = getRequestContext()!;
const warnings: { id: string; from: string; to: string }[] = [];
for (const item of dto.items) {
if (!(PHOTO_TIERS as readonly string[]).includes(item.photoTier)) {
throw new BadRequestException('photo_tier 非法(须 must/free/optional)');
}
const existing = await this.prisma.client.svcOrg.findFirst({
where: { id: item.id, tenantCode: ctx.tenantCode },
});
if (!existing) {
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);
}
if (existing.photoTier === 'must' && item.photoTier !== 'must') {
warnings.push({ id: item.id, from: existing.photoTier, to: item.photoTier });
}
await this.prisma.client.svcOrg.update({
where: { id: item.id },
data: { photoTier: item.photoTier },
});
}
return {
updated: dto.items.length,
warnings,
message: warnings.length > 0 ? `已放宽 ${warnings.length} 项,请确认` : undefined,
};
}
} }

View File

@ -1,5 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
/** /**
* 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之 * 机构项目库「勾选+配价」入参。机构只能改 `is_enabled`/`price`(PRD 五条纪律之
@ -33,3 +43,26 @@ export class ConfigSvcOrgDto {
@IsOptional() @IsOptional()
srcId?: string; srcId?: string;
} }
/** 拍照分档批量更新项:机构只能调 photoTier(must/free/optional),id 定位目标 svc_org 行。 */
export class PhotoTierItemDto {
@ApiProperty({ description: '机构项目库记录 ID(由 /svc/org/list 返回)' })
@IsString()
@IsNotEmpty()
id!: string;
@ApiProperty({ description: '拍照分档', enum: ['must', 'free', 'optional'] })
@IsString()
@IsIn(['must', 'free', 'optional'])
photoTier!: string;
}
/** 拍照分档批量更新入参。 */
export class UpdatePhotoTierDto {
@ApiProperty({ description: '批量更新项', type: [PhotoTierItemDto] })
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => PhotoTierItemDto)
items!: PhotoTierItemDto[];
}

View File

@ -131,6 +131,8 @@ 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' },
]; ];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */ /** 按钮资源(纯前端按钮,无独立受控接口)。 */
@ -177,6 +179,7 @@ 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] },

View File

@ -0,0 +1,17 @@
import { Controller, Get, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { ExportDocumentQuery } from './dto.js';
import { DocumentService } from './document.service.js';
@Controller('document')
export class DocumentController {
constructor(private readonly service: DocumentService) {}
@Get('export')
@RequirePermissions(Permissions.export.export)
@AuditLog('document', 'export')
export(@Query() q: ExportDocumentQuery) {
return this.service.export(q.elderId, q.startDate, q.endDate);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DocumentController } from './document.controller.js';
import { DocumentService } from './document.service.js';
@Module({
controllers: [DocumentController],
providers: [DocumentService],
})
export class DocumentModule {}

View File

@ -0,0 +1,220 @@
import { describe, expect, it, vi } from 'vitest';
import { DocumentService, EXPORT_NOTICE } from './document.service.js';
import type { PrismaService } from '../prisma/prisma.service.js';
import { ErrorCodes } from '../common/error-codes.js';
import { runWithRequestContext } from '../common/context/request-context.js';
import type { RequestContext } from '../common/context/request-context.js';
const ctx: RequestContext = {
tenantCode: 'T001',
userId: 'u1',
username: 'admin',
super: false,
dataScope: 'self',
orgCode: null,
orgCodes: [],
};
function makeService(
overrides: {
elderFindUnique?: ReturnType<typeof vi.fn>;
agencyFindUnique?: ReturnType<typeof vi.fn>;
planFindMany?: ReturnType<typeof vi.fn>;
planItemFindMany?: ReturnType<typeof vi.fn>;
recordFindMany?: ReturnType<typeof vi.fn>;
recordItemFindMany?: ReturnType<typeof vi.fn>;
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
} = {},
) {
const elderFindUnique = overrides.elderFindUnique ?? vi.fn().mockResolvedValue(null);
const agencyFindUnique = overrides.agencyFindUnique ?? vi.fn().mockResolvedValue({ code: 'T001', orgName: '测试机构' });
const planFindMany = overrides.planFindMany ?? vi.fn().mockResolvedValue([]);
const planItemFindMany = overrides.planItemFindMany ?? vi.fn().mockResolvedValue([]);
const recordFindMany = overrides.recordFindMany ?? vi.fn().mockResolvedValue([]);
const recordItemFindMany = overrides.recordItemFindMany ?? vi.fn().mockResolvedValue([]);
const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]);
const prisma = {
client: {
elder: { findUnique: elderFindUnique },
agency: { findUnique: agencyFindUnique },
plan: { findMany: planFindMany },
planItem: { findMany: planItemFindMany },
record: { findMany: recordFindMany },
recordItem: { findMany: recordItemFindMany },
recordAbnormal: { findMany: recordAbnormalFindMany },
},
};
const service = new DocumentService(prisma as unknown as PrismaService);
return {
service,
elderFindUnique,
agencyFindUnique,
planFindMany,
planItemFindMany,
recordFindMany,
recordItemFindMany,
recordAbnormalFindMany,
};
}
describe('DocumentService.export', () => {
it('老人不存在 → ELDER_NOT_FOUND', async () => {
const { service, recordFindMany } = makeService();
await expect(
runWithRequestContext(ctx, () => service.export('missing')),
).rejects.toMatchObject({ response: { code: ErrorCodes.ELDER_NOT_FOUND } });
expect(recordFindMany).not.toHaveBeenCalled();
});
it('组装一人一档:掩码身份证 + 日期转 YYYY-MM-DD + 计划/记录/异常 + 水印', async () => {
const elder = {
id: 'e1',
name: '张三',
gender: '1',
birthDate: new Date('1950-01-01T00:00:00Z'),
idCardNo: '530102195001010011',
phone: '13800000000',
address: '昆明市',
insurNo: 'I001',
careLevel: '重度',
treatStatus: 'active',
benefitStart: new Date('2026-01-01T00:00:00Z'),
benefitEnd: null,
};
const plans = [
{
id: 'p1',
elderId: 'e1',
planPeriodStart: new Date('2026-10-01T00:00:00Z'),
planPeriodEnd: new Date('2026-10-31T00:00:00Z'),
careLevel: '重度',
careMode: 'home_org',
planStatus: 'active',
},
];
const planItems = [
{ id: 'pi1', planId: 'p1', svcId: 'svc1', svcNameSnapshot: '协助翻身', freqType: 'weekly', freqValue: 3, durationMin: 30, photoTier: 'must' },
];
const records = [
{
id: 'r1',
elderId: 'e1',
checkinTime: new Date('2026-10-15T02:00:00Z'),
checkoutTime: new Date('2026-10-15T03:00:00Z'),
durationMin: 60,
recordStatus: 'submitted',
},
];
const recordItems = [
{ id: 'ri1', recordId: 'r1', svcId: 'svc1', svcNameSnapshot: '协助翻身', execResult: 'done', undoReason: null, extraFlag: false },
];
const abnormals = [
{ id: 'a1', elderId: 'e1', abnormalType: '跌倒', abnormalDesc: '轻微', handleStatus: 'open' },
];
const {
service,
elderFindUnique,
agencyFindUnique,
planFindMany,
planItemFindMany,
recordFindMany,
recordItemFindMany,
recordAbnormalFindMany,
} = makeService({
elderFindUnique: vi.fn().mockResolvedValue(elder),
planFindMany: vi.fn().mockResolvedValue(plans),
planItemFindMany: vi.fn().mockResolvedValue(planItems),
recordFindMany: vi.fn().mockResolvedValue(records),
recordItemFindMany: vi.fn().mockResolvedValue(recordItems),
recordAbnormalFindMany: vi.fn().mockResolvedValue(abnormals),
});
const res = await runWithRequestContext(ctx, () => service.export('e1', '2026-10-01', '2026-10-31'));
// 定位与范围
expect(elderFindUnique).toHaveBeenCalledWith({ where: { id: 'e1' } });
expect(agencyFindUnique).toHaveBeenCalledWith({ where: { code: 'T001' } });
expect(planFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' }, orderBy: { createdAt: 'desc' } });
expect(planItemFindMany).toHaveBeenCalledWith({ where: { planId: { in: ['p1'] } } });
expect(recordFindMany).toHaveBeenCalledWith({
where: {
elderId: 'e1',
checkinTime: {
gte: new Date('2026-10-01T00:00:00+08:00'),
lte: new Date('2026-10-31T23:59:59.999+08:00'),
},
},
orderBy: { checkinTime: 'desc' },
});
expect(recordItemFindMany).toHaveBeenCalledWith({ where: { recordId: { in: ['r1'] } } });
expect(recordAbnormalFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' } });
// 水印:机构名 + 时间 + 操作人(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' } });
});
});

View File

@ -0,0 +1,151 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
import { maskIdCard } from '../elder/elder.service.js';
/** 导出水印固定文案(R4:只用「日常留痕,随时可导出」,禁用迎检/应对巡查等词)。 */
export const EXPORT_NOTICE = '日常留痕,随时可导出';
/** @db.Date 字段 → YYYY-MM-DD(按 UTC 取日期,与 toDateOnly 的 UTC 零点存储口径一致)。 */
function fmtDate(v: unknown): string | null {
if (!v) return null;
const d = v instanceof Date ? v : new Date(v as string | number);
if (Number.isNaN(d.getTime())) return null;
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
}
/** 日期时间字段 → ISO 字符串。 */
function fmtDateTime(v: unknown): string | null {
if (!v) return null;
const d = v instanceof Date ? v : new Date(v as string | number);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
/** YYYY-MM-DD → 起/止边界(与 record.list 的服务日期口径一致:+08:00);否则按 ISO 原样解析。 */
function toRangeDate(s: string, endOfDay: boolean): Date {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
return new Date(`${s}T${endOfDay ? '23:59:59.999' : '00:00:00'}+08:00`);
}
return new Date(s);
}
/**
* 文书导出:一人一档结构化 JSON(结构化导出,前端组装文件,不引重型依赖)。
*
* - elder 基本信息(idCardNo 掩码、@db.Date 转 YYYY-MM-DD);
* - plan + plan_item(项目名快照);
* - record(checkinTime 范围)+ record_item(exec_result);
* - record_abnormal;
* - watermark 头(机构名 + 时间 + 操作人 + 固定文案)。
*/
@Injectable()
export class DocumentService {
constructor(private readonly prisma: PrismaService) {}
async export(elderId: string, startDate?: string, endDate?: string) {
const ctx = getRequestContext()!;
const elder = await this.prisma.client.elder.findUnique({ where: { id: elderId } });
if (!elder) {
throw new BusinessException(ErrorCodes.ELDER_NOT_FOUND, '老人档案不存在', HttpStatus.NOT_FOUND);
}
const [agency, plans, abnormals] = await Promise.all([
this.prisma.client.agency.findUnique({ where: { code: ctx.tenantCode } }),
this.prisma.client.plan.findMany({ where: { elderId }, orderBy: { createdAt: 'desc' } }),
this.prisma.client.recordAbnormal.findMany({ where: { elderId } }),
]);
const planIds = plans.map((p) => p.id);
const planItems = planIds.length
? await this.prisma.client.planItem.findMany({ where: { planId: { in: planIds } } })
: [];
const planItemsByPlan = new Map<string, any[]>();
for (const item of planItems) {
const list = planItemsByPlan.get(item.planId) ?? [];
list.push(item);
planItemsByPlan.set(item.planId, list);
}
const recordWhere: Record<string, unknown> = { elderId };
const checkinTime: Record<string, Date> = {};
if (startDate) checkinTime.gte = toRangeDate(startDate, false);
if (endDate) checkinTime.lte = toRangeDate(endDate, true);
if (Object.keys(checkinTime).length > 0) recordWhere.checkinTime = checkinTime;
const records = await this.prisma.client.record.findMany({
where: recordWhere,
orderBy: { checkinTime: 'desc' },
});
const recordIds = records.map((r) => r.id);
const recordItems = recordIds.length
? await this.prisma.client.recordItem.findMany({ where: { recordId: { in: recordIds } } })
: [];
const recordItemsByRecord = new Map<string, any[]>();
for (const item of recordItems) {
const list = recordItemsByRecord.get(item.recordId) ?? [];
list.push(item);
recordItemsByRecord.set(item.recordId, list);
}
return {
watermark: {
orgName: agency?.orgName ?? '',
exportTime: new Date().toISOString(),
operator: ctx.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,
})),
};
}
}

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

@ -0,0 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator';
/** 文书导出查询参数。 */
export class ExportDocumentQuery {
@ApiProperty({ description: '老人档案 ID' })
@IsString()
@IsNotEmpty()
elderId!: string;
@ApiPropertyOptional({ description: '留痕起始日期(YYYY-MM-DD 或 ISO 日期时间)' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ description: '留痕结束日期(YYYY-MM-DD 或 ISO 日期时间)' })
@IsOptional()
@IsDateString()
endDate?: string;
}

View File

@ -166,6 +166,36 @@ 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 });

View File

@ -126,18 +126,24 @@ export class PlanService {
} }
} }
/** 校验每个 svc_id 落本机构 L4,并解析 svc 名称/代码快照。 */ /** 校验每个 svc_id 落本机构 L4,并解析 svc 名称/代码快照 + photo_tier 默认源。 */
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 { ...item, svcName: snapshot.svcName, svcCode: snapshot.svcCode }; return {
...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 }> { private async requireSvcOrg(svcId: string): Promise<{ srcType: string; srcId: string; photoTier: 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);