feat(c4): 质控异常处理端点(handle_status/handle_result)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-15 01:50:42 +08:00
parent 915745226f
commit ba7f2397d7
5 changed files with 77 additions and 4 deletions

View File

@ -130,6 +130,7 @@ export const APIS: { code: string; name: string; method: string; path: string; p
{ code: Permissions.qc.exceptionLink, name: '异常联动改计划', method: 'POST', path: '/api/v1/qc/exception/link', parent: 'qc' },
{ code: Permissions.qc.correct, name: '补正', method: 'POST', path: '/api/v1/qc/correct', parent: 'qc' },
{ code: Permissions.qc.batchPass, name: '批量放行', method: 'POST', path: '/api/v1/qc/batch-pass', parent: 'qc' },
{ code: Permissions.abnormal.handle, name: '异常处理', method: 'POST', path: '/api/v1/qc/abnormal/:id/handle', parent: 'qc' },
];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsArray, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMaxSize, IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ChangePlanDto } from '../plan/dto.js';
/** 退回质控入参(qc_note 必填)。 */
@ -40,3 +40,17 @@ export class BatchPassDto {
@ArrayMaxSize(100)
recordIds!: string[];
}
/** 异常处理入参:质控员更新 handle_status/handle_result。 */
export class HandleAbnormalDto {
@ApiProperty({ description: '处理状态', enum: ['handled', 'closed'] })
@IsString()
@IsNotEmpty()
@IsIn(['handled', 'closed'])
handleStatus!: string;
@ApiPropertyOptional({ description: '处理结果' })
@IsOptional()
@IsString()
handleResult?: string;
}

View File

@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { BatchPassDto, CorrectDto, ExceptionLinkDto, ReturnQcDto } from './dto.js';
import { BatchPassDto, CorrectDto, ExceptionLinkDto, HandleAbnormalDto, ReturnQcDto } from './dto.js';
import { QcService } from './qc.service.js';
@Controller('qc')
@ -36,6 +36,13 @@ export class QcController {
return this.service.batchPass(dto);
}
@Post('abnormal/:id/handle')
@RequirePermissions(Permissions.abnormal.handle)
@AuditLog('qc', 'abnormal-handle')
handleAbnormal(@Param('id') id: string, @Body() dto: HandleAbnormalDto) {
return this.service.handleAbnormal(id, dto);
}
@Get(':id')
@RequirePermissions(Permissions.qc.detail)
detail(@Param('id') id: string) {

View File

@ -648,3 +648,42 @@ describe('QcService.exceptionLink', () => {
expect(res).toMatchObject({ id: 'p-new' });
});
});
describe('QcService.handleAbnormal', () => {
it('abnormal 不存在 → ABNORMAL_NOT_FOUND', async () => {
const { service, recordAbnormalUpdate } = makeService();
await expect(
runWithRequestContext(ctx, () => service.handleAbnormal('missing', { handleStatus: 'handled' })),
).rejects.toMatchObject({ response: { code: ErrorCodes.ABNORMAL_NOT_FOUND } });
expect(recordAbnormalUpdate).not.toHaveBeenCalled();
});
it('处理 → 更新 handle_status/handle_result', async () => {
const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', handleStatus: 'open' });
const recordAbnormalUpdate = vi.fn().mockResolvedValue({ id: 'a1' });
const { service } = makeService({ recordAbnormalFindUnique, recordAbnormalUpdate });
await runWithRequestContext(ctx, () =>
service.handleAbnormal('a1', { handleStatus: 'closed', handleResult: '已通知家属' }),
);
expect(recordAbnormalFindUnique).toHaveBeenCalledWith({ where: { id: 'a1' } });
expect(recordAbnormalUpdate).toHaveBeenCalledWith({
where: { id: 'a1' },
data: { handleStatus: 'closed', handleResult: '已通知家属' },
});
});
it('handleResult 缺省 → 更新为 null', async () => {
const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', handleStatus: 'open' });
const recordAbnormalUpdate = vi.fn().mockResolvedValue({ id: 'a1' });
const { service } = makeService({ recordAbnormalFindUnique, recordAbnormalUpdate });
await runWithRequestContext(ctx, () => service.handleAbnormal('a1', { handleStatus: 'handled' }));
expect(recordAbnormalUpdate).toHaveBeenCalledWith({
where: { id: 'a1' },
data: { handleStatus: 'handled', handleResult: null },
});
});
});

View File

@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js';
import { PlanService } from '../plan/plan.service.js';
import { BatchPassDto, CorrectDto, ExceptionLinkDto, ReturnQcDto } from './dto.js';
import { BatchPassDto, CorrectDto, ExceptionLinkDto, HandleAbnormalDto, ReturnQcDto } from './dto.js';
const DAY_MS = 24 * 60 * 60 * 1000;
/** 批量放行安全边界的未执行枚举。 */
@ -241,6 +241,18 @@ export class QcService {
});
}
/** 异常处理:质控员更新 handle_status/handle_result。 */
async handleAbnormal(id: string, dto: HandleAbnormalDto) {
const abnormal = await this.prisma.client.recordAbnormal.findUnique({ where: { id } });
if (!abnormal) {
throw new BusinessException(ErrorCodes.ABNORMAL_NOT_FOUND, '异常上报不存在', HttpStatus.NOT_FOUND);
}
return this.prisma.client.recordAbnormal.update({
where: { id },
data: { handleStatus: dto.handleStatus, handleResult: dto.handleResult ?? null },
});
}
/** 批量放行:安全边界(异常/未执行/加项/时长过短/已补正)任一命中 → QC_BATCH_UNSAFE;否则逐条 pass。 */
async batchPass(dto: BatchPassDto) {
const ctx = getRequestContext()!;