This commit is contained in:
2026-05-07 17:36:03 +03:00
parent 096fb1a3e2
commit 59e283945c
5 changed files with 126 additions and 49 deletions

View File

@@ -66,11 +66,6 @@ const routes = [
component: AdminSettings,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
},
// {
// path: '/olap-constructor',
// component: OLAPConstructor,
// meta: { requiresAuth: true, title: 'OLAP Constructor' }
// },
{
path: '/olap/queries',
component: OlapQueriesPage,

View File

@@ -135,7 +135,7 @@
<!-- Таблица SQL -->
<div class="flex flex-col">
<label class="text-xs text-gray-500">Таблица SQL</label>
<label class="text-xs text-gray-500">Таблица SQL *</label>
<input type="text" v-model="tableName" @input="validateTableName" class="input-field py-1 text-sm"
:class="{ 'border-green-500 bg-green-50': tableNameValid && tableName, 'border-red-500 bg-red-50': tableNameTouched && !tableNameValid && tableName }" />
</div>
@@ -581,7 +581,7 @@ const daysBack = ref(7)
const tableName = ref('')
const tableNameValid = ref(true)
const tableNameTouched = ref(false)
const active = ref(true)
const active = ref(false)
const searchQuery = ref('')
const activeTab = ref<'table' | 'sql'>('table')
const collapsed = ref({ number: false, category: false, filter: false })
@@ -874,6 +874,8 @@ async function loadQuery(id: number) {
// 1. Базовые поля запроса
queryName.value = data.name
active.value = data.active ?? true;
// 2. Подключение к БД
if (data.dbConnectionId) {
await loadDbConnections()
@@ -974,13 +976,18 @@ async function saveQuery() {
showNotification('Выберите хотя бы один ресторан', 'error')
return
}
if (!tableName.value.trim()) {
showNotification('Укажите название таблицы SQL', 'error');
return;
}
const config = buildConfigObject()
const payload = {
name: queryName.value,
dbConnectionId: selectedDbConnection.value.id,
config: config,
restaurantIds: selectedRestaurants.value.map(r => r.id)
}
restaurantIds: selectedRestaurants.value.map(r => r.id),
active: active.value
};
const url = queryId.value ? `/api/olap/queries/${queryId.value}` : '/api/olap/queries'
const method = queryId.value ? 'PUT' : 'POST'
try {
@@ -1011,9 +1018,17 @@ const filteredCategoryFields = computed(() => availableFields.value.filter((f):
const filteredFilterFields = computed(() => availableFields.value.filter((f): f is FilterField => f.role === 'filter' && matchesSearch(f)))
const validateTableName = () => {
tableNameTouched.value = true
if (!tableName.value) { tableNameValid.value = true; return }
tableNameValid.value = /^[A-Za-zА-Яа-я]/.test(tableName.value)
tableNameTouched.value = true;
const value = tableName.value.trim();
if (!value) {
tableNameValid.value = false;
return;
}
// Регулярное выражение:
// ^[A-Za-z] - первая буква (только английская)
// [A-Za-z0-9]* - далее любые английские буквы или цифры
const regex = /^[A-Za-z][A-Za-z0-9]*$/;
tableNameValid.value = regex.test(value);
}
const parseValues = (f: FilterField) => {
@@ -1184,7 +1199,7 @@ const confirmReset = () => {
refreshFieldsAndReset()
dateTo.value = ''
daysBack.value = 7
active.value = true
active.value = false
tableName.value = ''
tableNameValid.value = true
tableNameTouched.value = false

View File

@@ -12,6 +12,9 @@
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Название</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Активен</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Последнее выполнение</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Результат</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Подключение</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Рестораны</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500">Создан</th>
@@ -22,6 +25,17 @@
<tr v-for="q in queries" :key="q.id" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm">{{ q.id }}</td>
<td class="px-6 py-4 text-sm font-medium">{{ q.name }}</td>
<td class="px-6 py-4 text-sm">
<span :class="q.active ? 'text-green-600' : 'text-red-600'">
{{ q.active ? 'Да' : 'Нет' }}
</span>
</td>
<td class="px-6 py-4 text-sm">{{ q.lastRun ? formatDate(q.lastRun) : '—' }}</td>
<td class="px-6 py-4 text-sm">
<span v-if="q.lastRunSuccess === null"></span>
<span v-else-if="q.lastRunSuccess" class="text-green-600">Успешно</span>
<span v-else class="text-red-600">Ошибка</span>
</td>
<td class="px-6 py-4 text-sm">{{ q.dbConnectionName }}</td>
<td class="px-6 py-4 text-sm">{{ q.restaurants }}</td>
<td class="px-6 py-4 text-sm">{{ formatDate(q.created) }}</td>

View File

@@ -552,14 +552,31 @@ public class MainVerticle extends AbstractVerticle {
JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray());
List<Integer> restaurantIds = restaurantIdsArray.stream().map(id -> (Integer) id).collect(Collectors.toList());
// Получаем active: сначала из тела, иначе из конфига, иначе true
Boolean active = body.getBoolean("active");
if (active == null && config != null) active = config.getBoolean("active", true);
if (active == null) active = true;
if (name == null || dbConnectionId == null || config == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
// Сначала генерируем SQL
String tableName = config.getString("tableName");
if (tableName.isEmpty()) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
if (!olapQueryService.isValidTableName(tableName)) {
rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits");
return;
}
Boolean finalActive = active;
olapQueryService.generateSql(config, dbConnectionId)
.compose(sql -> olapQueryService.createQuery(name, dbConnectionId, config, restaurantIds, sql))
.compose(sql -> olapQueryService.createQuery(name, dbConnectionId, config, restaurantIds, sql, finalActive))
.onSuccess(id -> rc.response().setStatusCode(201).putHeader("Content-Type", "application/json").end(new JsonObject().put("id", id).encode()))
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});
@@ -573,13 +590,30 @@ public class MainVerticle extends AbstractVerticle {
JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray());
List<Integer> restaurantIds = restaurantIdsArray.stream().map(v -> (Integer) v).collect(Collectors.toList());
Boolean active = body.getBoolean("active");
if (active == null && config != null) active = config.getBoolean("active", true);
if (active == null) active = true;
if (name == null || dbConnectionId == null || config == null) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
String tableName = config.getString("tableName");
if (tableName.isEmpty()) {
rc.response().setStatusCode(400).end("Missing required fields");
return;
}
if (!olapQueryService.isValidTableName(tableName)) {
rc.response().setStatusCode(400).end("Invalid tableName: must start with a letter and contain only letters and digits");
return;
}
Boolean finalActive = active;
olapQueryService.generateSql(config, dbConnectionId)
.compose(sql -> olapQueryService.updateQuery(id, name, dbConnectionId, config, restaurantIds, sql))
.compose(sql -> olapQueryService.updateQuery(id, name, dbConnectionId, config, restaurantIds, sql, finalActive))
.onSuccess(v -> rc.response().end())
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
});

View File

@@ -5,6 +5,7 @@ import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import io.vertx.sqlclient.templates.SqlTemplate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -33,6 +34,9 @@ public class OlapQueryService {
config_json JSON NOT NULL,
full_config_json JSON NOT NULL,
sql_text TEXT,
active BOOLEAN NOT NULL DEFAULT true,
last_run TIMESTAMP NULL,
last_run_success BOOLEAN NULL,
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (db_connection_id) REFERENCES external_database(id) ON DELETE RESTRICT
@@ -56,16 +60,17 @@ public class OlapQueryService {
// Создание запроса
public Future<Integer> createQuery(String name, int dbConnectionId, JsonObject config,
List<Integer> restaurantIds, String generatedSql) {
List<Integer> restaurantIds, String generatedSql, boolean active) {
JsonObject fullConfig = generateFullIikoJson(config);
Map<String, Object> params = Map.of(
"name", name,
"db_connection_id", dbConnectionId,
"config_json", config.encode(),
"full_config_json", fullConfig.encode(),
"sql_text", generatedSql != null ? generatedSql : ""
"sql_text", generatedSql != null ? generatedSql : "",
"active", active
);
String sql = "INSERT INTO olap_queries (name, db_connection_id, config_json, full_config_json, sql_text) VALUES (#{name}, #{db_connection_id}, #{config_json}, #{full_config_json}, #{sql_text})";
String sql = "INSERT INTO olap_queries (name, db_connection_id, config_json, full_config_json, sql_text, active) VALUES (#{name}, #{db_connection_id}, #{config_json}, #{full_config_json}, #{sql_text}, #{active})";
return SqlTemplate.forUpdate(pool, sql)
.execute(params)
.compose(rows -> getLastInsertId())
@@ -73,7 +78,7 @@ public class OlapQueryService {
}
public Future<Void> updateQuery(int id, String name, int dbConnectionId, JsonObject config,
List<Integer> restaurantIds, String generatedSql) {
List<Integer> restaurantIds, String generatedSql, boolean active) {
JsonObject fullConfig = generateFullIikoJson(config);
Map<String, Object> params = Map.of(
"id", id,
@@ -81,15 +86,14 @@ public class OlapQueryService {
"db_connection_id", dbConnectionId,
"config_json", config.encode(),
"full_config_json", fullConfig.encode(),
"sql_text", generatedSql != null ? generatedSql : ""
"sql_text", generatedSql != null ? generatedSql : "",
"active", active
);
String sql = "UPDATE olap_queries SET name = #{name}, db_connection_id = #{db_connection_id}, config_json = #{config_json}, full_config_json = #{full_config_json}, sql_text = #{sql_text} WHERE id = #{id}";
String sql = "UPDATE olap_queries SET name = #{name}, db_connection_id = #{db_connection_id}, config_json = #{config_json}, full_config_json = #{full_config_json}, sql_text = #{sql_text}, active = #{active} WHERE id = #{id}";
return SqlTemplate.forUpdate(pool, sql)
.execute(params)
.compose(v -> {
return pool.query("DELETE FROM olap_query_restaurants WHERE query_id = " + id).execute()
.compose(del -> linkRestaurants(id, restaurantIds));
}).mapEmpty();
.compose(v -> pool.query("DELETE FROM olap_query_restaurants WHERE query_id = " + id).execute()
.compose(del -> linkRestaurants(id, restaurantIds))).mapEmpty();
}
public JsonObject generateFullIikoJson(JsonObject clientConfig) {
@@ -139,14 +143,13 @@ public class OlapQueryService {
// Добавляем системные
allFilters.mergeIn(systemFilters);
JsonObject result = new JsonObject()
return new JsonObject()
.put("reportType", reportType)
.put("buildSummary", buildSummary)
.put("groupByRowFields", rowFields)
.put("groupByColFields", columnFields)
.put("aggregateFields", valueFields)
.put("filters", allFilters);
return result;
}
private JsonObject buildDateFilter(String reportType, String dateToStr, int daysBack) {
@@ -195,36 +198,38 @@ public class OlapQueryService {
// Получить все запросы (без config_json, для списка)
public Future<JsonArray> getAllQueries() {
String sql = """
SELECT q.id, q.name, q.db_connection_id, q.created, q.updated,
GROUP_CONCAT(r.name SEPARATOR ', ') AS restaurants,
dc.name AS db_connection_name
FROM olap_queries q
LEFT JOIN olap_query_restaurants qr ON q.id = qr.query_id
LEFT JOIN restaurants r ON qr.restaurant_id = r.id
LEFT JOIN external_database dc ON q.db_connection_id = dc.id
GROUP BY q.id
ORDER BY q.id DESC
""";
SELECT q.id, q.name, q.db_connection_id, q.active, q.last_run, q.last_run_success, q.created, q.updated,
GROUP_CONCAT(r.name SEPARATOR ', ') AS restaurants,
dc.name AS db_connection_name
FROM olap_queries q
LEFT JOIN olap_query_restaurants qr ON q.id = qr.query_id
LEFT JOIN restaurants r ON qr.restaurant_id = r.id
LEFT JOIN external_database dc ON q.db_connection_id = dc.id
GROUP BY q.id
ORDER BY q.id DESC
""";
return pool.query(sql).execute()
.map(rows -> {
JsonArray arr = new JsonArray();
rows.forEach(row -> {
arr.add(new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("dbConnectionId", row.getInteger("db_connection_id"))
.put("dbConnectionName", row.getString("db_connection_name"))
.put("restaurants", row.getString("restaurants") != null ? row.getString("restaurants") : "")
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null));
});
rows.forEach(row -> arr.add(new JsonObject()
.put("id", row.getInteger("id"))
.put("name", row.getString("name"))
.put("dbConnectionId", row.getInteger("db_connection_id"))
.put("dbConnectionName", row.getString("db_connection_name"))
.put("restaurants", row.getString("restaurants") != null ? row.getString("restaurants") : "")
.put("active", row.getBoolean("active"))
.put("lastRun", row.getLocalDateTime("last_run") != null ? row.getLocalDateTime("last_run").toString() : null)
.put("lastRunSuccess", row.getBoolean("last_run_success"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null)));
return arr;
});
}
// Получить один запрос с полной конфигурацией
public Future<JsonObject> getQueryById(int id) {
String querySql = "SELECT id, name, db_connection_id, config_json, sql_text, created, updated FROM olap_queries WHERE id = ?";
String querySql = "SELECT id, name, db_connection_id, config_json, sql_text, active, created, updated FROM olap_queries WHERE id = ?";
String restaurantsSql = "SELECT restaurant_id FROM olap_query_restaurants WHERE query_id = ?";
return pool.preparedQuery(querySql).execute(io.vertx.sqlclient.Tuple.of(id))
@@ -237,6 +242,7 @@ public class OlapQueryService {
.put("dbConnectionId", row.getInteger("db_connection_id"))
.put("config", new JsonObject(row.getString("config_json")))
.put("sql", row.getString("sql_text"))
.put("active", row.getBoolean("active"))
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null);
@@ -250,6 +256,12 @@ public class OlapQueryService {
});
}
// Метод для обновления статуса выполнения
public Future<Void> updateRunStatus(int queryId, boolean success) {
String sql = "UPDATE olap_queries SET last_run = NOW(), last_run_success = ? WHERE id = ?";
return pool.preparedQuery(sql).execute(Tuple.of(success, queryId)).mapEmpty();
}
// Генерация SQL на основе конфигурации и ID подключения
public Future<String> generateSql(JsonObject config, int dbConnectionId) {
return externalDataBaseService.findById(dbConnectionId)
@@ -264,4 +276,11 @@ public class OlapQueryService {
}
});
}
public boolean isValidTableName(String tableName) {
if (tableName == null) return false;
String trimmed = tableName.trim();
// Первый символ — английская буква, далее буквы или цифры
return trimmed.matches("^[A-Za-z][A-Za-z0-9]*$");
}
}