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.
517 lines
18 KiB
517 lines
18 KiB
'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 : '预览主批量维度值失败' };
|
|
}
|
|
};
|
|
|