up...
This commit is contained in:
@@ -95,19 +95,19 @@
|
|||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="userStore.role === 'admin'"
|
v-if="userStore.role === 'admin'"
|
||||||
to="/olap-constructor"
|
to="/olap/queries"
|
||||||
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
|
||||||
:class="[
|
:class="[
|
||||||
route.path === '/olap-constructor' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
route.path === '/olap/queries' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
|
||||||
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
|
||||||
]"
|
]"
|
||||||
:title="sidebarCollapsed ? 'OLAP Конструктор' : ''"
|
:title="sidebarCollapsed ? 'OLAP Queries' : ''"
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6v12M16 6v12" />
|
||||||
</svg>
|
</svg>
|
||||||
<span v-if="!sidebarCollapsed" class="truncate">OLAP Конструктор</span>
|
<span v-if="!sidebarCollapsed" class="truncate">OLAP Queries</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import OlapColumnsView from '@/views/OlapColumnsView.vue'
|
|||||||
import DBConnections from '@/views/DBConnections.vue'
|
import DBConnections from '@/views/DBConnections.vue'
|
||||||
import AdminSettings from '@/views/AdminSettings.vue'
|
import AdminSettings from '@/views/AdminSettings.vue'
|
||||||
import Profile from '@/views/Profile.vue'
|
import Profile from '@/views/Profile.vue'
|
||||||
import OLAPConstructor from '@/views/OLAPConstructor.vue'
|
import OlapQueriesPage from '@/views/OlapQueriesPage.vue'
|
||||||
|
import OlapConstructor from '@/views/OlapConstructor.vue'
|
||||||
|
|
||||||
import NotFound from '@/views/NotFound.vue'
|
import NotFound from '@/views/NotFound.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -64,9 +66,19 @@ const routes = [
|
|||||||
component: AdminSettings,
|
component: AdminSettings,
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
|
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// path: '/olap-constructor',
|
||||||
|
// component: OLAPConstructor,
|
||||||
|
// meta: { requiresAuth: true, title: 'OLAP Constructor' }
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
path: '/olap-constructor',
|
path: '/olap/queries',
|
||||||
component: OLAPConstructor,
|
component: OlapQueriesPage,
|
||||||
|
meta: { requiresAuth: true, title: 'OLAP Queries' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/olap/constructor/:id?',
|
||||||
|
component: OlapConstructor,
|
||||||
meta: { requiresAuth: true, title: 'OLAP Constructor' }
|
meta: { requiresAuth: true, title: 'OLAP Constructor' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
131
frontend/src/views/OlapQueriesPage.vue
Normal file
131
frontend/src/views/OlapQueriesPage.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">OLAP запросы</h1>
|
||||||
|
<router-link to="/olap/constructor" class="btn-primary">+ Создать запрос</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<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-right text-xs font-medium text-gray-500">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
<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">{{ 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>
|
||||||
|
<td class="px-6 py-4 text-right space-x-2">
|
||||||
|
<router-link :to="`/olap/constructor/${q.id}`" class="text-blue-600 hover:text-blue-800">
|
||||||
|
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</router-link>
|
||||||
|
<button @click="confirmDelete(q.id)" class="text-red-600 hover:text-red-800">
|
||||||
|
<svg class="w-5 h-5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="queries.length === 0">
|
||||||
|
<td colspan="6" class="px-6 py-12 text-center text-gray-500">Нет запросов. Создайте первый!</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно удаления с улучшенной стилизацией -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="deleteModal.show" class="fixed inset-0 z-[9999] overflow-y-auto" @click.self="deleteModal.show = false">
|
||||||
|
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-xl max-w-md w-full transform transition-all">
|
||||||
|
<div class="p-6 text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Удалить запрос?</h3>
|
||||||
|
<p class="text-sm text-gray-500 mb-6">Действие необратимо. Вы уверены?</p>
|
||||||
|
<div class="flex justify-center space-x-3">
|
||||||
|
<button @click="deleteModal.show = false" class="btn-secondary">Отмена</button>
|
||||||
|
<button @click="deleteQuery" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import AppLayout from '@/components/Layout/AppLayout.vue'
|
||||||
|
import { useNotification } from '@/composables/useNotification'
|
||||||
|
|
||||||
|
const { showNotification } = useNotification()
|
||||||
|
const queries = ref([])
|
||||||
|
const deleteModal = ref({ show: false, id: null as number | null })
|
||||||
|
|
||||||
|
async function loadQueries() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/olap/queries')
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
queries.value = await res.json()
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Ошибка загрузки запросов', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
return dateStr ? new Date(dateStr).toLocaleString() : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(id: number) {
|
||||||
|
deleteModal.value = { show: true, id }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQuery() {
|
||||||
|
const id = deleteModal.value.id
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/olap/queries/${id}`, { method: 'DELETE' })
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
showNotification('Запрос удалён', 'success')
|
||||||
|
await loadQueries()
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Ошибка удаления', 'error')
|
||||||
|
} finally {
|
||||||
|
deleteModal.value.show = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadQueries)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import io.vertx.core.Future;
|
|||||||
import io.vertx.core.Promise;
|
import io.vertx.core.Promise;
|
||||||
import io.vertx.core.buffer.Buffer;
|
import io.vertx.core.buffer.Buffer;
|
||||||
import io.vertx.core.http.HttpServer;
|
import io.vertx.core.http.HttpServer;
|
||||||
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
import io.vertx.ext.web.Router;
|
import io.vertx.ext.web.Router;
|
||||||
import io.vertx.ext.web.RoutingContext;
|
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.handler.*;
|
||||||
import su.xserver.iikocon.iiko.IikoHandler;
|
import su.xserver.iikocon.iiko.IikoHandler;
|
||||||
import su.xserver.iikocon.iiko.IikoOlapClient;
|
import su.xserver.iikocon.iiko.IikoOlapClient;
|
||||||
|
import su.xserver.iikocon.iiko.OlapQueryService;
|
||||||
import su.xserver.iikocon.service.*;
|
import su.xserver.iikocon.service.*;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class MainVerticle extends AbstractVerticle {
|
public class MainVerticle extends AbstractVerticle {
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
private RestaurantService restaurantService;
|
private RestaurantService restaurantService;
|
||||||
private ExternalDataBaseService externalDataBaseService;
|
private ExternalDataBaseService externalDataBaseService;
|
||||||
private SettingsService settingsService;
|
private SettingsService settingsService;
|
||||||
|
private OlapQueryService olapQueryService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Promise<Void> startPromise) throws ClassNotFoundException {
|
public void start(Promise<Void> startPromise) throws ClassNotFoundException {
|
||||||
@@ -75,6 +79,7 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
restaurantService = new RestaurantService(db.getPool());
|
restaurantService = new RestaurantService(db.getPool());
|
||||||
settingsService = new SettingsService(db.getPool());
|
settingsService = new SettingsService(db.getPool());
|
||||||
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
|
externalDataBaseService = new ExternalDataBaseService(db.getPool(), vertx);
|
||||||
|
olapQueryService = new OlapQueryService(db.getPool(), externalDataBaseService);
|
||||||
|
|
||||||
userService.initDatabase().onFailure(err -> {
|
userService.initDatabase().onFailure(err -> {
|
||||||
log.error("Failed to initialize database", err);
|
log.error("Failed to initialize database", err);
|
||||||
@@ -92,6 +97,10 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
log.error("Failed to initialize database", err);
|
log.error("Failed to initialize database", err);
|
||||||
startPromise.fail(err);
|
startPromise.fail(err);
|
||||||
});
|
});
|
||||||
|
olapQueryService.initDatabase().onFailure(err -> {
|
||||||
|
log.error("Failed to initialize database", err);
|
||||||
|
startPromise.fail(err);
|
||||||
|
});
|
||||||
|
|
||||||
createRouterAndStartHttp(startPromise);
|
createRouterAndStartHttp(startPromise);
|
||||||
|
|
||||||
@@ -517,6 +526,98 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
new IikoHandler(vertx, router, db, restaurantService, authHandler);
|
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<Integer> 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<Integer> 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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
267
src/main/java/su/xserver/iikocon/iiko/OlapQueryService.java
Normal file
267
src/main/java/su/xserver/iikocon/iiko/OlapQueryService.java
Normal file
@@ -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<Void> 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<Integer> createQuery(String name, int dbConnectionId, JsonObject config,
|
||||||
|
List<Integer> restaurantIds, String generatedSql) {
|
||||||
|
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 : ""
|
||||||
|
);
|
||||||
|
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<Void> updateQuery(int id, String name, int dbConnectionId, JsonObject config,
|
||||||
|
List<Integer> restaurantIds, String generatedSql) {
|
||||||
|
JsonObject fullConfig = generateFullIikoJson(config);
|
||||||
|
Map<String, Object> 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<Integer> getLastInsertId() {
|
||||||
|
return pool.query("SELECT LAST_INSERT_ID() AS id").execute()
|
||||||
|
.map(rows -> rows.iterator().next().getInteger("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Future<Void> linkRestaurants(int queryId, List<Integer> restaurantIds) {
|
||||||
|
if (restaurantIds == null || restaurantIds.isEmpty()) return Future.succeededFuture();
|
||||||
|
List<Future<Void>> futures = new ArrayList<>();
|
||||||
|
for (Integer restId : restaurantIds) {
|
||||||
|
Map<String, Object> 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<Void> deleteQuery(int id) {
|
||||||
|
return SqlTemplate.forUpdate(pool, "DELETE FROM olap_queries WHERE id = #{id}")
|
||||||
|
.execute(Map.of("id", id)).mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить все запросы (без 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
|
||||||
|
""";
|
||||||
|
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<JsonObject> 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<String> 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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/main/java/su/xserver/iikocon/iiko/SqlGenerator.java
Normal file
108
src/main/java/su/xserver/iikocon/iiko/SqlGenerator.java
Normal file
@@ -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<String> 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<String> 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<String> 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_]", "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user