diff --git a/frontend/src/components/Layout/AppLayout.vue b/frontend/src/components/Layout/AppLayout.vue index bdf9e7a..bfe352f 100644 --- a/frontend/src/components/Layout/AppLayout.vue +++ b/frontend/src/components/Layout/AppLayout.vue @@ -95,19 +95,19 @@ - OLAP Конструктор + OLAP Queries
-

OLAP Конструктор

+

+ {{ queryId ? 'Редактирование запроса' : 'Новый OLAP запрос' }} +

- +
- +

Поля

@@ -28,9 +36,9 @@
Найдено: {{ filteredAvailableFields.length }} / {{ availableFields.length }}
-
...
-
...
-
...
+
Загрузка полей...
+
{{ error }}
+
Нет полей. Сначала инициализируйте структуру в разделе OLAP Columns.
@@ -106,8 +114,15 @@
+ + +
+ + +
+
-
+
@@ -118,56 +133,46 @@
- -
+ +
- +
- +
- +
- -
-
- +
-
- -
- - -
+
- +
- + -
-
+
+ :class="{ 'ring-2 ring-primary-400 bg-primary-50 rounded-lg': dragOverZone === 'filter' }">

Пользовательские фильтры

StringValue
@@ -271,7 +271,7 @@ @dragover.prevent="dragOverZone = 'row'" @dragleave="dragOverZone = null" @drop="dropOnZone('row', $event)" - :class="{'bg-primary-50': dragOverZone === 'row'}"> + :class="{ 'bg-primary-50': dragOverZone === 'row' }">
ROW
+ :class="{ 'bg-primary-50': dragOverZone === 'column' }">
COLUMN
- {{ rf.name }} - {{ cf.name }} + {{ rf.name }} + {{ cf.name }} + :class="{ 'bg-primary-50': dragOverZone === 'value' }">
VALUES
- — - — - — + — + — + —
-
+

Скрипт SQL

@@ -381,7 +381,7 @@

Сброс всех настроек

-

Вы уверены? Все выбранные поля, фильтры настройки будут удалены.

+

Вы уверены? Все выбранные поля, фильтры, настройки будут удалены.

