up, add OLAP columns page

This commit is contained in:
2026-05-01 19:04:18 +03:00
parent 50d4ea10c6
commit c801783779
12 changed files with 1145 additions and 435 deletions

View File

@@ -76,6 +76,23 @@
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span> <span v-if="!sidebarCollapsed" class="truncate">{{ t('app.restaurants') }}</span>
</router-link> </router-link>
<router-link
v-if="userStore.role === 'admin'"
to="/olap-columns"
class="flex items-center rounded-lg hover:bg-gray-100 transition-colors group"
:class="[
route.path === '/olap-columns' ? 'bg-primary-50 text-primary-700' : 'text-gray-700',
sidebarCollapsed ? 'justify-center p-2' : 'px-4 py-3 space-x-3'
]"
:title="sidebarCollapsed ? t('app.olapColumns') : ''"
>
<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="M8 6v12M16 6v12" />
</svg>
<span v-if="!sidebarCollapsed" class="truncate">{{ t('app.olapColumns') }}</span>
</router-link>
<router-link <router-link
v-if="userStore.role === 'admin'" v-if="userStore.role === 'admin'"
to="/settings" to="/settings"

View File

@@ -6,6 +6,7 @@
"users": "Users", "users": "Users",
"restaurants": "Restaurants", "restaurants": "Restaurants",
"settings": "Settings", "settings": "Settings",
"olapColumns": "OLAP Fields",
"profile": "Profile", "profile": "Profile",
"logout": "Logout", "logout": "Logout",
"language": "Language", "language": "Language",
@@ -21,7 +22,9 @@
"edit": "Edit", "edit": "Edit",
"add": "Add", "add": "Add",
"reset": "Reset", "reset": "Reset",
"loading": "Loading..." "loading": "Loading...",
"all": "all",
"confirm": "Confirm"
}, },
"common": { "common": {
"id": "ID", "id": "ID",
@@ -193,5 +196,43 @@
"minLength": "Must be at least {min} characters", "minLength": "Must be at least {min} characters",
"email": "Please enter a valid email address", "email": "Please enter a valid email address",
"passwordMismatch": "Passwords do not match" "passwordMismatch": "Passwords do not match"
},
"olap": {
"columnsTitle": "OLAP Reports Structure",
"initialize": "Initialize",
"filterFieldKey": "Field key",
"filterFieldKeyPlaceholder": "search by key...",
"filterReportType": "Report type",
"filterTag": "Tag",
"fieldKey": "Field (key)",
"reportTypes": "Report types",
"type": "Type",
"tags": "Tags",
"aggregation": "Aggregation",
"grouping": "Grouping",
"filtering": "Filtering",
"noColumnsFound": "No fields match the filters",
"selectRestaurant": "Select a restaurant to load the structure",
"loadError": "Error loading report structure",
"initSuccess": "Structure initialized successfully",
"initError": "Initialization error: {error}",
"selectRestaurantFirst": "Please select a restaurant",
"refreshStructure": "Refresh structure",
"refreshWarningTitle": "Full structure replacement",
"refreshWarningMessage": "You selected restaurant «{restaurant}». All existing OLAP fields data will be permanently deleted and replaced with data from this restaurant.",
"refreshWarningConfirm": "This action is irreversible. Continue?",
"searchRestaurant": "Search restaurant...",
"noRestaurantsFound": "No restaurants found",
"initializingData": "Initializing OLAP fields structure",
"refreshingData": "Refreshing OLAP fields structure",
"waitMessage": "Please wait. This operation may take a while...",
"editField": "Edit Field",
"displayType": "Display Type",
"updateSuccess": "Field updated successfully",
"updateError": "Error updating field",
"deleteSuccess": "Field deleted successfully",
"deleteError": "Error deleting field",
"deleteField": "Delete Field",
"deleteFieldConfirm": "Are you sure you want to delete this field? This action cannot be undone."
} }
} }

View File

@@ -2,10 +2,11 @@
"app": { "app": {
"title": "Панель администратора", "title": "Панель администратора",
"dashboard": "Панель управления", "dashboard": "Панель управления",
"database": "База Данных", "database": "База данных",
"users": "Пользователи", "users": "Пользователи",
"restaurants": "Рестораны", "restaurants": "Рестораны",
"settings": "Настройки", "settings": "Настройки",
"olapColumns": "OLAP поля",
"profile": "Профиль", "profile": "Профиль",
"logout": "Выйти", "logout": "Выйти",
"language": "Язык", "language": "Язык",
@@ -21,7 +22,9 @@
"edit": "Редактировать", "edit": "Редактировать",
"add": "Добавить", "add": "Добавить",
"reset": "Сбросить", "reset": "Сбросить",
"loading": "Загрузка..." "loading": "Загрузка...",
"all": "все",
"confirm": "Подтвердить"
}, },
"common": { "common": {
"id": "ID", "id": "ID",
@@ -193,5 +196,43 @@
"minLength": "Должно быть не менее {min} символов", "minLength": "Должно быть не менее {min} символов",
"email": "Введите корректный email адрес", "email": "Введите корректный email адрес",
"passwordMismatch": "Пароли не совпадают" "passwordMismatch": "Пароли не совпадают"
},
"olap": {
"columnsTitle": "Структура OLAP-отчётов",
"initialize": "Инициализировать",
"filterFieldKey": "Ключ поля",
"filterFieldKeyPlaceholder": "поиск по ключу...",
"filterReportType": "Тип отчёта",
"filterTag": "Тег",
"fieldKey": "Поле (ключ)",
"reportTypes": "Типы отчётов",
"type": "Тип",
"tags": "Теги",
"aggregation": "Агрегация",
"grouping": "Группировка",
"filtering": "Фильтрация",
"noColumnsFound": "Нет полей, соответствующих фильтрам",
"selectRestaurant": "Выберите ресторан для загрузки структуры",
"loadError": "Ошибка загрузки структуры отчётов",
"initSuccess": "Структура успешно инициализирована",
"initError": "Ошибка инициализации: {error}",
"selectRestaurantFirst": "Пожалуйста, выберите ресторан",
"refreshStructure": "Обновить структуру",
"refreshWarningTitle": "Полная замена структуры",
"refreshWarningMessage": "Вы выбрали ресторан «{restaurant}». Все текущие данные о полях OLAP-отчётов будут полностью удалены и заменены данными из этого ресторана.",
"refreshWarningConfirm": "Это действие необратимо. Продолжить?",
"searchRestaurant": "Поиск ресторана...",
"noRestaurantsFound": "Рестораны не найдены",
"initializingData": "Инициализация структуры OLAP-полей",
"refreshingData": "Обновление структуры OLAP-полей",
"waitMessage": "Пожалуйста, подождите. Операция может занять некоторое время...",
"editField": "Редактирование поля",
"displayType": "Тип отображения",
"updateSuccess": "Поле успешно обновлено",
"updateError": "Ошибка при обновлении поля",
"deleteSuccess": "Поле успешно удалено",
"deleteError": "Ошибка при удалении поля",
"deleteField": "Удаление поля",
"deleteFieldConfirm": "Вы уверены, что хотите удалить это поле? Это действие необратимо."
} }
} }

