Compare commits
8 Commits
9f232356e0
...
9fea4efe0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fea4efe0f | ||
|
|
a808581e60 | ||
|
|
00c258dda5 | ||
|
|
ba7f2397d7 | ||
|
|
915745226f | ||
|
|
f24b86064f | ||
|
|
8f9177e36a | ||
|
|
2828a9da58 |
36
prisma/migrations/20261015010000_c4_qc/migration.sql
Normal file
36
prisma/migrations/20261015010000_c4_qc/migration.sql
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `qc` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`record_id` VARCHAR(36) NOT NULL,
|
||||||
|
`qc_type` VARCHAR(20) NOT NULL DEFAULT 'normal',
|
||||||
|
`qc_result` VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
`qc_note` VARCHAR(200) NULL,
|
||||||
|
`qc_staff_id` VARCHAR(36) NULL,
|
||||||
|
`qc_time` DATETIME(3) NULL,
|
||||||
|
`pass_time` DATETIME(3) NULL,
|
||||||
|
`first_qc_id` VARCHAR(36) NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`created_by` VARCHAR(36) NULL,
|
||||||
|
`updated_at` DATETIME(3) NOT NULL,
|
||||||
|
`deleted_at` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `qc_tenant_code_record_id_idx`(`tenant_code`, `record_id`),
|
||||||
|
INDEX `qc_tenant_code_qc_result_idx`(`tenant_code`, `qc_result`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ho_record_correction` (
|
||||||
|
`id` VARCHAR(36) NOT NULL,
|
||||||
|
`tenant_code` VARCHAR(64) NOT NULL,
|
||||||
|
`record_id` VARCHAR(36) NOT NULL,
|
||||||
|
`qc_id` VARCHAR(36) NULL,
|
||||||
|
`correction_note` VARCHAR(200) NOT NULL,
|
||||||
|
`corrected_by` VARCHAR(36) NOT NULL,
|
||||||
|
`corrected_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `ho_record_correction_record_id_idx`(`record_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
|
||||||
@ -724,3 +724,39 @@ model Report {
|
|||||||
|
|
||||||
@@map("report")
|
@@map("report")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 质控记录
|
||||||
|
model Qc {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
recordId String @map("record_id") @db.VarChar(36)
|
||||||
|
qcType String @default("normal") @map("qc_type") @db.VarChar(20)
|
||||||
|
qcResult String @default("pending") @map("qc_result") @db.VarChar(20)
|
||||||
|
qcNote String? @map("qc_note") @db.VarChar(200)
|
||||||
|
qcStaffId String? @map("qc_staff_id") @db.VarChar(36)
|
||||||
|
qcTime DateTime? @map("qc_time")
|
||||||
|
passTime DateTime? @map("pass_time")
|
||||||
|
firstQcId String? @map("first_qc_id") @db.VarChar(36)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
createdBy String? @map("created_by") @db.VarChar(36)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
@@index([tenantCode, recordId])
|
||||||
|
@@index([tenantCode, qcResult])
|
||||||
|
@@map("qc")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 补正说明(留痕域 ho_)
|
||||||
|
model RecordCorrection {
|
||||||
|
id String @id @default(uuid(7)) @db.VarChar(36)
|
||||||
|
tenantCode String @map("tenant_code") @db.VarChar(64)
|
||||||
|
recordId String @map("record_id") @db.VarChar(36)
|
||||||
|
qcId String? @map("qc_id") @db.VarChar(36)
|
||||||
|
correctionNote String @map("correction_note") @db.VarChar(200)
|
||||||
|
correctedBy String @map("corrected_by") @db.VarChar(36)
|
||||||
|
correctedAt DateTime @default(now()) @map("corrected_at")
|
||||||
|
|
||||||
|
@@index([recordId])
|
||||||
|
@@map("ho_record_correction")
|
||||||
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import { ElderSvcModule } from './elder-svc/elder-svc.module.js';
|
|||||||
import { OrderModule } from './order/order.module.js';
|
import { OrderModule } from './order/order.module.js';
|
||||||
import { RecordModule } from './record/record.module.js';
|
import { RecordModule } from './record/record.module.js';
|
||||||
import { AbnormalModule } from './abnormal/abnormal.module.js';
|
import { AbnormalModule } from './abnormal/abnormal.module.js';
|
||||||
|
import { QcModule } from './qc/qc.module.js';
|
||||||
import { AppController } from './app.controller.js';
|
import { AppController } from './app.controller.js';
|
||||||
import { AppService } from './app.service.js';
|
import { AppService } from './app.service.js';
|
||||||
|
|
||||||
@ -67,6 +68,7 @@ import { AppService } from './app.service.js';
|
|||||||
OrderModule,
|
OrderModule,
|
||||||
RecordModule,
|
RecordModule,
|
||||||
AbnormalModule,
|
AbnormalModule,
|
||||||
|
QcModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -58,6 +58,15 @@ export const ErrorCodes = {
|
|||||||
RECORD_MD5_DUP: 45005,
|
RECORD_MD5_DUP: 45005,
|
||||||
ABNORMAL_NOT_FOUND: 45006,
|
ABNORMAL_NOT_FOUND: 45006,
|
||||||
PREPAID_CARRYOVER_INVALID: 45007,
|
PREPAID_CARRYOVER_INVALID: 45007,
|
||||||
|
// 服务机构端·质控(46)
|
||||||
|
QC_NOT_FOUND: 46001,
|
||||||
|
QC_ALREADY_HANDLED: 46002,
|
||||||
|
QC_SELF_REVIEW: 46003, // 自审自放行(4001)
|
||||||
|
QC_ELEMENT_MISSING: 46004, // 缺要素(1004)
|
||||||
|
QC_NOTE_REQUIRED: 46005,
|
||||||
|
QC_BATCH_UNSAFE: 46006,
|
||||||
|
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];
|
||||||
|
|||||||
@ -74,7 +74,10 @@ export const Permissions = {
|
|||||||
list: 'record:list', prepaidList: 'record:prepaid-list', prepaidCarryover: 'record:prepaid-carryover',
|
list: 'record:list', prepaidList: 'record:prepaid-list', prepaidCarryover: 'record:prepaid-carryover',
|
||||||
},
|
},
|
||||||
abnormal: { report: 'abnormal:report', list: 'abnormal:list', handle: 'abnormal:handle' },
|
abnormal: { report: 'abnormal:report', list: 'abnormal:list', handle: 'abnormal:handle' },
|
||||||
qc: { pendingList: 'qc:pending-list', detail: 'qc:detail', pass: 'qc:pass', return: 'qc:return', exceptionLink: 'qc:exception-link' },
|
qc: {
|
||||||
|
pendingList: 'qc:pending-list', detail: 'qc:detail', pass: 'qc:pass', return: 'qc:return',
|
||||||
|
exceptionLink: 'qc:exception-link', correct: 'qc:correct', batchPass: 'qc:batch-pass',
|
||||||
|
},
|
||||||
export: { export: 'export:export' },
|
export: { export: 'export:export' },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -122,6 +122,15 @@ export const APIS: { code: string; name: string; method: string; path: string; p
|
|||||||
{ code: Permissions.record.prepaidCarryover, name: '结转', method: 'POST', path: '/api/v1/record/prepaid/carryover', parent: 'record' },
|
{ code: Permissions.record.prepaidCarryover, name: '结转', method: 'POST', path: '/api/v1/record/prepaid/carryover', parent: 'record' },
|
||||||
{ code: Permissions.abnormal.report, name: '异常上报', method: 'POST', path: '/api/v1/abnormal', parent: 'record' },
|
{ code: Permissions.abnormal.report, name: '异常上报', method: 'POST', path: '/api/v1/abnormal', parent: 'record' },
|
||||||
{ code: Permissions.abnormal.list, name: '异常列表', method: 'GET', path: '/api/v1/abnormal', parent: 'record' },
|
{ code: Permissions.abnormal.list, name: '异常列表', method: 'GET', path: '/api/v1/abnormal', parent: 'record' },
|
||||||
|
// 服务机构端·质控(C4)
|
||||||
|
{ code: Permissions.qc.pendingList, name: '待质控列表', method: 'GET', path: '/api/v1/qc/pending/list', parent: 'qc' },
|
||||||
|
{ code: Permissions.qc.detail, name: '质控详情', method: 'GET', path: '/api/v1/qc/:id', parent: 'qc' },
|
||||||
|
{ code: Permissions.qc.pass, name: '放行', method: 'POST', path: '/api/v1/qc/:id/pass', parent: 'qc' },
|
||||||
|
{ code: Permissions.qc.return, name: '退回', method: 'POST', path: '/api/v1/qc/:id/return', parent: 'qc' },
|
||||||
|
{ 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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
|
||||||
@ -160,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] },
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
|
|||||||
/** 不带 tenantCode 的模型(租户表自身)。 */
|
/** 不带 tenantCode 的模型(租户表自身)。 */
|
||||||
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
|
const TENANT_FREE = new Set(['Tenant', 'Agency', 'SvcNational', 'SvcLocal', 'SvcMapping', 'LocConfig']);
|
||||||
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */
|
||||||
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder', 'Plan', 'Order', 'ElderSvc', 'Record']);
|
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice', 'Staff', 'SvcOrg', 'Elder', 'Plan', 'Order', 'ElderSvc', 'Record', 'Qc']);
|
||||||
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
|
||||||
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
|
||||||
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
const NO_WHERE_OPS = new Set(['create', 'createMany']);
|
||||||
|
|||||||
56
src/qc/dto.ts
Normal file
56
src/qc/dto.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ArrayMaxSize, IsArray, IsIn, IsNotEmpty, IsOptional, 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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常处理入参:质控员更新 handle_status/handle_result。 */
|
||||||
|
export class HandleAbnormalDto {
|
||||||
|
@ApiProperty({ description: '处理状态', enum: ['handling', 'closed'] })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsIn(['handling', 'closed'])
|
||||||
|
handleStatus!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '处理结果' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
handleResult?: string;
|
||||||
|
}
|
||||||
65
src/qc/qc.controller.ts
Normal file
65
src/qc/qc.controller.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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, HandleAbnormalDto, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/qc/qc.module.ts
Normal file
11
src/qc/qc.module.ts
Normal file
@ -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 {}
|
||||||
741
src/qc/qc.service.spec.ts
Normal file
741
src/qc/qc.service.spec.ts
Normal file
@ -0,0 +1,741 @@
|
|||||||
|
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<typeof vi.fn>;
|
||||||
|
rawQcFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
rawQcCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
// client.qc
|
||||||
|
qcFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
qcFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
qcFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
qcUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
qcCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
// client.record
|
||||||
|
recordFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
recordFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
recordUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
// client.recordItem / photo / abnormal / correction
|
||||||
|
recordItemFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
recordPhotoFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
recordAbnormalFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
recordAbnormalCount?: ReturnType<typeof vi.fn>;
|
||||||
|
recordAbnormalFindUnique?: ReturnType<typeof vi.fn>;
|
||||||
|
recordAbnormalUpdate?: ReturnType<typeof vi.fn>;
|
||||||
|
recordCorrectionFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
recordCorrectionCreate?: ReturnType<typeof vi.fn>;
|
||||||
|
recordCorrectionCount?: ReturnType<typeof vi.fn>;
|
||||||
|
// client.order / elder / plan
|
||||||
|
orderFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
orderFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
elderFindMany?: ReturnType<typeof vi.fn>;
|
||||||
|
planFindFirst?: ReturnType<typeof vi.fn>;
|
||||||
|
// planService
|
||||||
|
planChange?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
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 recordCorrectionCount = overrides.recordCorrectionCount ?? vi.fn().mockResolvedValue(0);
|
||||||
|
|
||||||
|
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, count: recordCorrectionCount },
|
||||||
|
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,
|
||||||
|
recordCorrectionCount,
|
||||||
|
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 recordFindUnique = vi.fn().mockResolvedValue(passableRecord);
|
||||||
|
const { service, qcUpdate } = makeService({ qcFindUnique, recordFindUnique });
|
||||||
|
await expect(runWithRequestContext(ctx, () => service.pass('qc1'))).rejects.toMatchObject({
|
||||||
|
response: { code: ErrorCodes.QC_ALREADY_HANDLED },
|
||||||
|
});
|
||||||
|
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 () => {
|
||||||
|
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('已处理(非 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 });
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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(五类聚合)', () => {
|
||||||
|
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' },
|
||||||
|
correctionCount: 0,
|
||||||
|
focus: false,
|
||||||
|
});
|
||||||
|
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', () => {
|
||||||
|
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('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' });
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QcService.handleAbnormal', () => {
|
||||||
|
it('abnormal 不存在 → ABNORMAL_NOT_FOUND', async () => {
|
||||||
|
const { service, recordAbnormalUpdate } = makeService();
|
||||||
|
await expect(
|
||||||
|
runWithRequestContext(ctx, () => service.handleAbnormal('missing', { handleStatus: 'handling' })),
|
||||||
|
).rejects.toMatchObject({ response: { code: ErrorCodes.ABNORMAL_NOT_FOUND } });
|
||||||
|
expect(recordAbnormalUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('处理 → 更新 handle_status/handle_result + 坐标 Decimal → string', async () => {
|
||||||
|
const recordAbnormalFindUnique = vi.fn().mockResolvedValue({ id: 'a1', handleStatus: 'open' });
|
||||||
|
const recordAbnormalUpdate = vi.fn().mockResolvedValue({
|
||||||
|
id: 'a1',
|
||||||
|
visitLat: { toString: () => '25.010000' },
|
||||||
|
visitLng: null,
|
||||||
|
});
|
||||||
|
const { service } = makeService({ recordAbnormalFindUnique, recordAbnormalUpdate });
|
||||||
|
|
||||||
|
const res = 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: '已通知家属' },
|
||||||
|
});
|
||||||
|
expect(res).toMatchObject({ id: 'a1', visitLat: '25.010000', visitLng: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'handling' }));
|
||||||
|
|
||||||
|
expect(recordAbnormalUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'a1' },
|
||||||
|
data: { handleStatus: 'handling', handleResult: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
379
src/qc/qc.service.ts
Normal file
379
src/qc/qc.service.ts
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
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, HandleAbnormalDto, 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, correctionCount] = await Promise.all([
|
||||||
|
this.prisma.client.record.findUnique({ where: { id: qc.recordId } }),
|
||||||
|
this.prisma.client.recordCorrection.count({ where: { recordId: qc.recordId } }),
|
||||||
|
]);
|
||||||
|
pendingQcList.push({
|
||||||
|
...qc,
|
||||||
|
record: record
|
||||||
|
? {
|
||||||
|
id: record.id,
|
||||||
|
elderId: record.elderId,
|
||||||
|
assigneeId: record.assigneeId,
|
||||||
|
checkinTime: record.checkinTime,
|
||||||
|
recordStatus: record.recordStatus,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
correctionCount,
|
||||||
|
focus: correctionCount >= 3,
|
||||||
|
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);
|
||||||
|
const record = await this.requireRecord(qc.recordId);
|
||||||
|
await this.assertPassable(qc, record, ctx.userId);
|
||||||
|
return this.markPassed(id, ctx.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退回:幂等守卫 → qc_note 必填 → returned。 */
|
||||||
|
async return(id: string, dto: ReturnQcDto) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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({
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常处理:质控员更新 handle_status/handle_result;返回前坐标 Decimal → string。 */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.client.recordAbnormal.update({
|
||||||
|
where: { id },
|
||||||
|
data: { handleStatus: dto.handleStatus, handleResult: dto.handleResult ?? null },
|
||||||
|
});
|
||||||
|
return this.serializeAbnormal(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量放行:先全量预检(批量边界 + 逐条自检),全过才逐条落库(原子,不部分放行)。 */
|
||||||
|
async batchPass(dto: BatchPassDto) {
|
||||||
|
const ctx = getRequestContext()!;
|
||||||
|
const pendingQcs = [];
|
||||||
|
// 阶段一:全量预检,任一不过整体抛错,不落任何 pass。
|
||||||
|
for (const recordId of dto.recordIds) {
|
||||||
|
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 } }),
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
throw new BusinessException(ErrorCodes.QC_NOT_FOUND, '质控记录不存在', HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return qc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireRecord(id: string): Promise<Record<string, any>> {
|
||||||
|
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)→ 幂等(46002)→ 自审自放行(4001)。 */
|
||||||
|
private async assertPassable(qc: Record<string, any>, record: Record<string, any>, 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 (qc.qcResult !== 'pending') {
|
||||||
|
throw new BusinessException(ErrorCodes.QC_ALREADY_HANDLED, '该质控已处理(幂等)', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (record.createdBy === userId) {
|
||||||
|
throw new BusinessException(ErrorCodes.QC_SELF_REVIEW, '不可自审自放行(4001)', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异常到访坐标 Decimal → string(null 保持 null),复用 abnormal 模块 list/report 口径。 */
|
||||||
|
private serializeAbnormal(abnormal: Record<string, any>): Record<string, any> {
|
||||||
|
return {
|
||||||
|
...abnormal,
|
||||||
|
visitLat: abnormal.visitLat != null ? abnormal.visitLat.toString() : null,
|
||||||
|
visitLng: abnormal.visitLng != null ? abnormal.visitLng.toString() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prisma Decimal 坐标转字符串(null 保持 null),复用 C3 口径,含异常到访坐标。 */
|
||||||
|
private serializeRecord(record: Record<string, any>): Record<string, any> {
|
||||||
|
const serialized: Record<string, any> = {
|
||||||
|
...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<string, any>) => ({
|
||||||
|
...a,
|
||||||
|
visitLat: a.visitLat != null ? a.visitLat.toString() : null,
|
||||||
|
visitLng: a.visitLng != null ? a.visitLng.toString() : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return serialized;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user