fix(c4): 质控放行自检顺序 + 补正≥3次进质控重点

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 01:37:56 +08:00
parent f24b86064f
commit 915745226f
2 changed files with 46 additions and 11 deletions

View File

@ -41,6 +41,7 @@ function makeService(
recordAbnormalUpdate?: ReturnType<typeof vi.fn>; recordAbnormalUpdate?: ReturnType<typeof vi.fn>;
recordCorrectionFindMany?: ReturnType<typeof vi.fn>; recordCorrectionFindMany?: ReturnType<typeof vi.fn>;
recordCorrectionCreate?: ReturnType<typeof vi.fn>; recordCorrectionCreate?: ReturnType<typeof vi.fn>;
recordCorrectionCount?: ReturnType<typeof vi.fn>;
// client.order / elder / plan // client.order / elder / plan
orderFindMany?: ReturnType<typeof vi.fn>; orderFindMany?: ReturnType<typeof vi.fn>;
orderFindFirst?: ReturnType<typeof vi.fn>; orderFindFirst?: ReturnType<typeof vi.fn>;
@ -72,6 +73,7 @@ function makeService(
const recordAbnormalUpdate = overrides.recordAbnormalUpdate ?? vi.fn().mockResolvedValue({ id: 'a1' }); const recordAbnormalUpdate = overrides.recordAbnormalUpdate ?? vi.fn().mockResolvedValue({ id: 'a1' });
const recordCorrectionFindMany = overrides.recordCorrectionFindMany ?? vi.fn().mockResolvedValue([]); const recordCorrectionFindMany = overrides.recordCorrectionFindMany ?? vi.fn().mockResolvedValue([]);
const recordCorrectionCreate = overrides.recordCorrectionCreate ?? vi.fn().mockResolvedValue({ id: 'c1' }); const recordCorrectionCreate = overrides.recordCorrectionCreate ?? vi.fn().mockResolvedValue({ id: 'c1' });
const recordCorrectionCount = overrides.recordCorrectionCount ?? vi.fn().mockResolvedValue(0);
const orderFindMany = overrides.orderFindMany ?? vi.fn().mockResolvedValue([]); const orderFindMany = overrides.orderFindMany ?? vi.fn().mockResolvedValue([]);
const orderFindFirst = overrides.orderFindFirst ?? vi.fn().mockResolvedValue(null); const orderFindFirst = overrides.orderFindFirst ?? vi.fn().mockResolvedValue(null);
@ -106,7 +108,7 @@ function makeService(
findUnique: recordAbnormalFindUnique, findUnique: recordAbnormalFindUnique,
update: recordAbnormalUpdate, update: recordAbnormalUpdate,
}, },
recordCorrection: { findMany: recordCorrectionFindMany, create: recordCorrectionCreate }, recordCorrection: { findMany: recordCorrectionFindMany, create: recordCorrectionCreate, count: recordCorrectionCount },
order: { findMany: orderFindMany, findFirst: orderFindFirst }, order: { findMany: orderFindMany, findFirst: orderFindFirst },
elder: { findMany: elderFindMany }, elder: { findMany: elderFindMany },
plan: { findFirst: planFindFirst }, plan: { findFirst: planFindFirst },
@ -138,6 +140,7 @@ function makeService(
recordAbnormalUpdate, recordAbnormalUpdate,
recordCorrectionFindMany, recordCorrectionFindMany,
recordCorrectionCreate, recordCorrectionCreate,
recordCorrectionCount,
orderFindMany, orderFindMany,
orderFindFirst, orderFindFirst,
elderFindMany, elderFindMany,
@ -199,15 +202,26 @@ describe('QcService.pass', () => {
expect(qcUpdate).not.toHaveBeenCalled(); expect(qcUpdate).not.toHaveBeenCalled();
}); });
it('已处理(非 pending)→ QC_ALREADY_HANDLED(幂等)', async () => { it('已处理(非 pending)且要素齐全 → QC_ALREADY_HANDLED(幂等)', async () => {
const qcFindUnique = vi.fn().mockResolvedValue({ id: 'qc1', recordId: 'r1', qcResult: 'passed' }); const qcFindUnique = vi.fn().mockResolvedValue({ id: 'qc1', recordId: 'r1', qcResult: 'passed' });
const { service, qcUpdate } = makeService({ qcFindUnique }); const recordFindUnique = vi.fn().mockResolvedValue(passableRecord);
const { service, qcUpdate } = makeService({ qcFindUnique, recordFindUnique });
await expect(runWithRequestContext(ctx, () => service.pass('qc1'))).rejects.toMatchObject({ await expect(runWithRequestContext(ctx, () => service.pass('qc1'))).rejects.toMatchObject({
response: { code: ErrorCodes.QC_ALREADY_HANDLED }, response: { code: ErrorCodes.QC_ALREADY_HANDLED },
}); });
expect(qcUpdate).not.toHaveBeenCalled(); expect(qcUpdate).not.toHaveBeenCalled();
}); });
it('已处理且缺要素 → QC_ELEMENT_MISSING(1004) 优先于 QC_ALREADY_HANDLED(顺序①在③前)', async () => {
const qcFindUnique = vi.fn().mockResolvedValue({ id: 'qc1', recordId: 'r1', qcResult: 'passed' });
const recordFindUnique = vi.fn().mockResolvedValue({ ...passableRecord, checkoutTime: null });
const { service, qcUpdate } = makeService({ qcFindUnique, recordFindUnique });
await expect(runWithRequestContext(ctx, () => service.pass('qc1'))).rejects.toMatchObject({
response: { code: ErrorCodes.QC_ELEMENT_MISSING, message: expect.stringContaining('1004') },
});
expect(qcUpdate).not.toHaveBeenCalled();
});
it('四要素缺 → QC_ELEMENT_MISSING(1004)', async () => { it('四要素缺 → QC_ELEMENT_MISSING(1004)', async () => {
const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); const qcFindUnique = vi.fn().mockResolvedValue(pendingQc);
const recordFindUnique = vi.fn().mockResolvedValue({ ...passableRecord, checkoutTime: null }); const recordFindUnique = vi.fn().mockResolvedValue({ ...passableRecord, checkoutTime: null });
@ -526,9 +540,25 @@ describe('QcService.pendingList(五类聚合)', () => {
expect(res.pendingQc.list[0]).toMatchObject({ expect(res.pendingQc.list[0]).toMatchObject({
id: 'qc1', id: 'qc1',
record: { id: 'r1', elderId: 'e1', assigneeId: 'staff1' }, record: { id: 'r1', elderId: 'e1', assigneeId: 'staff1' },
correctionCount: 0,
focus: false,
}); });
expect(res.pendingQc.list[0].waitMinutes).toBe(60); expect(res.pendingQc.list[0].waitMinutes).toBe(60);
}); });
it('补正 ≥3 次 → 待放行队列项 focus=true(质控重点)', async () => {
const qcFindMany = vi.fn().mockResolvedValue([
{ id: 'qc1', recordId: 'r1', qcResult: 'pending', createdAt: new Date() },
]);
const recordFindUnique = vi.fn().mockResolvedValue(passableRecord);
const recordCorrectionCount = vi.fn().mockResolvedValue(3);
const { service } = makeService({ qcFindMany, recordFindUnique, recordCorrectionCount });
const res = await runWithRequestContext(ctx, () => service.pendingList());
expect(recordCorrectionCount).toHaveBeenCalledWith({ where: { recordId: 'r1' } });
expect(res.pendingQc.list[0]).toMatchObject({ correctionCount: 3, focus: true });
});
}); });
describe('QcService.detail', () => { describe('QcService.detail', () => {

View File

@ -109,7 +109,10 @@ export class QcService {
const pendingQcList = []; const pendingQcList = [];
for (const qc of pendingQcs) { for (const qc of pendingQcs) {
const record = await this.prisma.client.record.findUnique({ where: { id: qc.recordId } }); const [record, correctionCount] = await Promise.all([
this.prisma.client.record.findUnique({ where: { id: qc.recordId } }),
this.prisma.client.recordCorrection.count({ where: { recordId: qc.recordId } }),
]);
pendingQcList.push({ pendingQcList.push({
...qc, ...qc,
record: record record: record
@ -121,6 +124,8 @@ export class QcService {
recordStatus: record.recordStatus, recordStatus: record.recordStatus,
} }
: null, : null,
correctionCount,
focus: correctionCount >= 3,
waitMinutes: Math.max(0, Math.floor((now.getTime() - new Date(qc.createdAt).getTime()) / 60000)), waitMinutes: Math.max(0, Math.floor((now.getTime() - new Date(qc.createdAt).getTime()) / 60000)),
}); });
} }
@ -159,15 +164,12 @@ export class QcService {
}; };
} }
/** 放行:自检(幂等 → 四要素 1004 → 必拍 3001 → 自审 4001)→ passed。 */ /** 放行:自检(四要素 1004 → 必拍 3001 → 幂等 → 自审 4001)→ passed。 */
async pass(id: string) { async pass(id: string) {
const ctx = getRequestContext()!; const ctx = getRequestContext()!;
const qc = await this.requireQc(id); const qc = await this.requireQc(id);
if (qc.qcResult !== 'pending') {
throw new BusinessException(ErrorCodes.QC_ALREADY_HANDLED, '该质控已处理(幂等)', HttpStatus.BAD_REQUEST);
}
const record = await this.requireRecord(qc.recordId); const record = await this.requireRecord(qc.recordId);
await this.assertPassable(record, ctx.userId); await this.assertPassable(qc, record, ctx.userId);
return this.prisma.client.qc.update({ return this.prisma.client.qc.update({
where: { id }, where: { id },
data: { qcResult: 'passed', qcStaffId: ctx.userId, qcTime: new Date(), passTime: new Date() }, data: { qcResult: 'passed', qcStaffId: ctx.userId, qcTime: new Date(), passTime: new Date() },
@ -291,8 +293,8 @@ export class QcService {
return record; return record;
} }
/** 放行自检:四要素(1004)→ 必拍已拍(3001)→ 自审自放行(4001)。 */ /** 放行自检(按序):四要素(1004)→ 必拍已拍(3001)→ 幂等(46002)→ 自审自放行(4001)。 */
private async assertPassable(record: Record<string, any>, userId: string) { private async assertPassable(qc: Record<string, any>, record: Record<string, any>, userId: string) {
if (!record.checkinTime || !record.checkoutTime || record.durationMin == null || !record.assigneeId) { if (!record.checkinTime || !record.checkoutTime || record.durationMin == null || !record.assigneeId) {
throw new BusinessException(ErrorCodes.QC_ELEMENT_MISSING, '留痕缺必填要素(1004)', HttpStatus.BAD_REQUEST); throw new BusinessException(ErrorCodes.QC_ELEMENT_MISSING, '留痕缺必填要素(1004)', HttpStatus.BAD_REQUEST);
} }
@ -312,6 +314,9 @@ export class QcService {
} }
} }
} }
if (qc.qcResult !== 'pending') {
throw new BusinessException(ErrorCodes.QC_ALREADY_HANDLED, '该质控已处理(幂等)', HttpStatus.BAD_REQUEST);
}
if (record.createdBy === userId) { if (record.createdBy === userId) {
throw new BusinessException(ErrorCodes.QC_SELF_REVIEW, '不可自审自放行(4001)', HttpStatus.BAD_REQUEST); throw new BusinessException(ErrorCodes.QC_SELF_REVIEW, '不可自审自放行(4001)', HttpStatus.BAD_REQUEST);
} }