'use strict'; const { toInt, normalizeOrderedInput, ensureObjectPayload } = require('./helpers'); const { buildChapterTree } = require('./tree'); const deepCloneJson = value => { if (value === null || value === undefined) return value; return JSON.parse(JSON.stringify(value)); }; const sanitizeBlockConfig = config => { const safeConfig = deepCloneJson(config) || {}; delete safeConfig.previewSeries; delete safeConfig.previewPoints; delete safeConfig.previewRows; delete safeConfig.previewColumns; delete safeConfig.previewTotal; return safeConfig; }; const REPORT_CHART_MARKER_REGEX = /(]*data-report-chart=['"])([^'"]+)(['"][^>]*><\/div>)/gi; const REPORT_KPI_MARKER_REGEX = /(<(?:span|a)[^>]*data-report-kpi=['"])([^'"]+)(['"][^>]*>)([\s\S]*?)(<\/(?:span|a)>)/gi; const rewriteReportChartMarkers = (html, blockIdMap) => { const raw = String(html || ''); if (!raw || !(blockIdMap instanceof Map) || !blockIdMap.size) return raw; return raw.replace(REPORT_CHART_MARKER_REGEX, (match, prefix, encoded, suffix) => { try { const payload = JSON.parse(decodeURIComponent(encoded)); const oldBlockId = String(payload?.blockId || '').trim(); if (!oldBlockId) return match; const nextBlockId = blockIdMap.get(oldBlockId); if (!nextBlockId) return match; return `${prefix}${encodeURIComponent(JSON.stringify({ ...payload, blockId: String(nextBlockId), }))}${suffix}`; } catch (error) { return match; } }); }; const buildKpiInlineLabelFromState = (blockState) => { const metrics = Array.isArray(blockState?.contentSnapshot?.metrics) ? blockState.contentSnapshot.metrics : []; const label = metrics .map(metric => ( metric?.value === null || metric?.value === undefined ? '' : String(metric.value) )) .filter(Boolean) .join(' / '); return label; }; const extractSnapshotContentHtml = (snapshot) => { if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return ''; return String(snapshot?.content || '').trim(); }; const rewriteReportKpiMarkers = (html, options = {}) => { const raw = String(html || ''); const blockIdMap = options?.blockIdMap; const chapterIdMap = options?.chapterIdMap; const getCreatedStateByInstanceId = options?.getCreatedStateByInstanceId; const preserveExistingLabelOnEmpty = Boolean(options?.preserveExistingLabelOnEmpty); const fallbackBlockIds = Array.isArray(options?.fallbackKpiBlockIds) ? options.fallbackKpiBlockIds.map(item => String(item || '').trim()).filter(Boolean) : []; if (!raw || !(blockIdMap instanceof Map) || !blockIdMap.size) return raw; let fallbackIndex = 0; return raw.replace( REPORT_KPI_MARKER_REGEX, (match, prefix, encoded, suffix, innerText, closingTag) => { try { const payload = JSON.parse(decodeURIComponent(encoded)); const oldBlockId = String(payload?.blockId || '').trim(); if (!oldBlockId) return match; const fallbackBlockId = fallbackBlockIds[fallbackIndex] || null; fallbackIndex += 1; const nextBlockId = blockIdMap.get(oldBlockId) || fallbackBlockId; if (!nextBlockId) return match; const oldChapterId = String(payload?.chapterId || '').trim(); const nextChapterId = oldChapterId ? String(chapterIdMap?.get(Number(oldChapterId)) || chapterIdMap?.get(oldChapterId) || oldChapterId).trim() : ''; const createdBlockState = typeof getCreatedStateByInstanceId === 'function' ? getCreatedStateByInstanceId(nextBlockId) : null; const nextLabel = buildKpiInlineLabelFromState(createdBlockState) || ( preserveExistingLabelOnEmpty ? String(payload?.label || innerText || '').trim() : '' ); if (!nextLabel) return ''; const nextPayload = { ...payload, blockId: String(nextBlockId), ...(nextChapterId ? { chapterId: nextChapterId } : {}), label: nextLabel, }; const nextSuffix = String(suffix || '') .replace(/data-report-block-id=['"][^'"]*['"]/i, `data-report-block-id="${String(nextBlockId)}"`) .replace(/data-report-chapter-id=['"][^'"]*['"]/i, nextChapterId ? `data-report-chapter-id="${nextChapterId}"` : 'data-report-chapter-id=""') .replace(/data-report-kpi-text=['"][^'"]*['"]/i, `data-report-kpi-text="${nextLabel}"`); return `${prefix}${encodeURIComponent(JSON.stringify(nextPayload))}${nextSuffix}${nextLabel}${closingTag}`; } catch (error) { return match; } } ); }; const rewriteBlockContentSnapshot = (snapshot, options = {}) => { if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { return snapshot || null; } const rawContent = snapshot.content; if ( typeof rawContent !== 'string' || (!rawContent.includes('data-report-chart=') && !rawContent.includes('data-report-kpi=')) ) { return snapshot; } const nextContent = rewriteReportKpiMarkers( rewriteReportChartMarkers(rawContent, options?.blockIdMap), options, ); if (nextContent === rawContent) return snapshot; return { ...deepCloneJson(snapshot), content: nextContent, }; }; const rewriteAiAnalysisConfigForTemplate = (config, blockIdMap) => { if (!config || typeof config !== 'object' || Array.isArray(config)) { return config || {}; } const nextConfig = deepCloneJson(config) || {}; if (!(blockIdMap instanceof Map) || !blockIdMap.size) return nextConfig; const sourceBlockId = String( nextConfig.sourceTemplateBlockId || nextConfig.sourceBlockId || nextConfig.linkedBlockId || '' ).trim(); if (!sourceBlockId) return nextConfig; const rewrittenId = blockIdMap.get(sourceBlockId); if (!rewrittenId) return nextConfig; nextConfig.sourceTemplateBlockId = rewrittenId; nextConfig.sourceBlockId = rewrittenId; nextConfig.linkedBlockId = rewrittenId; return nextConfig; }; const rewriteAnchorKeyWithChapterMap = (rawValue, chapterIdMap) => { const normalized = String(rawValue || '').trim(); if (!normalized || !(chapterIdMap instanceof Map) || !chapterIdMap.size) return normalized; const colonIndex = normalized.indexOf(':'); if (colonIndex <= 0) return normalized; const oldChapterId = normalized.slice(0, colonIndex); const nextChapterId = chapterIdMap.get(Number(oldChapterId)) || chapterIdMap.get(oldChapterId); if (!nextChapterId) return normalized; return `${nextChapterId}${normalized.slice(colonIndex)}`; }; const rewriteInlineAnchorConfig = (config, chapterIdMap) => { if (!config || typeof config !== 'object' || Array.isArray(config)) { return config || {}; } if (!(chapterIdMap instanceof Map) || !chapterIdMap.size) { return deepCloneJson(config) || {}; } const nextConfig = deepCloneJson(config) || {}; const inlineAnchor = ensureObjectPayload(nextConfig.inlineAnchor || {}, 'config.inlineAnchor'); if (!Object.keys(inlineAnchor).length) return nextConfig; const nextParaId = rewriteAnchorKeyWithChapterMap(inlineAnchor.paraId, chapterIdMap); const nextSecKey = rewriteAnchorKeyWithChapterMap(inlineAnchor.secKey, chapterIdMap); nextConfig.inlineAnchor = { ...inlineAnchor, ...(nextParaId ? { paraId: nextParaId } : {}), ...(nextSecKey ? { secKey: nextSecKey } : {}), }; return nextConfig; }; const getLatestSchemaVersion = async (ctx, transaction = null) => { const { models } = ctx.app.fs.dc; const latest = await models.ReportSchemaVersion.findOne({ order: [['id', 'DESC']], raw: true, transaction, }); if (!latest) throw '请先创建 schema version'; return latest; }; const resolveSchemaVersion = async (ctx, schemaVersionId, transaction = null) => { const { models } = ctx.app.fs.dc; if (schemaVersionId === null || schemaVersionId === undefined || schemaVersionId === '') { return getLatestSchemaVersion(ctx, transaction); } const parsedId = toInt(schemaVersionId); if (!parsedId) throw '参数错误: schemaVersionId'; const row = await models.ReportSchemaVersion.findByPk(parsedId, { raw: true, transaction }); if (!row) throw 'schema version 不存在'; return row; }; const ensureDataSource = async (ctx, dataSourceId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedId = toInt(dataSourceId); if (!parsedId) throw '缺少参数: dataSourceId'; const row = await models.ReportDataSource.findByPk(parsedId, { raw: true, transaction }); if (!row) throw 'data source 不存在'; return row; }; const ensureInstance = async (ctx, instanceId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedId = toInt(instanceId); if (!parsedId) throw '缺少参数: instanceId'; const row = await models.ReportInstance.findByPk(parsedId, { raw: true, transaction }); if (!row) throw '报表实例不存在'; return row; }; const ensureTemplate = async (ctx, templateId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedId = toInt(templateId); if (!parsedId) throw '缺少参数: templateId'; const row = await models.ReportTemplate.findByPk(parsedId, { raw: true, transaction }); if (!row) throw '模板不存在'; return row; }; const ensureActiveTemplate = async (ctx, templateId, transaction = null) => { const template = await ensureTemplate(ctx, templateId, transaction); if (!template.isActive) throw '模板未启用'; return template; }; const ensureTemplateBlock = async (ctx, templateId, templateBlockId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedTemplateId = toInt(templateId); const parsedTemplateBlockId = toInt(templateBlockId); if (!parsedTemplateId || !parsedTemplateBlockId) throw '参数错误'; const row = await models.ReportTemplateBlock.findOne({ where: { id: parsedTemplateBlockId, templateId: parsedTemplateId, }, raw: true, transaction, }); if (!row) throw '模板块不存在'; return row; }; const ensureTemplateChapter = async (ctx, templateId, templateChapterId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedTemplateId = toInt(templateId); const parsedTemplateChapterId = toInt(templateChapterId); if (!parsedTemplateId || !parsedTemplateChapterId) throw '参数错误'; const row = await models.ReportTemplateChapter.findOne({ where: { id: parsedTemplateChapterId, templateId: parsedTemplateId, }, raw: true, transaction, }); if (!row) throw '模板章节不存在'; return row; }; const getTemplateBatchDimension = async (ctx, templateId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedTemplateId = toInt(templateId); if (!parsedTemplateId) throw '缺少参数: templateId'; return models.ReportTemplateBatchDimension.findOne({ where: { templateId: parsedTemplateId }, raw: true, transaction, }); }; const ensureTemplateBatchDimension = async (ctx, templateId, transaction = null) => { const row = await getTemplateBatchDimension(ctx, templateId, transaction); if (!row) throw '模板未配置主批量维度'; return row; }; const getTemplateBlockBatchBinding = async (ctx, templateId, templateBlockId, transaction = null) => { const block = await ensureTemplateBlock(ctx, templateId, templateBlockId, transaction); const { models } = ctx.app.fs.dc; const row = await models.ReportTemplateBlockBatchBinding.findOne({ where: { templateBlockId: block.id }, raw: true, transaction, }); if (!row) return null; return row; }; const getTemplateBlockBatchBindingMap = async (ctx, templateId, transaction = null) => { const parsedTemplateId = toInt(templateId); if (!parsedTemplateId) throw '缺少参数: templateId'; const { models } = ctx.app.fs.dc; const rows = await models.ReportTemplateBlockBatchBinding.findAll({ include: [{ model: models.ReportTemplateBlock, required: true, attributes: [], where: { templateId: parsedTemplateId }, }], raw: true, transaction, }); const result = new Map(); rows.forEach(row => { result.set(Number(row.templateBlockId), row); }); return result; }; const ensureChapter = async (ctx, instanceId, chapterId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedChapterId = toInt(chapterId); const parsedInstanceId = toInt(instanceId); if (!parsedChapterId || !parsedInstanceId) throw '参数错误'; const row = await models.ReportChapter.findOne({ where: { id: parsedChapterId, instanceId: parsedInstanceId, }, raw: true, transaction, }); if (!row) throw '章节不存在'; return row; }; const ensureBlock = async (ctx, instanceId, blockId, transaction = null) => { const { models } = ctx.app.fs.dc; const parsedBlockId = toInt(blockId); const parsedInstanceId = toInt(instanceId); if (!parsedBlockId || !parsedInstanceId) throw '参数错误'; const row = await models.ReportBlock.findOne({ where: { id: parsedBlockId, instanceId: parsedInstanceId, }, raw: true, transaction, }); if (!row) throw '内容块不存在'; return row; }; const getInstanceChapterTree = async (ctx, instanceId, transaction = null) => { const { models } = ctx.app.fs.dc; const chapters = await models.ReportChapter.findAll({ where: { instanceId: toInt(instanceId) }, order: [['position', 'ASC'], ['id', 'ASC']], raw: true, transaction, }); const blocks = await models.ReportBlock.findAll({ where: { instanceId: toInt(instanceId) }, order: [['chapterId', 'ASC'], ['position', 'ASC'], ['id', 'ASC']], raw: true, transaction, }); return buildChapterTree(chapters, blocks); }; const getTemplateChapterTree = async (ctx, templateId, transaction = null) => { const { models } = ctx.app.fs.dc; const chapters = await models.ReportTemplateChapter.findAll({ where: { templateId: toInt(templateId) }, order: [['position', 'ASC'], ['id', 'ASC']], raw: true, transaction, }); const blocks = await models.ReportTemplateBlock.findAll({ where: { templateId: toInt(templateId) }, order: [['chapterId', 'ASC'], ['position', 'ASC'], ['id', 'ASC']], raw: true, transaction, }).then(rows => rows.map(row => ({ ...row, config: row.defaultConfig, contentSnapshot: row.contentSnapshot || null, }))); return buildChapterTree(chapters, blocks); }; const createBlocksFromPayload = async (ctx, instanceId, chapterId, blocks = [], transaction = null) => { const { models } = ctx.app.fs.dc; const orderedBlocks = normalizeOrderedInput(blocks); for (let i = 0; i < orderedBlocks.length; i += 1) { const block = orderedBlocks[i] || {}; await models.ReportBlock.create({ instanceId: toInt(instanceId), chapterId: toInt(chapterId), blockType: String(block.blockType || 'text'), position: i + 1, config: sanitizeBlockConfig(block.config || {}), contentSnapshot: block.contentSnapshot || null, updatedAt: new Date(), }, { transaction }); } }; const createChapterTreeFromPayload = async (ctx, instanceId, parentId, chapters = [], transaction = null) => { const { models } = ctx.app.fs.dc; const orderedChapters = normalizeOrderedInput(chapters); for (let i = 0; i < orderedChapters.length; i += 1) { const chapter = orderedChapters[i] || {}; const title = String(chapter.title || '').trim(); if (!title) throw '章节标题不能为空'; const created = await models.ReportChapter.create({ instanceId: toInt(instanceId), parentId: parentId === null ? null : toInt(parentId), title, position: i + 1, prompts: chapter.prompts || null, status: chapter.status || null, }, { transaction, returning: true, }); if (Array.isArray(chapter.blocks) && chapter.blocks.length) { await createBlocksFromPayload(ctx, instanceId, created.id, chapter.blocks, transaction); } if (Array.isArray(chapter.children) && chapter.children.length) { await createChapterTreeFromPayload(ctx, instanceId, created.id, chapter.children, transaction); } } }; const cloneTemplateToInstance = async ( ctx, templateId, instanceId, blockConfigOverrides = new Map(), transaction = null, options = {} ) => { const { models } = ctx.app.fs.dc; const templateChapters = await models.ReportTemplateChapter.findAll({ where: { templateId: toInt(templateId) }, order: [['id', 'ASC']], raw: true, transaction, }); const chapterIdMap = new Map(); const pending = [...templateChapters]; while (pending.length) { let progressed = false; for (let i = pending.length - 1; i >= 0; i -= 1) { const chapter = pending[i]; if (chapter.parentId && !chapterIdMap.has(chapter.parentId)) continue; const created = await models.ReportChapter.create({ instanceId: toInt(instanceId), parentId: chapter.parentId ? chapterIdMap.get(chapter.parentId) : null, title: chapter.title, position: chapter.position, prompts: null, status: null, }, { transaction, returning: true, }); chapterIdMap.set(chapter.id, created.id); pending.splice(i, 1); progressed = true; } if (!progressed) { throw '模板章节层级异常,无法复制'; } } const templateBlocks = await models.ReportTemplateBlock.findAll({ where: { templateId: toInt(templateId) }, order: [['chapterId', 'ASC'], ['position', 'ASC'], ['id', 'ASC']], raw: true, transaction, }); const templateTextCandidateByChapterId = new Map(); const templateKpiBlockIdsByChapterId = new Map(); templateBlocks.forEach(block => { const chapterKey = String(block.chapterId || '').trim(); const blockType = String(block?.blockType || '').trim().toLowerCase(); const contentHtml = extractSnapshotContentHtml(block?.contentSnapshot); if (chapterKey && contentHtml) { const currentCandidate = templateTextCandidateByChapterId.get(chapterKey) || null; const nextCandidate = { position: Number(block?.position || 1) || 1, contentSnapshot: deepCloneJson(block.contentSnapshot) || { content: contentHtml }, priority: blockType === 'text' ? 2 : 1, }; if ( !currentCandidate || nextCandidate.priority > currentCandidate.priority || ( nextCandidate.priority === currentCandidate.priority && nextCandidate.position < currentCandidate.position ) ) { templateTextCandidateByChapterId.set(chapterKey, nextCandidate); } } if (blockType !== 'kpi') return; if (!chapterKey) return; const currentList = templateKpiBlockIdsByChapterId.get(chapterKey) || []; currentList.push(String(block.id)); templateKpiBlockIdsByChapterId.set(chapterKey, currentList); }); const blockIdMap = new Map(); const createdBlockStateMap = new Map(); const createdBlockStateByInstanceId = new Map(); const hasCreatedTextBlockByChapterId = new Map(); for (const block of templateBlocks) { const newChapterId = chapterIdMap.get(block.chapterId); if (!newChapterId) continue; let blockConfig = blockConfigOverrides.has(block.id) ? blockConfigOverrides.get(block.id) : (block.defaultConfig || {}); blockConfig = rewriteInlineAnchorConfig(blockConfig, chapterIdMap); let contentSnapshot = deepCloneJson(block.contentSnapshot) || null; if (typeof options.resolveBlockPayload === 'function') { const resolvedPayload = await options.resolveBlockPayload(block, { config: blockConfig, contentSnapshot, }); if (resolvedPayload && typeof resolvedPayload === 'object') { if (Object.prototype.hasOwnProperty.call(resolvedPayload, 'config')) { blockConfig = deepCloneJson(resolvedPayload.config) || {}; } if (Object.prototype.hasOwnProperty.call(resolvedPayload, 'contentSnapshot')) { contentSnapshot = deepCloneJson(resolvedPayload.contentSnapshot) || null; } } } else if (typeof options.resolveBlockConfig === 'function') { blockConfig = await options.resolveBlockConfig(block, blockConfig); } const createdBlock = await models.ReportBlock.create({ instanceId: toInt(instanceId), chapterId: newChapterId, blockType: block.blockType, position: block.position, config: blockConfig, contentSnapshot, updatedAt: new Date(), }, { transaction, returning: true, }); const oldBlockId = String(block.id); blockIdMap.set(oldBlockId, createdBlock.id); if (String(block?.blockType || '').trim().toLowerCase() === 'text') { hasCreatedTextBlockByChapterId.set(String(newChapterId), true); } createdBlockStateMap.set(oldBlockId, { createdId: createdBlock.id, blockType: block.blockType, config: blockConfig, contentSnapshot, }); createdBlockStateByInstanceId.set(createdBlock.id, { createdId: createdBlock.id, blockType: block.blockType, config: blockConfig, contentSnapshot, }); } for (const block of templateBlocks) { const oldBlockId = String(block.id); const createdState = createdBlockStateMap.get(oldBlockId); if (!createdState) continue; let rewrittenConfig = deepCloneJson(createdState.config) || {}; let rewrittenContentSnapshot = rewriteBlockContentSnapshot(createdState.contentSnapshot, { blockIdMap, chapterIdMap, fallbackKpiBlockIds: ( templateKpiBlockIdsByChapterId.get(String(block.chapterId || '').trim()) || [] ) .map(templateBlockId => blockIdMap.get(String(templateBlockId))) .filter(Boolean), getCreatedStateByInstanceId: instanceBlockId => ( createdBlockStateByInstanceId.get(Number(instanceBlockId)) || null ), }); if (typeof options.finalizeCreatedBlock === 'function') { const finalizedPayload = await options.finalizeCreatedBlock( block, { createdId: createdState.createdId, blockType: createdState.blockType, config: rewrittenConfig, contentSnapshot: rewrittenContentSnapshot, }, { blockIdMap, getCreatedStateByTemplateBlockId: templateBlockId => ( createdBlockStateMap.get(String(templateBlockId)) || null ), getCreatedStateByInstanceId: instanceBlockId => ( createdBlockStateByInstanceId.get(Number(instanceBlockId)) || null ), } ); if (finalizedPayload && typeof finalizedPayload === 'object') { if (Object.prototype.hasOwnProperty.call(finalizedPayload, 'config')) { rewrittenConfig = deepCloneJson(finalizedPayload.config) || {}; } if (Object.prototype.hasOwnProperty.call(finalizedPayload, 'contentSnapshot')) { rewrittenContentSnapshot = deepCloneJson(finalizedPayload.contentSnapshot) || null; } } } const configChanged = JSON.stringify(rewrittenConfig) !== JSON.stringify(createdState.config); const snapshotChanged = JSON.stringify(rewrittenContentSnapshot) !== JSON.stringify(createdState.contentSnapshot); if (!configChanged && !snapshotChanged) continue; await models.ReportBlock.update({ ...(configChanged ? { config: rewrittenConfig } : {}), ...(snapshotChanged ? { contentSnapshot: rewrittenContentSnapshot } : {}), updatedAt: new Date(), }, { where: { id: createdState.createdId }, transaction, }); createdState.config = rewrittenConfig; createdState.contentSnapshot = rewrittenContentSnapshot; createdBlockStateByInstanceId.set(createdState.createdId, { ...createdBlockStateByInstanceId.get(createdState.createdId), config: rewrittenConfig, contentSnapshot: rewrittenContentSnapshot, }); } for (const [oldChapterId, candidate] of templateTextCandidateByChapterId.entries()) { const newChapterId = chapterIdMap.get(Number(oldChapterId)) || chapterIdMap.get(oldChapterId); if (!newChapterId) continue; if (hasCreatedTextBlockByChapterId.get(String(newChapterId))) continue; const rewrittenContentSnapshot = rewriteBlockContentSnapshot(candidate.contentSnapshot, { blockIdMap, chapterIdMap, fallbackKpiBlockIds: ( templateKpiBlockIdsByChapterId.get(String(oldChapterId || '').trim()) || [] ) .map(templateBlockId => blockIdMap.get(String(templateBlockId))) .filter(Boolean), getCreatedStateByInstanceId: instanceBlockId => ( createdBlockStateByInstanceId.get(Number(instanceBlockId)) || null ), }); if (!extractSnapshotContentHtml(rewrittenContentSnapshot)) continue; const chapterBlocks = await models.ReportBlock.findAll({ where: { instanceId: toInt(instanceId), chapterId: toInt(newChapterId), }, order: [['position', 'DESC'], ['id', 'DESC']], raw: true, transaction, }); const insertPosition = Math.max(1, Number(candidate?.position || 1) || 1); for (const row of chapterBlocks) { const currentPosition = Number(row?.position || 0); if (currentPosition < insertPosition) continue; await models.ReportBlock.update({ position: currentPosition + 1, }, { where: { id: row.id }, transaction, }); } await models.ReportBlock.create({ instanceId: toInt(instanceId), chapterId: toInt(newChapterId), blockType: 'text', position: insertPosition, config: { title: '正文', }, contentSnapshot: rewrittenContentSnapshot, updatedAt: new Date(), }, { transaction, }); hasCreatedTextBlockByChapterId.set(String(newChapterId), true); } return { chapterIdMap, blockIdMap, }; }; const normalizeTemplateBlockConfigOverrides = async ( ctx, templateId, blockConfigOverrides, transaction = null ) => { if (!Array.isArray(blockConfigOverrides)) { throw 'invalid param: blockConfigOverrides'; } const parsedTemplateId = toInt(templateId); if (!parsedTemplateId) throw '缺少参数: templateId'; const { models } = ctx.app.fs.dc; const overrideMap = new Map(); for (let i = 0; i < blockConfigOverrides.length; i += 1) { const row = ensureObjectPayload( blockConfigOverrides[i], `blockConfigOverrides[${i}]` ); const templateBlockId = toInt(row.templateBlockId); if (!templateBlockId) { throw `invalid param: blockConfigOverrides[${i}].templateBlockId`; } if (overrideMap.has(templateBlockId)) { throw `duplicate param: blockConfigOverrides.templateBlockId (${templateBlockId})`; } const config = ensureObjectPayload( row.config, `blockConfigOverrides[${i}].config` ); overrideMap.set(templateBlockId, config); } if (!overrideMap.size) return overrideMap; const candidateIds = Array.from(overrideMap.keys()); const rows = await models.ReportTemplateBlock.findAll({ where: { templateId: parsedTemplateId, id: candidateIds, }, attributes: ['id'], raw: true, transaction, }); const validIdSet = new Set(rows.map(item => Number(item.id))); for (const id of candidateIds) { if (!validIdSet.has(id)) { throw `invalid param: blockConfigOverrides.templateBlockId (${id})`; } } return overrideMap; }; const cloneInstanceToTemplate = async (ctx, instanceId, templateId, transaction = null) => { const { models } = ctx.app.fs.dc; const instanceChapters = await models.ReportChapter.findAll({ where: { instanceId: toInt(instanceId) }, order: [['id', 'ASC']], raw: true, transaction, }); const chapterIdMap = new Map(); const pending = [...instanceChapters]; while (pending.length) { let progressed = false; for (let i = pending.length - 1; i >= 0; i -= 1) { const chapter = pending[i]; if (chapter.parentId && !chapterIdMap.has(chapter.parentId)) continue; const created = await models.ReportTemplateChapter.create({ templateId: toInt(templateId), parentId: chapter.parentId ? chapterIdMap.get(chapter.parentId) : null, title: chapter.title, position: chapter.position, }, { transaction, returning: true, }); chapterIdMap.set(chapter.id, created.id); pending.splice(i, 1); progressed = true; } if (!progressed) { throw '报表章节层级异常,无法复制为模板'; } } const reportBlocks = await models.ReportBlock.findAll({ where: { instanceId: toInt(instanceId) }, order: [['chapterId', 'ASC'], ['position', 'ASC'], ['id', 'ASC']], raw: true, transaction, }); const reportKpiBlockIdsByChapterId = new Map(); reportBlocks.forEach(block => { if (String(block?.blockType || '').trim().toLowerCase() !== 'kpi') return; const chapterKey = String(block.chapterId || '').trim(); if (!chapterKey) return; const currentList = reportKpiBlockIdsByChapterId.get(chapterKey) || []; currentList.push(String(block.id)); reportKpiBlockIdsByChapterId.set(chapterKey, currentList); }); const blockIdMap = new Map(); const createdBlockStateMap = new Map(); for (const block of reportBlocks) { const newChapterId = chapterIdMap.get(block.chapterId); if (!newChapterId) continue; const createdBlock = await models.ReportTemplateBlock.create({ templateId: toInt(templateId), chapterId: newChapterId, blockType: block.blockType, position: block.position, defaultConfig: rewriteInlineAnchorConfig(block.config || {}, chapterIdMap), contentSnapshot: block.contentSnapshot || null, }, { transaction, returning: true, }); const oldBlockId = String(block.id); blockIdMap.set(oldBlockId, createdBlock.id); createdBlockStateMap.set(oldBlockId, { createdId: createdBlock.id, blockType: block.blockType, config: block.config || {}, contentSnapshot: block.contentSnapshot || null, }); } for (const block of reportBlocks) { const oldBlockId = String(block.id); const createdState = createdBlockStateMap.get(oldBlockId); if (!createdState) continue; const rewrittenConfig = String(block?.blockType || '').trim().toLowerCase() === 'ai_analysis' ? rewriteAiAnalysisConfigForTemplate(createdState.config, blockIdMap) : createdState.config; const rewrittenContentSnapshot = rewriteBlockContentSnapshot(createdState.contentSnapshot, { blockIdMap, chapterIdMap, preserveExistingLabelOnEmpty: true, fallbackKpiBlockIds: ( reportKpiBlockIdsByChapterId.get(String(block.chapterId || '').trim()) || [] ) .map(reportBlockId => blockIdMap.get(String(reportBlockId))) .filter(Boolean), }); const configChanged = JSON.stringify(rewrittenConfig) !== JSON.stringify(createdState.config); const snapshotChanged = JSON.stringify(rewrittenContentSnapshot) !== JSON.stringify(createdState.contentSnapshot); if (!configChanged && !snapshotChanged) { continue; } await models.ReportTemplateBlock.update({ ...(configChanged ? { defaultConfig: rewrittenConfig } : {}), ...(snapshotChanged ? { contentSnapshot: rewrittenContentSnapshot } : {}), }, { where: { id: createdState.createdId }, transaction, }); } }; module.exports = { getLatestSchemaVersion, resolveSchemaVersion, ensureDataSource, ensureInstance, ensureTemplate, ensureActiveTemplate, ensureTemplateChapter, ensureChapter, ensureBlock, ensureTemplateBlock, getTemplateBatchDimension, ensureTemplateBatchDimension, getTemplateBlockBatchBinding, getTemplateBlockBatchBindingMap, getInstanceChapterTree, getTemplateChapterTree, createBlocksFromPayload, createChapterTreeFromPayload, cloneTemplateToInstance, normalizeTemplateBlockConfigOverrides, cloneInstanceToTemplate, };