From f24b86064f588587481ee1ac03471b6654b20e27 Mon Sep 17 00:00:00 2001 From: liuyun Date: Thu, 15 Oct 2026 01:32:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(c4):=20=E8=B4=A8=E6=8E=A7=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=88=E5=85=A8=E9=87=8F=E8=B4=A8=E6=8E=A7/?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E9=80=80=E5=9B=9E/=E4=BA=94=E7=B1=BB?= =?UTF-8?q?=E6=8E=A8=E9=80=81/=E5=BC=82=E5=B8=B8=E8=81=94=E5=8A=A8/?= =?UTF-8?q?=E8=A1=A5=E6=AD=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/app.module.ts | 2 + src/qc/dto.ts | 42 +++ src/qc/qc.controller.ts | 58 ++++ src/qc/qc.module.ts | 11 + src/qc/qc.service.spec.ts | 620 ++++++++++++++++++++++++++++++++++++++ src/qc/qc.service.ts | 336 +++++++++++++++++++++ 6 files changed, 1069 insertions(+) create mode 100644 src/qc/dto.ts create mode 100644 src/qc/qc.controller.ts create mode 100644 src/qc/qc.module.ts create mode 100644 src/qc/qc.service.spec.ts create mode 100644 src/qc/qc.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index b63db2e..db01224 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,6 +35,7 @@ import { ElderSvcModule } from './elder-svc/elder-svc.module.js'; import { OrderModule } from './order/order.module.js'; import { RecordModule } from './record/record.module.js'; import { AbnormalModule } from './abnormal/abnormal.module.js'; +import { QcModule } from './qc/qc.module.js'; import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; @@ -67,6 +68,7 @@ import { AppService } from './app.service.js'; OrderModule, RecordModule, AbnormalModule, + QcModule, ], controllers: [AppController], providers: [ diff --git a/src/qc/dto.ts b/src/qc/dto.ts new file mode 100644 index 0000000..34d6b51 --- /dev/null +++ b/src/qc/dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMaxSize, IsArray, IsNotEmpty, IsString } from 'class-validator'; +import { ChangePlanDto } from '../plan/dto.js'; + +/** 退回质控入参(qc_note 必填)。 */ +export class ReturnQcDto { + @ApiProperty({ description: '退回原因(必填)' }) + @IsString() + @IsNotEmpty() + qcNote!: string; +} + +/** 补正入参:只写更正说明 + 生成 recheck,不改原记录证据字段。 */ +export class CorrectDto { + @ApiProperty({ description: '留痕记录 ID' }) + @IsString() + @IsNotEmpty() + recordId!: string; + + @ApiProperty({ description: '补正说明' }) + @IsString() + @IsNotEmpty() + correctionNote!: string; +} + +/** 异常联动改计划入参:复用 plan.change 字段 + abnormalId。 */ +export class ExceptionLinkDto extends ChangePlanDto { + @ApiProperty({ description: '异常上报 ID(ho_record_abnormal.id)' }) + @IsString() + @IsNotEmpty() + abnormalId!: string; +} + +/** 批量放行入参:待放行的留痕 ID 列表。 */ +export class BatchPassDto { + @ApiProperty({ description: '待批量放行的留痕 ID 列表', type: [String] }) + @IsArray() + @IsString({ each: true }) + @IsNotEmpty() + @ArrayMaxSize(100) + recordIds!: string[]; +} diff --git a/src/qc/qc.controller.ts b/src/qc/qc.controller.ts new file mode 100644 index 0000000..fa725c7 --- /dev/null +++ b/src/qc/qc.controller.ts @@ -0,0 +1,58 @@ +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 { QcService } from './qc.service.js'; + +@Controller('qc') +export class QcController { + constructor(private readonly service: QcService) {} + + // 静态路径必须先于 GET /:id 声明,否则 :id 会吞掉 pending/list。 + @Get('pending/list') + @RequirePermissions(Permissions.qc.pendingList) + pendingList() { + return this.service.pendingList(); + } + + @Post('exception/link') + @RequirePermissions(Permissions.qc.exceptionLink) + @AuditLog('qc', 'exception-link') + exceptionLink(@Body() dto: ExceptionLinkDto) { + return this.service.exceptionLink(dto); + } + + @Post('correct') + @RequirePermissions(Permissions.qc.correct) + @AuditLog('qc', 'correct') + correct(@Body() dto: CorrectDto) { + return this.service.correct(dto); + } + + @Post('batch-pass') + @RequirePermissions(Permissions.qc.batchPass) + @AuditLog('qc', 'batch-pass') + batchPass(@Body() dto: BatchPassDto) { + return this.service.batchPass(dto); + } + + @Get(':id') + @RequirePermissions(Permissions.qc.detail) + detail(@Param('id') id: string) { + return this.service.detail(id); + } + + @Post(':id/pass') + @RequirePermissions(Permissions.qc.pass) + @AuditLog('qc', 'pass') + pass(@Param('id') id: string) { + return this.service.pass(id); + } + + @Post(':id/return') + @RequirePermissions(Permissions.qc.return) + @AuditLog('qc', 'return') + return(@Param('id') id: string, @Body() dto: ReturnQcDto) { + return this.service.return(id, dto); + } +} diff --git a/src/qc/qc.module.ts b/src/qc/qc.module.ts new file mode 100644 index 0000000..91e20a0 --- /dev/null +++ b/src/qc/qc.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PlanService } from '../plan/plan.service.js'; +import { QcController } from './qc.controller.js'; +import { QcService } from './qc.service.js'; + +@Module({ + controllers: [QcController], + // PlanService 用于异常联动复用 plan.change;PrismaService 为 @Global,无需显式导入。 + providers: [QcService, PlanService], +}) +export class QcModule {} diff --git a/src/qc/qc.service.spec.ts b/src/qc/qc.service.spec.ts new file mode 100644 index 0000000..33ba2fe --- /dev/null +++ b/src/qc/qc.service.spec.ts @@ -0,0 +1,620 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QcService } from './qc.service.js'; +import type { PrismaService } from '../prisma/prisma.service.js'; +import type { PlanService } from '../plan/plan.service.js'; +import { ErrorCodes } from '../common/error-codes.js'; +import { runWithRequestContext } from '../common/context/request-context.js'; +import type { RequestContext } from '../common/context/request-context.js'; + +const ctx: RequestContext = { + tenantCode: 'T001', + userId: 'u1', + username: 'qc_staff', + super: false, + dataScope: 'all', + orgCode: null, + orgCodes: [], +}; + +function makeService( + overrides: { + // raw(定时任务跨租户) + rawRecordFindMany?: ReturnType; + rawQcFindFirst?: ReturnType; + rawQcCreate?: ReturnType; + // client.qc + qcFindUnique?: ReturnType; + qcFindFirst?: ReturnType; + qcFindMany?: ReturnType; + qcUpdate?: ReturnType; + qcCreate?: ReturnType; + // client.record + recordFindUnique?: ReturnType; + recordFindFirst?: ReturnType; + recordUpdate?: ReturnType; + // client.recordItem / photo / abnormal / correction + recordItemFindMany?: ReturnType; + recordPhotoFindMany?: ReturnType; + recordAbnormalFindMany?: ReturnType; + recordAbnormalCount?: ReturnType; + recordAbnormalFindUnique?: ReturnType; + recordAbnormalUpdate?: ReturnType; + recordCorrectionFindMany?: ReturnType; + recordCorrectionCreate?: ReturnType; + // client.order / elder / plan + orderFindMany?: ReturnType; + orderFindFirst?: ReturnType; + elderFindMany?: ReturnType; + planFindFirst?: ReturnType; + // planService + planChange?: ReturnType; + } = {}, +) { + const rawRecordFindMany = overrides.rawRecordFindMany ?? vi.fn().mockResolvedValue([]); + const rawQcFindFirst = overrides.rawQcFindFirst ?? vi.fn().mockResolvedValue(null); + const rawQcCreate = overrides.rawQcCreate ?? vi.fn().mockResolvedValue({ id: 'qc1' }); + + const qcFindUnique = overrides.qcFindUnique ?? vi.fn().mockResolvedValue(null); + const qcFindFirst = overrides.qcFindFirst ?? vi.fn().mockResolvedValue(null); + const qcFindMany = overrides.qcFindMany ?? vi.fn().mockResolvedValue([]); + const qcUpdate = overrides.qcUpdate ?? vi.fn().mockResolvedValue({ id: 'qc1' }); + const qcCreate = overrides.qcCreate ?? vi.fn().mockResolvedValue({ id: 'qc1' }); + + const recordFindUnique = overrides.recordFindUnique ?? vi.fn().mockResolvedValue(null); + const recordFindFirst = overrides.recordFindFirst ?? vi.fn().mockResolvedValue(null); + const recordUpdate = overrides.recordUpdate ?? vi.fn().mockResolvedValue({ id: 'r1' }); + + const recordItemFindMany = overrides.recordItemFindMany ?? vi.fn().mockResolvedValue([]); + const recordPhotoFindMany = overrides.recordPhotoFindMany ?? vi.fn().mockResolvedValue([]); + const recordAbnormalFindMany = overrides.recordAbnormalFindMany ?? vi.fn().mockResolvedValue([]); + const recordAbnormalCount = overrides.recordAbnormalCount ?? vi.fn().mockResolvedValue(0); + const recordAbnormalFindUnique = overrides.recordAbnormalFindUnique ?? vi.fn().mockResolvedValue(null); + const recordAbnormalUpdate = overrides.recordAbnormalUpdate ?? vi.fn().mockResolvedValue({ id: 'a1' }); + const recordCorrectionFindMany = overrides.recordCorrectionFindMany ?? vi.fn().mockResolvedValue([]); + const recordCorrectionCreate = overrides.recordCorrectionCreate ?? vi.fn().mockResolvedValue({ id: 'c1' }); + + const orderFindMany = overrides.orderFindMany ?? vi.fn().mockResolvedValue([]); + const orderFindFirst = overrides.orderFindFirst ?? vi.fn().mockResolvedValue(null); + const elderFindMany = overrides.elderFindMany ?? vi.fn().mockResolvedValue([]); + const planFindFirst = overrides.planFindFirst ?? vi.fn().mockResolvedValue(null); + + const planChange = overrides.planChange ?? vi.fn().mockResolvedValue({ id: 'p-new' }); + + const prisma = { + raw: { + record: { findMany: rawRecordFindMany }, + qc: { findFirst: rawQcFindFirst, create: rawQcCreate }, + }, + client: { + qc: { + findUnique: qcFindUnique, + findFirst: qcFindFirst, + findMany: qcFindMany, + update: qcUpdate, + create: qcCreate, + }, + record: { + findUnique: recordFindUnique, + findFirst: recordFindFirst, + update: recordUpdate, + }, + recordItem: { findMany: recordItemFindMany }, + recordPhoto: { findMany: recordPhotoFindMany }, + recordAbnormal: { + findMany: recordAbnormalFindMany, + count: recordAbnormalCount, + findUnique: recordAbnormalFindUnique, + update: recordAbnormalUpdate, + }, + recordCorrection: { findMany: recordCorrectionFindMany, create: recordCorrectionCreate }, + order: { findMany: orderFindMany, findFirst: orderFindFirst }, + elder: { findMany: elderFindMany }, + plan: { findFirst: planFindFirst }, + }, + }; + + const service = new QcService( + prisma as unknown as PrismaService, + { change: planChange } as unknown as PlanService, + ); + return { + service, + rawRecordFindMany, + rawQcFindFirst, + rawQcCreate, + qcFindUnique, + qcFindFirst, + qcFindMany, + qcUpdate, + qcCreate, + recordFindUnique, + recordFindFirst, + recordUpdate, + recordItemFindMany, + recordPhotoFindMany, + recordAbnormalFindMany, + recordAbnormalCount, + recordAbnormalFindUnique, + recordAbnormalUpdate, + recordCorrectionFindMany, + recordCorrectionCreate, + orderFindMany, + orderFindFirst, + elderFindMany, + planFindFirst, + planChange, + }; +} + +const pendingQc = { id: 'qc1', recordId: 'r1', qcResult: 'pending', qcType: 'normal' }; +const passableRecord = { + id: 'r1', + recordStatus: 'submitted', + elderId: 'e1', + checkinTime: new Date('2026-10-15T02:00:00Z'), + checkoutTime: new Date('2026-10-15T03:00:00Z'), + durationMin: 60, + assigneeId: 'staff1', + createdBy: 'u2', +}; + +describe('QcService.generatePendingQc', () => { + it('submitted 且无 qc → 每条 create pending qc(raw 跨租户,tenantCode 取 record)', async () => { + const rawRecordFindMany = vi.fn().mockResolvedValue([ + { id: 'r1', tenantCode: 'T001' }, + { id: 'r2', tenantCode: 'T002' }, + ]); + const { service, rawQcCreate } = makeService({ rawRecordFindMany }); + + await service.generatePendingQc(); + + expect(rawRecordFindMany).toHaveBeenCalledWith({ where: { recordStatus: 'submitted' } }); + expect(rawQcCreate).toHaveBeenCalledTimes(2); + expect(rawQcCreate).toHaveBeenNthCalledWith(1, { + data: { tenantCode: 'T001', recordId: 'r1', qcType: 'normal', qcResult: 'pending' }, + }); + expect(rawQcCreate).toHaveBeenNthCalledWith(2, { + data: { tenantCode: 'T002', recordId: 'r2', qcType: 'normal', qcResult: 'pending' }, + }); + }); + + it('已有 qc 的 record 跳过(防重复)', async () => { + const rawRecordFindMany = vi.fn().mockResolvedValue([{ id: 'r1', tenantCode: 'T001' }]); + const rawQcFindFirst = vi.fn().mockResolvedValue({ id: 'qc-exist' }); + const { service, rawQcCreate } = makeService({ rawRecordFindMany, rawQcFindFirst }); + + await service.generatePendingQc(); + + expect(rawQcFindFirst).toHaveBeenCalledWith({ where: { recordId: 'r1' } }); + expect(rawQcCreate).not.toHaveBeenCalled(); + }); +}); + +describe('QcService.pass', () => { + it('qc 不存在 → QC_NOT_FOUND', async () => { + const { service, qcUpdate } = makeService(); + await expect(runWithRequestContext(ctx, () => service.pass('missing'))).rejects.toMatchObject({ + response: { code: ErrorCodes.QC_NOT_FOUND }, + }); + 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.pass('qc1'))).rejects.toMatchObject({ + response: { code: ErrorCodes.QC_ALREADY_HANDLED }, + }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('四要素缺 → QC_ELEMENT_MISSING(1004)', async () => { + const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); + 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('必拍未拍 → RECORD_PHOTO_REQUIRED(3001)', async () => { + const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); + const recordFindUnique = vi.fn().mockResolvedValue(passableRecord); + const recordItemFindMany = vi.fn().mockResolvedValue([ + { id: 'ri1', svcId: 'svc1', photoRequired: true, execResult: 'done', isRefusedPhoto: false }, + ]); + const recordPhotoFindMany = vi.fn().mockResolvedValue([]); + const { service, qcUpdate } = makeService({ + qcFindUnique, + recordFindUnique, + recordItemFindMany, + recordPhotoFindMany, + }); + await expect(runWithRequestContext(ctx, () => service.pass('qc1'))).rejects.toMatchObject({ + response: { code: ErrorCodes.RECORD_PHOTO_REQUIRED, message: expect.stringContaining('3001') }, + }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('自审自放行(createdBy === ctx.userId)→ QC_SELF_REVIEW(4001)', async () => { + const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); + const recordFindUnique = vi.fn().mockResolvedValue({ ...passableRecord, createdBy: 'u1' }); + const { service, qcUpdate } = makeService({ qcFindUnique, recordFindUnique }); + await expect(runWithRequestContext(ctx, () => service.pass('qc1'))).rejects.toMatchObject({ + response: { code: ErrorCodes.QC_SELF_REVIEW, message: expect.stringContaining('4001') }, + }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('通过 → qc.update passed + qcStaffId + qcTime + passTime', async () => { + const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); + const recordFindUnique = vi.fn().mockResolvedValue(passableRecord); + const { service, qcUpdate } = makeService({ qcFindUnique, recordFindUnique }); + + await runWithRequestContext(ctx, () => service.pass('qc1')); + + expect(qcUpdate).toHaveBeenCalledWith({ + where: { id: 'qc1' }, + data: expect.objectContaining({ + qcResult: 'passed', + qcStaffId: 'u1', + qcTime: expect.any(Date), + passTime: expect.any(Date), + }), + }); + }); +}); + +describe('QcService.return', () => { + it('qc_note 空 → QC_NOTE_REQUIRED', async () => { + const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); + const { service, qcUpdate } = makeService({ qcFindUnique }); + await expect( + runWithRequestContext(ctx, () => service.return('qc1', { qcNote: '' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_NOTE_REQUIRED } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('退回 → qc.update returned + qcNote', async () => { + const qcFindUnique = vi.fn().mockResolvedValue(pendingQc); + const { service, qcUpdate } = makeService({ qcFindUnique }); + + await runWithRequestContext(ctx, () => service.return('qc1', { qcNote: '请补拍照片' })); + + expect(qcUpdate).toHaveBeenCalledWith({ + where: { id: 'qc1' }, + data: expect.objectContaining({ qcResult: 'returned', qcNote: '请补拍照片', qcStaffId: 'u1', qcTime: expect.any(Date) }), + }); + }); +}); + +describe('QcService.correct', () => { + it('record 非 submitted → QC_RECORD_NOT_SUBMITTED', async () => { + const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'serving' }); + const { service, recordCorrectionCreate } = makeService({ recordFindUnique }); + await expect( + runWithRequestContext(ctx, () => service.correct({ recordId: 'r1', correctionNote: '说明' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_RECORD_NOT_SUBMITTED } }); + expect(recordCorrectionCreate).not.toHaveBeenCalled(); + }); + + it('无退回 qc → QC_NOT_FOUND', async () => { + const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'submitted' }); + const qcFindFirst = vi.fn().mockResolvedValue(null); + const { service } = makeService({ recordFindUnique, qcFindFirst }); + await expect( + runWithRequestContext(ctx, () => service.correct({ recordId: 'r1', correctionNote: '说明' })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_NOT_FOUND } }); + }); + + it('补正不改原值 + 生成 recheck qc(firstQcId=退回 qc.id)', async () => { + const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'submitted' }); + const qcFindFirst = vi.fn().mockResolvedValue({ id: 'qc-returned', recordId: 'r1', qcResult: 'returned', firstQcId: null }); + const { service, recordCorrectionCreate, qcCreate, recordUpdate } = makeService({ + recordFindUnique, + qcFindFirst, + }); + + const res = await runWithRequestContext(ctx, () => service.correct({ recordId: 'r1', correctionNote: '补充说明' })); + + expect(recordCorrectionCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantCode: 'T001', + recordId: 'r1', + qcId: 'qc-returned', + correctionNote: '补充说明', + correctedBy: 'u1', + }), + }); + expect(qcCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantCode: 'T001', + recordId: 'r1', + qcType: 'recheck', + qcResult: 'pending', + firstQcId: 'qc-returned', + }), + }); + // 不改原值:不触碰 record 任何证据字段。 + expect(recordUpdate).not.toHaveBeenCalled(); + expect(res).toMatchObject({ id: 'qc1' }); + }); + + it('recheck 链:退回的是 recheck qc → firstQcId 沿用原 firstQcId', async () => { + const recordFindUnique = vi.fn().mockResolvedValue({ id: 'r1', recordStatus: 'submitted' }); + const qcFindFirst = vi.fn().mockResolvedValue({ id: 'qc-recheck-1', recordId: 'r1', qcResult: 'returned', firstQcId: 'qc-first' }); + const { service, qcCreate } = makeService({ recordFindUnique, qcFindFirst }); + + await runWithRequestContext(ctx, () => service.correct({ recordId: 'r1', correctionNote: '再次说明' })); + + expect(qcCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ qcType: 'recheck', firstQcId: 'qc-first' }), + }); + }); +}); + +describe('QcService.batchPass', () => { + const safeRecord = { id: 'r1', recordStatus: 'submitted', durationMin: 60, createdBy: 'u2', checkinTime: new Date(), checkoutTime: new Date(), assigneeId: 'staff1' }; + const pendingQcForRecord = { id: 'qc1', recordId: 'r1', qcResult: 'pending' }; + + it('含异常记录 → QC_BATCH_UNSAFE', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(safeRecord); + const recordAbnormalFindMany = vi.fn().mockResolvedValue([{ id: 'a1' }]); + const { service, qcUpdate } = makeService({ recordFindUnique, recordAbnormalFindMany }); + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_BATCH_UNSAFE } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('含 undo 项 → QC_BATCH_UNSAFE', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(safeRecord); + const recordItemFindMany = vi.fn().mockResolvedValue([{ execResult: 'undo_not_done' }]); + const { service, qcUpdate } = makeService({ recordFindUnique, recordItemFindMany }); + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_BATCH_UNSAFE } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('含临时加项 extraFlag → QC_BATCH_UNSAFE', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(safeRecord); + const recordItemFindMany = vi.fn().mockResolvedValue([{ execResult: 'done', extraFlag: true }]); + const { service, qcUpdate } = makeService({ recordFindUnique, recordItemFindMany }); + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_BATCH_UNSAFE } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('时长 <5min → QC_BATCH_UNSAFE', async () => { + const recordFindUnique = vi.fn().mockResolvedValue({ ...safeRecord, durationMin: 3 }); + const { service, qcUpdate } = makeService({ recordFindUnique }); + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_BATCH_UNSAFE } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('含补正记录 → QC_BATCH_UNSAFE', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(safeRecord); + const recordCorrectionFindMany = vi.fn().mockResolvedValue([{ id: 'c1' }]); + const { service, qcUpdate } = makeService({ recordFindUnique, recordCorrectionFindMany }); + await expect( + runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1'] })), + ).rejects.toMatchObject({ response: { code: ErrorCodes.QC_BATCH_UNSAFE } }); + expect(qcUpdate).not.toHaveBeenCalled(); + }); + + it('安全记录 → 逐条 pass(qc.update passed)', async () => { + const recordFindUnique = vi.fn().mockResolvedValue(safeRecord); + const qcFindFirst = vi.fn().mockResolvedValue(pendingQcForRecord); + const qcFindUnique = vi.fn().mockResolvedValue(pendingQcForRecord); + const { service, qcUpdate } = makeService({ recordFindUnique, qcFindFirst, qcFindUnique }); + + const res = await runWithRequestContext(ctx, () => service.batchPass({ recordIds: ['r1'] })); + + expect(qcUpdate).toHaveBeenCalledWith({ + where: { id: 'qc1' }, + data: expect.objectContaining({ qcResult: 'passed', qcStaffId: 'u1' }), + }); + expect(res).toMatchObject({ passed: 1 }); + }); +}); + +describe('QcService.pendingList(五类聚合)', () => { + it('五类计数 + 消除条件齐全', async () => { + const recordAbnormalCount = vi.fn().mockResolvedValue(2); + const qcFindMany = vi.fn().mockResolvedValue([{ id: 'qc1', recordId: 'r1', qcResult: 'pending', createdAt: new Date() }]); + const recordFindUnique = vi.fn().mockResolvedValue(passableRecord); + const { service } = makeService({ recordAbnormalCount, qcFindMany, recordFindUnique }); + + const res = await runWithRequestContext(ctx, () => service.pendingList()); + + expect(res).toMatchObject({ + abnormalOpen: { count: 2 }, + overdue: { count: 0 }, + hospActive: { count: 0 }, + missingPlan: { count: 0 }, + pendingQc: { count: 1 }, + }); + expect(res.abnormalOpen.clearCondition).toBeTruthy(); + expect(res.overdue.clearCondition).toBeTruthy(); + expect(res.hospActive.clearCondition).toBeTruthy(); + expect(res.missingPlan.clearCondition).toBeTruthy(); + expect(res.pendingQc.clearCondition).toBeTruthy(); + }); + + it('逾期未做:无 record 计入,有 record 消除', async () => { + const overdueOrder = { id: 'o1', orderStatus: 'assigned', timeSlotEnd: new Date(Date.now() - 2 * 86400000) }; + const orderFindMany = vi.fn().mockResolvedValue([overdueOrder]); + const recordFindFirst = vi.fn().mockResolvedValue(null); + const { service } = makeService({ orderFindMany, recordFindFirst }); + const res = await runWithRequestContext(ctx, () => service.pendingList()); + expect(res.overdue.count).toBe(1); + expect(res.overdue.list).toHaveLength(1); + }); + + it('逾期未做消除:工单已有留痕 → 不计入', async () => { + const orderFindMany = vi.fn().mockResolvedValue([{ id: 'o1', orderStatus: 'assigned' }]); + const recordFindFirst = vi.fn().mockResolvedValue({ id: 'r1' }); + const { service } = makeService({ orderFindMany, recordFindFirst }); + const res = await runWithRequestContext(ctx, () => service.pendingList()); + expect(res.overdue.count).toBe(0); + }); + + it('逾期未做查询口径:timeSlotEnd < now-24h 且 orderStatus 排除 cancelled/not_met/done', async () => { + const orderFindMany = vi.fn().mockResolvedValue([]); + const { service } = makeService({ orderFindMany }); + await runWithRequestContext(ctx, () => service.pendingList()); + expect(orderFindMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + timeSlotEnd: { lt: expect.any(Date) }, + orderStatus: { notIn: ['cancelled', 'not_met', 'done'] }, + }), + }); + }); + + it('住院仍有派单:hosp_pause + 活跃 order → 计入;无活跃 order → 消除', async () => { + const elderFindMany = vi.fn() + .mockImplementation((args: any) => { + if (args?.where?.treatStatus === 'hosp_pause') return Promise.resolve([{ id: 'e1', treatStatus: 'hosp_pause' }]); + return Promise.resolve([]); + }); + const orderFindFirst = vi.fn().mockResolvedValue({ id: 'o1', orderStatus: 'assigned' }); + const { service } = makeService({ elderFindMany, orderFindFirst }); + const res = await runWithRequestContext(ctx, () => service.pendingList()); + expect(res.hospActive.count).toBe(1); + + // 无活跃 order → 消除 + const orderFindFirstNone = vi.fn().mockResolvedValue(null); + const svc2 = makeService({ elderFindMany, orderFindFirst: orderFindFirstNone }).service; + const res2 = await runWithRequestContext(ctx, () => svc2.pendingList()); + expect(res2.hospActive.count).toBe(0); + }); + + it('计划未制定:benefit_start 已过且无 active plan → 计入;有 active plan → 消除', async () => { + const elderFindMany = vi.fn() + .mockImplementation((args: any) => { + if (args?.where?.benefitStart) return Promise.resolve([{ id: 'e1', benefitStart: new Date('2026-10-01') }]); + return Promise.resolve([]); + }); + const planFindFirst = vi.fn().mockResolvedValue(null); + const { service } = makeService({ elderFindMany, planFindFirst }); + const res = await runWithRequestContext(ctx, () => service.pendingList()); + expect(res.missingPlan.count).toBe(1); + + // 有 active plan → 消除 + const planFindFirstActive = vi.fn().mockResolvedValue({ id: 'p1', planStatus: 'active' }); + const svc2 = makeService({ elderFindMany, planFindFirst: planFindFirstActive }).service; + const res2 = await runWithRequestContext(ctx, () => svc2.pendingList()); + expect(res2.missingPlan.count).toBe(0); + }); + + it('留痕待放行:pending qc 含 record 摘要 + 等待时长', async () => { + const qcCreatedAt = new Date(Date.now() - 60 * 60 * 1000); + const qcFindMany = vi.fn().mockResolvedValue([ + { id: 'qc1', recordId: 'r1', qcResult: 'pending', qcType: 'normal', createdAt: qcCreatedAt }, + ]); + const recordFindUnique = vi.fn().mockResolvedValue(passableRecord); + const { service } = makeService({ qcFindMany, recordFindUnique }); + + const res = await runWithRequestContext(ctx, () => service.pendingList()); + + expect(res.pendingQc.count).toBe(1); + expect(res.pendingQc.list[0]).toMatchObject({ + id: 'qc1', + record: { id: 'r1', elderId: 'e1', assigneeId: 'staff1' }, + }); + expect(res.pendingQc.list[0].waitMinutes).toBe(60); + }); +}); + +describe('QcService.detail', () => { + it('返回 qc + record(items/photos/abnormals + 坐标 string)+ correction 历史', async () => { + const qcFindUnique = vi.fn().mockResolvedValue({ id: 'qc1', recordId: 'r1', qcResult: 'pending' }); + const recordFindUnique = vi.fn().mockResolvedValue({ + id: 'r1', + recordStatus: 'submitted', + checkinLat: { toString: () => '25.010000' }, + checkinLng: null, + }); + const recordItemFindMany = vi.fn().mockResolvedValue([{ id: 'ri1' }]); + const recordPhotoFindMany = vi.fn().mockResolvedValue([{ id: 'rp1' }]); + const recordAbnormalFindMany = vi.fn().mockResolvedValue([ + { id: 'ra1', visitLat: { toString: () => '25.020000' }, visitLng: { toString: () => '102.710000' } }, + ]); + const recordCorrectionFindMany = vi.fn().mockResolvedValue([{ id: 'c1' }]); + const { service } = makeService({ + qcFindUnique, + recordFindUnique, + recordItemFindMany, + recordPhotoFindMany, + recordAbnormalFindMany, + recordCorrectionFindMany, + }); + + const res = await runWithRequestContext(ctx, () => service.detail('qc1')); + + expect(res).toMatchObject({ + id: 'qc1', + record: { + id: 'r1', + items: [{ id: 'ri1' }], + photos: [{ id: 'rp1' }], + abnormals: [{ id: 'ra1' }], + }, + corrections: [{ id: 'c1' }], + }); + expect(res.record.checkinLat).toBe('25.010000'); + expect(res.record.checkinLng).toBeNull(); + expect(res.record.abnormals[0].visitLat).toBe('25.020000'); + expect(res.record.abnormals[0].visitLng).toBe('102.710000'); + }); +}); + +describe('QcService.exceptionLink', () => { + const changeDto = { + abnormalId: 'a1', + changeFromPlanId: 'p-old', + elderId: 'e1', + planPeriodStart: '2026-11-01', + planPeriodEnd: '2026-11-30', + careLevel: 'care_1', + careMode: 'inst_medical', + items: [{ svcId: 'svc1' }], + }; + + it('abnormal 不存在 → ABNORMAL_NOT_FOUND', async () => { + const { service, planChange } = makeService(); + await expect(runWithRequestContext(ctx, () => service.exceptionLink(changeDto))).rejects.toMatchObject({ + response: { code: ErrorCodes.ABNORMAL_NOT_FOUND }, + }); + expect(planChange).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' }); + const recordAbnormalUpdate = vi.fn().mockResolvedValue({ id: 'a1' }); + const { service } = makeService({ recordAbnormalFindUnique, planChange, recordAbnormalUpdate }); + + const res = await runWithRequestContext(ctx, () => service.exceptionLink(changeDto)); + + expect(planChange).toHaveBeenCalledWith({ + changeFromPlanId: 'p-old', + elderId: 'e1', + planPeriodStart: '2026-11-01', + planPeriodEnd: '2026-11-30', + careLevel: 'care_1', + careMode: 'inst_medical', + items: [{ svcId: 'svc1' }], + }); + expect(recordAbnormalUpdate).toHaveBeenCalledWith({ + where: { id: 'a1' }, + data: { linkPlanChange: true }, + }); + expect(res).toMatchObject({ id: 'p-new' }); + }); +}); diff --git a/src/qc/qc.service.ts b/src/qc/qc.service.ts new file mode 100644 index 0000000..daa95cf --- /dev/null +++ b/src/qc/qc.service.ts @@ -0,0 +1,336 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service.js'; +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'; + +const DAY_MS = 24 * 60 * 60 * 1000; +/** 批量放行安全边界的未执行枚举。 */ +const UNDONE_EXEC_RESULTS = ['undo_not_done', 'undo_refused']; + +/** now 之前 N 个工作日的时刻(跳过周六日),用于「计划未制定」5 工作日口径。 */ +function subtractWorkdays(now: Date, days: number): Date { + const d = new Date(now.getTime()); + let remaining = days; + while (remaining > 0) { + d.setDate(d.getDate() - 1); + const dow = d.getDay(); // 0=Sun, 6=Sat + if (dow !== 0 && dow !== 6) remaining--; + } + return d; +} + +/** + * 质控闸门(qc / ho_record_correction)业务服务。 + * + * - generatePendingQc:@Cron 每小时 raw 跨租户扫描 submitted 且无 qc 的留痕 → 生成 pending qc。 + * - pass:放行自检(幂等 → 四要素 1004 → 必拍 3001 → 自审 4001)→ passed。 + * - return:qc_note 必填 → returned。 + * - pendingList:五类主动推送聚合(异常/逾期/住院/计划/待放行),带消除条件。 + * - detail:qc + 关联留痕(全文/项目/照片/异常/定位)+ 补正历史。 + * - exceptionLink:复用 plan.change 生成新计划 + 回填 link_plan_change。 + * - correct:补正说明 + 生成 recheck qc,不改原记录证据字段。 + * - batchPass:安全边界(异常/未执行/加项/时长过短/已补正)→ 逐条 pass。 + */ +@Injectable() +export class QcService { + constructor( + private readonly prisma: PrismaService, + private readonly planService: PlanService, + ) {} + + /** 定时任务:每小时全量扫描 submitted 且无 qc 的留痕 → 生成 pending qc(raw 跨租户)。 */ + @Cron('0 * * * *') + async generatePendingQc() { + const submitted = await this.prisma.raw.record.findMany({ where: { recordStatus: 'submitted' } }); + const created = []; + for (const record of submitted) { + const existing = await this.prisma.raw.qc.findFirst({ where: { recordId: record.id } }); + if (existing) continue; // 有 qc 的由 pass/return/correct 管理,防重复生成。 + created.push( + await this.prisma.raw.qc.create({ + data: { + tenantCode: record.tenantCode, + recordId: record.id, + qcType: 'normal', + qcResult: 'pending', + }, + }), + ); + } + return created; + } + + /** 五类主动推送聚合(跨表查询,不落 qc 表)。 */ + async pendingList() { + const now = new Date(); + const [abnormalOpenCount, overdueOrders, hospElders, planElders, pendingQcs] = await Promise.all([ + // 1. 异常待处理(消除条件=已处理,即 handle_status ≠ open)。 + this.prisma.client.recordAbnormal.count({ where: { handleStatus: 'open' } }), + // 2. 逾期未做:过时段超 24h 且未取消/扑空/完成(无 record 再二次过滤)。 + this.prisma.client.order.findMany({ + where: { + timeSlotEnd: { lt: new Date(now.getTime() - DAY_MS) }, + orderStatus: { notIn: ['cancelled', 'not_met', 'done'] }, + }, + }), + // 3. 住院仍有派单:treat_status=hosp_pause 的老人(有活跃 order 再二次过滤)。 + this.prisma.client.elder.findMany({ where: { treatStatus: 'hosp_pause' } }), + // 4. 计划未制定:待遇生效满 5 工作日(无 active plan 再二次过滤)。 + this.prisma.client.elder.findMany({ where: { benefitStart: { lte: subtractWorkdays(now, 5) } } }), + // 5. 留痕待放行。 + this.prisma.client.qc.findMany({ where: { qcResult: 'pending' }, orderBy: { createdAt: 'asc' } }), + ]); + + const overdue = []; + for (const order of overdueOrders) { + const record = await this.prisma.client.record.findFirst({ where: { orderId: order.id } }); + if (!record) overdue.push(order); + } + + const hospActive = []; + for (const elder of hospElders) { + const order = await this.prisma.client.order.findFirst({ + where: { elderId: elder.id, orderStatus: { in: ['to_assign', 'assigned', 'serving'] } }, + }); + if (order) hospActive.push({ elder, order }); + } + + const missingPlan = []; + for (const elder of planElders) { + const plan = await this.prisma.client.plan.findFirst({ + where: { elderId: elder.id, planStatus: 'active' }, + }); + if (!plan) missingPlan.push(elder); + } + + const pendingQcList = []; + for (const qc of pendingQcs) { + const record = await this.prisma.client.record.findUnique({ where: { id: qc.recordId } }); + pendingQcList.push({ + ...qc, + record: record + ? { + id: record.id, + elderId: record.elderId, + assigneeId: record.assigneeId, + checkinTime: record.checkinTime, + recordStatus: record.recordStatus, + } + : null, + waitMinutes: Math.max(0, Math.floor((now.getTime() - new Date(qc.createdAt).getTime()) / 60000)), + }); + } + + return { + abnormalOpen: { count: abnormalOpenCount, clearCondition: '异常已处理(handle_status ≠ open)' }, + overdue: { count: overdue.length, list: overdue, clearCondition: '已产生留痕,或工单已取消/扑空/完成' }, + hospActive: { count: hospActive.length, list: hospActive, clearCondition: '住院暂停老人的待处理派单已清除' }, + missingPlan: { count: missingPlan.length, list: missingPlan, clearCondition: '已制定 active 服务计划' }, + pendingQc: { count: pendingQcs.length, list: pendingQcList, clearCondition: '留痕质控已放行/退回' }, + }; + } + + /** 质控详情:qc + 关联留痕(全文/项目/照片/异常/定位)+ 补正历史。 */ + async detail(id: string) { + const qc = await this.requireQc(id); + const [record, corrections] = await Promise.all([ + this.prisma.client.record.findUnique({ where: { id: qc.recordId } }), + this.prisma.client.recordCorrection.findMany({ + where: { recordId: qc.recordId }, + orderBy: { correctedAt: 'asc' }, + }), + ]); + if (!record) { + throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '留痕不存在', HttpStatus.NOT_FOUND); + } + const [items, photos, abnormals] = await Promise.all([ + this.prisma.client.recordItem.findMany({ where: { recordId: record.id } }), + this.prisma.client.recordPhoto.findMany({ where: { recordId: record.id } }), + this.prisma.client.recordAbnormal.findMany({ where: { recordId: record.id } }), + ]); + return { + ...qc, + record: this.serializeRecord({ ...record, items, photos, abnormals }), + corrections, + }; + } + + /** 放行:自检(幂等 → 四要素 1004 → 必拍 3001 → 自审 4001)→ passed。 */ + async pass(id: string) { + const ctx = getRequestContext()!; + 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); + await this.assertPassable(record, ctx.userId); + return this.prisma.client.qc.update({ + where: { id }, + data: { qcResult: 'passed', qcStaffId: ctx.userId, qcTime: new Date(), passTime: new Date() }, + }); + } + + /** 退回:qc_note 必填 → returned。 */ + async return(id: string, dto: ReturnQcDto) { + const ctx = getRequestContext()!; + await this.requireQc(id); + if (!dto.qcNote) { + throw new BusinessException(ErrorCodes.QC_NOTE_REQUIRED, '退回须填写原因', HttpStatus.BAD_REQUEST); + } + return this.prisma.client.qc.update({ + where: { id }, + data: { qcResult: 'returned', qcNote: dto.qcNote, qcStaffId: ctx.userId, qcTime: new Date() }, + }); + } + + /** 异常联动改计划:复用 plan.change 生成新 plan + 回填 link_plan_change=true。 */ + async exceptionLink(dto: ExceptionLinkDto) { + const abnormal = await this.prisma.client.recordAbnormal.findUnique({ where: { id: dto.abnormalId } }); + if (!abnormal) { + throw new BusinessException(ErrorCodes.ABNORMAL_NOT_FOUND, '异常上报不存在', HttpStatus.NOT_FOUND); + } + const { abnormalId: _abnormalId, ...changeDto } = dto; + const newPlan = await this.planService.change(changeDto); + await this.prisma.client.recordAbnormal.update({ + where: { id: dto.abnormalId }, + data: { linkPlanChange: true }, + }); + return newPlan; + } + + /** 补正:写更正说明 + 生成 recheck qc;不改原记录任何证据字段(R3)。 */ + async correct(dto: CorrectDto) { + const ctx = getRequestContext()!; + const record = await this.prisma.client.record.findUnique({ where: { id: dto.recordId } }); + if (!record) { + throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '留痕不存在', HttpStatus.NOT_FOUND); + } + if (record.recordStatus !== 'submitted') { + throw new BusinessException(ErrorCodes.QC_RECORD_NOT_SUBMITTED, '留痕非 submitted,不可补正', HttpStatus.BAD_REQUEST); + } + const returnedQc = await this.prisma.client.qc.findFirst({ + where: { recordId: record.id, qcResult: 'returned' }, + orderBy: { createdAt: 'desc' }, + }); + if (!returnedQc) { + throw new BusinessException(ErrorCodes.QC_NOT_FOUND, '无退回的质控记录,不可补正', HttpStatus.BAD_REQUEST); + } + await this.prisma.client.recordCorrection.create({ + data: { + tenantCode: ctx.tenantCode, + recordId: record.id, + qcId: returnedQc.id, + correctionNote: dto.correctionNote, + correctedBy: ctx.userId, + }, + }); + return this.prisma.client.qc.create({ + data: { + tenantCode: ctx.tenantCode, + recordId: record.id, + qcType: 'recheck', + qcResult: 'pending', + firstQcId: returnedQc.firstQcId ?? returnedQc.id, + }, + }); + } + + /** 批量放行:安全边界(异常/未执行/加项/时长过短/已补正)任一命中 → QC_BATCH_UNSAFE;否则逐条 pass。 */ + async batchPass(dto: BatchPassDto) { + const ctx = getRequestContext()!; + 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 [abnormals, items, corrections] = await Promise.all([ + this.prisma.client.recordAbnormal.findMany({ where: { recordId } }), + this.prisma.client.recordItem.findMany({ where: { recordId } }), + this.prisma.client.recordCorrection.findMany({ where: { recordId } }), + ]); + const unsafe = + abnormals.length > 0 || + items.some((i) => UNDONE_EXEC_RESULTS.includes(i.execResult)) || + items.some((i) => i.extraFlag === true) || + (record.durationMin != null && record.durationMin < 5) || + corrections.length > 0; + if (unsafe) { + throw new BusinessException( + ErrorCodes.QC_BATCH_UNSAFE, + '含异常/未执行/临时加项/时长过短/已补正记录,须逐条审核', + HttpStatus.BAD_REQUEST, + ); + } + const qc = await this.prisma.client.qc.findFirst({ where: { recordId, qcResult: 'pending' } }); + if (qc) { + await this.pass(qc.id); + } + } + return { passed: dto.recordIds.length }; + } + + // ---- 内部 ---- + + private async requireQc(id: string): Promise> { + const qc = await this.prisma.client.qc.findUnique({ where: { id } }); + if (!qc) { + throw new BusinessException(ErrorCodes.QC_NOT_FOUND, '质控记录不存在', HttpStatus.NOT_FOUND); + } + return qc; + } + + private async requireRecord(id: string): Promise> { + const record = await this.prisma.client.record.findUnique({ where: { id } }); + if (!record) { + throw new BusinessException(ErrorCodes.RECORD_NOT_FOUND, '留痕不存在', HttpStatus.NOT_FOUND); + } + return record; + } + + /** 放行自检:四要素(1004)→ 必拍已拍(3001)→ 自审自放行(4001)。 */ + private async assertPassable(record: Record, userId: string) { + if (!record.checkinTime || !record.checkoutTime || record.durationMin == null || !record.assigneeId) { + throw new BusinessException(ErrorCodes.QC_ELEMENT_MISSING, '留痕缺必填要素(1004)', HttpStatus.BAD_REQUEST); + } + const [items, photos] = await Promise.all([ + this.prisma.client.recordItem.findMany({ where: { recordId: record.id } }), + this.prisma.client.recordPhoto.findMany({ where: { recordId: record.id } }), + ]); + for (const item of items) { + if (item.photoRequired && item.execResult === 'done' && !item.isRefusedPhoto) { + const hasPhoto = photos.some((p) => p.svcId === item.svcId); + if (!hasPhoto) { + throw new BusinessException( + ErrorCodes.RECORD_PHOTO_REQUIRED, + '必拍项目未拍照,请补拍或走拒拍出路(3001)', + HttpStatus.BAD_REQUEST, + ); + } + } + } + if (record.createdBy === userId) { + throw new BusinessException(ErrorCodes.QC_SELF_REVIEW, '不可自审自放行(4001)', HttpStatus.BAD_REQUEST); + } + } + + /** Prisma Decimal 坐标转字符串(null 保持 null),复用 C3 口径,含异常到访坐标。 */ + private serializeRecord(record: Record): Record { + const serialized: Record = { + ...record, + checkinLat: record.checkinLat != null ? record.checkinLat.toString() : null, + checkinLng: record.checkinLng != null ? record.checkinLng.toString() : null, + }; + if (Array.isArray(record.abnormals)) { + serialized.abnormals = record.abnormals.map((a: Record) => ({ + ...a, + visitLat: a.visitLat != null ? a.visitLat.toString() : null, + visitLng: a.visitLng != null ? a.visitLng.toString() : null, + })); + } + return serialized; + } +}