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:
parent
a808581e60
commit
9fea4efe0f
@ -66,6 +66,7 @@ export const ErrorCodes = {
|
|||||||
QC_NOTE_REQUIRED: 46005,
|
QC_NOTE_REQUIRED: 46005,
|
||||||
QC_BATCH_UNSAFE: 46006,
|
QC_BATCH_UNSAFE: 46006,
|
||||||
QC_RECORD_NOT_SUBMITTED: 46007,
|
QC_RECORD_NOT_SUBMITTED: 46007,
|
||||||
|
QC_ABNORMAL_ELDER_MISMATCH: 46008, // 异常关联老人与计划变更老人不一致
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||||
|
|||||||
@ -169,6 +169,14 @@ export const BUSINESS_ROLES: { code: string; name: string; permissions: string[]
|
|||||||
Permissions.svcOrg.list,
|
Permissions.svcOrg.list,
|
||||||
Permissions.svcOrg.config,
|
Permissions.svcOrg.config,
|
||||||
Permissions.svcOrg.newNational,
|
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] },
|
{ code: 'leader', name: '组长', permissions: [Permissions.staff.list, Permissions.svcOrg.list] },
|
||||||
|
|||||||
@ -290,6 +290,15 @@ describe('QcService.return', () => {
|
|||||||
expect(qcUpdate).not.toHaveBeenCalled();
|
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 () => {
|
it('退回 → qc.update returned + qcNote', async () => {
|
||||||
const qcFindUnique = vi.fn().mockResolvedValue(pendingQc);
|
const qcFindUnique = vi.fn().mockResolvedValue(pendingQc);
|
||||||
const { service, qcUpdate } = makeService({ qcFindUnique });
|
const { service, qcUpdate } = makeService({ qcFindUnique });
|
||||||
@ -435,6 +444,34 @@ describe('QcService.batchPass', () => {
|
|||||||
});
|
});
|
||||||
expect(res).toMatchObject({ passed: 1 });
|
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(五类聚合)', () => {
|
describe('QcService.pendingList(五类聚合)', () => {
|
||||||
@ -624,6 +661,16 @@ describe('QcService.exceptionLink', () => {
|
|||||||
expect(planChange).not.toHaveBeenCalled();
|
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 () => {
|
it('联动:plan.change 生成新 plan(abnormalId 已剥离)+ link_plan_change=true', async () => {
|
||||||
const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', elderId: 'e1' });
|
const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', elderId: 'e1' });
|
||||||
const planChange = vi.fn().mockResolvedValue({ id: 'p-new' });
|
const planChange = vi.fn().mockResolvedValue({ id: 'p-new' });
|
||||||
|
|||||||
@ -170,16 +170,16 @@ export class QcService {
|
|||||||
const qc = await this.requireQc(id);
|
const qc = await this.requireQc(id);
|
||||||
const record = await this.requireRecord(qc.recordId);
|
const record = await this.requireRecord(qc.recordId);
|
||||||
await this.assertPassable(qc, record, ctx.userId);
|
await this.assertPassable(qc, record, ctx.userId);
|
||||||
return this.prisma.client.qc.update({
|
return this.markPassed(id, ctx.userId);
|
||||||
where: { id },
|
|
||||||
data: { qcResult: 'passed', qcStaffId: ctx.userId, qcTime: new Date(), passTime: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 退回:qc_note 必填 → returned。 */
|
/** 退回:幂等守卫 → qc_note 必填 → returned。 */
|
||||||
async return(id: string, dto: ReturnQcDto) {
|
async return(id: string, dto: ReturnQcDto) {
|
||||||
const ctx = getRequestContext()!;
|
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) {
|
if (!dto.qcNote) {
|
||||||
throw new BusinessException(ErrorCodes.QC_NOTE_REQUIRED, '退回须填写原因', HttpStatus.BAD_REQUEST);
|
throw new BusinessException(ErrorCodes.QC_NOTE_REQUIRED, '退回须填写原因', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
@ -195,6 +195,9 @@ export class QcService {
|
|||||||
if (!abnormal) {
|
if (!abnormal) {
|
||||||
throw new BusinessException(ErrorCodes.ABNORMAL_NOT_FOUND, '异常上报不存在', HttpStatus.NOT_FOUND);
|
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 { abnormalId: _abnormalId, ...changeDto } = dto;
|
||||||
const newPlan = await this.planService.change(changeDto);
|
const newPlan = await this.planService.change(changeDto);
|
||||||
await this.prisma.client.recordAbnormal.update({
|
await this.prisma.client.recordAbnormal.update({
|
||||||
@ -254,14 +257,13 @@ export class QcService {
|
|||||||
return this.serializeAbnormal(updated);
|
return this.serializeAbnormal(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量放行:安全边界(异常/未执行/加项/时长过短/已补正)任一命中 → QC_BATCH_UNSAFE;否则逐条 pass。 */
|
/** 批量放行:先全量预检(批量边界 + 逐条自检),全过才逐条落库(原子,不部分放行)。 */
|
||||||
async batchPass(dto: BatchPassDto) {
|
async batchPass(dto: BatchPassDto) {
|
||||||
const ctx = getRequestContext()!;
|
const ctx = getRequestContext()!;
|
||||||
|
const pendingQcs = [];
|
||||||
|
// 阶段一:全量预检,任一不过整体抛错,不落任何 pass。
|
||||||
for (const recordId of dto.recordIds) {
|
for (const recordId of dto.recordIds) {
|
||||||
const record = await this.prisma.client.record.findUnique({ where: { id: recordId } });
|
const record = await this.requireRecord(recordId);
|
||||||
if (!record) {
|
|
||||||
throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '留痕不存在', HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
const [abnormals, items, corrections] = await Promise.all([
|
const [abnormals, items, corrections] = await Promise.all([
|
||||||
this.prisma.client.recordAbnormal.findMany({ where: { recordId } }),
|
this.prisma.client.recordAbnormal.findMany({ where: { recordId } }),
|
||||||
this.prisma.client.recordItem.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' } });
|
const qc = await this.prisma.client.qc.findFirst({ where: { recordId, qcResult: 'pending' } });
|
||||||
if (qc) {
|
if (!qc) {
|
||||||
await this.pass(qc.id);
|
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 };
|
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>> {
|
private async requireQc(id: string): Promise<Record<string, any>> {
|
||||||
const qc = await this.prisma.client.qc.findUnique({ where: { id } });
|
const qc = await this.prisma.client.qc.findUnique({ where: { id } });
|
||||||
if (!qc) {
|
if (!qc) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user