feat(qc): 质控闸门 R9-R12 修复(权限/批量原子化/异常老人校验/退回幂等)

- R9:质控员角色补全 qc 六权限 + abnormal.list/handle
- R10:batchPass 两阶段全量预检,整批通过才逐条放行(原子不部分放行)
- R11:exceptionLink 校验 abnormal 关联老人与计划变更老人一致(46008)
- R12:return 增加幂等守卫(非 pending 抛 QC_ALREADY_HANDLED)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 02:06:03 +08:00
parent a808581e60
commit 9fea4efe0f
4 changed files with 85 additions and 13 deletions

View File

@ -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];

View File

@ -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] },

View File

@ -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' });

View File

@ -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<Record<string, any>> {
const qc = await this.prisma.client.qc.findUnique({ where: { id } });
if (!qc) {