@@ -436,9 +436,7 @@
-
- Рестораны не найдены -
+
Рестораны не найдены
@@ -486,9 +484,7 @@
-
- Подключения не найдены -
+
Подключения не найдены
@@ -503,12 +499,15 @@ @@ -1182,8 +1273,6 @@ onMounted(() => { .fade-leave-to { opacity: 0; } - -/* Стили для скроллбара внутри левой панели */ .overflow-y-auto::-webkit-scrollbar { width: 6px; } diff --git a/frontend/src/views/OlapQueriesPage.vue b/frontend/src/views/OlapQueriesPage.vue new file mode 100644 index 0000000..e9de66f --- /dev/null +++ b/frontend/src/views/OlapQueriesPage.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/main/java/su/xserver/iikocon/MainVerticle.java b/src/main/java/su/xserver/iikocon/MainVerticle.java index d3cc278..3be79e3 100644 --- a/src/main/java/su/xserver/iikocon/MainVerticle.java +++ b/src/main/java/su/xserver/iikocon/MainVerticle.java @@ -8,6 +8,7 @@ import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.buffer.Buffer; import io.vertx.core.http.HttpServer; +import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.Router; import io.vertx.ext.web.RoutingContext; @@ -26,11 +27,13 @@ import su.xserver.iikocon.config.AppConfig; import su.xserver.iikocon.handler.*; import su.xserver.iikocon.iiko.IikoHandler; import su.xserver.iikocon.iiko.IikoOlapClient; +import su.xserver.iikocon.iiko.OlapQueryService; import su.xserver.iikocon.service.*; import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; public class MainVerticle extends AbstractVerticle { @@ -46,6 +49,7 @@ public class MainVerticle extends AbstractVerticle { private RestaurantService restaurantService; private ExternalDataBaseService externalDataBaseService; private SettingsService settingsService; + private OlapQueryService olapQueryService; @Override public void start(Promise startPromise) throws ClassNotFoundException { @@ -75,6 +79,7 @@ public class MainVerticle extends AbstractVerticle { restaurantService = new RestaurantService(db.getPool()); settingsService = new SettingsService(db.getPool()); externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx); + olapQueryService = new OlapQueryService(db.getPool(), externalDataBaseService); userService.initDatabase().onFailure(err -> { log.error("Failed to initialize database", err); @@ -92,6 +97,10 @@ public class MainVerticle extends AbstractVerticle { log.error("Failed to initialize database", err); startPromise.fail(err); }); + olapQueryService.initDatabase().onFailure(err -> { + log.error("Failed to initialize database", err); + startPromise.fail(err); + }); createRouterAndStartHttp(startPromise); @@ -517,6 +526,98 @@ public class MainVerticle extends AbstractVerticle { new IikoHandler(vertx, router, db, restaurantService, authHandler); + + // Роуты для OLAP запросов + router.get("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> { + olapQueryService.getAllQueries() + .onSuccess(queries -> rc.response().putHeader("Content-Type", "application/json").end(queries.encode())) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + + router.get("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> { + int id = Integer.parseInt(rc.pathParam("id")); + olapQueryService.getQueryById(id) + .onSuccess(query -> { + if (query == null) rc.response().setStatusCode(404).end(); + else rc.response().putHeader("Content-Type", "application/json").end(query.encode()); + }) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + + router.post("/api/olap/queries").handler(authHandler::requireAuth).handler(rc -> { + JsonObject body = rc.body().asJsonObject(); + String name = body.getString("name"); + Integer dbConnectionId = body.getInteger("dbConnectionId"); + JsonObject config = body.getJsonObject("config"); + JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray()); + List restaurantIds = restaurantIdsArray.stream().map(id -> (Integer) id).collect(Collectors.toList()); + + if (name == null || dbConnectionId == null || config == null) { + rc.response().setStatusCode(400).end("Missing required fields"); + return; + } + + // Сначала генерируем SQL + olapQueryService.generateSql(config, dbConnectionId) + .compose(sql -> olapQueryService.createQuery(name, dbConnectionId, config, restaurantIds, sql)) + .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())); + }); + + router.put("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> { + int id = Integer.parseInt(rc.pathParam("id")); + JsonObject body = rc.body().asJsonObject(); + String name = body.getString("name"); + Integer dbConnectionId = body.getInteger("dbConnectionId"); + JsonObject config = body.getJsonObject("config"); + JsonArray restaurantIdsArray = body.getJsonArray("restaurantIds", new JsonArray()); + List restaurantIds = restaurantIdsArray.stream().map(v -> (Integer) v).collect(Collectors.toList()); + + if (name == null || dbConnectionId == null || config == null) { + rc.response().setStatusCode(400).end("Missing required fields"); + return; + } + + olapQueryService.generateSql(config, dbConnectionId) + .compose(sql -> olapQueryService.updateQuery(id, name, dbConnectionId, config, restaurantIds, sql)) + .onSuccess(v -> rc.response().end()) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + + router.delete("/api/olap/queries/:id").handler(authHandler::requireAuth).handler(rc -> { + int id = Integer.parseInt(rc.pathParam("id")); + olapQueryService.deleteQuery(id) + .onSuccess(v -> rc.response().end()) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + + router.post("/api/olap/generate-sql").handler(authHandler::requireAuth).handler(rc -> { + JsonObject body = rc.body().asJsonObject(); + JsonObject config = body.getJsonObject("config"); + Integer dbConnectionId = body.getInteger("dbConnectionId"); + if (config == null || dbConnectionId == null) { + rc.response().setStatusCode(400).end("Missing config or dbConnectionId"); + return; + } + olapQueryService.generateSql(config, dbConnectionId) + .onSuccess(sql -> rc.response().putHeader("Content-Type", "text/plain").end(sql)) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + + router.post("/api/olap/export-json").handler(authHandler::requireAuth).handler(rc -> { + JsonObject body = rc.body().asJsonObject(); + JsonObject config = body.getJsonObject("config"); + if (config == null) { + rc.response().setStatusCode(400).end("Missing config"); + return; + } + JsonObject fullJson = olapQueryService.generateFullIikoJson(config); + rc.response() + .putHeader("Content-Type", "application/json") + .putHeader("Content-Disposition", "attachment; filename=olap_export.json") + .end(fullJson.encodePrettily()); + }); + return router; } diff --git a/src/main/java/su/xserver/iikocon/iiko/OlapQueryService.java b/src/main/java/su/xserver/iikocon/iiko/OlapQueryService.java new file mode 100644 index 0000000..f219d0f --- /dev/null +++ b/src/main/java/su/xserver/iikocon/iiko/OlapQueryService.java @@ -0,0 +1,267 @@ +package su.xserver.iikocon.iiko; + +import io.vertx.core.Future; +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.templates.SqlTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import su.xserver.iikocon.service.ExternalDataBaseService; + +import java.util.*; + +public class OlapQueryService { + private static final Logger log = LoggerFactory.getLogger(OlapQueryService.class); + private final Pool pool; + private final ExternalDataBaseService externalDataBaseService; + private final SqlGenerator sqlGenerator; + + public OlapQueryService(Pool pool, ExternalDataBaseService externalDataBaseService) { + this.pool = pool; + this.externalDataBaseService = externalDataBaseService; + this.sqlGenerator = new SqlGenerator(); + } + + public Future initDatabase() { + String createQueriesTable = """ + CREATE TABLE IF NOT EXISTS olap_queries ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + db_connection_id INT NOT NULL, + config_json JSON NOT NULL, + full_config_json JSON NOT NULL, + sql_text TEXT, + 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 + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """; + + String createQueryRestaurantsTable = """ + CREATE TABLE IF NOT EXISTS olap_query_restaurants ( + query_id INT NOT NULL, + restaurant_id INT NOT NULL, + PRIMARY KEY (query_id, restaurant_id), + FOREIGN KEY (query_id) REFERENCES olap_queries(id) ON DELETE CASCADE, + FOREIGN KEY (restaurant_id) REFERENCES restaurants(id) ON DELETE CASCADE + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """; + + return pool.query(createQueriesTable).execute() + .compose(v -> pool.query(createQueryRestaurantsTable).execute()) + .mapEmpty(); + } + + // Создание запроса + public Future createQuery(String name, int dbConnectionId, JsonObject config, + List restaurantIds, String generatedSql) { + JsonObject fullConfig = generateFullIikoJson(config); + Map params = Map.of( + "name", name, + "db_connection_id", dbConnectionId, + "config_json", config.encode(), + "full_config_json", fullConfig.encode(), + "sql_text", generatedSql != null ? generatedSql : "" + ); + 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})"; + return SqlTemplate.forUpdate(pool, sql) + .execute(params) + .compose(rows -> getLastInsertId()) + .compose(queryId -> linkRestaurants(queryId, restaurantIds).map(queryId)); + } + + public Future updateQuery(int id, String name, int dbConnectionId, JsonObject config, + List restaurantIds, String generatedSql) { + JsonObject fullConfig = generateFullIikoJson(config); + Map params = Map.of( + "id", id, + "name", name, + "db_connection_id", dbConnectionId, + "config_json", config.encode(), + "full_config_json", fullConfig.encode(), + "sql_text", generatedSql != null ? generatedSql : "" + ); + 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}"; + 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(); + } + + public JsonObject generateFullIikoJson(JsonObject clientConfig) { + String reportType = clientConfig.getString("reportType", "SALES"); + boolean buildSummary = clientConfig.getBoolean("buildSummary", false); + String dateToStr = clientConfig.getString("dateTo", ""); + int daysBack = clientConfig.getInteger("daysBack", 7); + JsonArray rowFields = clientConfig.getJsonArray("rowFields", new JsonArray()); + JsonArray columnFields = clientConfig.getJsonArray("columnFields", new JsonArray()); + JsonArray valueFields = clientConfig.getJsonArray("valueFields", new JsonArray()); + JsonArray filterFields = clientConfig.getJsonArray("filterFields", new JsonArray()); + + // Пользовательские фильтры + JsonObject userFilters = new JsonObject(); + for (Object fObj : filterFields) { + JsonObject f = (JsonObject) fObj; + String fieldKey = f.getString("fieldKey"); + String filterType = f.getString("filterType"); + JsonObject filterDef = new JsonObject().put("filterType", filterType); + if ("IncludeValues".equals(filterType) || "ExcludeValues".equals(filterType)) { + filterDef.put("values", f.getJsonArray("values", new JsonArray())); + } else if ("EnumValue".equals(filterType)) { + filterDef.put("enumKey", f.getString("enumKey", "")); + filterDef.put("enumValue", f.getString("enumValue", "")); + } else if ("StringValue".equals(filterType)) { + filterDef.put("value", f.getString("value", "")); + } + userFilters.put(fieldKey, filterDef); + } + + // Фильтр дат + JsonObject dateFilter = buildDateFilter(reportType, dateToStr, daysBack); + // Системные фильтры + JsonObject systemFilters = new JsonObject() + .put("DeletedWithWriteoff", new JsonObject() + .put("filterType", "ExcludeValues") + .put("values", new JsonArray().add("DELETED_WITH_WRITEOFF").add("DELETED_WITHOUT_WRITEOFF"))) + .put("OrderDeleted", new JsonObject() + .put("filterType", "IncludeValues") + .put("values", new JsonArray().add("NOT_DELETED"))); + + JsonObject allFilters = new JsonObject(); + // Добавляем пользовательские + userFilters.forEach(entry -> allFilters.put(entry.getKey(), entry.getValue())); + // Добавляем дату + allFilters.mergeIn(dateFilter); + // Добавляем системные + allFilters.mergeIn(systemFilters); + + JsonObject result = 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) { + // Определяем корректную дату "до" (конец дня) + java.time.ZonedDateTime toDate; + if (dateToStr != null && !dateToStr.isEmpty()) { + toDate = java.time.LocalDate.parse(dateToStr).atStartOfDay(java.time.ZoneOffset.UTC); + } else { + toDate = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC); + } + toDate = toDate.withHour(23).withMinute(59).withSecond(59).withNano(999_999_999); + java.time.ZonedDateTime fromDate = toDate.minusDays(Math.max(1, daysBack)) + .withHour(0).withMinute(0).withSecond(0).withNano(0); + + String filterKey = "TRANSACTIONS".equals(reportType) ? "DateTime.DateTyped" : "OpenDate.Typed"; + return new JsonObject().put(filterKey, new JsonObject() + .put("filterType", "DateRange") + .put("periodType", "CUSTOM") + .put("from", fromDate.toString()) + .put("to", toDate.toString())); + } + + private Future getLastInsertId() { + return pool.query("SELECT LAST_INSERT_ID() AS id").execute() + .map(rows -> rows.iterator().next().getInteger("id")); + } + + private Future linkRestaurants(int queryId, List restaurantIds) { + if (restaurantIds == null || restaurantIds.isEmpty()) return Future.succeededFuture(); + List> futures = new ArrayList<>(); + for (Integer restId : restaurantIds) { + Map params = Map.of("query_id", queryId, "restaurant_id", restId); + futures.add(SqlTemplate.forUpdate(pool, + "INSERT INTO olap_query_restaurants (query_id, restaurant_id) VALUES (#{query_id}, #{restaurant_id})") + .execute(params).mapEmpty()); + } + return Future.all(futures).mapEmpty(); + } + + // Удаление + public Future deleteQuery(int id) { + return SqlTemplate.forUpdate(pool, "DELETE FROM olap_queries WHERE id = #{id}") + .execute(Map.of("id", id)).mapEmpty(); + } + + // Получить все запросы (без config_json, для списка) + public Future 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 + """; + 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)); + }); + return arr; + }); + } + + // Получить один запрос с полной конфигурацией + public Future getQueryById(int id) { + String querySql = "SELECT id, name, db_connection_id, config_json, sql_text, 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)) + .compose(rows -> { + if (rows.size() == 0) return Future.succeededFuture(null); + Row row = rows.iterator().next(); + JsonObject result = new JsonObject() + .put("id", row.getInteger("id")) + .put("name", row.getString("name")) + .put("dbConnectionId", row.getInteger("db_connection_id")) + .put("config", new JsonObject(row.getString("config_json"))) + .put("sql", row.getString("sql_text")) + .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) + .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null); + + return pool.preparedQuery(restaurantsSql).execute(io.vertx.sqlclient.Tuple.of(id)) + .map(restRows -> { + JsonArray restIds = new JsonArray(); + restRows.forEach(restRow -> restIds.add(restRow.getInteger("restaurant_id"))); + result.put("restaurantIds", restIds); + return result; + }); + }); + } + + // Генерация SQL на основе конфигурации и ID подключения + public Future generateSql(JsonObject config, int dbConnectionId) { + return externalDataBaseService.findById(dbConnectionId) + .compose(conn -> { + if (conn == null) return Future.failedFuture("Database connection not found"); + String dbType = conn.getString("type"); + try { + String sql = sqlGenerator.generate(config, dbType); + return Future.succeededFuture(sql); + } catch (Exception e) { + return Future.failedFuture("SQL generation failed: " + e.getMessage()); + } + }); + } +} diff --git a/src/main/java/su/xserver/iikocon/iiko/SqlGenerator.java b/src/main/java/su/xserver/iikocon/iiko/SqlGenerator.java new file mode 100644 index 0000000..97417f7 --- /dev/null +++ b/src/main/java/su/xserver/iikocon/iiko/SqlGenerator.java @@ -0,0 +1,108 @@ +package su.xserver.iikocon.iiko; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class SqlGenerator { + + public String generate(JsonObject config, String dbType) { + String reportType = config.getString("reportType", "SALES"); + String dateCol = reportType.equals("TRANSACTIONS") ? "DateTime_DateTyped" : "OpenDate_Typed"; + String tableName = sanitizeTableName(config.getString("tableName", "olap_table")); + boolean buildSummary = config.getBoolean("buildSummary", false); + String dateTo = config.getString("dateTo", ""); + int daysBack = config.getInteger("daysBack", 7); + JsonArray rowFields = config.getJsonArray("rowFields", new JsonArray()); + JsonArray columnFields = config.getJsonArray("columnFields", new JsonArray()); + JsonArray valueFields = config.getJsonArray("valueFields", new JsonArray()); + JsonArray filterFields = config.getJsonArray("filterFields", new JsonArray()); + + // Собираем список всех столбцов + List allColumns = new ArrayList<>(); + allColumns.add(dateCol); + rowFields.forEach(f -> allColumns.add(f.toString())); + columnFields.forEach(f -> allColumns.add(f.toString())); + if (valueFields.isEmpty()) { + allColumns.add("dummy"); + } else { + valueFields.forEach(vf -> allColumns.add(vf.toString())); + } + + // Формируем CREATE TABLE + String createTable = buildCreateTable(tableName, allColumns, dateCol, dbType); + + // Формируем INSERT + String insert = buildInsert(tableName, allColumns, dateCol, dateTo, daysBack, dbType); + + // Если нужно SUMMARY, добавим позже (пока пропустим, можно расширить) + return createTable + "\n\n" + insert; + } + + private String buildCreateTable(String tableName, List columns, String dateCol, String dbType) { + StringBuilder sb = new StringBuilder("CREATE TABLE IF NOT EXISTS "); + if (dbType.equals("mysql")) { + sb.append("`").append(tableName).append("` (\n"); + for (String col : columns) { + sb.append(" `").append(col).append("` ").append(getColumnType(col, dbType)).append(",\n"); + } + sb.append(" PRIMARY KEY (`").append(dateCol).append("`)\n"); + sb.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); + } else if (dbType.equals("postgres")) { + sb.append("\"").append(tableName).append("\" (\n"); + for (String col : columns) { + sb.append(" \"").append(col).append("\" ").append(getColumnType(col, dbType)).append(",\n"); + } + sb.append(" PRIMARY KEY (\"").append(dateCol).append("\")\n"); + sb.append(");"); + } else { // ClickHouse + sb.append("`default`.`").append(tableName).append("` (\n"); + for (String col : columns) { + sb.append(" `").append(col).append("` ").append(getColumnType(col, dbType)).append(",\n"); + } + sb.append(") ENGINE = ReplacingMergeTree()\n"); + sb.append("ORDER BY (`").append(dateCol).append("`)\n"); + sb.append("SETTINGS index_granularity = 8192;"); + } + return sb.toString(); + } + + private String buildInsert(String tableName, List columns, String dateCol, + String dateTo, int daysBack, String dbType) { + StringBuilder sb = new StringBuilder("INSERT INTO "); + if (dbType.equals("mysql")) { + sb.append("`").append(tableName).append("` ("); + } else if (dbType.equals("postgres")) { + sb.append("\"").append(tableName).append("\" ("); + } else { + sb.append("`default`.`").append(tableName).append("` ("); + } + String columnNames = columns.stream() + .map(c -> dbType.equals("postgres") ? "\"" + c + "\"" : "`" + c + "`") + .collect(Collectors.joining(", ")); + sb.append(columnNames).append(") VALUES\n"); + // Здесь должна быть реальная выборка из iiko, но для демонстрации – заглушка + sb.append("-- Здесь будет реальный SELECT из источника данных\n"); + sb.append("-- (например, из временной таблицы или прямого запроса к iiko API)"); + return sb.toString(); + } + + private String getColumnType(String column, String dbType) { + if (column.equalsIgnoreCase("dummy")) return "String"; + if (column.contains("Date")) { + if (dbType.equals("mysql")) return "DATE"; + if (dbType.equals("postgres")) return "DATE"; + return "Date"; + } + if (dbType.equals("clickhouse")) return "String"; + return "VARCHAR(255)"; + } + + private String sanitizeTableName(String name) { + if (name == null || name.trim().isEmpty()) return "olap_table"; + return name.replaceAll("[^a-zA-Z0-9_]", "_"); + } +}