From 9fea4efe0f05bec54ac0b4e9c4086947a3c486d7 Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 02:06:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(qc):=20=E8=B4=A8=E6=8E=A7=E9=97=B8?= =?UTF-8?q?=E9=97=A8=20R9-R12=20=E4=BF=AE=E5=A4=8D=EF=BC=88=E6=9D=83?= =?UTF-8?q?=E9=99=90/=E6=89=B9=E9=87=8F=E5=8E=9F=E5=AD=90=E5=8C=96/?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E8=80=81=E4=BA=BA=E6=A0=A1=E9=AA=8C/?= =?UTF-8?q?=E9=80=80=E5=9B=9E=E5=B9=82=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R9:质控员角色补全 qc 六权限 + abnormal.list/handle - R10:batchPass 两阶段全量预检,整批通过才逐条放行(原子不部分放行) - R11:exceptionLink 校验 abnormal 关联老人与计划变更老人一致(46008) - R12:return 增加幂等守卫(非 pending 抛 QC_ALREADY_HANDLED) Co-Authored-By: Claude Sonnet 4.6 --- src/common/error-codes.ts | 1 + src/common/tenant-template.ts | 8 ++++++ src/qc/qc.service.spec.ts | 47 +++++++++++++++++++++++++++++++++++ src/qc/qc.service.ts | 42 +++++++++++++++++++++---------- 4 files changed, 85 insertions(+), 13 deletions(-) diff --git a/src/common/error-codes.ts b/src/common/error-codes.ts index 7d0377d..a9add69 100644 --- a/src/common/error-codes.ts +++ b/src/common/error-codes.ts @@ -66,6 +66,7 @@ export const ErrorCodes = { QC_NOTE_REQUIRED: 46005, QC_BATCH_UNSAFE: 46006, QC_RECORD_NOT_SUBMITTED: 46007, + QC_ABNORMAL_ELDER_MISMATCH: 46008, // 异常关联老人与计划变更老人不一致 } as const; export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; diff --git a/src/common/tenant-template.ts b/src/common/tenant-template.ts index 19d77a7..aeb2ea5 100644 --- a/src/common/tenant-template.ts +++ b/src/common/tenant-template.ts @@ -169,6 +169,14 @@ export const BUSINESS_ROLES: { code: string; name: string; permissions: string[] Permissions.svcOrg.list, Permissions.svcOrg.config, Permissions.svcOrg.newNational, + Permissions.qc.pendingList, + Permissions.qc.detail, + Permissions.qc.pass, + Permissions.qc.return, + Permissions.qc.exceptionLink, + Permissions.qc.batchPass, + Permissions.abnormal.list, + Permissions.abnormal.handle, ], }, { code: 'leader', name: '组长', permissions: [Permissions.staff.list, Permissions.svcOrg.list] }, diff --git a/src/qc/qc.service.spec.ts b/src/qc/qc.service.spec.ts index 826808a..afc4542 100644 --- a/src/qc/qc.service.spec.ts +++ b/src/qc/qc.service.spec.ts @@ -290,6 +290,15 @@ describe('QcService.return', () => { expect(qcUpdate).not.toHaveBeenCalled(); }); + it('已处理(非 pending)→ QC_ALREADY_HANDLED(幂等)', async () => { + const qcFindUnique = vi.fn().mockResolvedValue({ id: 'qc1', recordId: 'r1', qcResult: 'passed' }); + const { service, qcUpdate } = makeService({ qcFindUnique }); + await expect( + runWithRequestContext(ctx, () => service.return('qc1', { qcNote: '补拍' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_ALREADY_HANDLED } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + it('退回 → qc.update returned + qcNote', async () => { const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); const { service, qcUpdate } = makeService({ qcFindUnique }); @@ -435,6 +444,34 @@ describe('QcService.batchPass', () => { }); expect(res).toMatchObject({ passed: 1 }); }); + + it('批量含缺要素记录 → 整批拒绝且无部分放行', async () => { + const recordFindUnique = vi.fn().mockImplementation((args: any) => { + if (args?.where?.id === 'r1') return Promise.resolve(safeRecord); + return Promise.resolve({ ...safeRecord, checkoutTime: null }); // r2 缺要素 + }); + const qcFindFirst = vi.fn().mockResolvedValue(pendingQcForRecord); + const { service, qcUpdate } = makeService({ recordFindUnique, qcFindFirst }); + + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1', 'r2'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_ELEMENT_MISSING } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('批量含自审记录 → 整批拒绝且无部分放行', async () => { + const recordFindUnique = vi.fn().mockImplementation((args: any) => { + if (args?.where?.id === 'r1') return Promise.resolve(safeRecord); + return Promise.resolve({ ...safeRecord, createdBy: 'u1' }); // r2 自审 + }); + const qcFindFirst = vi.fn().mockResolvedValue(pendingQcForRecord); + const { service, qcUpdate } = makeService({ recordFindUnique, qcFindFirst }); + + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1', 'r2'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_SELF_REVIEW } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); }); describe('QcService.pendingList(五类聚合)', () => { @@ -624,6 +661,16 @@ describe('QcService.exceptionLink', () => { expect(planChange).not.toHaveBeenCalled(); }); + it('abnormal 关联老人 ≠ 计划变更老人 → QC_ABNORMAL_ELDER_MISMATCH', async () => { + const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', elderId: 'e2' }); + const { service, planChange, recordAbnormalUpdate } = makeService({ recordAbnormalFindUnique }); + await expect(runWithRequestContext(ctx, () => service.exceptionLink(changeDto))).rejects.toMatchObject({ + response: { code: ErrorCodes.QC_ABNORMAL_ELDER_MISMATCH }, + }); + expect(planChange).not.toHaveBeenCalled(); + expect(recordAbnormalUpdate).not.toHaveBeenCalled(); + }); + it('联动:plan.change 生成新 plan(abnormalId 已剥离)+ link_plan_change=true', async () => { const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', elderId: 'e1' }); const planChange = vi.fn().mockResolvedValue({ id: 'p-new' }); diff --git a/src/qc/qc.service.ts b/src/qc/qc.service.ts index 0ed7982..e09e7c5 100644 --- a/src/qc/qc.service.ts +++ b/src/qc/qc.service.ts @@ -170,16 +170,16 @@ export class QcService { const qc = await this.requireQc(id); const record = await this.requireRecord(qc.recordId); await this.assertPassable(qc, record, ctx.userId); - return this.prisma.client.qc.update({ - where: { id }, - data: { qcResult: 'passed', qcStaffId: ctx.userId, qcTime: new Date(), passTime: new Date() }, - }); + return this.markPassed(id, ctx.userId); } - /** 退回:qc_note 必填 → returned。 */ + /** 退回:幂等守卫 → qc_note 必填 → returned。 */ async return(id: string, dto: ReturnQcDto) { const ctx = getRequestContext()!; - await this.requireQc(id); + const qc = await this.requireQc(id); + if (qc.qcResult !== 'pending') { + throw new BusinessException(ErrorCodes.QC_ALREADY_HANDLED, '该质控已处理(幂等)', HttpStatus.BAD_REQUEST); + } if (!dto.qcNote) { throw new BusinessException(ErrorCodes.QC_NOTE_REQUIRED, '退回须填写原因', HttpStatus.BAD_REQUEST); } @@ -195,6 +195,9 @@ export class QcService { if (!abnormal) { throw new BusinessException(ErrorCodes.ABNORMAL_NOT_FOUND, '异常上报不存在', HttpStatus.NOT_FOUND); } + if (abnormal.elderId !== dto.elderId) { + throw new BusinessException(ErrorCodes.QC_ABNORMAL_ELDER_MISMATCH, '异常关联老人与计划变更老人不一致', HttpStatus.BAD_REQUEST); + } const { abnormalId: _abnormalId, ...changeDto } = dto; const newPlan = await this.planService.change(changeDto); await this.prisma.client.recordAbnormal.update({ @@ -254,14 +257,13 @@ export class QcService { return this.serializeAbnormal(updated); } - /** 批量放行:安全边界(异常/未执行/加项/时长过短/已补正)任一命中 → QC_BATCH_UNSAFE;否则逐条 pass。 */ + /** 批量放行:先全量预检(批量边界 + 逐条自检),全过才逐条落库(原子,不部分放行)。 */ async batchPass(dto: BatchPassDto) { const ctx = getRequestContext()!; + const pendingQcs = []; + // 阶段一:全量预检,任一不过整体抛错,不落任何 pass。 for (const recordId of dto.recordIds) { - const record = await this.prisma.client.record.findUnique({ where: { id: recordId } }); - if (!record) { - throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '留痕不存在', HttpStatus.NOT_FOUND); - } + const record = await this.requireRecord(recordId); const [abnormals, items, corrections] = await Promise.all([ this.prisma.client.recordAbnormal.findMany({ where: { recordId } }), this.prisma.client.recordItem.findMany({ where: { recordId } }), @@ -281,15 +283,29 @@ export class QcService { ); } const qc = await this.prisma.client.qc.findFirst({ where: { recordId, qcResult: 'pending' } }); - if (qc) { - await this.pass(qc.id); + if (!qc) { + throw new BusinessException(ErrorCodes.QC_ALREADY_HANDLED, '存在非待质控记录,不可批量放行', HttpStatus.BAD_REQUEST); } + await this.assertPassable(qc, record, ctx.userId); + pendingQcs.push(qc); + } + // 阶段二:全部预检通过后逐条放行。 + for (const qc of pendingQcs) { + await this.markPassed(qc.id, ctx.userId); } return { passed: dto.recordIds.length }; } // ---- 内部 ---- + /** 放行落库:qc_result=passed + qc_staff_id + qc_time + pass_time。 */ + private markPassed(id: string, userId: string) { + return this.prisma.client.qc.update({ + where: { id }, + data: { qcResult: 'passed', qcStaffId: userId, qcTime: new Date(), passTime: new Date() }, + }); + } + private async requireQc(id: string): Promise> { const qc = await this.prisma.client.qc.findUnique({ where: { id } }); if (!qc) {