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