View File

@@ -7,21 +7,67 @@ import Register from '@/views/auth/Register.vue'
import Dashboard from '@/views/Dashboard.vue' import Dashboard from '@/views/Dashboard.vue'
import Users from '@/views/Users.vue' import Users from '@/views/Users.vue'
import Restaurants from '@/views/Restaurants.vue' import Restaurants from '@/views/Restaurants.vue'
import OlapColumnsView from '@/views/OlapColumnsView.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 NotFound from '@/views/NotFound.vue' import NotFound from '@/views/NotFound.vue'
const routes = [ const routes = [
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } }, {
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } }, path: '/login',
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } }, component: Login,
{ path: '/', redirect: '/dashboard' }, meta: { title: 'Login', requiresAuth: false }
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true, title: 'Dashboard' } }, },
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' } }, {
{ path: '/restaurants', component: Restaurants, meta: { requiresAuth: true, title: 'Restaurants' } }, path: '/register',
{ path: '/settings', component: AdminSettings, meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' } }, component: Register,
{ path: '/profile', component: Profile, meta: { requiresAuth: true, title: 'Profile' } }, meta: { title: 'Register', requiresAuth: false }
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { title: 'Page Not Found', requiresAuth: false } } },
{
path: '/setup',
component: Setup,
meta: { title: 'Setup', requiresAuth: false }
},
{
path: '/',
redirect: '/dashboard'
},
{
path: '/dashboard',
component: Dashboard,
meta: { requiresAuth: true, title: 'Dashboard' }
},
{
path: '/users',
component: Users,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Users' }
},
{
path: '/restaurants',
component: Restaurants,
meta: { requiresAuth: true, title: 'Restaurants' }
},
{
path: '/olap-columns',
component: OlapColumnsView,
meta: { requiresAuth: true, requiresAdmin: true, title: 'OlapColumns' }
},
{
path: '/settings',
component: AdminSettings,
meta: { requiresAuth: true, requiresAdmin: true, title: 'Settings' }
},
{
path: '/profile',
component: Profile,
meta: { requiresAuth: true, title: 'Profile' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound,
meta: { title: 'Page Not Found', requiresAuth: false }
}
] ]
const router = createRouter({ history: createWebHistory(), routes }) const router = createRouter({ history: createWebHistory(), routes })

View File

