'use strict'; const { getDataSourceTableColumns, previewDataSourceQuery } = require('./dataSourceQuery'); const { hasOwn, parsePagination, ensureNonEmptyString, ensureObjectPayload } = require('./helpers'); const { ensureActiveTemplate, ensureTemplateBlock, getTemplateBatchDimension, ensureTemplateBatchDimension, getTemplateBlockBatchBinding, } = require('./repository'); const SUPPORTED_DIMENSION_TYPES = new Set(['enum', 'time']); const SUPPORTED_TIME_GRANULARITIES = new Set(['day', 'week', 'month']); const SUPPORTED_BINDING_TYPES = new Set(['filter_eq', 'time_range']); const SUPPORTED_VALUE_SOURCES = new Set(['key', 'label']); const normalizeOptionalString = value => { const normalized = String(value || '').trim(); return normalized || null; }; const formatDateTime = date => { const safeDate = date instanceof Date ? date : new Date(date); const pad = value => String(value).padStart(2, '0'); return `${safeDate.getFullYear()}-${pad(safeDate.getMonth() + 1)}-${pad(safeDate.getDate())} ` + `${pad(safeDate.getHours())}:${pad(safeDate.getMinutes())}:${pad(safeDate.getSeconds())}`; }; const formatDateOnly = date => formatDateTime(date).slice(0, 10); const parseDateValue = (value, label) => { const normalized = ensureNonEmptyString(value, label); const parsed = new Date(normalized); if (Number.isNaN(parsed.getTime())) { throw `invalid param: ${label}`; } return parsed; }; const startOfDay = value => { const date = new Date(value); date.setHours(0, 0, 0, 0); return date; }; const endOfDay = value => { const date = new Date(value); date.setHours(23, 59, 59, 999); return date; }; const startOfWeek = value => { const date = startOfDay(value); const day = date.getDay() || 7; date.setDate(date.getDate() - day + 1); return date; }; const endOfWeek = value => { const date = startOfWeek(value); date.setDate(date.getDate() + 6); return endOfDay(date); }; const startOfMonth = value => { const date = startOfDay(value); date.setDate(1); return date; }; const endOfMonth = value => { const date = startOfMonth(value); date.setMonth(date.getMonth() + 1); date.setDate(0); return endOfDay(date); }; const addGranularity = (value, granularity) => { const date = new Date(value); if (granularity === 'day') { date.setDate(date.getDate() + 1); return date; } if (granularity === 'week') { date.setDate(date.getDate() + 7); return date; } date.setMonth(date.getMonth() + 1); return date; }; const buildTimePreviewRows = (start, end, granularity) => { const normalizedStart = startOfDay(start); const normalizedEnd = endOfDay(end); if (normalizedStart.getTime() > normalizedEnd.getTime()) { throw 'invalid param: start must be earlier than or equal to end'; } const rows = []; let cursor = new Date(normalizedStart); while (cursor.getTime() <= normalizedEnd.getTime()) { let bucketStart = null; let bucketEnd = null; let label = ''; let key = ''; if (granularity === 'day') { bucketStart = startOfDay(cursor); bucketEnd = endOfDay(cursor); key = formatDateOnly(bucketStart); label = key; } else if (granularity === 'week') { bucketStart = startOfWeek(cursor); bucketEnd = endOfWeek(cursor); key = formatDateOnly(bucketStart); label = `${formatDateOnly(bucketStart)} ~ ${formatDateOnly(bucketEnd)}`; } else { bucketStart = startOfMonth(cursor); bucketEnd = endOfMonth(cursor); key = `${bucketStart.getFullYear()}-${String(bucketStart.getMonth() + 1).padStart(2, '0')}`; label = key; } const clippedStart = bucketStart.getTime() < normalizedStart.getTime() ? new Date(normalizedStart) : bucketStart; const clippedEnd = bucketEnd.getTime() > normalizedEnd.getTime() ? new Date(normalizedEnd) : bucketEnd; rows.push({ key, label, timeStart: formatDateTime(clippedStart), timeEnd: formatDateTime(clippedEnd), }); cursor = addGranularity(bucketStart, granularity); } return rows; }; const normalizeDimensionType = value => { const normalized = ensureNonEmptyString(value, 'dimensionType').toLowerCase(); if (!SUPPORTED_DIMENSION_TYPES.has(normalized)) throw 'invalid param: dimensionType'; return normalized; }; const normalizeTimeGranularity = (value, required = false) => { const normalized = normalizeOptionalString(value); if (!normalized) { if (required) throw 'missing param: timeGranularity'; return null; } if (!SUPPORTED_TIME_GRANULARITIES.has(normalized)) throw 'invalid param: timeGranularity'; return normalized; }; const normalizeBindingType = value => { const normalized = ensureNonEmptyString(value, 'bindingType').toLowerCase(); if (!SUPPORTED_BINDING_TYPES.has(normalized)) throw 'invalid param: bindingType'; return normalized; }; const normalizeValueSource = (value, bindingType) => { const normalized = normalizeOptionalString(value) || 'key'; if (bindingType !== 'filter_eq') return 'key'; if (!SUPPORTED_VALUE_SOURCES.has(normalized)) throw 'invalid param: valueSource'; return normalized; }; const extractBlockQueryConfig = config => { const safeConfig = ensureObjectPayload(config || {}, 'config'); if (safeConfig.query && typeof safeConfig.query === 'object' && !Array.isArray(safeConfig.query)) { return safeConfig.query; } return safeConfig; }; const extractBlockQueryTable = config => { const safeConfig = ensureObjectPayload(config || {}, 'config'); const queryConfig = extractBlockQueryConfig(safeConfig); return String( queryConfig.table || queryConfig.tableId || queryConfig.tableName || safeConfig.table || safeConfig.tableId || safeConfig.tableName || safeConfig.previewQuery?.table || '' ).trim(); }; const getFieldSetForTable = async (ctx, dataSourceId, tableId, transaction = null) => { const payload = await getDataSourceTableColumns(ctx, dataSourceId, tableId, transaction); return new Set((payload.rows || []).map(item => String(item.name || '').trim()).filter(Boolean)); }; const validateDimensionSourceAndFields = async (ctx, template, payload, transaction = null) => { const sourceTable = ensureNonEmptyString(payload.sourceTable, 'sourceTable'); const fieldSet = await getFieldSetForTable(ctx, template.dataSourceId, sourceTable, transaction); const keyField = ensureNonEmptyString(payload.keyField, 'keyField'); if (!fieldSet.has(keyField)) throw `invalid param: keyField (${keyField})`; if (payload.labelField && !fieldSet.has(payload.labelField)) { throw `invalid param: labelField (${payload.labelField})`; } if (payload.timeField && !fieldSet.has(payload.timeField)) { throw `invalid param: timeField (${payload.timeField})`; } return sourceTable; }; const loadTemplateAndDimension = async (ctx, templateId, transaction = null) => { const template = await ensureActiveTemplate(ctx, templateId, transaction); if (!template.dataSourceId) throw '模板未绑定数据源'; const dimension = await getTemplateBatchDimension(ctx, templateId, transaction); return { template, dimension }; }; module.exports.getTemplateBatchDimension = async (ctx, next) => { try { await ensureActiveTemplate(ctx, ctx.params.templateId); const row = await getTemplateBatchDimension(ctx, ctx.params.templateId); ctx.body = row || null; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '获取模板主批量维度失败' }; } }; module.exports.upsertTemplateBatchDimension = 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 body = ensureObjectPayload(ctx.request.body || {}, 'body'); const dimensionType = normalizeDimensionType(body.dimensionType); const timeGranularity = normalizeTimeGranularity( body.timeGranularity, dimensionType === 'time' ); if (dimensionType === 'enum' && normalizeOptionalString(body.timeField)) { throw 'invalid param: timeField'; } if (dimensionType === 'time' && !normalizeOptionalString(body.timeField)) { throw 'missing param: timeField'; } const payload = { name: ensureNonEmptyString(body.name, 'name'), dimensionType, sourceTable: ensureNonEmptyString(body.sourceTable, 'sourceTable'), keyField: ensureNonEmptyString(body.keyField, 'keyField'), labelField: normalizeOptionalString(body.labelField), timeField: normalizeOptionalString(body.timeField), timeGranularity, config: hasOwn(body, 'config') ? ensureObjectPayload(body.config, 'config') : {}, }; payload.sourceTable = await validateDimensionSourceAndFields( ctx, template, payload, transaction ); const existing = await getTemplateBatchDimension(ctx, template.id, transaction); if (existing) { await models.ReportTemplateBatchDimension.update({ ...payload, updatedAt: new Date(), }, { where: { id: existing.id }, transaction, }); } else { await models.ReportTemplateBatchDimension.create({ templateId: template.id, ...payload, updatedAt: new Date(), }, { transaction }); } const fresh = await getTemplateBatchDimension(ctx, template.id, transaction); await transaction.commit(); ctx.body = fresh; ctx.status = 200; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '保存模板主批量维度失败' }; } }; module.exports.deleteTemplateBatchDimension = 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); await models.ReportTemplateBatchDimension.destroy({ where: { templateId: template.id }, transaction, }); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '删除模板主批量维度失败' }; } }; module.exports.getTemplateBlockBatchBinding = async (ctx, next) => { try { await ensureActiveTemplate(ctx, ctx.params.templateId); const row = await getTemplateBlockBatchBinding( ctx, ctx.params.templateId, ctx.params.templateBlockId ); ctx.body = row || null; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '获取模板块批量绑定失败' }; } }; module.exports.upsertTemplateBlockBatchBinding = 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); const dimension = await ensureTemplateBatchDimension(ctx, template.id, transaction); const block = await ensureTemplateBlock( ctx, template.id, ctx.params.templateBlockId, transaction ); const body = ensureObjectPayload(ctx.request.body || {}, 'body'); const bindingType = normalizeBindingType(body.bindingType); if (bindingType === 'filter_eq' && dimension.dimensionType !== 'enum') { throw '枚举型主维度才能使用 filter_eq'; } if (bindingType === 'time_range' && dimension.dimensionType !== 'time') { throw '时间型主维度才能使用 time_range'; } const currentTable = extractBlockQueryTable(block.defaultConfig || {}); if (!currentTable) throw '模板块未配置查询表/数据集'; const targetTable = normalizeOptionalString(body.targetTable) || currentTable; if (targetTable !== currentTable) { throw 'targetTable 必须与模板块当前查询表/数据集一致'; } const targetField = ensureNonEmptyString(body.targetField, 'targetField'); const valueSource = normalizeValueSource(body.valueSource, bindingType); const filterOperator = bindingType === 'filter_eq' ? (normalizeOptionalString(body.filterOperator) || '=') : null; if (bindingType === 'filter_eq' && filterOperator !== '=') { throw 'invalid param: filterOperator'; } const fieldSet = await getFieldSetForTable(ctx, template.dataSourceId, targetTable, transaction); if (!fieldSet.has(targetField)) throw `invalid param: targetField (${targetField})`; const payload = { templateBlockId: block.id, templateBatchDimensionId: dimension.id, bindingType, targetTable, targetField, filterOperator, valueSource, config: hasOwn(body, 'config') ? ensureObjectPayload(body.config, 'config') : {}, updatedAt: new Date(), }; const existing = await getTemplateBlockBatchBinding( ctx, template.id, block.id, transaction ); if (existing) { await models.ReportTemplateBlockBatchBinding.update(payload, { where: { id: existing.id }, transaction, }); } else { await models.ReportTemplateBlockBatchBinding.create(payload, { transaction }); } const fresh = await getTemplateBlockBatchBinding( ctx, template.id, block.id, transaction ); await transaction.commit(); ctx.body = fresh; ctx.status = 200; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '保存模板块批量绑定失败' }; } }; module.exports.deleteTemplateBlockBatchBinding = 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); const block = await ensureTemplateBlock( ctx, template.id, ctx.params.templateBlockId, transaction ); await models.ReportTemplateBlockBatchBinding.destroy({ where: { templateBlockId: block.id }, transaction, }); await transaction.commit(); ctx.status = 204; } catch (error) { await transaction.rollback(); ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '删除模板块批量绑定失败' }; } }; module.exports.previewTemplateBatchDimensionValues = async (ctx, next) => { try { const { template, dimension } = await loadTemplateAndDimension(ctx, ctx.params.templateId); if (!dimension) throw '模板未配置主批量维度'; const body = ensureObjectPayload(ctx.request.body || {}, 'body'); if (dimension.dimensionType === 'time') { const start = parseDateValue(body.start, 'start'); const end = parseDateValue(body.end, 'end'); const granularity = normalizeTimeGranularity( body.granularity || dimension.timeGranularity, true ); ctx.body = { dimensionType: 'time', rows: buildTimePreviewRows(start, end, granularity), }; ctx.status = 200; return; } const { offset, limit, page, pageSize } = parsePagination(body); const keyword = normalizeOptionalString(body.keyword); const keyField = ensureNonEmptyString(dimension.keyField, 'dimension.keyField'); const labelField = normalizeOptionalString(dimension.labelField) || keyField; const previewLimit = page * pageSize; const dsl = { table: dimension.sourceTable, selectFields: [keyField], groupBy: [keyField], limit: previewLimit, }; if (keyField === labelField) { dsl.orderBy = [{ field: keyField, direction: 'ASC' }]; } else { dsl.aggregations = [{ func: 'any', field: labelField, alias: '__dimension_label__', }]; dsl.orderBy = [ { field: '__dimension_label__', direction: 'ASC' }, { field: keyField, direction: 'ASC' }, ]; } if (keyword) { dsl.filters = [{ field: labelField, operator: 'LIKE', value: `%${keyword}%`, }]; } const preview = await previewDataSourceQuery(ctx, template.dataSourceId, dsl); const rows = (preview.rows || []) .slice(offset, offset + limit) .map(item => ({ key: item?.[keyField] == null ? '' : String(item[keyField]), label: item?.[keyField === labelField ? labelField : '__dimension_label__'] == null ? '' : String(item[keyField === labelField ? labelField : '__dimension_label__']), })) .filter(item => item.key); ctx.body = { dimensionType: 'enum', rows, }; ctx.status = 200; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { message: typeof error === 'string' ? error : '预览主批量维度值失败' }; } };