'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 : '获取批量生成任务详情失败' }; } };