feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
This commit is contained in:
@@ -67,79 +67,86 @@ export class IitBatchController {
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 统计(按 record+event 组合)
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
let uncertainCount = 0;
|
||||
|
||||
const uniqueRecords = new Set<string>();
|
||||
// 3. V3.2: 按 record 级别聚合状态(每个 record 取最严重状态)
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const recordWorstStatus = new Map<string, string>();
|
||||
|
||||
for (const result of results) {
|
||||
uniqueRecords.add(result.recordId);
|
||||
|
||||
if (result.overallStatus === 'PASS') passCount++;
|
||||
else if (result.overallStatus === 'FAIL') failCount++;
|
||||
else if (result.overallStatus === 'WARNING') warningCount++;
|
||||
else uncertainCount++;
|
||||
|
||||
// 更新录入汇总表(取最差状态)
|
||||
const existingSummary = await prisma.iitRecordSummary.findUnique({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } }
|
||||
});
|
||||
|
||||
const statusPriority: Record<string, number> = { 'FAIL': 3, 'WARNING': 2, 'UNCERTAIN': 1, 'PASS': 0 };
|
||||
const currentPriority = statusPriority[result.overallStatus] || 0;
|
||||
const existingPriority = statusPriority[existingSummary?.latestQcStatus || 'PASS'] || 0;
|
||||
|
||||
// 只更新为更严重的状态
|
||||
if (!existingSummary || currentPriority > existingPriority) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId: result.recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId: result.recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: result.overallStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
const existing = recordWorstStatus.get(result.recordId);
|
||||
const currentPrio = statusPriority[result.overallStatus] ?? 0;
|
||||
const existingPrio = existing ? (statusPriority[existing] ?? 0) : -1;
|
||||
if (currentPrio > existingPrio) {
|
||||
recordWorstStatus.set(result.recordId, result.overallStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新项目统计表
|
||||
// V3.2: 用本次批量质控结果更新 record_summary(覆盖旧状态)
|
||||
for (const [recordId, worstStatus] of recordWorstStatus) {
|
||||
await prisma.iitRecordSummary.upsert({
|
||||
where: { projectId_recordId: { projectId, recordId } },
|
||||
create: {
|
||||
projectId,
|
||||
recordId,
|
||||
lastUpdatedAt: new Date(),
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date(),
|
||||
formStatus: {},
|
||||
updateCount: 1
|
||||
},
|
||||
update: {
|
||||
latestQcStatus: worstStatus,
|
||||
latestQcAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// V3.2: 清理该项目旧版本日志(event_id 为 NULL 的遗留数据)
|
||||
const deletedLegacy = await prisma.iitQcLog.deleteMany({
|
||||
where: { projectId, eventId: null }
|
||||
});
|
||||
if (deletedLegacy.count > 0) {
|
||||
logger.info('🧹 清理旧版质控日志', { projectId, deletedCount: deletedLegacy.count });
|
||||
}
|
||||
|
||||
// V3.2: record 级别统计
|
||||
let passCount = 0;
|
||||
let failCount = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const status of recordWorstStatus.values()) {
|
||||
if (status === 'PASS') passCount++;
|
||||
else if (status === 'FAIL') failCount++;
|
||||
else warningCount++;
|
||||
}
|
||||
|
||||
const totalRecords = recordWorstStatus.size;
|
||||
|
||||
// 4. 更新项目统计表(record 级别)
|
||||
await prisma.iitQcProjectStats.upsert({
|
||||
where: { projectId },
|
||||
create: {
|
||||
projectId,
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount + uncertainCount
|
||||
warningRecords: warningCount
|
||||
},
|
||||
update: {
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
passedRecords: passCount,
|
||||
failedRecords: failCount,
|
||||
warningRecords: warningCount + uncertainCount
|
||||
warningRecords: warningCount
|
||||
}
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
logger.info('✅ 事件级全量质控完成', {
|
||||
projectId,
|
||||
uniqueRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passCount,
|
||||
failCount,
|
||||
warningCount,
|
||||
uncertainCount,
|
||||
durationMs
|
||||
});
|
||||
|
||||
@@ -147,13 +154,14 @@ export class IitBatchController {
|
||||
success: true,
|
||||
message: '事件级全量质控完成',
|
||||
stats: {
|
||||
totalRecords: uniqueRecords.size,
|
||||
totalRecords,
|
||||
totalEventCombinations: results.length,
|
||||
passed: passCount,
|
||||
failed: failCount,
|
||||
warnings: warningCount,
|
||||
uncertain: uncertainCount,
|
||||
passRate: `${((passCount / results.length) * 100).toFixed(1)}%`
|
||||
passRate: totalRecords > 0
|
||||
? `${((passCount / totalRecords) * 100).toFixed(1)}%`
|
||||
: '0%'
|
||||
},
|
||||
durationMs
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ProjectIdParams {
|
||||
interface ListProjectsQuery {
|
||||
status?: string;
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface TestConnectionBody {
|
||||
@@ -29,6 +30,27 @@ interface LinkKbBody {
|
||||
|
||||
// ==================== 控制器函数 ====================
|
||||
|
||||
/**
|
||||
* 获取租户选项列表(供创建项目时选择租户)
|
||||
*/
|
||||
export async function listTenantOptions(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const tenants = await prisma.tenants.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true, code: true, name: true, type: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send({ success: true, data: tenants });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取租户列表失败', { error: msg });
|
||||
return reply.status(500).send({ success: false, error: msg });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
@@ -37,9 +59,17 @@ export async function listProjects(
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { status, search } = request.query;
|
||||
const { status, search, tenantId: queryTenantId } = request.query;
|
||||
const user = request.user;
|
||||
const service = getIitProjectService(prisma);
|
||||
const projects = await service.listProjects({ status, search });
|
||||
|
||||
// Phase 3 租户隔离:非 SUPER_ADMIN/IIT_OPERATOR 只能看自己租户的项目
|
||||
let effectiveTenantId = queryTenantId;
|
||||
if (user && user.role !== 'SUPER_ADMIN' && user.role !== 'IIT_OPERATOR') {
|
||||
effectiveTenantId = user.tenantId;
|
||||
}
|
||||
|
||||
const projects = await service.listProjects({ status, search, tenantId: effectiveTenantId });
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
@@ -92,11 +122,12 @@ export async function getProject(
|
||||
* 创建项目
|
||||
*/
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: CreateProjectInput }>,
|
||||
request: FastifyRequest<{ Body: CreateProjectInput & { tenantId?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const input = request.body;
|
||||
const user = request.user;
|
||||
|
||||
// 验证必填字段
|
||||
if (!input.name) {
|
||||
@@ -113,6 +144,17 @@ export async function createProject(
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 3: 自动绑定 tenantId
|
||||
// SUPER_ADMIN/IIT_OPERATOR: 可以指定 tenantId(创建时选择为哪个客户创建)
|
||||
// PHARMA_ADMIN/HOSPITAL_ADMIN: 自动绑定自己的 tenantId
|
||||
if (user) {
|
||||
if (user.role === 'SUPER_ADMIN' || user.role === 'IIT_OPERATOR') {
|
||||
// 可以使用请求体中指定的 tenantId,或不指定
|
||||
} else {
|
||||
input.tenantId = user.tenantId;
|
||||
}
|
||||
}
|
||||
|
||||
const service = getIitProjectService(prisma);
|
||||
const project = await service.createProject(input);
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './iitProjectController.js';
|
||||
|
||||
export async function iitProjectRoutes(fastify: FastifyInstance) {
|
||||
// ==================== 租户选项(创建项目时选择) ====================
|
||||
fastify.get('/tenant-options', controller.listTenantOptions);
|
||||
|
||||
// ==================== 项目 CRUD ====================
|
||||
|
||||
// 获取项目列表
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CreateProjectInput {
|
||||
redcapApiToken: string;
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateProjectInput {
|
||||
@@ -28,6 +29,7 @@ export interface UpdateProjectInput {
|
||||
fieldMappings?: Record<string, unknown>;
|
||||
knowledgeBaseId?: string;
|
||||
status?: string;
|
||||
isDemo?: boolean;
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
@@ -41,6 +43,7 @@ export interface TestConnectionResult {
|
||||
export interface ProjectListFilters {
|
||||
status?: string;
|
||||
search?: string;
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
// ==================== 服务实现 ====================
|
||||
@@ -60,6 +63,10 @@ export class IitProjectService {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
if (filters?.tenantId) {
|
||||
where.tenantId = filters.tenantId;
|
||||
}
|
||||
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.search, mode: 'insensitive' } },
|
||||
@@ -77,10 +84,14 @@ export class IitProjectService {
|
||||
redcapProjectId: true,
|
||||
redcapUrl: true,
|
||||
knowledgeBaseId: true,
|
||||
tenantId: true,
|
||||
status: true,
|
||||
lastSyncAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
tenant: {
|
||||
select: { id: true, name: true, code: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
userMappings: true,
|
||||
@@ -91,6 +102,8 @@ export class IitProjectService {
|
||||
|
||||
return projects.map((p) => ({
|
||||
...p,
|
||||
tenantName: p.tenant?.name || null,
|
||||
tenantCode: p.tenant?.code || null,
|
||||
userMappingCount: p._count.userMappings,
|
||||
}));
|
||||
}
|
||||
@@ -102,6 +115,7 @@ export class IitProjectService {
|
||||
const project = await this.prisma.iitProject.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true, code: true } },
|
||||
userMappings: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -173,8 +187,9 @@ export class IitProjectService {
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings || {},
|
||||
fieldMappings: (input.fieldMappings || {}) as any,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
tenantId: input.tenantId || null,
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
@@ -207,9 +222,10 @@ export class IitProjectService {
|
||||
redcapUrl: input.redcapUrl,
|
||||
redcapProjectId: input.redcapProjectId,
|
||||
redcapApiToken: input.redcapApiToken,
|
||||
fieldMappings: input.fieldMappings,
|
||||
fieldMappings: input.fieldMappings as any,
|
||||
knowledgeBaseId: input.knowledgeBaseId,
|
||||
status: input.status,
|
||||
isDemo: input.isDemo,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,23 +97,21 @@ export async function createUserMapping(
|
||||
const { projectId } = request.params;
|
||||
const input = request.body;
|
||||
|
||||
// 验证必填字段 - 只有企业微信用户 ID 是必填的
|
||||
if (!input.wecomUserId) {
|
||||
// 验证:至少提供 userId(平台用户)或 wecomUserId(企业微信用户)
|
||||
if (!input.userId && !input.wecomUserId) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: '请输入企业微信用户 ID',
|
||||
error: '请选择平台用户或输入企业微信用户 ID',
|
||||
});
|
||||
}
|
||||
|
||||
// 如果没有提供 systemUserId,使用 wecomUserId 作为默认值
|
||||
const fallbackId = input.wecomUserId || input.userId || 'unknown';
|
||||
if (!input.systemUserId) {
|
||||
input.systemUserId = input.wecomUserId;
|
||||
input.systemUserId = fallbackId;
|
||||
}
|
||||
// 如果没有提供 redcapUsername,使用 wecomUserId 作为默认值
|
||||
if (!input.redcapUsername) {
|
||||
input.redcapUsername = input.wecomUserId;
|
||||
input.redcapUsername = fallbackId;
|
||||
}
|
||||
// 如果没有提供 role,默认为 PI
|
||||
if (!input.role) {
|
||||
input.role = 'PI';
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateUserMappingInput {
|
||||
systemUserId: string;
|
||||
redcapUsername: string;
|
||||
wecomUserId?: string;
|
||||
userId?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,7 @@ export interface UpdateUserMappingInput {
|
||||
systemUserId?: string;
|
||||
redcapUsername?: string;
|
||||
wecomUserId?: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@@ -54,6 +56,9 @@ export class IitUserMappingService {
|
||||
|
||||
const mappings = await this.prisma.iitUserMapping.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, phone: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
@@ -106,6 +111,7 @@ export class IitUserMappingService {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
userId: input.userId || null,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
@@ -132,6 +138,7 @@ export class IitUserMappingService {
|
||||
systemUserId: input.systemUserId,
|
||||
redcapUsername: input.redcapUsername,
|
||||
wecomUserId: input.wecomUserId,
|
||||
userId: input.userId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
@@ -164,6 +171,7 @@ export class IitUserMappingService {
|
||||
*/
|
||||
getRoleOptions() {
|
||||
return [
|
||||
{ value: 'PM', label: '项目管理员 (PM)' },
|
||||
{ value: 'PI', label: '主要研究者 (PI)' },
|
||||
{ value: 'Sub-I', label: '次要研究者 (Sub-I)' },
|
||||
{ value: 'CRC', label: '临床研究协调员 (CRC)' },
|
||||
|
||||
@@ -50,7 +50,8 @@ export async function getUserQueryScope(
|
||||
switch (userRole) {
|
||||
case 'SUPER_ADMIN':
|
||||
case 'PROMPT_ENGINEER':
|
||||
return {}; // 无限制
|
||||
case 'IIT_OPERATOR':
|
||||
return {}; // 无限制(IIT 项目跨机构协作,需搜索所有租户用户)
|
||||
case 'HOSPITAL_ADMIN':
|
||||
case 'PHARMA_ADMIN':
|
||||
return { tenantId }; // 只能查看本租户
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
import { authenticate, requireRoles } from '../../../common/auth/auth.middleware.js';
|
||||
|
||||
export async function systemKbRoutes(fastify: FastifyInstance) {
|
||||
// 所有路由都需要认证 + SUPER_ADMIN 或 ADMIN 角色
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN')];
|
||||
// 认证 + 角色守卫:SUPER_ADMIN / ADMIN / IIT_OPERATOR 均可完整操作知识库
|
||||
const preHandler = [authenticate, requireRoles('SUPER_ADMIN', 'ADMIN', 'IIT_OPERATOR')];
|
||||
|
||||
// ==================== 知识库 CRUD ====================
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ export class SystemKbService {
|
||||
|
||||
try {
|
||||
// 3. 生成 OSS 存储路径并上传
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename);
|
||||
const ossKey = this.generateOssKey(kbId, doc.id, filename, kb.category);
|
||||
const ossUrl = await storage.upload(ossKey, fileBuffer);
|
||||
|
||||
// 4. 更新 file_path
|
||||
@@ -473,15 +473,15 @@ export class SystemKbService {
|
||||
/**
|
||||
* 生成 OSS 存储路径
|
||||
*
|
||||
* 格式:system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*
|
||||
* @param kbId - 知识库 ID
|
||||
* @param docId - 文档 ID
|
||||
* @param filename - 原始文件名(用于获取扩展名)
|
||||
* 系统知识库: system/knowledge-bases/{kbId}/{docId}.{ext}
|
||||
* IIT 项目知识库: system/iit-knowledge-bases/{kbId}/{docId}.{ext}
|
||||
*/
|
||||
private generateOssKey(kbId: string, docId: string, filename: string): string {
|
||||
private generateOssKey(kbId: string, docId: string, filename: string, category?: string | null): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
return `system/knowledge-bases/${kbId}/${docId}${ext}`;
|
||||
const prefix = category === 'iit_project'
|
||||
? 'system/iit-knowledge-bases'
|
||||
: 'system/knowledge-bases';
|
||||
return `${prefix}/${kbId}/${docId}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user