diff --git a/src/main/java/su/xserver/iikocon/test/IikoOlapClient.java b/src/main/java/su/xserver/iikocon/test/IikoOlapClient.java new file mode 100644 index 0000000..466a43a --- /dev/null +++ b/src/main/java/su/xserver/iikocon/test/IikoOlapClient.java @@ -0,0 +1,112 @@ +package su.xserver.iikocon.test; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClient; + +public class IikoOlapClient { + + private final WebClient webClient; + private final String baseUrl; + private final String login; + private final String password; + + // Конструктор клиента + public IikoOlapClient(Vertx vertx, String baseUrl, String login, String password) { + this.webClient = WebClient.create(vertx); + this.baseUrl = baseUrl; + this.login = login; + this.password = password; + } + + // Основной метод для получения OLAP-отчета + public Future getOlapReport(JsonObject reportRequest) { + Promise promise = Promise.promise(); + + // 1. Аутентификация + authenticate() + .compose(this::getOrganizations) // 2. Получение организаций + .compose(orgId -> executeReport(reportRequest, orgId)) // 3. Запрос отчета + .onSuccess(promise::complete) + .onFailure(promise::fail); + + return promise.future(); + } + + // Аутентификация и получение токена + private Future authenticate() { + Promise promise = Promise.promise(); + + JsonObject authRequest = new JsonObject() + .put("login", login) + .put("password", password); + + webClient.post(443, baseUrl, "/resto/api/auth") + .ssl(true) + .putHeader("Content-Type", "application/json") + .sendJson(authRequest) + .onSuccess(response -> { + if (response.statusCode() == 200) { + String token = response.bodyAsJsonObject().getString("token"); + promise.complete(token); + } else { + promise.fail("Authentication failed: " + response.statusMessage()); + } + }) + .onFailure(promise::fail); + + return promise.future(); + } + + // Получение ID организации (для отчета) + private Future getOrganizations(String token) { + Promise promise = Promise.promise(); + + webClient.get(443, baseUrl, "/resto/api/organizations") + .ssl(true) + .putHeader("Authorization", "Bearer " + token) + .send() + .onSuccess(response -> { + if (response.statusCode() == 200) { + // Берем ID первой организации из списка + String orgId = response.bodyAsJsonArray() + .getJsonObject(0) + .getString("id"); + promise.complete(orgId); + } else { + promise.fail("Failed to get organizations: " + response.statusMessage()); + } + }) + .onFailure(promise::fail); + + return promise.future(); + } + + // Выполнение запроса OLAP-отчета + private Future executeReport(JsonObject reportRequest, String organizationId) { + Promise promise = Promise.promise(); + + // Добавляем ID организации в тело запроса + JsonObject fullRequest = reportRequest.copy() + .put("organizationId", organizationId); + + webClient.post(443, baseUrl, "/resto/api/v2/reports/olap") + .ssl(true) + .putHeader("Content-Type", "application/json") + .sendJson(fullRequest) + .onSuccess(response -> { + if (response.statusCode() == 200) { + promise.complete(response.bodyAsJsonObject()); + } else { + promise.fail("OLAP report request failed: " + response.statusMessage()); + } + }) + .onFailure(promise::fail); + + return promise.future(); + } +} diff --git a/src/main/java/su/xserver/iikocon/test/IikoOlapColumnsImporter.java b/src/main/java/su/xserver/iikocon/test/IikoOlapColumnsImporter.java new file mode 100644 index 0000000..c503d55 --- /dev/null +++ b/src/main/java/su/xserver/iikocon/test/IikoOlapColumnsImporter.java @@ -0,0 +1,375 @@ +package su.xserver.iikocon.test; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.mysqlclient.MySQLConnectOptions; +import io.vertx.sqlclient.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +public class IikoOlapColumnsImporter { + + private static final Logger log = LoggerFactory.getLogger(IikoOlapColumnsImporter.class); + private final WebClient httpClient; + private final Pool dbPool; + private final String iikoServer; + private final String iikoLogin; + private final String iikoPassword; + private static long time; + + private static final List REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES"); + + public IikoOlapColumnsImporter(Vertx vertx, + String iikoServer, + String iikoLogin, + String iikoPassword, + String dbHost, int dbPort, + String dbName, String dbUser, String dbPassword) { + WebClientOptions options = new WebClientOptions() + .setSsl(true) + .setTrustAll(true) + .setVerifyHost(false); + this.httpClient = WebClient.create(vertx, options); + this.iikoServer = iikoServer; + this.iikoLogin = iikoLogin; + this.iikoPassword = iikoPassword; + + MySQLConnectOptions connectOptions = new MySQLConnectOptions() + .setHost(dbHost) + .setPort(dbPort) + .setDatabase(dbName) + .setUser(dbUser) + .setPassword(dbPassword) + .setCharset("utf8mb4"); + PoolOptions poolOptions = new PoolOptions().setMaxSize(5); + this.dbPool = Pool.pool(vertx, connectOptions, poolOptions); + } + + // Главный метод: последовательно для каждого reportType делаем auth -> fetch -> store -> logout + public Future fetchAndStoreAll() { + return createTablesIfNotExist() + .compose(v -> processAllReportTypesSequentially()) + .onSuccess(v -> log.info("All reports imported successfully")) + .onFailure(err -> log.error("Import failed: {}", err.getMessage())); + } + + private Future processAllReportTypesSequentially() { + time = System.currentTimeMillis(); + Future result = Future.succeededFuture(); + for (String reportType : REPORT_TYPES) { + result = result.compose(v -> processOneReportType(reportType)); + } + return result; + } + + private Future processOneReportType(String reportType) { + log.info("Processing report type: {}", reportType); + return authenticate() + .compose(token -> { + return fetchColumnsFromIiko(reportType, token) + .compose(columnsJson -> storeColumnsToDb(reportType, columnsJson)) + .onComplete(ignored -> logout(token)); // logout всегда, даже при ошибке + }); + } + + // Аутентификация: GET /resto/api/auth?login=...&pass=SHA1 + private Future authenticate() { + Promise promise = Promise.promise(); + String passHash = sha1(iikoPassword); + String url = "https://" + iikoServer + ":443/resto/api/auth?login=" + iikoLogin + "&pass=" + passHash; + + httpClient.getAbs(url) + .send() + .onSuccess(resp -> { + if (resp.statusCode() == 200) { + String token = resp.bodyAsString(); + log.info("Authenticated, token: {}", token); + promise.complete(token); + } else { + promise.fail("Auth failed for " + iikoLogin + ": " + resp.statusCode()); + } + }) + .onFailure(promise::fail); + return promise.future(); + } + + // Logout: GET /resto/api/logout?key=токен + private Future logout(String token) { + if (token == null || token.isEmpty()) { + return Future.succeededFuture(); + } + Promise promise = Promise.promise(); + String url = "https://" + iikoServer + "/resto/api/logout?key=" + token; + httpClient.getAbs(url) + .send() + .onSuccess(resp -> { + log.info("Logout completed for token, status {}", resp.statusCode()); + promise.complete(); + }) + .onFailure(err -> { + log.error("Logout request failed: {}", err.getMessage()); + promise.complete(); // не ломаем цепочку + }); + return promise.future(); + } + + // Запрос полей для конкретного reportType + private Future fetchColumnsFromIiko(String reportType, String token) { + + Promise promise = Promise.promise(); + String url = "https://" + iikoServer + "/resto/api/v2/reports/olap/columns?key=" + token + "&reportType=" + reportType; + + log.info("Connect to : {}", url); + + httpClient.getAbs(url) + .send() + .onSuccess(resp -> { + if (resp.statusCode() == 200) { + JsonObject body = resp.bodyAsJsonObject(); + // Если есть обёртка data, распаковываем + JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject + ? body.getJsonObject("data") + : body; + promise.complete(data); + + log.info("time: {}", (System.currentTimeMillis() - time) + "ms"); + } else { + promise.fail("Failed to fetch columns for " + reportType + ": HTTP " + resp.statusCode()); + } + }) + .onFailure(promise::fail); + return promise.future(); + } + + // SHA-1 + private String sha1(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(input.getBytes()); + return HexFormat.of().formatHex(digest).toLowerCase(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + // ---------- Методы работы с БД (те же, с поддержкой UTF8) ---------- + private Future storeColumnsToDb(String reportType, JsonObject columns) { + return getOrCreateReportType(reportType) + .compose(reportTypeId -> { + List> fieldFutures = new ArrayList<>(); + for (String fieldKey : columns.fieldNames()) { + JsonObject fieldDef = columns.getJsonObject(fieldKey); + fieldFutures.add(storeSingleField(reportTypeId, fieldKey, fieldDef)); + } + return Future.all(fieldFutures).mapEmpty(); + }); + } + + private Future getOrCreateReportType(String reportType) { + Promise promise = Promise.promise(); + String selectSql = "SELECT report_type_id FROM report_types WHERE name = ?"; + dbPool.preparedQuery(selectSql) + .execute(Tuple.of(reportType)) + .onComplete(ar -> { + if (ar.succeeded() && ar.result().size() > 0) { + promise.complete(ar.result().iterator().next().getInteger("report_type_id")); + } else if (ar.succeeded()) { + String insertSql = "INSERT INTO report_types (name, description) VALUES (?, ?)"; + dbPool.preparedQuery(insertSql) + .execute(Tuple.of(reportType, "OLAP report type: " + reportType)) + .onComplete(insAr -> { + if (insAr.succeeded()) { + dbPool.preparedQuery(selectSql) + .execute(Tuple.of(reportType)) + .onComplete(selAr -> { + if (selAr.succeeded() && selAr.result().size() > 0) { + promise.complete(selAr.result().iterator().next().getInteger("report_type_id")); + } else { + promise.fail("Cannot retrieve inserted report_type_id for " + reportType); + } + }); + } else { + promise.fail(insAr.cause()); + } + }); + } else { + promise.fail(ar.cause()); + } + }); + return promise.future(); + } + + private Future storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) { + // Нормализованный ключ (без точек) + String fieldKeyNormal = fieldKey.replace('.', '_'); + + String name = fieldDef.getString("name"); + String originalType = fieldDef.getString("type"); + String typeNormal = normalizeType(originalType); + + boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false); + boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false); + boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false); + JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray()); + + String insertFieldSql = """ + INSERT INTO fields ( + report_type_id, field_key, field_key_normal, name, type, type_normal, + aggregation_allowed, grouping_allowed, filtering_allowed + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + field_key_normal = VALUES(field_key_normal), + name = VALUES(name), + type_normal = VALUES(type_normal), + aggregation_allowed = VALUES(aggregation_allowed), + grouping_allowed = VALUES(grouping_allowed), + filtering_allowed = VALUES(filtering_allowed) + """; + + return dbPool.preparedQuery(insertFieldSql) + .execute(Tuple.of( + reportTypeId, fieldKey, fieldKeyNormal, name, originalType, typeNormal, + aggregationAllowed, groupingAllowed, filteringAllowed + )) + .compose(ignored -> { + String selectFieldIdSql = "SELECT field_id FROM fields WHERE report_type_id = ? AND field_key = ?"; + return dbPool.preparedQuery(selectFieldIdSql) + .execute(Tuple.of(reportTypeId, fieldKey)) + .compose(rows -> { + if (rows.size() == 0) { + return Future.failedFuture("Field not found after upsert: " + fieldKey); + } + int fieldId = rows.iterator().next().getInteger("field_id"); + return processTags(fieldId, tagsArray); + }); + }); + } + + private Future processTags(int fieldId, JsonArray tags) { + List> tagFutures = new ArrayList<>(); + for (Object tagObj : tags) { + String tagName = tagObj.toString(); + tagFutures.add(getOrCreateTag(tagName) + .compose(tagId -> linkFieldTag(fieldId, tagId))); + } + return Future.all(tagFutures).mapEmpty(); + } + + private Future getOrCreateTag(String tagName) { + Promise promise = Promise.promise(); + String selectSql = "SELECT tag_id FROM tags WHERE tag_name = ?"; + dbPool.preparedQuery(selectSql) + .execute(Tuple.of(tagName)) + .onComplete(ar -> { + if (ar.succeeded() && ar.result().size() > 0) { + promise.complete(ar.result().iterator().next().getInteger("tag_id")); + } else { + String insertSql = "INSERT IGNORE INTO tags (tag_name) VALUES (?)"; + dbPool.preparedQuery(insertSql) + .execute(Tuple.of(tagName)) + .onComplete(insAr -> { + // После IGNORE всё равно выбираем ID (он мог уже существовать) + dbPool.preparedQuery(selectSql) + .execute(Tuple.of(tagName)) + .onComplete(selAr -> { + if (selAr.succeeded() && selAr.result().size() > 0) { + promise.complete(selAr.result().iterator().next().getInteger("tag_id")); + } else { + promise.fail("Cannot retrieve tag_id for " + tagName); + } + }); + }); + } + }); + return promise.future(); + } + + private Future linkFieldTag(int fieldId, int tagId) { + String sql = "INSERT IGNORE INTO field_tags (field_id, tag_id) VALUES (?, ?)"; + return dbPool.preparedQuery(sql) + .execute(Tuple.of(fieldId, tagId)) + .mapEmpty(); + } + + private Future createTablesIfNotExist() { + String createReportTypesTable = """ + CREATE TABLE IF NOT EXISTS report_types ( + report_type_id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT NOT NULL + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """; + String createFieldsTable = """ + CREATE TABLE IF NOT EXISTS fields ( + field_id INT AUTO_INCREMENT PRIMARY KEY, + report_type_id INT NOT NULL, + field_key VARCHAR(255) NOT NULL, + field_key_normal VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + type_normal VARCHAR(50) NOT NULL, + aggregation_allowed BOOLEAN NOT NULL DEFAULT 0, + grouping_allowed BOOLEAN NOT NULL DEFAULT 0, + filtering_allowed BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_fields_report_type_field_key (report_type_id, field_key), + FOREIGN KEY (report_type_id) REFERENCES report_types(report_type_id) ON DELETE RESTRICT + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """; + String createTagsTable = """ + CREATE TABLE IF NOT EXISTS tags ( + tag_id INT AUTO_INCREMENT PRIMARY KEY, + tag_name VARCHAR(100) UNIQUE NOT NULL + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """; + String createFieldTagsTable = """ + CREATE TABLE IF NOT EXISTS field_tags ( + field_id INT NOT NULL, + tag_id INT NOT NULL, + PRIMARY KEY (field_id, tag_id), + FOREIGN KEY (field_id) REFERENCES fields(field_id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(tag_id) ON DELETE CASCADE + ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci + """; + String createIdxFieldsReportType = "CREATE INDEX IF NOT EXISTS idx_fields_report_type ON fields(report_type_id)"; + String createIdxFieldsName = "CREATE INDEX IF NOT EXISTS idx_fields_name ON fields(name)"; + String createIdxFieldTagsTagId = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON field_tags(tag_id)"; + + return dbPool.query(createReportTypesTable).execute() + .compose(ignored -> dbPool.query(createFieldsTable).execute()) + .compose(ignored -> dbPool.query(createTagsTable).execute()) + .compose(ignored -> dbPool.query(createFieldTagsTable).execute()) + .compose(ignored -> dbPool.query(createIdxFieldsReportType).execute()) + .compose(ignored -> dbPool.query(createIdxFieldsName).execute()) + .compose(ignored -> dbPool.query(createIdxFieldTagsTagId).execute()) + .mapEmpty(); + } + + private String normalizeType(String iikoType) { + if (iikoType == null) return "string"; + return switch (iikoType) { + case "ENUM", "STRING", "ID", "ID_STRING" -> "string"; + case "DATETIME" -> "datetime"; + case "INTEGER", "DURATION_IN_SECONDS" -> "integer"; + case "PERCENT", "AMOUNT", "MONEY" -> "decimal"; + default -> "string"; + }; + } + + public void close() { + dbPool.close(); + httpClient.close(); + } +} diff --git a/src/main/java/su/xserver/iikocon/test/Main.java b/src/main/java/su/xserver/iikocon/test/Main.java new file mode 100644 index 0000000..09a0b40 --- /dev/null +++ b/src/main/java/su/xserver/iikocon/test/Main.java @@ -0,0 +1,43 @@ +package su.xserver.iikocon.test; + +import io.vertx.core.Vertx; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Main { + private static final Logger log = LoggerFactory.getLogger(Main.class); + + public static void main(String[] args) { + long time = System.currentTimeMillis(); + + Vertx vertx = Vertx.vertx(); + + IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter( + vertx, + "folk-amber-co.iiko.it", // без https:// + "4444", + "4444", + "phpmyadmin.xserver.su", // хост MariaDB + 3306, + "test", // имя БД + "test", + "test" + ); + + + + importer.fetchAndStoreAll() + .onComplete(ar -> { + if (ar.succeeded()) { + System.out.println("Import completed successfully."); + log.info("time to sc: {}", (System.currentTimeMillis() - time) + "ms"); + + } else { + System.err.println("Import failed: " + ar.cause().getMessage()); + } +// importer.close(); +// vertx.close(); + }); + + } +} diff --git a/src/main/java/su/xserver/iikocon/test/ProxyVerticlev2.java b/src/main/java/su/xserver/iikocon/test/ProxyVerticlev2.java new file mode 100644 index 0000000..790c6d2 --- /dev/null +++ b/src/main/java/su/xserver/iikocon/test/ProxyVerticlev2.java @@ -0,0 +1,109 @@ +package su.xserver.iikocon.test; + +import io.vertx.core.AbstractVerticle; +import io.vertx.core.Promise; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.Json; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.ext.web.codec.BodyCodec; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public class ProxyVerticlev2 extends AbstractVerticle { + + private static final Logger log = LoggerFactory.getLogger(ProxyVerticlev2.class); + private WebClient webClient; + + @Override + public void start(Promise startPromise) { + webClient = WebClient.create(vertx, new WebClientOptions() + .setSsl(true) + .setTrustAll(true) + .setVerifyHost(false)); + + Router router = Router.router(vertx); + router.get("/").handler(this::handleGet); + + int port = 80; + vertx.createHttpServer() + .requestHandler(router) + .listen(port).onComplete(http -> { + if (http.succeeded()) { + log.info("Proxy server started on port {}", port); + startPromise.complete(); + } else { + startPromise.fail(http.cause()); + } + }); + } + + private void handleGet(RoutingContext ctx) { + String server = "folk-amber-co.iiko.it"; + String password = "4444"; + String login = "4444"; + String reportType = "DELIVERIES"; + + String signature = sha1(password); + String authUrl = "https://" + server + ":443/resto/api/auth?login=" + login + "&pass=" + signature; + webClient.getAbs(authUrl) + .as(BodyCodec.string()) + .send() + .onSuccess(authResp -> { + if (authResp.statusCode() != 200) { + fail(ctx, authResp.statusCode(), "Authentication failed: " + authResp.statusMessage()); + return; + } + String token = authResp.body(); + String dataUrl = "https://" + server + "/resto/api/v2/reports/olap/columns" + + "?key=" + token + "&reportType=" + reportType; + log.info("URL: {}", dataUrl); + webClient.getAbs(dataUrl) + .as(BodyCodec.jsonObject()) + .send() + .onSuccess(dataResp -> { + // logout (fire and forget) + webClient.getAbs("https://" + server + ":443/resto/api/logout?key=" + token) + .send() + .onFailure(err -> log.error("Logout failed: {}", err.getMessage())); + if (dataResp.statusCode() == 200) { + JsonObject responseBody = dataResp.body(); + log.info(dataResp.headers().toString()); + Object data = responseBody.getValue("data"); + if (data == null) { + ctx.response().setStatusCode(200).end(responseBody.encode()); + } else { + // data может быть массивом, объектом или другим типом + ctx.response().setStatusCode(200).end(Json.encode(data)); + } + } else { + fail(ctx, dataResp.statusCode(), "External API error: " + dataResp.statusMessage()); + } + }) + .onFailure(err -> fail(ctx, 500, "Data request failed: " + err.getMessage())); + }) + .onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage())); + } + + private String sha1(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + byte[] digest = md.digest(input.getBytes()); + return HexFormat.of().formatHex(digest).toLowerCase(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private void fail(RoutingContext ctx, int status, String message) { + log.info("Error: {}", message); + ctx.response().setStatusCode(status).end(new JsonObject().put("error", message).encode()); + } +}