You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
522 lines
20 KiB
522 lines
20 KiB
'use strict';
|
|
|
|
const { hasOwn, toInt, parsePagination, ensureCreatedBy, ensureObjectPayload, ensureNonEmptyString, normalizeNullableInt } = require('./helpers');
|
|
const { normalizeTemplateBlockConfigOverrides, ensureActiveTemplate, ensureTemplateBatchDimension, getTemplateBlockBatchBindingMap, cloneTemplateToInstance } = require('./repository');
|
|
const {
|
|
resolveBlockPayload,
|
|
finalizeResolvedBlockPayload,
|
|
} = require('./blockPayloadResolver');
|
|
const { sanitizeQueryBlockConfig } = require('./chartSnapshot');
|
|
|
|
const TASK_STATUSES = new Set(['pending', 'running', 'partial_success', 'success', 'failed']);
|
|
const ITEM_STATUSES = {
|
|
pending: 'pending',
|
|
running: 'running',
|
|
success: 'success',
|
|
failed: 'failed',
|
|
};
|
|
|
|
const normalizeOptionalString = value => {
|
|
const normalized = String(value || '').trim();
|
|
return normalized || null;
|
|
};
|
|
|
|
const ensureValidDateInput = (value, label) => {
|
|
const normalized = ensureNonEmptyString(value, label);
|
|
const parsed = new Date(normalized);
|
|
if (Number.isNaN(parsed.getTime())) throw `invalid param: ${label}`;
|
|
return normalized;
|
|
};
|
|
|
|
const deepCloneJson = value => {
|
|
if (value === null || value === undefined) return value;
|
|
return JSON.parse(JSON.stringify(value));
|
|
};
|
|
|
|
const renderNamePattern = (pattern, templateName, dimensionItem) => {
|
|
const dimensionKey = String(dimensionItem.key || '').trim();
|
|
const dimensionLabel = String(dimensionItem.label || dimensionKey).trim() || dimensionKey;
|
|
const rawPattern = String(pattern || '').trim() || '{{templateName}}-{{dimensionLabel}}';
|
|
const rendered = rawPattern
|
|
.replace(/\{\{\s*templateName\s*\}\}/g, templateName)
|
|
.replace(/\{\{\s*dimensionKey\s*\}\}/g, dimensionKey)
|
|
.replace(/\{\{\s*dimensionLabel\s*\}\}/g, dimensionLabel);
|
|
return rendered.trim() || `${templateName}-${dimensionLabel || dimensionKey}`;
|
|
};
|
|
|
|
const extractQueryHolder = config => {
|
|
if (config.query && typeof config.query === 'object' && !Array.isArray(config.query)) {
|
|
return config.query;
|
|
}
|
|
return config;
|
|
};
|
|
|
|
const extractBlockQueryTable = config => {
|
|
const safeConfig = ensureObjectPayload(config || {}, 'config');
|
|
const queryConfig = extractQueryHolder(safeConfig);
|
|
return String(
|
|
queryConfig.table
|
|
|| queryConfig.tableId
|
|
|| queryConfig.tableName
|
|
|| safeConfig.table
|
|
|| safeConfig.tableId
|
|
|| safeConfig.tableName
|
|
|| safeConfig.previewQuery?.table
|
|
|| ''
|
|
).trim();
|
|
};
|
|
|
|
const normalizeConfiguredBinding = (config = {}, currentTable = '') => {
|
|
const binding = ensureObjectPayload(config?.templateBatchBinding || {}, 'config.templateBatchBinding');
|
|
const targetField = String(binding.targetField || '').trim();
|
|
if (!targetField) return null;
|
|
const bindingType = String(binding.bindingType || '').trim().toLowerCase();
|
|
if (!bindingType) return null;
|
|
const targetTable = String(binding.targetTable || currentTable || '').trim() || null;
|
|
return {
|
|
bindingType,
|
|
targetTable,
|
|
targetField,
|
|
filterOperator: String(binding.filterOperator || '').trim() || null,
|
|
valueSource: String(binding.valueSource || '').trim() || null,
|
|
};
|
|
};
|
|
|
|
const collectConfiguredFilterFields = (config = {}) => {
|
|
const fieldSet = new Set();
|
|
const safeConfig = deepCloneJson(config) || {};
|
|
const filterLists = [
|
|
safeConfig?.filters,
|
|
safeConfig?.query?.filters,
|
|
safeConfig?.previewQuery?.filters,
|
|
];
|
|
filterLists.forEach(filters => {
|
|
(Array.isArray(filters) ? filters : []).forEach(item => {
|
|
const field = String(item?.field || '').trim();
|
|
if (field) fieldSet.add(field);
|
|
});
|
|
});
|
|
return fieldSet;
|
|
};
|
|
|
|
const collectConfiguredTimeFields = (config = {}) => {
|
|
const result = [];
|
|
const seen = new Set();
|
|
const safeConfig = deepCloneJson(config) || {};
|
|
const ranges = [
|
|
safeConfig?.timeRange,
|
|
safeConfig?.query?.timeRange,
|
|
safeConfig?.previewQuery?.timeRange,
|
|
];
|
|
ranges.forEach(item => {
|
|
const field = String(item?.field || '').trim();
|
|
if (!field || seen.has(field)) return;
|
|
seen.add(field);
|
|
result.push(field);
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const deriveImplicitBatchBinding = (config = {}, dimension = null) => {
|
|
if (!dimension || !dimension.sourceTable) return null;
|
|
const currentTable = extractBlockQueryTable(config);
|
|
if (!currentTable || currentTable !== String(dimension.sourceTable).trim()) {
|
|
return null;
|
|
}
|
|
|
|
if (String(dimension.dimensionType || '').trim() === 'time') {
|
|
const configuredTimeFields = collectConfiguredTimeFields(config);
|
|
const dimensionTimeField = String(dimension.timeField || '').trim();
|
|
const targetField =
|
|
(dimensionTimeField && configuredTimeFields.includes(dimensionTimeField))
|
|
? dimensionTimeField
|
|
: (configuredTimeFields[0] || dimensionTimeField || String(dimension.keyField || '').trim());
|
|
if (!targetField) return null;
|
|
return {
|
|
bindingType: 'time_range',
|
|
targetTable: currentTable,
|
|
targetField,
|
|
filterOperator: null,
|
|
valueSource: null,
|
|
};
|
|
}
|
|
|
|
const configuredFilterFields = collectConfiguredFilterFields(config);
|
|
const dimensionKeyField = String(dimension.keyField || '').trim();
|
|
const dimensionLabelField = String(dimension.labelField || '').trim();
|
|
let targetField = '';
|
|
let valueSource = 'key';
|
|
if (
|
|
dimensionLabelField &&
|
|
dimensionLabelField !== dimensionKeyField &&
|
|
configuredFilterFields.has(dimensionLabelField)
|
|
) {
|
|
targetField = dimensionLabelField;
|
|
valueSource = 'label';
|
|
} else if (dimensionKeyField && configuredFilterFields.has(dimensionKeyField)) {
|
|
targetField = dimensionKeyField;
|
|
valueSource = 'key';
|
|
} else if (dimensionLabelField && dimensionLabelField !== dimensionKeyField) {
|
|
targetField = dimensionLabelField;
|
|
valueSource = 'label';
|
|
} else {
|
|
targetField = dimensionKeyField;
|
|
valueSource = 'key';
|
|
}
|
|
if (!targetField) return null;
|
|
return {
|
|
bindingType: 'filter_eq',
|
|
targetTable: currentTable,
|
|
targetField,
|
|
filterOperator: '=',
|
|
valueSource,
|
|
};
|
|
};
|
|
|
|
const upsertFilter = (queryConfig, targetField, operator, value) => {
|
|
const nextFilters = Array.isArray(queryConfig.filters)
|
|
? queryConfig.filters.filter(item => String(item?.field || '').trim() !== targetField)
|
|
: [];
|
|
nextFilters.push({ field: targetField, operator, value });
|
|
queryConfig.filters = nextFilters;
|
|
};
|
|
|
|
const applyTimeRange = (queryConfig, targetField, timeStart, timeEnd) => {
|
|
queryConfig.timeRange = {
|
|
field: targetField,
|
|
start: timeStart,
|
|
end: timeEnd,
|
|
};
|
|
};
|
|
|
|
const upsertMetricFilter = (metric = {}, targetField, operator, value) => {
|
|
const nextMetric = deepCloneJson(metric) || {};
|
|
const nextFilters = Array.isArray(nextMetric.filters)
|
|
? nextMetric.filters.filter(item => String(item?.field || '').trim() !== targetField)
|
|
: [];
|
|
nextFilters.push({ field: targetField, operator, value });
|
|
nextMetric.filters = nextFilters;
|
|
return nextMetric;
|
|
};
|
|
|
|
const applyMetricTimeRange = (metric = {}, targetField, timeStart, timeEnd) => {
|
|
const nextMetric = deepCloneJson(metric) || {};
|
|
nextMetric.timeRange = {
|
|
field: targetField,
|
|
start: timeStart,
|
|
end: timeEnd,
|
|
};
|
|
return nextMetric;
|
|
};
|
|
|
|
const injectBatchBindingIntoConfig = (config, binding, dimensionItem) => {
|
|
const nextConfig = deepCloneJson(config) || {};
|
|
const queryConfig = extractQueryHolder(nextConfig);
|
|
const metricList = Array.isArray(nextConfig.kpiMetrics) ? nextConfig.kpiMetrics : [];
|
|
if (binding.bindingType === 'filter_eq') {
|
|
const sourceValue = binding.valueSource === 'label'
|
|
? String(dimensionItem.label || dimensionItem.key || '')
|
|
: String(dimensionItem.key || '');
|
|
upsertFilter(queryConfig, binding.targetField, binding.filterOperator || '=', sourceValue);
|
|
if (nextConfig.previewQuery && typeof nextConfig.previewQuery === 'object' && !Array.isArray(nextConfig.previewQuery)) {
|
|
upsertFilter(nextConfig.previewQuery, binding.targetField, binding.filterOperator || '=', sourceValue);
|
|
}
|
|
if (metricList.length) {
|
|
nextConfig.kpiMetrics = metricList.map(metric =>
|
|
upsertMetricFilter(metric, binding.targetField, binding.filterOperator || '=', sourceValue)
|
|
);
|
|
}
|
|
} else {
|
|
applyTimeRange(queryConfig, binding.targetField, dimensionItem.timeStart, dimensionItem.timeEnd);
|
|
if (nextConfig.previewQuery && typeof nextConfig.previewQuery === 'object' && !Array.isArray(nextConfig.previewQuery)) {
|
|
applyTimeRange(nextConfig.previewQuery, binding.targetField, dimensionItem.timeStart, dimensionItem.timeEnd);
|
|
}
|
|
if (metricList.length) {
|
|
nextConfig.kpiMetrics = metricList.map(metric =>
|
|
applyMetricTimeRange(metric, binding.targetField, dimensionItem.timeStart, dimensionItem.timeEnd)
|
|
);
|
|
}
|
|
}
|
|
return sanitizeQueryBlockConfig(nextConfig);
|
|
};
|
|
|
|
const normalizeDimensionItems = (dimension, rawItems) => {
|
|
if (!Array.isArray(rawItems) || !rawItems.length) throw 'invalid param: dimensionItems';
|
|
const uniqueKeySet = new Set();
|
|
return rawItems.map((item, index) => {
|
|
const row = ensureObjectPayload(item, `dimensionItems[${index}]`);
|
|
const key = ensureNonEmptyString(row.key, `dimensionItems[${index}].key`);
|
|
if (uniqueKeySet.has(key)) throw `duplicate param: dimensionItems.key (${key})`;
|
|
uniqueKeySet.add(key);
|
|
const label = normalizeOptionalString(row.label) || key;
|
|
if (dimension.dimensionType === 'time') {
|
|
const timeStart = ensureValidDateInput(row.timeStart, `dimensionItems[${index}].timeStart`);
|
|
const timeEnd = ensureValidDateInput(row.timeEnd, `dimensionItems[${index}].timeEnd`);
|
|
if (new Date(timeStart).getTime() > new Date(timeEnd).getTime()) {
|
|
throw `invalid param: dimensionItems[${index}].timeStart`;
|
|
}
|
|
return { key, label, timeStart, timeEnd };
|
|
}
|
|
return {
|
|
key,
|
|
label,
|
|
timeStart: null,
|
|
timeEnd: null,
|
|
};
|
|
});
|
|
};
|
|
|
|
const resolveBatchStatus = (successCount, failedCount) => {
|
|
if (!failedCount) return 'success';
|
|
if (!successCount) return 'failed';
|
|
return 'partial_success';
|
|
};
|
|
|
|
const loadBatchDetail = async (ctx, batchId) => {
|
|
const { models } = ctx.app.fs.dc;
|
|
const parsedBatchId = toInt(batchId);
|
|
if (!parsedBatchId) throw '缺少参数: batchId';
|
|
const batch = await models.ReportGenerationBatch.findByPk(parsedBatchId, {
|
|
include: [
|
|
{ model: models.ReportTemplate, required: false },
|
|
{ model: models.ReportTemplateBatchDimension, required: false },
|
|
],
|
|
});
|
|
if (!batch) throw '批量生成任务不存在';
|
|
const items = await models.ReportGenerationBatchItem.findAll({
|
|
where: { batchId: parsedBatchId },
|
|
include: [{ model: models.ReportInstance, required: false }],
|
|
order: [['itemIndex', 'ASC'], ['id', 'ASC']],
|
|
});
|
|
return {
|
|
...batch.toJSON(),
|
|
items: items.map(item => item.toJSON()),
|
|
};
|
|
};
|
|
|
|
module.exports.createBatchInstancesFromTemplate = async (ctx, next) => {
|
|
const transaction = await ctx.app.fs.dc.orm.transaction();
|
|
try {
|
|
const { models } = ctx.app.fs.dc;
|
|
const template = await ensureActiveTemplate(ctx, ctx.params.templateId, transaction);
|
|
if (!template.dataSourceId) throw '模板未绑定数据源';
|
|
const dimension = await ensureTemplateBatchDimension(ctx, template.id, transaction);
|
|
const body = ensureObjectPayload(ctx.request.body || {}, 'body');
|
|
const createdBy = ensureCreatedBy(body);
|
|
const departmentId = hasOwn(body, 'departmentId')
|
|
? normalizeNullableInt(body.departmentId, 'departmentId')
|
|
: normalizeNullableInt(template.departmentId, 'departmentId');
|
|
const namePattern = normalizeOptionalString(body.namePattern) || '{{templateName}}-{{dimensionLabel}}';
|
|
const dimensionItems = normalizeDimensionItems(dimension, body.dimensionItems);
|
|
const blockConfigOverrides = hasOwn(body, 'blockConfigOverrides')
|
|
? await normalizeTemplateBlockConfigOverrides(
|
|
ctx,
|
|
template.id,
|
|
body.blockConfigOverrides,
|
|
transaction
|
|
)
|
|
: new Map();
|
|
const bindingMap = await getTemplateBlockBatchBindingMap(ctx, template.id, transaction);
|
|
|
|
const createdBatch = await models.ReportGenerationBatch.create({
|
|
templateId: template.id,
|
|
templateBatchDimensionId: dimension.id,
|
|
createdBy,
|
|
namePattern,
|
|
status: 'pending',
|
|
totalCount: dimensionItems.length,
|
|
successCount: 0,
|
|
failedCount: 0,
|
|
requestPayload: {
|
|
...body,
|
|
templateId: template.id,
|
|
templateName: template.name,
|
|
templateBatchDimensionId: dimension.id,
|
|
createdBy,
|
|
departmentId,
|
|
namePattern,
|
|
dimensionItems,
|
|
},
|
|
}, {
|
|
transaction,
|
|
returning: true,
|
|
});
|
|
const createdItems = await models.ReportGenerationBatchItem.bulkCreate(
|
|
dimensionItems.map((item, index) => ({
|
|
batchId: createdBatch.id,
|
|
itemIndex: index + 1,
|
|
dimensionKey: item.key,
|
|
dimensionLabel: item.label,
|
|
timeStart: item.timeStart ? new Date(item.timeStart) : null,
|
|
timeEnd: item.timeEnd ? new Date(item.timeEnd) : null,
|
|
status: ITEM_STATUSES.pending,
|
|
})),
|
|
{ transaction, returning: true }
|
|
);
|
|
await models.ReportGenerationBatch.update({
|
|
status: 'running',
|
|
startedAt: new Date(),
|
|
}, {
|
|
where: { id: createdBatch.id },
|
|
transaction,
|
|
});
|
|
await transaction.commit();
|
|
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
for (let index = 0; index < dimensionItems.length; index += 1) {
|
|
const dimensionItem = dimensionItems[index];
|
|
const itemRow = createdItems[index];
|
|
await models.ReportGenerationBatchItem.update({
|
|
status: ITEM_STATUSES.running,
|
|
}, {
|
|
where: { id: itemRow.id },
|
|
});
|
|
|
|
const itemTransaction = await ctx.app.fs.dc.orm.transaction();
|
|
try {
|
|
const createdInstance = await models.ReportInstance.create({
|
|
templateId: template.id,
|
|
generationBatchId: createdBatch.id,
|
|
schemaVersionId: template.schemaVersionId,
|
|
name: renderNamePattern(namePattern, template.name, dimensionItem),
|
|
type: String(template.type || 'general'),
|
|
dataSourceId: toInt(template.dataSourceId),
|
|
createdBy,
|
|
departmentId,
|
|
batchDimensionKey: dimensionItem.key,
|
|
batchDimensionLabel: dimensionItem.label,
|
|
batchTimeStart: dimensionItem.timeStart ? new Date(dimensionItem.timeStart) : null,
|
|
batchTimeEnd: dimensionItem.timeEnd ? new Date(dimensionItem.timeEnd) : null,
|
|
}, {
|
|
transaction: itemTransaction,
|
|
returning: true,
|
|
});
|
|
|
|
await cloneTemplateToInstance(
|
|
ctx,
|
|
template.id,
|
|
createdInstance.id,
|
|
blockConfigOverrides,
|
|
itemTransaction,
|
|
{
|
|
resolveBlockPayload: async (templateBlock, payload) => {
|
|
const { config, contentSnapshot } = payload || {};
|
|
const resolvedConfigSource = deepCloneJson(config) || {};
|
|
const currentTable = extractBlockQueryTable(resolvedConfigSource);
|
|
const binding =
|
|
bindingMap.get(Number(templateBlock.id))
|
|
|| normalizeConfiguredBinding(resolvedConfigSource, currentTable)
|
|
|| deriveImplicitBatchBinding(resolvedConfigSource, dimension);
|
|
const resolvedConfig = binding
|
|
? injectBatchBindingIntoConfig(resolvedConfigSource, binding, dimensionItem)
|
|
: resolvedConfigSource;
|
|
return resolveBlockPayload(ctx, {
|
|
blockType: templateBlock?.blockType,
|
|
dataSourceId: template.dataSourceId,
|
|
config: resolvedConfig,
|
|
contentSnapshot: deepCloneJson(contentSnapshot) || null,
|
|
transaction: itemTransaction,
|
|
});
|
|
},
|
|
finalizeCreatedBlock: async (templateBlock, createdPayload, helpers) => {
|
|
return finalizeResolvedBlockPayload(ctx, {
|
|
blockType: templateBlock?.blockType,
|
|
config: createdPayload?.config || {},
|
|
contentSnapshot: createdPayload?.contentSnapshot || null,
|
|
blockIdMap: helpers?.blockIdMap,
|
|
getCreatedBlockStateByInstanceId: helpers?.getCreatedStateByInstanceId,
|
|
});
|
|
},
|
|
}
|
|
);
|
|
|
|
await models.ReportGenerationBatchItem.update({
|
|
status: ITEM_STATUSES.success,
|
|
reportInstanceId: createdInstance.id,
|
|
finishedAt: new Date(),
|
|
errorMessage: null,
|
|
}, {
|
|
where: { id: itemRow.id },
|
|
transaction: itemTransaction,
|
|
});
|
|
await itemTransaction.commit();
|
|
successCount += 1;
|
|
} catch (error) {
|
|
await itemTransaction.rollback();
|
|
failedCount += 1;
|
|
await models.ReportGenerationBatchItem.update({
|
|
status: ITEM_STATUSES.failed,
|
|
finishedAt: new Date(),
|
|
errorMessage: typeof error === 'string' ? error : (error?.message || '批量生成实例失败'),
|
|
}, {
|
|
where: { id: itemRow.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
await models.ReportGenerationBatch.update({
|
|
status: resolveBatchStatus(successCount, failedCount),
|
|
successCount,
|
|
failedCount,
|
|
finishedAt: new Date(),
|
|
}, {
|
|
where: { id: createdBatch.id },
|
|
});
|
|
|
|
ctx.body = await loadBatchDetail(ctx, createdBatch.id);
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
if (!transaction.finished) await transaction.rollback();
|
|
ctx.logger.log(error);
|
|
ctx.status = 400;
|
|
ctx.body = { message: typeof error === 'string' ? error : '批量生成报表实例失败' };
|
|
}
|
|
};
|
|
|
|
module.exports.getGenerationBatches = async (ctx, next) => {
|
|
try {
|
|
const { models } = ctx.app.fs.dc;
|
|
const { offset, limit } = parsePagination(ctx.request.query);
|
|
const where = {};
|
|
const templateId = toInt(ctx.request.query.templateId);
|
|
const createdBy = normalizeOptionalString(ctx.request.query.createdBy);
|
|
const status = normalizeOptionalString(ctx.request.query.status);
|
|
if (templateId) where.templateId = templateId;
|
|
if (createdBy) where.createdBy = createdBy;
|
|
if (status) {
|
|
if (!TASK_STATUSES.has(status)) throw 'invalid param: status';
|
|
where.status = status;
|
|
}
|
|
const rows = await models.ReportGenerationBatch.findAndCountAll({
|
|
where,
|
|
include: [
|
|
{ model: models.ReportTemplate, required: false },
|
|
{ model: models.ReportTemplateBatchDimension, required: false },
|
|
],
|
|
order: [['id', 'DESC']],
|
|
offset,
|
|
limit,
|
|
});
|
|
ctx.body = {
|
|
count: rows.count,
|
|
rows: rows.rows,
|
|
};
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.log(error);
|
|
ctx.status = 400;
|
|
ctx.body = { message: typeof error === 'string' ? error : '获取批量生成任务列表失败' };
|
|
}
|
|
};
|
|
|
|
module.exports.getGenerationBatchDetail = async (ctx, next) => {
|
|
try {
|
|
ctx.body = await loadBatchDetail(ctx, ctx.params.batchId);
|
|
ctx.status = 200;
|
|
} catch (error) {
|
|
ctx.logger.log(error);
|
|
ctx.status = 400;
|
|
ctx.body = { message: typeof error === 'string' ? error : '获取批量生成任务详情失败' };
|
|
}
|
|
};
|
|
|