feat(dc): Add multi-metric transformation feature (direction 1+2)

Summary:
- Implement intelligent multi-metric grouping detection algorithm
- Add direction 1: timepoint-as-row, metric-as-column (analysis format)
- Add direction 2: timepoint-as-column, metric-as-row (display format)
- Fix column name pattern detection (FMA___ issue)
- Maintain original Record ID order in output
- Add full-select/clear buttons in UI
- Integrate into TransformDialog with Radio selection
- Update 3 documentation files

Technical Details:
- Python: detect_metric_groups(), apply_multi_metric_to_long(), apply_multi_metric_to_matrix()
- Backend: 3 new methods in QuickActionService
- Frontend: MultiMetricPanel.tsx (531 lines)
- Total: ~1460 lines of new code

Status: Fully tested and verified, ready for production
This commit is contained in:
2025-12-21 15:06:15 +08:00
parent 8be8cdcf53
commit 9b81aef9a7
123 changed files with 4781 additions and 150 deletions

View File

@@ -310,6 +310,12 @@ runTests().catch((error) => {

View File

@@ -289,6 +289,12 @@ Content-Type: application/json

View File

@@ -368,6 +368,12 @@ export class ExcelExporter {

View File

@@ -225,6 +225,12 @@ export const conflictDetectionService = new ConflictDetectionService();

View File

@@ -253,6 +253,12 @@ export const templateService = new TemplateService();

View File

@@ -390,3 +390,9 @@ async function countCompletedBatches(taskId: string): Promise<number> {
}

View File

@@ -182,3 +182,9 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \

View File

@@ -25,7 +25,7 @@ import { prisma } from '../../../../config/database.js';
interface QuickActionRequest {
sessionId: string;
action: 'filter' | 'recode' | 'binning' | 'conditional' | 'dropna' | 'dedup';
action: 'filter' | 'recode' | 'binning' | 'conditional' | 'dropna' | 'dedup' | 'compute' | 'pivot' | 'unpivot' | 'metric_time' | 'multi_metric_to_long' | 'multi_metric_to_matrix';
params: any;
userId?: string;
}
@@ -105,6 +105,18 @@ export class QuickActionController {
case 'pivot':
actionDescription = 'Pivot转换';
break;
case 'unpivot':
actionDescription = 'Unpivot转换宽→长表';
break;
case 'metric_time':
actionDescription = '指标-时间表转换';
break;
case 'multi_metric_to_long':
actionDescription = '多指标转长表';
break;
case 'multi_metric_to_matrix':
actionDescription = '多指标转矩阵';
break;
default:
logger.warn(`[QuickAction] 不支持的操作: ${action}`);
return reply.code(400).send({
@@ -184,6 +196,22 @@ export class QuickActionController {
pivotValueOrder
);
break;
case 'unpivot':
// Unpivot不需要columnMapping直接执行
executeResult = await quickActionService.executeUnpivot(fullData, params);
break;
case 'metric_time':
// 指标-时间表转换
executeResult = await quickActionService.executeMetricTime(fullData, params);
break;
case 'multi_metric_to_long':
// 多指标转长表
executeResult = await quickActionService.executeMultiMetricToLong(fullData, params);
break;
case 'multi_metric_to_matrix':
// 多指标转矩阵
executeResult = await quickActionService.executeMultiMetricToMatrix(fullData, params);
break;
}
if (!executeResult.success) {
@@ -340,9 +368,27 @@ export class QuickActionController {
case 'dropna':
executeResult = await quickActionService.executeDropna(fullData, params);
break;
case 'compute':
executeResult = await quickActionService.executeCompute(fullData, params);
break;
case 'dedup':
// TODO: 实现去重功能
return reply.code(400).send({ success: false, error: '去重功能尚未实现' });
case 'pivot':
executeResult = await quickActionService.executePivot(fullData, params);
break;
case 'unpivot':
executeResult = await quickActionService.executeUnpivot(fullData, params);
break;
case 'metric_time':
executeResult = await quickActionService.executeMetricTime(fullData, params);
break;
case 'multi_metric_to_long':
executeResult = await quickActionService.executeMultiMetricToLong(fullData, params);
break;
case 'multi_metric_to_matrix':
executeResult = await quickActionService.executeMultiMetricToMatrix(fullData, params);
break;
default:
return reply.code(400).send({ success: false, error: '不支持的操作' });
}
@@ -361,14 +407,29 @@ export class QuickActionController {
const newRows = resultData.length;
let estimatedChange = '';
if (action === 'filter' || action === 'dropna') {
estimatedChange = `将保留 ${newRows} 行(删除 ${originalRows - newRows} 行)`;
} else if (action === 'recode' || action === 'binning' || action === 'conditional' || action === 'compute') {
estimatedChange = `新增 1 列`;
} else if (action === 'pivot') {
const originalCols = Object.keys(fullData[0] || {}).length;
const newCols = Object.keys(resultData[0] || {}).length;
estimatedChange = `行数: ${originalRows}${newRows}, 列数: ${originalCols}${newCols}`;
switch (action) {
case 'filter':
case 'dropna':
estimatedChange = `保留 ${newRows} 行(删除 ${originalRows - newRows} 行)`;
break;
case 'recode':
case 'binning':
case 'conditional':
case 'compute':
estimatedChange = `将新增 1 列`;
break;
case 'pivot':
case 'unpivot':
case 'metric_time':
case 'multi_metric_to_long':
case 'multi_metric_to_matrix': {
const originalCols = Object.keys(fullData[0] || {}).length;
const newCols = Object.keys(resultData[0] || {}).length;
estimatedChange = `行数: ${originalRows}${newRows}, 列数: ${originalCols}${newCols}`;
break;
}
default:
estimatedChange = `操作完成`;
}
return reply.code(200).send({
@@ -541,6 +602,95 @@ export class QuickActionController {
});
}
}
/**
* POST /api/v1/dc/tool-c/metric-time/detect
* 检测指标-时间表转换模式
*/
async handleMetricTimeDetect(request: FastifyRequest, reply: FastifyReply) {
try {
const { sessionId, valueVars } = request.body as { sessionId: string; valueVars: string[] };
logger.info(`[QuickAction] 检测指标-时间表模式: session=${sessionId}, ${valueVars?.length || 0}`);
// 验证参数
if (!valueVars || valueVars.length < 2) {
return reply.code(400).send({
success: false,
error: '至少需要2列才能检测模式'
});
}
// 调用Service检测模式
const result = await quickActionService.detectMetricTimePattern(valueVars);
if (!result.success) {
return reply.code(500).send({
success: false,
error: result.error || '模式检测失败'
});
}
return reply.code(200).send({
success: true,
pattern: result.pattern,
execution_time: result.execution_time
});
} catch (error: any) {
logger.error(`[QuickAction] 模式检测失败: ${error.message}`);
return reply.code(500).send({
success: false,
error: error.message
});
}
}
/**
* POST /api/v1/dc/tool-c/multi-metric/detect
* 检测多指标分组
*/
async handleMultiMetricDetect(request: FastifyRequest, reply: FastifyReply) {
try {
const { sessionId, valueVars, separators } = request.body as {
sessionId: string;
valueVars: string[];
separators?: string[];
};
logger.info(`[QuickAction] 检测多指标分组: session=${sessionId}, ${valueVars?.length || 0}`);
// 验证参数
if (!valueVars || valueVars.length < 2) {
return reply.code(400).send({
success: false,
error: '至少需要2列才能检测分组'
});
}
// 调用Service检测分组
const result = await quickActionService.detectMultiMetricGroups(valueVars, separators);
if (!result.success) {
return reply.code(500).send({
success: false,
error: result.message || '分组检测失败'
});
}
return reply.code(200).send({
success: true,
grouping: result
});
} catch (error: any) {
logger.error(`[QuickAction] 多指标分组检测失败: ${error.message}`);
return reply.code(500).send({
success: false,
error: error.message
});
}
}
}
// ==================== 导出单例 ====================

View File

@@ -236,3 +236,9 @@ export const streamAIController = new StreamAIController();

View File

@@ -133,5 +133,19 @@ export async function toolCRoutes(fastify: FastifyInstance) {
fastify.post('/fillna/mice', {
handler: quickActionController.handleFillnaMice.bind(quickActionController),
});
// ✨ 指标-时间表转换(新增)
// 检测指标-时间表转换模式
fastify.post('/metric-time/detect', {
handler: quickActionController.handleMetricTimeDetect.bind(quickActionController),
});
// ✨ 多指标转换(新增)
// 检测多指标分组
fastify.post('/multi-metric/detect', {
handler: quickActionController.handleMultiMetricDetect.bind(quickActionController),
});
}

View File

@@ -77,6 +77,49 @@ interface PivotParams {
unusedAggMethod?: 'first' | 'mode' | 'mean'; // ✨ 新增:未选择列的聚合方式
}
interface UnpivotParams {
idVars: string[]; // ID列保持不变的列
valueVars: string[]; // 值列(需要转换的列)
varName: string; // 变量名列名
valueName: string; // 值列名
parseColumnNames?: boolean; // 是否解析列名
separator?: string; // 分隔符
metricName?: string; // 指标列名
timeName?: string; // 时间列名
dropna?: boolean; // 是否删除缺失值行
}
interface MetricTimeParams {
idVars: string[]; // ID列保持不变的列
valueVars: string[]; // 值列(同一指标的多个时间点)
metricName?: string; // 指标名称(可选,自动检测)
separator?: string; // 分隔符(可选,自动检测)
timepointColName?: string; // 时间点列名
}
interface MultiMetricToLongParams {
idVars: string[]; // ID列
valueVars: string[]; // 值列(多个指标的多个时间点)
separators?: string[]; // 可选的分隔符列表
eventColName?: string; // 时间点列名(默认 'Event_Name'
}
interface MultiMetricToMatrixParams {
idVars: string[]; // ID列
valueVars: string[]; // 值列(多个指标的多个时间点)
separators?: string[]; // 可选的分隔符列表
metricColName?: string; // 指标列名(默认 '指标名'
}
interface MetricGrouping {
success: boolean;
metric_groups?: Record<string, string[]>; // 指标分组
separator?: string; // 检测到的分隔符
timepoints?: string[]; // 时间点列表
confidence?: number; // 置信度
message?: string;
}
interface FillnaSimpleParams {
column: string;
newColumnName: string;
@@ -100,6 +143,7 @@ interface OperationResult {
error?: string;
message?: string;
stats?: any;
pattern?: any; // ✨ 新增:用于指标-时间表模式检测
}
// ==================== 服务类 ====================
@@ -359,6 +403,209 @@ export class QuickActionService {
}
}
/**
* 执行Unpivot宽表转长表
*/
async executeUnpivot(data: any[], params: UnpivotParams): Promise<OperationResult> {
try {
logger.info(`[QuickActionService] 调用Unpivot API: ${params.idVars.length} ID列 × ${params.valueVars.length} 值列`);
const response = await axios.post(`${PYTHON_SERVICE_URL}/api/operations/unpivot`, {
data,
id_vars: params.idVars,
value_vars: params.valueVars,
var_name: params.varName || '变量',
value_name: params.valueName || '值',
parse_column_names: params.parseColumnNames || false,
separator: params.separator || '_',
metric_name: params.metricName,
time_name: params.timeName,
dropna: params.dropna || false,
}, {
timeout: 60000,
});
logger.info(`[QuickActionService] Unpivot成功: ${response.data.result_shape?.[0] || 0}`);
return response.data;
} catch (error: any) {
logger.error(`[QuickActionService] Unpivot失败: ${error.message}`);
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
error: error.message || 'Unpivot失败',
};
}
}
/**
* 检测指标-时间表转换模式
*/
async detectMetricTimePattern(valueVars: string[]): Promise<OperationResult> {
try {
logger.info(`[QuickActionService] 检测指标-时间表模式: ${valueVars.length}`);
const response = await axios.post(`${PYTHON_SERVICE_URL}/api/operations/metric-time/detect`, {
value_vars: valueVars,
}, {
timeout: 10000,
});
logger.info(`[QuickActionService] 模式检测成功`);
return response.data;
} catch (error: any) {
logger.error(`[QuickActionService] 模式检测失败: ${error.message}`);
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
error: error.message || '模式检测失败',
};
}
}
/**
* 执行指标-时间表转换
*/
async executeMetricTime(data: any[], params: MetricTimeParams): Promise<OperationResult> {
try {
logger.info(`[QuickActionService] 调用指标-时间表转换API: ${params.idVars.length} ID列 × ${params.valueVars.length} 值列`);
const response = await axios.post(`${PYTHON_SERVICE_URL}/api/operations/metric-time`, {
data,
id_vars: params.idVars,
value_vars: params.valueVars,
metric_name: params.metricName,
separator: params.separator,
timepoint_col_name: params.timepointColName || '时间点',
}, {
timeout: 60000,
});
logger.info(`[QuickActionService] 指标-时间表转换成功: ${response.data.result_shape?.[0] || 0}`);
return response.data;
} catch (error: any) {
logger.error(`[QuickActionService] 指标-时间表转换失败: ${error.message}`);
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
error: error.message || '指标-时间表转换失败',
};
}
}
/**
* 检测多指标分组
*/
async detectMultiMetricGroups(valueVars: string[], separators?: string[]): Promise<MetricGrouping> {
try {
logger.info(`[QuickActionService] 调用多指标分组检测API: ${valueVars.length}`);
const response = await axios.post(`${PYTHON_SERVICE_URL}/api/operations/multi-metric/detect`, {
value_vars: valueVars,
separators: separators,
}, {
timeout: 10000,
});
logger.info(`[QuickActionService] 多指标分组检测成功: ${Object.keys(response.data.metric_groups || {}).length} 个指标`);
return response.data;
} catch (error: any) {
logger.error(`[QuickActionService] 多指标分组检测失败: ${error.message}`);
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
message: error.message || '多指标分组检测失败',
};
}
}
/**
* 执行多指标转长表(时间点为行,指标为列)
*/
async executeMultiMetricToLong(data: any[], params: MultiMetricToLongParams): Promise<OperationResult> {
try {
logger.info(`[QuickActionService] 调用多指标转长表API: ${params.idVars.length} ID列 × ${params.valueVars.length} 值列`);
const response = await axios.post(`${PYTHON_SERVICE_URL}/api/operations/multi-metric/to-long`, {
data,
id_vars: params.idVars,
value_vars: params.valueVars,
separators: params.separators,
event_col_name: params.eventColName || 'Event_Name',
}, {
timeout: 60000,
});
logger.info(`[QuickActionService] 多指标转长表成功: ${response.data.result_shape?.[0] || 0}`);
return response.data;
} catch (error: any) {
logger.error(`[QuickActionService] 多指标转长表失败: ${error.message}`);
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
error: error.message || '多指标转长表失败',
};
}
}
/**
* 执行多指标转矩阵(时间点为列,指标为行)
*/
async executeMultiMetricToMatrix(data: any[], params: MultiMetricToMatrixParams): Promise<OperationResult> {
try {
logger.info(`[QuickActionService] 调用多指标转矩阵API: ${params.idVars.length} ID列 × ${params.valueVars.length} 值列`);
const response = await axios.post(`${PYTHON_SERVICE_URL}/api/operations/multi-metric/to-matrix`, {
data,
id_vars: params.idVars,
value_vars: params.valueVars,
separators: params.separators,
metric_col_name: params.metricColName || '指标名',
}, {
timeout: 60000,
});
logger.info(`[QuickActionService] 多指标转矩阵成功: ${response.data.result_shape?.[0] || 0}`);
return response.data;
} catch (error: any) {
logger.error(`[QuickActionService] 多指标转矩阵失败: ${error.message}`);
if (error.response?.data) {
return error.response.data;
}
return {
success: false,
error: error.message || '多指标转矩阵失败',
};
}
}
/**
* 获取列的缺失值统计
*/
@@ -463,3 +710,21 @@ export class QuickActionService {
export const quickActionService = new QuickActionService();
// ==================== 导出类型 ====================
export type {
FilterParams,
RecodeParams,
BinningParams,
ConditionalParams,
PivotParams,
UnpivotParams,
MetricTimeParams,
MultiMetricToLongParams,
MultiMetricToMatrixParams,
MetricGrouping,
FillnaSimpleParams,
FillnaMiceParams,
OperationResult,
};