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:
2026-03-01 15:27:05 +08:00
parent c3f7d54fdf
commit 0b29fe88b5
61 changed files with 6877 additions and 524 deletions

View File

@@ -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
});

View File

@@ -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);

View File

@@ -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 ====================
// 获取项目列表

View File

@@ -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(),
},
});

View File

@@ -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';
}

View File

@@ -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)' },

View File

@@ -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 }; // 只能查看本租户

View File

@@ -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 ====================

View File

@@ -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}`;
}
/**