@@ -0,0 +1,641 @@
<template>
<AppLayout>
<div class="flex justify-between items-center mb-6 flex-wrap gap-4">
<h1 class="text-2xl font-bold text-gray-900">{{ t('olap.columnsTitle') }}</h1>
<div class="flex gap-3">
<button
v-if="hasData && !loading && !initializing"
@click="openRefreshModal"
class="btn-secondary flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('olap.refreshStructure') }}
</button>
<button
v-if="!hasData && !loading && !initializing"
@click="openInitModal"
class="btn-primary flex items-center gap-2"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ t('olap.initialize') }}
</button>
</div>
</div>
<!-- Фильтры -->
<div v-if="hasData" class="card mb-6 p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterFieldKey') }}</label>
<input v-model="filters.fieldKey" type="text" class="input-field" :placeholder="t('olap.filterFieldKeyPlaceholder')" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterReportType') }}</label>
<select v-model="filters.reportType" class="input-field">
<option value="">{{ t('app.all') }}</option>
<option v-for="rt in availableReportTypes" :key="rt" :value="rt">{{ rt }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.filterTag') }}</label>
<select v-model="filters.tag" class="input-field">
<option value="">{{ t('app.all') }}</option>
<option v-for="tag in availableTags" :key="tag" :value="tag">{{ tag }}</option>
</select>
</div>
<div class="flex items-end">
<button @click="resetFilters" class="btn-secondary w-full">{{ t('app.reset') }}</button>
</div>
</div>
</div>
<!-- Таблица -->
<div v-if="loading" class="card p-8 text-center">
<svg class="animate-spin h-8 w-8 text-primary-600 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="mt-2 text-gray-500">{{ t('app.loading') }}</p>
</div>
<div v-else-if="hasData && filteredColumns.length === 0" class="card p-8 text-center text-gray-500">
{{ t('olap.noColumnsFound') }}
</div>
<div v-else-if="hasData" 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 uppercase tracking-wider">{{ t('olap.fieldKey') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.reportTypes') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.type') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.tags') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.aggregation') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.grouping') }}</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('olap.filtering') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<tr v-for="col in filteredColumns" :key="col.fieldKey" class="hover:bg-gray-50">
<td class="px-6 py-4 text-sm font-mono text-gray-900">{{ col.fieldKey }}</td>
<td class="px-6 py-4 text-sm text-gray-900">{{ col.name }}</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
<span v-for="rt in col.reportTypes" :key="rt" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
{{ rt }}
</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
{{ col.typeNormal || col.type }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
<div class="flex flex-wrap gap-1">
<span v-for="tag in col.tags" :key="tag" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
{{ tag }}
</span>
<span v-if="!col.tags || col.tags.length === 0" class="text-gray-400"></span>
</div>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.aggregationAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.groupingAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="col.filteringAllowed" class="text-green-600"></span>
<span v-else class="text-gray-300"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<!-- <button @click="openEditModal(col)" class="text-blue-600 hover:text-blue-800 transition-colors" :title="t('app.edit')">-->
<!-- <svg class="w-4 h-4" 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>-->
<!-- </button>-->
<button @click="openDeleteFieldModal(col.fieldKey)" class="text-red-600 hover:text-red-800 transition-colors" :title="t('app.delete')">
<svg class="w-4 h-4" 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>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Модалка выбора ресторана (для инициализации и обновления) -->
<Transition name="fade">
<div v-if="initModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeInitModal">
<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-lg w-full max-h-[90vh] flex flex-col">
<!-- Заголовок -->
<div class="flex justify-between items-center p-6 border-b">
<h3 class="text-xl font-bold text-gray-900">{{ initModalTitle }}</h3>
<button @click="closeInitModal" class="text-gray-400 hover:text-gray-600 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Поле поиска -->
<div class="p-4 border-b">
<div class="relative">
<svg class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
v-model="restaurantSearch"
type="text"
class="input-field pl-9"
:placeholder="t('olap.searchRestaurant')"
/>
</div>
</div>
<!-- Список ресторанов (скролл) -->
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<div
v-for="rest in filteredRestaurants"
:key="rest.id"
@click="selectedRestaurantId = rest.id"
class="flex items-center justify-between p-4 rounded-xl border cursor-pointer transition-all duration-150 hover:shadow-md"
:class="[
selectedRestaurantId === rest.id
? 'border-primary-500 bg-primary-50 ring-1 ring-primary-500'
: 'border-gray-200 hover:border-gray-300'
]"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="font-medium text-gray-900">{{ rest.name }}</p>
<p class="text-sm text-gray-500">{{ rest.host }}</p>
</div>
</div>
<div v-if="selectedRestaurantId === rest.id" class="text-primary-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div v-if="filteredRestaurants.length === 0" class="text-center py-8 text-gray-500">
{{ t('olap.noRestaurantsFound') }}
</div>
</div>
<!-- Кнопки действий -->
<div class="flex justify-end space-x-3 p-6 border-t bg-gray-50 rounded-b-2xl">
<button @click="closeInitModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button
@click="onInitConfirm"
:disabled="!selectedRestaurantId"
class="btn-primary"
>
{{ t('app.confirm') }}
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- Модалка предупреждения перед обновлением (после выбора ресторана) -->
<Transition name="fade">
<div v-if="refreshWarningModal.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="refreshWarningModal.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">
<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">{{ t('olap.refreshWarningTitle') }}</h3>
<p class="text-sm text-gray-500 mb-4">
{{ t('olap.refreshWarningMessage', { restaurant: pendingRestaurantName }) }}
</p>
<p class="text-sm font-semibold text-red-600 mb-6">{{ t('olap.refreshWarningConfirm') }}</p>
<div class="flex justify-center space-x-3">
<button @click="refreshWarningModal.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="executeInitialize" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
{{ t('app.confirm') }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<!-- Модалка редактирования поля -->
<Transition name="fade">
<div v-if="editModalOpen" class="fixed inset-0 z-50 overflow-y-auto" @click.self="closeEditModal">
<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">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-xl font-bold text-gray-900">{{ t('olap.editField') }}</h2>
<button @click="closeEditModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="updateField" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('common.name') }}</label>
<input v-model="editForm.name" type="text" class="input-field" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('olap.displayType') }}</label>
<select v-model="editForm.typeNormal" class="input-field">
<option value="string">string</option>
<option value="integer">integer</option>
<option value="decimal">decimal</option>
<option value="datetime">datetime</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" v-model="editForm.aggregationAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">{{ t('olap.aggregation') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" v-model="editForm.groupingAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">{{ t('olap.grouping') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" v-model="editForm.filteringAllowed" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
<label class="text-sm font-medium text-gray-700">{{ t('olap.filtering') }}</label>
</div>
<div class="flex justify-end space-x-3 pt-2">
<button type="button" @click="closeEditModal" class="btn-secondary">{{ t('app.cancel') }}</button>
<button type="submit" class="btn-primary">{{ t('app.save') }}</button>
</div>
</form>
</div>
</div>
</div>
</Transition>
<!-- Модалка подтверждения удаления поля -->
<Transition name="fade">
<div v-if="deleteFieldConfirm.show" class="fixed inset-0 z-50 overflow-y-auto" @click.self="deleteFieldConfirm.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">
<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">{{ t('olap.deleteField') }}</h3>
<p class="text-sm text-gray-500 mb-6">{{ t('olap.deleteFieldConfirm') }}</p>
<div class="flex justify-center space-x-3">
<button @click="deleteFieldConfirm.show = false" class="btn-secondary">{{ t('app.cancel') }}</button>
<button @click="confirmDeleteField" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">{{ t('app.delete') }}</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
<Transition name="fade">
<div v-if="initializing" class="fixed inset-0 z-60 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div class="bg-white rounded-2xl shadow-xl p-8 flex flex-col items-center gap-4 max-w-sm w-full mx-4">
<svg class="animate-spin h-12 w-12 text-primary-600" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-700 font-medium">
{{ initializingText }}
</p>
<p class="text-sm text-gray-500 text-center">
{{ t('olap.waitMessage') }}
</p>
</div>
</div>
</Transition>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import AppLayout from '@/components/Layout/AppLayout.vue';
import { useI18n } from 'vue-i18n';
import { useNotification } from '@/composables/useNotification';
const { t } = useI18n();
const { showNotification } = useNotification();
interface Column {
fieldKey: string;
fieldKeyNormal: string;
reportTypes: string[];
name: string;
type: string;
typeNormal: string;
aggregationAllowed: boolean;
groupingAllowed: boolean;
filteringAllowed: boolean;
tags: string[];
}
interface Restaurant {
id: number;
name: string;
host: string;
}
const columns = ref<Column[]>([]);
const loading = ref(false);
const initializing = ref(false);
const initModalOpen = ref(false);
const initModalTitle = ref('');
const restaurants = ref<Restaurant[]>([]);
const selectedRestaurantId = ref<number | null>(null);
const pendingRestaurantId = ref<number | null>(null);
const pendingRestaurantName = ref('');
const refreshWarningModal = ref({ show: false });
const initializingText = ref('');
const filters = ref({
fieldKey: '',
reportType: '',
tag: ''
});
const editModalOpen = ref(false);
const editForm = ref({
fieldKey: '',
name: '',
typeNormal: '',
aggregationAllowed: false,
groupingAllowed: false,
filteringAllowed: false
});
const deleteFieldConfirm = ref({ show: false, fieldKey: '' });
const hasData = computed(() => columns.value.length > 0);
const availableReportTypes = computed(() => {
const types = new Set<string>();
for (const col of columns.value) {
for (const rt of col.reportTypes) {
types.add(rt);
}
}
return Array.from(types).sort();
});
const availableTags = computed(() => {
const tags = new Set<string>();
for (const col of columns.value) {
for (const tag of col.tags) {
tags.add(tag);
}
}
return Array.from(tags).sort();
});
const filteredColumns = computed(() => {
let result = columns.value;
if (filters.value.fieldKey) {
const lowerKey = filters.value.fieldKey.toLowerCase();
result = result.filter(col => col.fieldKey.toLowerCase().includes(lowerKey));
}
if (filters.value.reportType) {
result = result.filter(col => col.reportTypes.includes(filters.value.reportType));
}
if (filters.value.tag) {
result = result.filter(col => col.tags.includes(filters.value.tag));
}
return result;
});
async function loadColumns() {
loading.value = true;
try {
const res = await fetch('/api/reports/olap/columns');
if (!res.ok) {
if (res.status === 404 || res.status === 204) {
columns.value = [];
} else {
throw new Error(`HTTP ${res.status}`);
}
} else {
const data = await res.json();
columns.value = data.columns || [];
}
} catch (error) {
console.error(error);
showNotification('olap.loadError', 'error');
columns.value = [];
} finally {
loading.value = false;
}
}
async function loadRestaurants() {
try {
const res = await fetch('/api/admin/restaurants');
if (res.ok) {
const data = await res.json();
restaurants.value = data;
} else {
showNotification('restaurants.loadError', 'error');
}
} catch (error) {
showNotification('restaurants.loadError', 'error');
}
}
const restaurantSearch = ref('');
const filteredRestaurants = computed(() => {
if (!restaurantSearch.value) return restaurants.value;
const lower = restaurantSearch.value.toLowerCase();
return restaurants.value.filter(r =>
r.name.toLowerCase().includes(lower) ||
r.host.toLowerCase().includes(lower)
);
});
function openInitModal() {
initModalTitle.value = t('olap.selectRestaurant');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function openRefreshModal() {
initModalTitle.value = t('olap.refreshStructure');
loadRestaurants().then(() => { initModalOpen.value = true; });
}
function closeInitModal() {
initModalOpen.value = false;
selectedRestaurantId.value = null;
}
function onInitConfirm() {
if (!selectedRestaurantId.value) return;
const selectedRest = restaurants.value.find(r => r.id === selectedRestaurantId.value);
pendingRestaurantName.value = selectedRest ? selectedRest.name : '';
pendingRestaurantId.value = selectedRestaurantId.value;
if (hasData.value) {
closeInitModal();
refreshWarningModal.value.show = true;
} else {
executeInitialize();
}
}
async function executeInitialize() {
const id = pendingRestaurantId.value ?? selectedRestaurantId.value;
if (!id) {
showNotification('olap.selectRestaurantFirst', 'error');
return;
}
initModalOpen.value = false;
refreshWarningModal.value.show = false;
editModalOpen.value = false;
deleteFieldConfirm.value.show = false;
initializingText.value = hasData.value ? t('olap.refreshingData') : t('olap.initializingData');
initializing.value = true;
try {
const res = await fetch('/api/reports/olap/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ restaurantId: id })
});
if (!res.ok) {
const errText = await res.text();
throw new Error(errText || `HTTP ${res.status}`);
}
showNotification('olap.initSuccess', 'success');
await loadColumns();
} catch (error: any) {
showNotification('olap.initError', 'error', { error: error.message });
} finally {
initializing.value = false;
initializingText.value = '';
pendingRestaurantId.value = null;
pendingRestaurantName.value = '';
selectedRestaurantId.value = null;
}
}
function openEditModal(col: Column) {
editForm.value = {
fieldKey: col.fieldKey,
name: col.name,
typeNormal: col.typeNormal,
aggregationAllowed: col.aggregationAllowed,
groupingAllowed: col.groupingAllowed,
filteringAllowed: col.filteringAllowed
};
editModalOpen.value = true;
}
function closeEditModal() {
editModalOpen.value = false;
}
async function updateField() {
try {
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(editForm.value.fieldKey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editForm.value.name,
typeNormal: editForm.value.typeNormal,
aggregationAllowed: editForm.value.aggregationAllowed,
groupingAllowed: editForm.value.groupingAllowed,
filteringAllowed: editForm.value.filteringAllowed
})
});
if (!res.ok) throw new Error();
showNotification('olap.updateSuccess', 'success');
closeEditModal();
await loadColumns();
} catch (error) {
showNotification('olap.updateError', 'error');
}
}
function openDeleteFieldModal(fieldKey: string) {
deleteFieldConfirm.value = { show: true, fieldKey };
}
async function confirmDeleteField() {
const fieldKey = deleteFieldConfirm.value.fieldKey;
if (!fieldKey) return;
try {
const res = await fetch(`/api/reports/olap/columns/${encodeURIComponent(fieldKey)}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error();
showNotification('olap.deleteSuccess', 'success');
deleteFieldConfirm.value.show = false;
await loadColumns();
} catch (error) {
showNotification('olap.deleteError', 'error');
}
}
function resetFilters() {
filters.value = { fieldKey: '', reportType: '', tag: '' };
}
onMounted(() => {
loadColumns();
});
</script>
<style scoped>
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.z-60 {
z-index: 60;
}
</style>

View File

@@ -19,6 +19,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import su.xserver.iikocon.config.AppConfig; 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.IikoOlapClient; import su.xserver.iikocon.iiko.IikoOlapClient;
import su.xserver.iikocon.service.*; import su.xserver.iikocon.service.*;
@@ -417,6 +418,8 @@ public class MainVerticle extends AbstractVerticle {
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
}); });
new IikoHandler(vertx, router, db, restaurantService);
return router; return router;
} }

View File

@@ -0,0 +1,263 @@
package su.xserver.iikocon.iiko;
import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Router;
import io.vertx.ext.web.RoutingContext;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import su.xserver.iikocon.service.DataBaseService;
import su.xserver.iikocon.service.RestaurantService;
public class IikoHandler {
private final Logger log = LoggerFactory.getLogger("[IikoHandler]");
private final DataBaseService db;
private final Vertx vertx;
private final RestaurantService restaurantService;
public IikoHandler(Vertx vertx, Router router, DataBaseService db, RestaurantService restaurantService) {
this.vertx = vertx;
this.restaurantService = restaurantService;
this.db = db;
createTablesIfNotExist().onFailure(err -> {
log.error("Failed to initialize database", err);
});
router.get("/api/reports/olap/columns").handler(this::getColumns);
router.delete("/api/reports/olap/columns/:fieldKey").handler(this::deleteColumn);
router.post("/api/reports/olap/initialize").handler(this::postInitialize);
}
private void getColumns(RoutingContext ctx) {
getAllFieldsWithReportAndTags()
.onSuccess(ar -> ctx.response()
.putHeader("Content-Type", "application/json")
.end(ar.encodePrettily()))
.onFailure(err -> ctx.response()
.setStatusCode(500)
.end(err.getMessage()));
}
public void deleteColumn(RoutingContext ctx) {
String fieldKey = ctx.pathParam("fieldKey");
String sql = "DELETE FROM iiko_fields_common WHERE field_key = ?";
db.getPool().preparedQuery(sql)
.execute(Tuple.of(fieldKey))
.onSuccess(res -> {
ctx.end();
})
.onFailure(err -> ctx.response().setStatusCode(500).end(err.getMessage()));
}
private void postInitialize(RoutingContext ctx) {
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
ctx.response()
.setStatusCode(400)
.end("Request body is missing or not a JSON object");
return;
}
if (!body.containsKey("restaurantId") || body.getValue("restaurantId") == null) {
ctx.response()
.setStatusCode(400)
.end("restaurantId is required");
return;
}
Integer restaurantId;
try {
restaurantId = body.getInteger("restaurantId");
if (restaurantId == null) {
throw new IllegalArgumentException("restaurantId must be a number");
}
} catch (ClassCastException e) {
ctx.response()
.setStatusCode(400)
.end("restaurantId must be a valid integer");
return;
}
restaurantService.findById(restaurantId)
.onSuccess(rest -> {
IikoOlapClient iiko = new IikoOlapClient(vertx, rest);
iiko.checkConnection()
.onSuccess(ping -> clearTables()
.onSuccess(data -> {
IikoOlapColumnsImporter importer = new IikoOlapColumnsImporter(iiko, db);
importer.fetchAndStoreAll()
.onSuccess(res -> ctx.end("OK"))
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage())))
.onFailure(err -> ctx.response().setStatusCode(400).end(err.getMessage()));
})
.onFailure(err -> ctx.response()
.setStatusCode(400)
.end(err.getMessage()));
}
public Future<JsonObject> getAllFieldsWithReportAndTags() {
String sql = """
SELECT
fc.field_key,
fc.field_key_normal,
fc.name,
fc.type,
fc.type_normal,
fc.aggregation_allowed,
fc.grouping_allowed,
fc.filtering_allowed,
GROUP_CONCAT(DISTINCT rt.name ORDER BY rt.name SEPARATOR ',') AS report_names,
GROUP_CONCAT(DISTINCT t.tag_name ORDER BY t.tag_name SEPARATOR ',') AS tag_names
FROM iiko_fields_common fc
LEFT JOIN iiko_report_type_fields rtf ON fc.field_id = rtf.field_id
LEFT JOIN iiko_report_types rt ON rtf.report_type_id = rt.report_type_id
LEFT JOIN iiko_field_tags ft ON fc.field_id = ft.field_id
LEFT JOIN iiko_tags t ON ft.tag_id = t.tag_id
GROUP BY fc.field_id
ORDER BY fc.field_key
""";
return db.getPool().query(sql).execute()
.map(rows -> {
JsonArray columnsArray = new JsonArray();
for (Row row : rows) {
String reportNamesStr = row.getString("report_names");
JsonArray reportTypes = new JsonArray();
if (reportNamesStr != null && !reportNamesStr.isBlank()) {
for (String name : reportNamesStr.split(",")) {
reportTypes.add(name.trim());
}
}
String tagNamesStr = row.getString("tag_names");
JsonArray tags = new JsonArray();
if (tagNamesStr != null && !tagNamesStr.isBlank()) {
for (String tag : tagNamesStr.split(",")) {
tags.add(tag.trim());
}
}
JsonObject fieldObj = new JsonObject()
.put("fieldKey", row.getString("field_key"))
.put("fieldKeyNormal", row.getString("field_key_normal"))
.put("reportTypes", reportTypes)
.put("name", row.getString("name"))
.put("type", row.getString("type"))
.put("typeNormal", row.getString("type_normal"))
.put("aggregationAllowed", row.getBoolean("aggregation_allowed"))
.put("groupingAllowed", row.getBoolean("grouping_allowed"))
.put("filteringAllowed", row.getBoolean("filtering_allowed"))
.put("tags", tags);
columnsArray.add(fieldObj);
}
return new JsonObject().put("columns", columnsArray);
});
}
private Future<Void> createTablesIfNotExist() {
String createReportTypes = """
CREATE TABLE IF NOT EXISTS iiko_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 createFieldsCommon = """
CREATE TABLE IF NOT EXISTS iiko_fields_common (
field_id INT AUTO_INCREMENT PRIMARY KEY,
field_key VARCHAR(255) NOT NULL UNIQUE,
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
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createReportTypeFields = """
CREATE TABLE IF NOT EXISTS iiko_report_type_fields (
report_type_id INT NOT NULL,
field_id INT NOT NULL,
PRIMARY KEY (report_type_id, field_id),
FOREIGN KEY (report_type_id) REFERENCES iiko_report_types(report_type_id) ON DELETE CASCADE,
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createTags = """
CREATE TABLE IF NOT EXISTS iiko_tags (
tag_id INT AUTO_INCREMENT PRIMARY KEY,
tag_name VARCHAR(100) UNIQUE NOT NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createFieldTags = """
CREATE TABLE IF NOT EXISTS iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields_common(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_tags(tag_id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String idxKeyNormal = "CREATE INDEX IF NOT EXISTS idx_fields_common_key_normal ON iiko_fields_common(field_key_normal)";
String idxFieldName = "CREATE INDEX IF NOT EXISTS idx_fields_common_name ON iiko_fields_common(name)";
String idxFieldTagsTag = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_field_tags(tag_id)";
return db.getPool().query(createReportTypes).execute()
.compose(v -> db.getPool().query(createFieldsCommon).execute())
.compose(v -> db.getPool().query(createReportTypeFields).execute())
.compose(v -> db.getPool().query(createTags).execute())
.compose(v -> db.getPool().query(createFieldTags).execute())
.compose(v -> db.getPool().query(idxKeyNormal).execute())
.compose(v -> db.getPool().query(idxFieldName).execute())
.compose(v -> db.getPool().query(idxFieldTagsTag).execute())
.mapEmpty();
}
private Future<Void> clearTables() {
String sql = """
-- Отключаем проверку внешних ключей
SET FOREIGN_KEY_CHECKS = 0;
-- Удаляем данные из всех таблиц (порядок не важен при отключённой проверке)
DELETE FROM iiko_field_tags;
DELETE FROM iiko_report_type_fields;
DELETE FROM iiko_fields_common;
DELETE FROM iiko_tags;
DELETE FROM iiko_report_types;
-- Сбрасываем счётчики AUTO_INCREMENT (чтобы новые ID начинались с 1)
ALTER TABLE iiko_fields_common AUTO_INCREMENT = 1;
ALTER TABLE iiko_tags AUTO_INCREMENT = 1;
ALTER TABLE iiko_report_types AUTO_INCREMENT = 1;
-- Включаем проверку обратно
SET FOREIGN_KEY_CHECKS = 1;
""";
return db.getPool().query(sql).execute().mapEmpty();
}
}

View File

@@ -20,13 +20,6 @@ public class IikoOlapClient {
private final String iikoLogin; private final String iikoLogin;
private final String iikoPassHash; private final String iikoPassHash;
public IikoOlapClient(Vertx vertx, String host, String login, String passHash, boolean https) {
this.webClient = WebClient.create(vertx);
this.iikoHost = (https ? "https://" : "http://") + host + (https ? ":443" : ":80");
this.iikoLogin = login;
this.iikoPassHash = passHash;
}
public IikoOlapClient(Vertx vertx, JsonObject rest) { public IikoOlapClient(Vertx vertx, JsonObject rest) {
this.webClient = WebClient.create(vertx); this.webClient = WebClient.create(vertx);
this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80"); this.iikoHost = (rest.getBoolean("https") ? "https://" : "http://") + rest.getString("host") + (rest.getBoolean("https") ? ":443" : ":80");
@@ -36,7 +29,7 @@ public class IikoOlapClient {
private Future<String> authenticate() { private Future<String> authenticate() {
Promise<String> promise = Promise.promise(); Promise<String> promise = Promise.promise();
String url = iikoHost + "/resto/api/auth"; //?login=" + iikoLogin + "&pass=" + iikoPassHash; String url = iikoHost + "/resto/api/auth";
webClient.getAbs(url) webClient.getAbs(url)
.addQueryParam("login", iikoLogin) .addQueryParam("login", iikoLogin)
@@ -104,7 +97,6 @@ public class IikoOlapClient {
.onSuccess(resp -> { .onSuccess(resp -> {
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
JsonObject body = resp.bodyAsJsonObject(); JsonObject body = resp.bodyAsJsonObject();
// Если есть обёртка data, распаковываем
JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject JsonObject data = body.containsKey("data") && body.getValue("data") instanceof JsonObject
? body.getJsonObject("data") ? body.getJsonObject("data")
: body; : body;

View File

@@ -1,16 +1,12 @@
package su.xserver.iikocon.iiko; package su.xserver.iikocon.iiko;
import io.vertx.core.Future; 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.JsonArray;
import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonObject;
import io.vertx.mysqlclient.MySQLConnectOptions;
import io.vertx.sqlclient.Pool;
import io.vertx.sqlclient.PoolOptions;
import io.vertx.sqlclient.Tuple; import io.vertx.sqlclient.Tuple;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import su.xserver.iikocon.service.DataBaseService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -18,27 +14,17 @@ import java.util.List;
public class IikoOlapColumnsImporter { public class IikoOlapColumnsImporter {
private static final Logger log = LoggerFactory.getLogger("[IikoOlapColumnsImporter]"); private static final Logger log = LoggerFactory.getLogger("[IikoOlapColumnsImporter]");
private final Pool dbPool; private final DataBaseService db;
private final IikoOlapClient iikoOlapClient; private final IikoOlapClient iikoOlapClient;
private static final List<String> REPORT_TYPES = List.of("SALES", "TRANSACTIONS", "DELIVERIES"); private static final List<String> 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) { public IikoOlapColumnsImporter(IikoOlapClient iikoOlapClient, DataBaseService db) {
this.iikoOlapClient = new IikoOlapClient(vertx, iikoServer, iikoLogin, iikoPassword, true); this.iikoOlapClient = iikoOlapClient;
MySQLConnectOptions connectOptions = new MySQLConnectOptions() this.db = db;
.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);
} }
public Future<Void> fetchAndStoreAll() { public Future<Void> fetchAndStoreAll() {
return createTablesIfNotExist() return processAllReportTypesSequentially()
.compose(v -> processAllReportTypesSequentially())
.onSuccess(v -> log.info("All reports imported successfully")) .onSuccess(v -> log.info("All reports imported successfully"))
.onFailure(err -> log.error("Import failed: {}", err.getMessage())); .onFailure(err -> log.error("Import failed: {}", err.getMessage()));
} }
@@ -57,18 +43,11 @@ public class IikoOlapColumnsImporter {
.compose(columnsJson -> storeColumnsToDb(reportType, columnsJson)); .compose(columnsJson -> storeColumnsToDb(reportType, columnsJson));
} }
// Запрос полей для конкретного reportType
private Future<JsonObject> fetchColumnsFromIiko(String reportType) { private Future<JsonObject> fetchColumnsFromIiko(String reportType) {
Promise<JsonObject> promise = Promise.promise(); return iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns",
new JsonObject().put("reportType", reportType));
iikoOlapClient.handleGet("/resto/api/v2/reports/olap/columns", new JsonObject().put("reportType", reportType))
.onSuccess(promise::complete)
.onFailure(promise::fail);
return promise.future();
} }
// ---------- Методы работы с БД (с префиксом iiko_) ----------
private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) { private Future<Void> storeColumnsToDb(String reportType, JsonObject columns) {
return getOrCreateReportType(reportType) return getOrCreateReportType(reportType)
.compose(reportTypeId -> { .compose(reportTypeId -> {
@@ -82,86 +61,86 @@ public class IikoOlapColumnsImporter {
} }
private Future<Integer> getOrCreateReportType(String reportType) { private Future<Integer> getOrCreateReportType(String reportType) {
Promise<Integer> promise = Promise.promise();
String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?"; String selectSql = "SELECT report_type_id FROM iiko_report_types WHERE name = ?";
dbPool.preparedQuery(selectSql) return db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.execute(Tuple.of(reportType)) .compose(rows -> {
.onComplete(ar -> { if (rows.size() > 0) {
if (ar.succeeded() && ar.result().size() > 0) { return Future.succeededFuture(rows.iterator().next().getInteger("report_type_id"));
promise.complete(ar.result().iterator().next().getInteger("report_type_id"));
} else if (ar.succeeded()) {
String insertSql = "INSERT INTO iiko_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 { } else {
promise.fail(ar.cause()); String insertSql = "INSERT INTO iiko_report_types (name, description) VALUES (?, ?)";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(reportType, "OLAP report type: " + reportType))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(reportType))
.map(rows2 -> rows2.iterator().next().getInteger("report_type_id"))
);
} }
}); });
return promise.future();
} }
/**
* Сохранить одно поле (без дублирования).
* Сначала получаем/создаём запись в iiko_fields_common,
* затем связываем её с report_type_id через iiko_report_type_fields,
* потом обрабатываем теги.
*/
private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) { private Future<Void> storeSingleField(int reportTypeId, String fieldKey, JsonObject fieldDef) {
// Нормализованный ключ (без точек)
String fieldKeyNormal = fieldKey.replace('.', '_'); String fieldKeyNormal = fieldKey.replace('.', '_');
String name = fieldDef.getString("name"); String name = fieldDef.getString("name");
String originalType = fieldDef.getString("type"); String originalType = fieldDef.getString("type");
String typeNormal = normalizeType(originalType); String typeNormal = normalizeType(originalType);
boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false); boolean aggregationAllowed = fieldDef.getBoolean("aggregationAllowed", false);
boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false); boolean groupingAllowed = fieldDef.getBoolean("groupingAllowed", false);
boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false); boolean filteringAllowed = fieldDef.getBoolean("filteringAllowed", false);
JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray()); JsonArray tagsArray = fieldDef.getJsonArray("tags", new JsonArray());
String insertFieldSql = """ return getOrCreateCommonField(fieldKey, fieldKeyNormal, name, originalType, typeNormal,
INSERT INTO iiko_fields ( aggregationAllowed, groupingAllowed, filteringAllowed)
report_type_id, field_key, field_key_normal, name, type, type_normal, .compose(fieldId -> linkFieldToReportType(reportTypeId, fieldId)
aggregation_allowed, grouping_allowed, filtering_allowed .compose(v -> processTags(fieldId, tagsArray))
) );
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( * Найти или создать поле в iiko_fields_common (по уникальному field_key).
reportTypeId, fieldKey, fieldKeyNormal, name, originalType, typeNormal, */
aggregationAllowed, groupingAllowed, filteringAllowed private Future<Integer> getOrCreateCommonField(String fieldKey, String fieldKeyNormal, String name,
)) String type, String typeNormal,
.compose(ignored -> { boolean aggAllowed, boolean groupAllowed, boolean filterAllowed) {
String selectFieldIdSql = "SELECT field_id FROM iiko_fields WHERE report_type_id = ? AND field_key = ?"; String selectSql = "SELECT field_id FROM iiko_fields_common WHERE field_key = ?";
return dbPool.preparedQuery(selectFieldIdSql) return db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.execute(Tuple.of(reportTypeId, fieldKey)) .compose(rows -> {
.compose(rows -> { if (rows.size() > 0) {
if (rows.size() == 0) { return Future.succeededFuture(rows.iterator().next().getInteger("field_id"));
return Future.failedFuture("Field not found after upsert: " + fieldKey); } else {
} String insertSql = """
int fieldId = rows.iterator().next().getInteger("field_id"); INSERT INTO iiko_fields_common
return processTags(fieldId, tagsArray); (field_key, field_key_normal, name, type, type_normal,
}); aggregation_allowed, grouping_allowed, filtering_allowed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
return db.getPool().preparedQuery(insertSql)
.execute(Tuple.of(fieldKey, fieldKeyNormal, name, type, typeNormal,
aggAllowed, groupAllowed, filterAllowed))
.compose(ignored ->
db.getPool().preparedQuery(selectSql).execute(Tuple.of(fieldKey))
.map(rows2 -> rows2.iterator().next().getInteger("field_id"))
);
}
}); });
} }
/**
* Привязать поле к типу отчёта (если ещё не привязано).
*/
private Future<Void> linkFieldToReportType(int reportTypeId, int fieldId) {
String sql = "INSERT IGNORE INTO iiko_report_type_fields (report_type_id, field_id) VALUES (?, ?)";
return db.getPool().preparedQuery(sql).execute(Tuple.of(reportTypeId, fieldId)).mapEmpty();
}
/**
* Обработать теги поля (теги одинаковы для всех типов отчётов).
*/
private Future<Void> processTags(int fieldId, JsonArray tags) { private Future<Void> processTags(int fieldId, JsonArray tags) {
List<Future<Void>> tagFutures = new ArrayList<>(); List<Future<Void>> tagFutures = new ArrayList<>();
for (Object tagObj : tags) { for (Object tagObj : tags) {
@@ -173,93 +152,25 @@ public class IikoOlapColumnsImporter {
} }
private Future<Integer> getOrCreateTag(String tagName) { private Future<Integer> getOrCreateTag(String tagName) {
Promise<Integer> promise = Promise.promise();
String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?"; String selectSql = "SELECT tag_id FROM iiko_tags WHERE tag_name = ?";
dbPool.preparedQuery(selectSql) return db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
.execute(Tuple.of(tagName)) .compose(rows -> {
.onComplete(ar -> { if (rows.size() > 0) {
if (ar.succeeded() && ar.result().size() > 0) { return Future.succeededFuture(rows.iterator().next().getInteger("tag_id"));
promise.complete(ar.result().iterator().next().getInteger("tag_id"));
} else { } else {
String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)"; String insertSql = "INSERT IGNORE INTO iiko_tags (tag_name) VALUES (?)";
dbPool.preparedQuery(insertSql) return db.getPool().preparedQuery(insertSql).execute(Tuple.of(tagName))
.execute(Tuple.of(tagName)) .compose(ignored ->
.onComplete(insAr -> { db.getPool().preparedQuery(selectSql).execute(Tuple.of(tagName))
// После IGNORE всё равно выбираем ID (он мог уже существовать) .map(rows2 -> rows2.iterator().next().getInteger("tag_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<Void> linkFieldTag(int fieldId, int tagId) { private Future<Void> linkFieldTag(int fieldId, int tagId) {
String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)"; String sql = "INSERT IGNORE INTO iiko_field_tags (field_id, tag_id) VALUES (?, ?)";
return dbPool.preparedQuery(sql) return db.getPool().preparedQuery(sql).execute(Tuple.of(fieldId, tagId)).mapEmpty();
.execute(Tuple.of(fieldId, tagId))
.mapEmpty();
}
private Future<Void> createTablesIfNotExist() {
String createReportTypesTable = """
CREATE TABLE IF NOT EXISTS iiko_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 iiko_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 iiko_report_types(report_type_id) ON DELETE RESTRICT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
""";
String createTagsTable = """
CREATE TABLE IF NOT EXISTS iiko_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 iiko_field_tags (
field_id INT NOT NULL,
tag_id INT NOT NULL,
PRIMARY KEY (field_id, tag_id),
FOREIGN KEY (field_id) REFERENCES iiko_fields(field_id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES iiko_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 iiko_fields(report_type_id)";
String createIdxFieldsName = "CREATE INDEX IF NOT EXISTS idx_fields_name ON iiko_fields(name)";
String createIdxFieldTagsTagId = "CREATE INDEX IF NOT EXISTS idx_field_tags_tag_id ON iiko_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) { private String normalizeType(String iikoType) {

View File

@@ -1,43 +0,0 @@
package su.xserver.iikocon.iiko;
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",
"92f2fd99879b0c2466ab8648afb63c49032379c1",
"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();
});
}
}

View File

@@ -2,7 +2,6 @@ package su.xserver.iikocon.test;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
public class DateRangeSetup { public class DateRangeSetup {
public static void main(String[] args) { public static void main(String[] args) {
@@ -10,29 +9,11 @@ public class DateRangeSetup {
// Вычисление dateFrom и dateTo // Вычисление dateFrom и dateTo
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
LocalDate dateFrom = today.minusDays(7); LocalDate dateFrom = today.minusDays(7);
LocalDate dateTo = today;
// Переопределение из аргументов командной строки
if (args.length > 0 && args[0] != null && !args[0].isEmpty()) {
try {
dateFrom = LocalDate.parse(args[0]);
} catch (DateTimeParseException e) {
System.err.println("Ошибка парсинга dateFrom: " + args[0] + ". Используется значение по умолчанию.");
}
}
if (args.length > 1 && args[1] != null && !args[1].isEmpty()) {
try {
dateTo = LocalDate.parse(args[1]);
} catch (DateTimeParseException e) {
System.err.println("Ошибка парсинга dateTo: " + args[1] + ". Используется значение по умолчанию.");
}
}
// Форматирование дат в YYYY-MM-DD // Форматирование дат в YYYY-MM-DD
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
String formattedDateFrom = dateFrom.format(formatter); String formattedDateFrom = dateFrom.format(formatter);
String formattedDateTo = dateTo.format(formatter); String formattedDateTo = today.format(formatter);
System.out.println("dateFrom=" + formattedDateFrom); System.out.println("dateFrom=" + formattedDateFrom);
System.out.println("dateTo=" + formattedDateTo); System.out.println("dateTo=" + formattedDateTo);

View File

@@ -1,183 +0,0 @@
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 java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public class ProxyVerticle extends AbstractVerticle {
private WebClient webClient;
@Override
public void start(Promise<Void> startPromise) {
webClient = WebClient.create(vertx, new WebClientOptions()
.setSsl(true)
.setTrustAll(true)
.setVerifyHost(false));
Router router = Router.router(vertx);
router.post("/api/proxy").handler(this::handlePost);
router.get("/api/proxy").handler(this::handleGet);
int port = 8080;
vertx.createHttpServer()
.requestHandler(router)
.listen(port).onComplete(http -> {
if (http.succeeded()) {
System.out.println("Proxy server started on port " + port);
startPromise.complete();
} else {
startPromise.fail(http.cause());
}
});
}
private void handlePost(RoutingContext ctx) {
String apiServer = System.getenv("IIKO_API_SERVER");
String apiLogin = System.getenv("IIKO_API_LOGIN");
String apiPass = System.getenv("IIKO_API_PASS");
String externalEndpoint = System.getenv("IIKO_API_ENDPOINT");
if (externalEndpoint == null || externalEndpoint.isBlank()) {
externalEndpoint = "/your-endpoint";
}
if (apiServer == null || apiLogin == null || apiPass == null) {
fail(ctx, 500, "Missing required environment variables: IIKO_API_SERVER, IIKO_API_LOGIN, IIKO_API_PASS");
return;
}
JsonObject body = ctx.body().asJsonObject();
if (body == null) {
fail(ctx, 400, "Request body must be JSON");
return;
}
String signature = sha1(apiPass);
String authUrl = "https://" + apiServer + ":443/resto/api/auth?login=" + apiLogin + "&pass=" + signature;
String finalExternalEndpoint = externalEndpoint;
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 targetUrl = "https://" + apiServer + finalExternalEndpoint;
webClient.request(HttpMethod.POST, targetUrl)
.putHeader("Content-Type", "application/json")
.as(BodyCodec.jsonObject())
.sendJsonObject(body)
.onSuccess(apiResp -> {
webClient.getAbs("https://" + apiServer + ":443/resto/api/logout?key=" + token)
.send()
.onFailure(err -> System.err.println("Logout failed: " + err.getMessage()));
if (apiResp.statusCode() == 200) {
ctx.response().setStatusCode(200).end(apiResp.body().encode());
} else {
fail(ctx, apiResp.statusCode(), "External API error: " + apiResp.statusMessage());
}
})
.onFailure(err -> fail(ctx, 500, "Request to external API failed: " + err.getMessage()));
})
.onFailure(err -> fail(ctx, 500, "Auth request failed: " + err.getMessage()));
}
private void handleGet(RoutingContext ctx) {
String presetId = ctx.queryParam("presetId").stream().findFirst().orElse(null);
String dateFrom = ctx.queryParam("dateFrom").stream().findFirst().orElse(null);
String dateTo = ctx.queryParam("dateTo").stream().findFirst().orElse(null);
String server = ctx.queryParam("server").stream().findFirst().orElse(null);
String password = ctx.queryParam("password").stream().findFirst().orElse(null);
String login = ctx.queryParam("login").stream().findFirst().orElse(null);
String type = ctx.queryParam("type").stream().findFirst().orElse(null);
String rootType = ctx.queryParam("rootType").stream().findFirst().orElse(null);
if (server == null || login == null || password == null) {
fail(ctx, 400, "Missing required parameters: server, login, password");
return;
}
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;
if ("entity".equals(type)) {
dataUrl = "https://" + server + "/resto/api/v2/entities/list?key=" + token;
if (rootType != null && !rootType.isBlank()) {
dataUrl += "&rootType=" + rootType;
}
} else {
if (presetId == null || dateFrom == null || dateTo == null) {
fail(ctx, 400, "Missing presetId, dateFrom or dateTo for report request");
return;
}
dataUrl = "https://" + server + "/resto/api/v2/reports/olap/byPresetId/" + presetId +
"?key=" + token + "&dateFrom=" + dateFrom + "&dateTo=" + dateTo;
}
System.out.println("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 -> System.err.println("Logout failed: " + err.getMessage()));
if (dataResp.statusCode() == 200) {
JsonObject responseBody = dataResp.body();
if ("entity".equals(type)) {
ctx.response().setStatusCode(200).end(responseBody.encode());
} else {
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) {
System.err.println("Error: " + message);
ctx.response().setStatusCode(status).end(new JsonObject().put("error", message).encode());
}
}