fix(c5): 终审 R6-R9 溯源/null过滤/操作人/权限

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 08:04:06 +08:00
parent 5cba51abb8
commit 9d1a3b2374
7 changed files with 84 additions and 8 deletions

View File

@ -115,6 +115,35 @@ describe('AgencyService', () => {
});
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', () => {

View File

@ -22,6 +22,15 @@ function agencyDefaults(code: string): Record<string, unknown> {
};
}
/** 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,
* 因此读写一律按 `ctx.tenantCode` 定位,而非 BaseCrudService 的 `findById(id)`。
@ -44,12 +53,13 @@ export class AgencyService {
async update(dto: UpdateAgencyDto) {
const ctx = getRequestContext()!;
const existing = await this.getCurrent();
const data = definedFields(dto);
if (!existing) {
return this.prisma.client.agency.create({
data: { ...agencyDefaults(ctx.tenantCode), ...dto } as any,
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

@ -179,6 +179,7 @@ export const BUSINESS_ROLES: { code: string; name: string; permissions: string[]
Permissions.qc.batchPass,
Permissions.abnormal.list,
Permissions.abnormal.handle,
Permissions.export.export,
],
},
{ code: 'leader', name: '组长', permissions: [Permissions.staff.list, Permissions.svcOrg.list] },

View File

@ -153,10 +153,10 @@ describe('DocumentService.export', () => {
expect(recordItemFindMany).toHaveBeenCalledWith({ where: { recordId: { in: ['r1'] } } });
expect(recordAbnormalFindMany).toHaveBeenCalledWith({ where: { elderId: 'e1' } });
// 水印:机构名 + 时间 + 操作人 + R4 文案
// 水印:机构名 + 时间 + 操作人(username)+ R4 文案
expect(res.watermark).toMatchObject({
orgName: '测试机构',
operator: 'u1',
operator: 'admin',
notice: EXPORT_NOTICE,
});
expect(res.watermark.exportTime).toEqual(expect.any(String));

View File

@ -94,7 +94,7 @@ export class DocumentService {
watermark: {
orgName: agency?.orgName ?? '',
exportTime: new Date().toISOString(),
operator: ctx.userId,
operator: ctx.username,
notice: EXPORT_NOTICE,
},
elder: {

View File

@ -166,6 +166,36 @@ describe('PlanService', () => {
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 () => {
const elderFindFirst = vi.fn().mockResolvedValue(null);
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[]> {
return Promise.all(
items.map(async (item) => {
const svcOrg = await this.requireSvcOrg(item.svcId);
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 } });
if (!svcOrg) {
throw new BusinessException(ErrorCodes.SVC_ORG_NOT_FOUND, '机构项目不存在', HttpStatus.NOT_FOUND);