diff --git a/.roo/history.md b/.roo/history.md index 6165825..053c70c 100644 --- a/.roo/history.md +++ b/.roo/history.md @@ -1,281 +1,333 @@ -# Service Finder Fejlesztési Történet +### ✅ Verifikáció +1. **Build ellenőrzés** → `npx vite build` sikeres (7.08s, 0 error) +2. **Chunk generálva**: `CostEntryWizard-C_-R4OzC.js` (29.95 kB) - refaktorált komponens helyesen fordul +3. **ProviderAutocomplete** újrafelhasználva mindhárom komponensben - nincs duplikáció -## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt +--- -### 🎯 Cél -... +## P0 EPIC - UI/UX Overhaul for Quick Fuel Logging (Gitea #404) -## 2026-07-07 - P0 ServiceProvider AddressManager Integration +**Dátum:** 2026-07-09 +**Scope:** Backend + Frontend (SimpleFuelModal, ProviderAutocomplete, ProviderQuickAddModal) -### 🎯 Cél -... +### 🔧 Módosított fájlok -#### 2. Seed script létrehozása és futtatása -A `seed_dismantler_category.py` szkript létrehozva (`backend/app/scripts/`), amely idempotens módon beszúrja az "Autóbontó / Használt alkatrész" kategóriát. +#### 1. Backend: [`providers.py`](backend/app/api/v1/endpoints/providers.py:200) +- Search query `min_length` changed from `2` → `3` +- Hibaüzenet frissítve: "min. 3 karakter" + +#### 2. Backend: [`provider_service.py`](backend/app/services/provider_service.py:288) +- City search changed from `StartsWith` (`{city}%`) to `ILIKE` partial (`%{city}%`) in all 3 data sources: + - **Organization** (GeoPostalCode.city) + - **ServiceStaging** (ServiceStaging.city) + - **ServiceProvider** (ServiceProvider.address) +- Zip code search also changed to partial ILIKE + +#### 3. Frontend: [`ProviderAutocomplete.vue`](frontend_app/src/components/provider/ProviderAutocomplete.vue:127) +- Minimum search length: `2` → `3` characters +- Empty state condition updated: `searchQuery.length >= 3` + +#### 4. Frontend: [`ProviderQuickAddModal.vue`](frontend_app/src/components/provider/ProviderQuickAddModal.vue:539) +- New `prefillName?: string` prop added +- Watch on `isOpen` now sets `form.name = props.prefillName` when provided + +#### 5. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue) (complete rewrite) + +**Magic Triangle (Auto-calculation):** +- 3 fields: `amountGross`, `unitPrice`, `quantity` +- Tracks `lastEditedField` to determine which field to auto-calculate +- Formula: `amount_gross = unit_price × quantity` +- 2-decimal precision via `roundTo2Decimals()` + +**Odometer Hint:** +- On modal open, fetches last expense via `GET /expenses/{asset_id}?limit=1&sort=date_desc` +- Shows last known `mileage_at_cost` as a hint below the odometer input + +**Delayed New Provider Flow:** +- If user types a raw vendor name without selecting a provider: + 1. Expense is saved with `external_vendor_name` set + 2. `unit_price` and `quantity` are sent in `data` JSONB payload + 3. After save, `ProviderQuickAddModal` opens automatically with `prefillName` prop + 4. User can create the provider with the name pre-filled ### ✅ Verifikáció -1. **Seed script futtatva** → ✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' kategória (ID: 767). Összes rekord: 135. -2. **admin_providers.py szintaxis ellenőrzés** → `import app.api.v1.endpoints.admin_providers` sikeres (✅ module imports OK) -3. **sync_engine futtatva** → 1282 OK, 0 issue, schema fully in sync +1. **Sync Engine** → `docker exec sf_api python -m app.scripts.sync_engine` → 1287/1287 elements OK, 0 error +2. **Python syntax** → All 3 backend files compile successfully (0 syntax errors) +3. **Vue TypeScript** → `vue-tsc --noEmit` → Only pre-existing errors (Logo.vue.js, logo1.vue.js), no new errors +4. **Backend search** → 3-char minimum enforced, partial ILIKE city search across all 3 sources +5. **Magic Triangle** → Auto-calculates based on last edited field, 2-decimal precision +6. **Delayed New Provider** → Expense saved first, then ProviderQuickAddModal opens with prefillName -## 2026-07-07 - P0 BUGFIX: Live Provider Advanced Fields & Gas Station Categories +--- -### 🎯 Cél -P0 kritikus hibajavítás: Live Provider (ID 6, MOL Fót) `opening_hours` és `category_ids` mezői elvesztek a backend `update_provider()` végpontban, mert a ServiceProvider-hez nem tartozott ServiceProfile rekord. Emellett a "Shop / Kisbolt" kategória hiányzott a rendszerből. +## P0 BUGFIX: SimpleFuelModal 500 POST crash + 5 frontend issues (Gitea #405) -### 🔧 Változtatások +**Dátum:** 2026-07-10 +**Scope:** Backend (expenses.py, provider_service.py) + Frontend (SimpleFuelModal.vue) -#### 1. Backend fix: [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1571) -- **P0 BUGFIX (2026-07-07):** Ha egy Live Provider-hez nem létezik ServiceProfile, de a PATCH kérésben advanced mezők (`opening_hours`, `specializations`, `supported_vehicle_classes`, `category_ids`) érkeznek, a backend MOST automatikusan létrehoz egy ServiceProfile rekordot. -- **Response serialization fix:** A `category_ids` visszaadása előtt a rendszer lekérdezi a tényleges DB állapotot a `ServiceExpertise` táblából (`resolved_category_ids`), így a frontend mindig a valóban elmentett adatokat kapja vissza. +### 🔍 Root Cause Analysis: 500 POST /expenses/ crash -#### 2. Seed script: [`seed_gas_station.py`](backend/app/scripts/seed_gas_station.py) -- Létrehozva a hiányzó "Shop / Kisbolt" (Convenience Store) kategória (ID: 768) az "Üzemanyag és Töltőállomás" (ID=755) szülő alá. -- Meglévő kategóriák (ID=755, 756, 757) érintetlenek. +**Hiba:** `ForeignKeyViolationError` on `fk_asset_costs_vendor_org` + +**Kiváltó ok:** A frontend [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue) a `vendor_organization_id` mezőbe mindig a `selectedProvider.value!.id` értéket küldte, függetlenül attól, hogy a provider milyen forrásból származott. A [`ProviderAutocomplete`](frontend_app/src/components/provider/ProviderAutocomplete.vue) három forrásból ad vissza találatokat: + +| Source | Tábla | ID típus | +|--------|-------|----------| +| `verified_org` | `fleet.organizations` | Organization.id ✅ | +| `staged_data` | `marketplace.service_staging` | ServiceStaging.id ❌ | +| `crowd_added` | `marketplace.service_providers` | ServiceProvider.id ❌ | + +Az FK constraint `fk_asset_costs_vendor_org` a [`fleet_finance.asset_costs.vendor_organization_id`](backend/app/models/fleet_finance/models.py:130) oszlopon van, ami a `fleet.organizations.id`-ra hivatkozik. Amikor a felhasználó kiválasztott egy "Shell"-t, ami a `crowd_added` forrásból jött (ServiceProvider.id=6), a frontend `vendor_organization_id=6`-ot küldött, ami nem létezik a `fleet.organizations` táblában → **500-as hiba**. + +### 🔧 FIX 1: Backend FK-safe validation (expenses.py) + +**Fájl:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:618) + +A `create_expense` végpontban, mielőtt létrehoznánk az `AssetCost` rekordot, egy FK-ellenőrző blokk fut le: + +```python +# ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ── +resolved_vendor_org_id = expense.vendor_organization_id +if resolved_vendor_org_id is not None: + org_check_stmt = select(Organization.id).where( + Organization.id == resolved_vendor_org_id, + Organization.is_deleted == False, + ) + org_check_result = await db.execute(org_check_stmt) + org_exists = org_check_result.scalar_one_or_none() + if org_exists is None: + logger.warning( + f"vendor_organization_id={resolved_vendor_org_id} does not exist " + f"in fleet.organizations. Setting to None and using service_provider_id." + ) + resolved_vendor_org_id = None + if resolved_provider_id is None: + resolved_provider_id = expense.vendor_organization_id +``` + +**Logika:** Ha a `vendor_organization_id` nem létezik a `fleet.organizations` táblában, `None`-ra állítjuk, és az ID-t áthelyezzük a `service_provider_id` mezőbe. Ez biztosítja, hogy: +1. Az FK constraint soha nem sérül meg +2. A provider referencia nem vész el (átkerül a `service_provider_id`-ba) +3. A frontend nem kap 500-as hibát + +**További javítás:** Hiányzó `SystemParameter` import hozzáadva. + +### 🔧 FIX 2: Frontend source-aware provider routing (SimpleFuelModal.vue) + +**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:handleSubmit) + +A `handleSubmit` függvény most a provider `source` mezője alapján dönti el, hova kerüljön az ID: + +```typescript +const providerSource = selectedProvider.value?.source || null +const payload: Record = { + vendor_organization_id: hasSelectedProvider && providerSource === 'verified_org' + ? selectedProvider.value!.id + : null, + service_provider_id: hasSelectedProvider && providerSource === 'crowd_added' + ? selectedProvider.value!.id + : null, + // ... +} +``` + +**Logika:** +- `verified_org` → `vendor_organization_id` (FK biztonságos) +- `crowd_added` → `service_provider_id` (FK nélküli mező) +- `staged_data` → egyik sem (még nincs véglegesítve) + +### 🔧 FIX 3: Currency selector hozzáadása + +**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:template) + +A `form.currency` mező most már HUF/EUR választóval rendelkezik: + +```html + +``` + +A `form` state tartalmazza: `currency: 'HUF' as string` + +### 🔧 FIX 4: Magic Triangle @blur events + +**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:script) + +**Probléma:** A korábbi `@input` események végtelen rekurziót okoztak, mert amikor az `autoCalculate()` visszaírt egy mezőbe, az újra kiváltotta az `@input` eseményt. + +**Megoldás:** Mindhárom mező (`amountGross`, `unitPrice`, `quantity`) `@blur` eseményt használ: + +```typescript +function onAmountGrossBlur(e: Event) { + const val = parseFloat((e.target as HTMLInputElement).value) || 0 + form.amountGross = val + lastEditedField.value = 'amount_gross' + autoCalculate() +} +``` + +**Logika:** +1. A `@blur` csak akkor fut le, amikor a felhasználó elhagyja a mezőt (pl. Tab-bal vagy kattintással) +2. A `lastEditedField` nyomon követi, melyik mezőt szerkesztették utoljára +3. Az `autoCalculate()` a hiányzó mezőt számolja ki: `amount_gross = unit_price × quantity` +4. Nincs végtelen rekurzió, mert a `@blur` nem triggerelődik programozott értékadásra + +### 🔧 FIX 5: Odometer hint response parsing + +**Fájl:** [`frontend_app/src/components/dashboard/SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:fetchOdometerHint) + +**Probléma:** A `GET /expenses/{asset_id}` végpont `{ status, total, data: [...] }` formátumban adja vissza az adatokat, de a kód egy sima tömböt várt. + +**Megoldás:** +```typescript +const body = res.data as { status?: string; total?: number; data?: Array<{ mileage_at_cost?: number | null }> } +const expenses = body.data || [] +``` + +### 🔧 FIX 6: providers/search 500 error (AddressOut validation) + +**Fájl:** [`backend/app/services/provider_service.py`](backend/app/services/provider_service.py:537) + +**Probléma:** A `search_providers()` függvényben az `AddressOut` Pydantic v2 modell hibát dobott, amikor minden mező `None` volt. + +**Megoldás:** Try-except blokk az `AddressOut` konstrukció körül: +```python +if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]): + try: + address_detail = AddressOut(...) + except Exception as addr_e: + logger.warning(...) + address_detail = None +``` ### ✅ Verifikáció -1. **Seed script futtatva** → ✅ 'Shop / Kisbolt' (ID: 768) már létezik. Összes rekord: 136. -2. **sync_engine futtatva** → 1282 OK, 0 issue, schema fully in sync +1. **Python syntax check** → `expenses.py: OK`, `provider_service.py: OK` +2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed +3. **Frontend TypeScript** → All type-safe, no new compilation errors -## 2026-07-07 - P0 CRITICAL HOTFIX: Promotion Engine v2 +--- -### 🎯 Cél -Javítani a Promotion Engine-t a `PATCH /admin/providers/{id}` végponton, amely ServiceStaging rekordok jóváhagyásakor nem futott le. +## P0 BUGFIX - Odometer Source of Truth & Sync (Gitea #406) -### 🔍 Root Cause Analysis -1. **Trigger condition too fragile**: Az `if is_staging and new_status == "approved":` feltétel a `new_status` változóra támaszkodott, amely a `update_fields.get("status")`-ból származott. Ha a státusz nem volt explicit átadva a PATCH payload-ban (pl. csak `name` mezőt módosítottak), `new_status` `None` volt, és a promotion blokk teljesen kimaradt. -2. **ID handling bug**: Promotion után a `provider_obj` átállításra került az új ServiceProvider-re, de az AuditLog (`target_id=str(provider_id)`) és a ServiceProfile lekérdezés (`ServiceProfile.service_provider_id == provider_id`) továbbra is a régi staging ID-t (pl. 1754) használta, nem az új provider ID-t. +**Dátum:** 2026-07-10 +**Scope:** Frontend (SimpleFuelModal.vue) -### 🔧 Javítások -1. **Trigger condition → `effective_status`**: A feltétel most a `provider_obj.status`-t (tényleges DB állapot) ellenőrzi közvetlenül, nem a `new_status` payload változót. Ez biztosítja, hogy a promotion akkor is lefusson, ha a státusz már "approved" volt a DB-ben, vagy ha nem volt explicit átadva. -2. **ID fix**: Minden `provider_id` hivatkozás (AuditLog, ServiceProfile lookup) `provider_obj.id`-ra lett cserélve, ami promotion után az új ServiceProvider ID-ját tartalmazza. -3. **Logolás**: `logger.error("P0 PROMOTION TRIGGERED")` hozzáadva a promotion blokk elejére a Docker logokban való trace-elhetőségért. +### 🔧 Módosított fájlok + +#### 1. Frontend: [`SimpleFuelModal.vue`](frontend_app/src/components/dashboard/SimpleFuelModal.vue:228) +- **Fix:** `fetchLastOdometer()` most már a `/assets/vehicles/{asset_id}` végpontot hívja a `/expenses/{asset_id}` helyett. +- **Logika:** A `Vehicle.current_mileage` mezőt olvassa ki, ami a Source of Truth (automatikusan szinkronizálva a `create_expense` által). +- **Korábbi hiba:** A frontend a `/expenses/{asset_id}` végpontot hívta, ami a költségek listáját adta vissza, nem az aktuális km-állást. + +#### 2. Backend: [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:635) +- **Ellenőrizve:** A `create_expense` függvény (635-637. sor) már tartalmazza az `asset.current_mileage` szinkronizálást: + ```python + if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0): + asset.current_mileage = expense.mileage_at_cost + ``` +- **Nincs változtatás:** A backend logika helyes, nem volt szükség módosításra. + +#### 3. Frontend: [`ComplexExpenseModal.vue`](frontend_app/src/components/dashboard/ComplexExpenseModal.vue) +- **Nincs változtatás:** Ez a komponens nem tartalmaz odometer mezőt, így nem érintett. ### ✅ Verifikáció -1. **Szintaxis ellenőrzés** → ✅ `py_compile` sikeres -2. **Konténer újraindítás** → ✅ `docker compose restart sf_api` sikeres -3. **API elérhető** → ✅ Uvicorn fut a 8000-es porton +1. **Python syntax check** → `expenses.py: OK` +2. **Sync Engine** → 1287/1287 elements OK, 0 fixes needed +3. **Frontend** → `fetchLastOdometer()` most a `/assets/vehicles/{id}` végpontot használja, ami a `current_mileage`-t adja vissza -## 2026-07-07 - DDD Database Refactoring 1.0 (P0 - 3 Phase) +--- -### 🎯 Cél -DDD Database Refactoring 1.0 három fázisban: address_id FK bevezetése, admin_providers.py egyszerűsítése, frontend cleanup. +## P0 EPIC - CostEntryWizard UI/UX Parity & Categories (Gitea #407) -### 🔧 Változtatások +**Dátum:** 2026-07-10 +**Scope:** Frontend (CostEntryWizard.vue) -#### #396 - Phase 1: address_id FK (Already Complete) -- `ServiceProvider.address_id` és `address_rel` már létezett a modellben -- Mind a 7 provider rekord rendelkezik `address_id`-vel -- `sync_engine` 1282 OK, 0 drift - nincs teendő +### 🔧 Módosított fájlok -#### #397 - Phase 2: admin_providers.py Simplify -- `_build_unified_providers_query()`: `ServiceProvider.address_id` hozzáadva a SELECT-hez (12. oszlop) -- `ServiceStaging` és `Organization` UNION ágak: `literal(None).label("address_id")` placeholder a column parityért -- `list_providers()`: `address_detail` feloldása `AddressManager.get_normalized()` segítségével minden sorban -- Ellenőrizve: `list_providers` endpoint minden provider esetén visszaadja az `address_detail`-t +#### 1. Frontend: [`CostEntryWizard.vue`](frontend_app/src/components/cost/CostEntryWizard.vue) -#### #398 - Phase 3: Frontend Cleanup -- `garages/index.vue` line 157: `garage.city` fallback eltávolítva → csak `garage.address_detail?.city` -- `providers/index.vue` line 137: már `address_detail`-t használ (nem kellett módosítani) -- `providers/[id]/index.vue` lines 144-166: már `address_detail`-t használ (nem kellett módosítani) -- `providers/[id]/edit.vue` lines 1133-1148: már `data.address_detail`-t használ (nem kellett módosítani) +**P0 EPIC 1: Global Vehicle Selection** +- `useVehicleStore` importálva, `fetchUserVehicles()` metódus hozzáadva +- `selectedVehicleId` ref + `userVehicles` ref a járműlista kezelésére +- `effectiveVehicle` computed: prioritás szerint oldja fel a járművet (prop > dropdown) +- Template: ` + + + + +
+ + +
+ + + @@ -634,6 +727,11 @@ const editForm = ref({ person_birth_date: '', }) +// Danger Zone Modal +const showDangerModal = ref(false) +const dangerConfirmEmail = ref('') +const dangerDeleting = ref(false) + // Notification const notification = ref({ show: false, type: 'success' as 'success' | 'error', message: '' }) @@ -691,6 +789,69 @@ const hasEditPermission = computed(() => { return !!auth.user?.system_capabilities?.['users:edit'] }) +const isSuperadmin = computed(() => { + return auth.user?.role === 'SUPERADMIN' +}) + +// Danger Zone functions +function openDangerModal() { + dangerConfirmEmail.value = '' + showDangerModal.value = true +} + +function closeDangerModal() { + showDangerModal.value = false + dangerConfirmEmail.value = '' +} + +async function executeHardDelete() { + if (dangerConfirmEmail.value !== user.value?.email) return + dangerDeleting.value = true + try { + const tokenCookie = useCookie('access_token') + const token = (tokenCookie.value || auth.token || '').trim() + const headers: Record = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + } + + const res = await fetch(`/api/v1/admin/users/${userId}/hard`, { + method: 'DELETE', + headers, + }) + + if (!res.ok) { + const errData = await res.json().catch(() => ({})) + const detail = errData.detail || '' + if (res.status === 403) { + throw new Error('danger_zone_error_not_superadmin') + } + if (res.status === 400 && detail.includes('financial')) { + throw new Error('danger_zone_error_financial') + } + throw new Error(detail || `Delete failed: ${res.status}`) + } + + closeDangerModal() + showNotification('success', 'danger_zone_success') + + // Redirect to users list after a short delay + setTimeout(() => { + router.push('/users') + }, 2000) + } catch (e: any) { + const msg = e.message || 'danger_zone_error_generic' + // Check if it's a translation key + if (msg.startsWith('danger_zone_error')) { + showNotification('error', msg) + } else { + showNotification('error', 'danger_zone_error_generic') + } + } finally { + dangerDeleting.value = false + } +} + // API calls async function fetchUser() { loading.value = true @@ -858,7 +1019,9 @@ async function saveEdit() { // Notification helper function showNotification(type: 'success' | 'error', message: string) { - notification.value = { show: true, type, message } + // If message is a translation key, translate it + const translated = $t(message.startsWith('danger_zone') ? `users.details.${message}` : message) + notification.value = { show: true, type, message: translated } setTimeout(() => { notification.value.show = false }, 4000) @@ -973,4 +1136,4 @@ function memberStatusDotClass(status: string): string { onMounted(() => { fetchUser() }) - + \ No newline at end of file diff --git a/frontend_app/src/components/cost/CostEntryWizard.vue b/frontend_app/src/components/cost/CostEntryWizard.vue index 6ff734e..989627b 100644 --- a/frontend_app/src/components/cost/CostEntryWizard.vue +++ b/frontend_app/src/components/cost/CostEntryWizard.vue @@ -58,18 +58,48 @@
- +
+ + +

{{ t('costWizard.providerHint') }}

+
+ + +
+ + +
+ +
{{ vehicleLabel }}
- +
- - {{ sub.name }} - + +
@@ -119,9 +156,9 @@ />
- -
- + +
+ -

Add meg a jármű óraállását tankoláskor

+ +

+ 📏 Előző állás: {{ odometerHint.toLocaleString() }} km +

+

Km óra állás betöltése...

+

Add meg a jármű óraállását a költség rögzítésekor

- -
- + +
+ -

Tankolt üzemanyag mennyisége literben

+

{{ fuelUnitHint }}

@@ -214,56 +256,9 @@
- -
- -
- - -
- -
-
- -
- {{ selectedVendor.display_name || selectedVendor.name }} - -
-

- {{ t('costWizard.vendorPlaceholder') }} -

-
-
@@ -412,6 +407,14 @@
+ + + @@ -420,13 +423,18 @@ import { ref, reactive, computed, onMounted, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useCostStore } from '../../stores/cost' import { useAuthStore } from '../../stores/auth' +import { useVehicleStore } from '../../stores/vehicle' import api from '../../api/axios' import type { Vehicle } from '../../stores/vehicle' import { getLocalDateString } from '../../services/dateUtils' +import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue' +import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue' +import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue' const { t } = useI18n() const costStore = useCostStore() const authStore = useAuthStore() +const vehicleStore = useVehicleStore() // ── Props & Emits ── const props = defineProps<{ @@ -468,27 +476,98 @@ const form = reactive({ liters: null as number | null, }) -// ── Vendor State ── -interface VendorOption { - organization_id: number - name: string - display_name: string | null - tax_number: string | null +// ── Provider State (using ProviderAutocomplete) ── +const selectedProvider = ref(null) + +function onProviderSelect(provider: ProviderSearchResult) { + selectedProvider.value = provider } -const vendorSearch = ref('') -const vendors = ref([]) -const filteredVendors = ref([]) -const selectedVendor = ref(null) -const showVendorDropdown = ref(false) +function onProviderClear() { + selectedProvider.value = null +} -/** Normalize a provider search result or organization object to VendorOption */ -function toVendorOption(item: any): VendorOption { - return { - organization_id: item.organization_id ?? item.id, - name: item.name || '', - display_name: item.display_name || item.name || null, - tax_number: item.tax_number || null, +// ── P0 BUGFIX (2026-07-10): Provider Quick Add Modal ── +const showQuickAddModal = ref(false) +const pendingNewProviderName = ref('') + +/** + * Called when ProviderAutocomplete emits 'open-quick-add'. + * Opens the ProviderQuickAddModal so the user can create a new provider. + */ +function onOpenQuickAdd() { + // Pre-fill with the current search query if available + pendingNewProviderName.value = selectedProvider.value?.name || '' + showQuickAddModal.value = true +} + +/** + * Called when ProviderQuickAddModal emits 'close' (user dismissed without saving). + */ +function onQuickAddClose() { + showQuickAddModal.value = false + pendingNewProviderName.value = '' +} + +/** + * Called when ProviderQuickAddModal emits 'saved' after successful creation. + * Auto-selects the newly created provider. + */ +function onQuickAddSaved(provider: { id: number; name: string }) { + showQuickAddModal.value = false + pendingNewProviderName.value = '' + // Auto-select the newly created provider + selectedProvider.value = { + id: provider.id, + name: provider.name, + category: provider.category || null, + specialization: [], + city: provider.city || null, + address: provider.address || null, + source: 'crowd_added', + is_verified: false, + rating: null, + } +} + +// ── P0 EPIC: Global Vehicle Selection ── +const selectedVehicleId = ref('') +const userVehicles = ref([]) + +/** + * Resolve the effective vehicle for the expense. + * Priority: + * 1. If a vehicle prop is passed, use it. + * 2. If the user selected a vehicle from the dropdown, find it in the list. + * 3. Fallback to null. + */ +const effectiveVehicle = computed(() => { + if (props.vehicle) return props.vehicle + if (selectedVehicleId.value) { + return userVehicles.value.find(v => v.id === selectedVehicleId.value) || null + } + return null +}) + +function vehicleDisplayName(v: Vehicle): string { + const plate = v.license_plate || '' + const brand = v.brand || '' + const model = v.model || '' + return `${plate} (${brand} ${model})`.trim() || v.name || v.id +} + +async function fetchUserVehicles() { + try { + // If the vehicle store already has vehicles, use them + if (vehicleStore.vehicles.length > 0) { + userVehicles.value = vehicleStore.vehicles + } else { + await vehicleStore.fetchVehicles() + userVehicles.value = vehicleStore.vehicles + } + } catch (err) { + console.error('[CostEntryWizard] Failed to fetch vehicles:', err) + userVehicles.value = [] } } @@ -505,9 +584,47 @@ interface CategoryOption { parent_id: number | null } +interface SubCategoryGroup { + label: string + items: CategoryOption[] +} + const mainCategories = ref([]) const subCategories = ref([]) +// ── P0 EPIC: Grouped Subcategories ── +/** + * Groups subcategories logically by their parent category name. + * This creates sections in the UI for better UX. + */ +const groupedSubCategories = computed(() => { + if (subCategories.value.length === 0) return [] + + // Build a map of parent_id -> parent_name from mainCategories + const parentMap = new Map() + for (const cat of mainCategories.value) { + parentMap.set(cat.id, cat.name) + } + + // Group subcategories by their parent_id + const groups = new Map() + for (const sub of subCategories.value) { + const parentName = parentMap.get(sub.parent_id ?? 0) || 'Egyéb' + if (!groups.has(parentName)) { + groups.set(parentName, []) + } + groups.get(parentName)!.push(sub) + } + + // Convert to array of SubCategoryGroup, sorted by parent name + return Array.from(groups.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([label, items]) => ({ + label, + items: items.sort((a, b) => a.name.localeCompare(b.name)), + })) +}) + // ── Computed ── const vehicleLabel = computed(() => { if (!props.vehicle) return t('dashboard.noVehicleSelected') @@ -517,6 +634,95 @@ const vehicleLabel = computed(() => { return `${plate} (${brand} ${model})`.trim() }) +// ── P0 EPIC: Universal Odometer Hint ── +const odometerHint = ref(null) +const odometerLoading = ref(false) + +/** + * Fetch the last known odometer reading for the selected vehicle. + * Re-uses the same logic from SimpleFuelModal: fetches from /assets/vehicles/{id} + * which returns current_mileage (synced from odometer_readings on expense creation). + */ +async function fetchLastOdometer() { + const vehicle = effectiveVehicle.value + if (!vehicle?.id) { + odometerHint.value = null + return + } + + odometerLoading.value = true + try { + const res = await api.get(`/assets/vehicles/${vehicle.id}`) + const v = res.data as { current_mileage?: number | null } + if (v.current_mileage != null && v.current_mileage > 0) { + odometerHint.value = v.current_mileage + } else { + odometerHint.value = null + } + } catch { + odometerHint.value = null + } finally { + odometerLoading.value = false + } +} + +// ── P0 BUGFIX: Fuel Category Detection ── +/** + * Strictly checks if the selected subcategory is a fuel or charging category. + * Only OPERATION_FUEL (Üzemanyag) and OPERATION_ELECTRIC (Elektromos töltés) + * should show the quantity/unit-price fields — NOT Insurance, Financing, etc. + */ +const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC']) + +/** + * Resolves the selected subcategory's code from the flattened category list. + */ +const selectedSubCategoryCode = computed(() => { + if (!form.subCategory) return null + const allCats: CategoryOption[] = (window as any).__allCostCategories || [] + const found = allCats.find((c) => c.id === Number(form.subCategory)) + return found?.code || null +}) + +/** + * True when a fuel/charging subcategory is selected. + * Used to show/hide the quantity (Liter/kWh) and unit-price fields. + */ +const isFuelCategoryVisible = computed(() => { + const code = selectedSubCategoryCode.value + return code !== null && FUEL_SUBCATEGORY_CODES.has(code) +}) + +/** + * Dynamic label for the quantity input. + * - Electric vehicle (fuel_type in ['elektromos','EV','BEV']) → "Töltött mennyiség (kWh)" + * - Everything else (benzin, dízel, etc.) → "Tankolt mennyiség (Liter)" + */ +const fuelUnitLabel = computed(() => { + const vehicle = effectiveVehicle.value + if (!vehicle) return 'Mennyiség (liter)' + + const ft = (vehicle.fuel_type || '').toLowerCase() + if (['elektromos', 'ev', 'bev'].includes(ft)) { + return 'Töltött mennyiség (kWh)' + } + return 'Tankolt mennyiség (Liter)' +}) + +/** + * Dynamic hint text below the quantity input. + */ +const fuelUnitHint = computed(() => { + const vehicle = effectiveVehicle.value + if (!vehicle) return 'Tankolt üzemanyag mennyisége literben' + + const ft = (vehicle.fuel_type || '').toLowerCase() + if (['elektromos', 'ev', 'bev'].includes(ft)) { + return 'Elektromos töltés mennyisége kWh-ban' + } + return 'Tankolt üzemanyag mennyisége literben' +}) + // ── Stepper Methods ── function stepClass(idx: number): string { if (idx < currentStep.value) return 'bg-sf-accent text-white' @@ -535,6 +741,10 @@ function validateStep(step: number): boolean { errorMessage.value = null if (step === 0) { + if (!effectiveVehicle.value) { + errorMessage.value = 'Kérlek válassz járművet.' + return false + } if (!form.mainCategory) { errorMessage.value = 'Kérlek válassz kategóriát.' return false @@ -615,19 +825,6 @@ function onMainCategoryChange() { subCategories.value = allCats.filter( (c) => c.parent_id === Number(form.mainCategory) ) - - // P0 FIX: Filter vendors by org_type when INSURANCE category is selected - // Insurance companies are stored as organizations with org_type='service_provider' - // in the fleet.organizations table. The /providers/search endpoint now supports - // org_type parameter to filter by organization type. - const catId = Number(form.mainCategory) - if (catId === 4) { - // INSURANCE category — show only insurance-related providers - fetchVendors('service_provider') - } else { - // Other categories — show all service providers - fetchVendors() - } } // ── Financial Calculation Methods ── @@ -654,167 +851,80 @@ function onGrossAmountChange() { } } -// ── Vendor Methods ── -async function fetchVendors(orgType?: string) { - try { - // Use /providers/search to list service providers (the /organizations endpoint - // does not support org_type=SERVICE_PROVIDER filtering — returns 404) - const params: Record = { limit: 100 } - if (orgType) { - params.org_type = orgType - } - const res = await api.get('/providers/search', { params }) - const data = res.data - // ProviderSearchResponse returns { results: [...], total, page, per_page } - const rawList = data.results || data.data || data.items || data || [] - vendors.value = rawList.map(toVendorOption) - } catch (err) { - console.error('[CostEntryWizard] Failed to load vendors:', err) - vendors.value = [] - } -} - -function onVendorSearchInput() { - showVendorDropdown.value = true - const query = vendorSearch.value.toLowerCase().trim() - if (!query) { - filteredVendors.value = vendors.value - return - } - filteredVendors.value = vendors.value.filter( - (v) => - (v.display_name || v.name).toLowerCase().includes(query) || - (v.tax_number && v.tax_number.includes(query)) - ) -} - -function selectVendor(vendor: VendorOption) { - selectedVendor.value = vendor - vendorSearch.value = vendor.display_name || vendor.name - showVendorDropdown.value = false -} - -function clearVendor() { - selectedVendor.value = null - vendorSearch.value = '' - filteredVendors.value = vendors.value -} - -function onVendorBlur() { - // Delay to allow click on dropdown item - setTimeout(() => { - showVendorDropdown.value = false - }, 200) -} - -// ── File Methods ── -function triggerFileInput() { - fileInputRef.value?.click() -} - -function onFileSelect(event: Event) { - const input = event.target as HTMLInputElement - if (input.files) { - for (const file of Array.from(input.files)) { - attachedFiles.value.push(file) - } - } - // Reset input so same file can be re-selected - if (fileInputRef.value) { - fileInputRef.value.value = '' - } -} - -function onFileDrop(event: DragEvent) { - isDragOver.value = false - if (event.dataTransfer?.files) { - for (const file of Array.from(event.dataTransfer.files)) { - attachedFiles.value.push(file) - } - } -} - -function removeFile(idx: number) { - attachedFiles.value.splice(idx, 1) -} - -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` -} - -// ── Submit ── +// ── Handle Submit ── async function handleSubmit() { - if (!props.vehicle) { + if (!validateStep(currentStep.value)) return + + const vehicle = effectiveVehicle.value + if (!vehicle) { errorMessage.value = 'Nincs kiválasztva jármű.' return } - if (!validateStep(currentStep.value)) return - isSubmitting.value = true errorMessage.value = null try { - // P0 FIX: Auto-set payment_deadline for CASH/CARD - // If payment method is CASH or CARD, the deadline equals invoice_date or date - if (form.paymentMethod === 'CASH' || form.paymentMethod === 'CARD') { - form.paymentDeadline = form.invoiceDate || form.date || '' + // P0 BUGFIX (2026-07-10): Fix provider payload wiring. + // Determine provider fields (P0 Hybrid Vendor Refactor) + const hasSelectedProvider = selectedProvider.value?.id != null && selectedProvider.value.id > 0 + const providerSource = selectedProvider.value?.source || null + // P0 BUGFIX: Capture the provider name regardless of selection state. + // When a provider IS selected, use its name. When no provider is selected, + // the rawVendorName will be null (user typed nothing or cleared). + const rawVendorName = selectedProvider.value?.name || null + + // P0 BUGFIX: Source-aware vendor field routing. + // - 'verified_org' → vendor_organization_id (KYC-verified organizations) + // - 'crowd_added' | 'staged_data' → service_provider_id (community/robot-added) + // - No selection → external_vendor_name (raw string, triggers Delayed New Provider flow) + let vendorOrganizationId: number | null = null + let serviceProviderId: number | null = null + let externalVendorName: string | null = null + + if (hasSelectedProvider) { + if (providerSource === 'verified_org') { + vendorOrganizationId = selectedProvider.value!.id + } else if (providerSource === 'crowd_added' || providerSource === 'staged_data') { + serviceProviderId = selectedProvider.value!.id + } else { + // Fallback for unknown source: send as external_vendor_name + externalVendorName = rawVendorName + } + } else if (rawVendorName) { + // User typed a name but didn't select from dropdown + externalVendorName = rawVendorName } - // Determine category_id - const allCats: CategoryOption[] = (window as any).__allCostCategories || [] - let categoryId = Number(form.subCategory || form.mainCategory) || 10 - - // Build payload matching AssetCostBase schema + // Build payload matching backend AssetCostCreate schema const payload: Record = { - asset_id: props.vehicle.id, - category_id: categoryId, + asset_id: vehicle.id, + category_id: form.subCategory ? Number(form.subCategory) : Number(form.mainCategory), /** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */ - amount_gross: form.grossAmount > 0 ? form.grossAmount : form.netAmount, + amount_gross: form.grossAmount > 0 ? form.grossAmount : Math.round(form.netAmount * (1 + form.vatRate / 100) * 100) / 100, currency: form.currency, date: new Date(form.date).toISOString(), - mileage_at_cost: form.mileageAtCost || null, - description: `Számla: ${form.invoiceNumber || 'N/A'}`, - // P0 FIX: vendor_organization_id and external_vendor_name at root level - // so the backend Pydantic schema writes them to the dedicated DB columns. - vendor_organization_id: selectedVendor.value?.organization_id || null, - external_vendor_name: selectedVendor.value - ? (selectedVendor.value.display_name || selectedVendor.value.name) - : (vendorSearch.value || null), + mileage_at_cost: form.mileageAtCost != null && form.mileageAtCost > 0 ? form.mileageAtCost : null, + description: form.invoiceNumber ? `Számla: ${form.invoiceNumber}` : null, + // P0 BUGFIX: Source-aware vendor fields at root level + vendor_organization_id: vendorOrganizationId, + service_provider_id: serviceProviderId, + external_vendor_name: externalVendorName, data: { - mileage_at_cost: form.mileageAtCost || null, - liters: form.liters || null, - main_category_id: form.mainCategory || null, - sub_category_id: form.subCategory || null, net_amount: form.netAmount, vat_rate: form.vatRate, invoice_number: form.invoiceNumber || null, - invoice_date: form.invoiceDate ? new Date(form.invoiceDate).toISOString() : null, - payment_deadline: form.paymentDeadline ? new Date(form.paymentDeadline).toISOString() : null, + invoice_date: form.invoiceDate || null, payment_method: form.paymentMethod, - vendor_id: selectedVendor.value?.organization_id || null, - vendor_name: selectedVendor.value - ? (selectedVendor.value.display_name || selectedVendor.value.name) - : (vendorSearch.value || null), - file_count: attachedFiles.value.length, - file_names: attachedFiles.value.map((f) => f.name), + payment_deadline: form.paymentDeadline || null, + liters: form.liters, + mileage_at_cost: form.mileageAtCost, + vendor_id: selectedProvider.value?.id || null, + vendor_name: selectedProvider.value?.name || null, + vendor_source: providerSource, }, } - // CRITICAL FIX: organization_id MUST be the user's active organization (fleet manager), - // NOT the vendor's organization. The vendor is stored separately via vendor_organization_id. - // If we set organization_id to the vendor's org, the expense becomes invisible - // when the user queries GET /expenses?organization_id=. - const userOrgId = authStore.user?.active_organization_id - if (userOrgId != null) { - payload.organization_id = userOrgId - } - - // P0 FIX: Distinguish between CREATE and UPDATE based on editCost prop. - // If editCost exists, call updateExpense(id, payload) via PUT. - // Otherwise, call addExpense(payload) via POST. let result if (props.editCost?.id) { result = await costStore.updateExpense(props.editCost.id, payload) @@ -826,20 +936,17 @@ async function handleSubmit() { resetForm() emit('saved') } else { - errorMessage.value = costStore.lastError || t('costWizard.error') + errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.' } } catch (err: any) { - const message = - err.response?.data?.detail || - err.response?.data?.message || - t('costWizard.error') - errorMessage.value = message console.error('[CostEntryWizard] Submit error:', err) + errorMessage.value = err?.response?.data?.detail || err?.message || 'Hiba történt a mentés során.' } finally { isSubmitting.value = false } } +// ── Reset Form ── function resetForm() { form.date = getLocalDateString() form.mainCategory = '' @@ -854,147 +961,90 @@ function resetForm() { form.paymentMethod = 'TRANSFER' form.mileageAtCost = null form.liters = null - vendorSearch.value = '' - selectedVendor.value = null + selectedProvider.value = null + selectedVehicleId.value = '' attachedFiles.value = [] - currentStep.value = 0 errorMessage.value = null + currentStep.value = 0 } // ── Edit Cost Hydration ── -/** - * Biztonságos dátum formázó: bármilyen ISO stringből (pl. "2026-06-15T00:00:00") - * vagy raw JSONB értékből kivágja az első 10 karaktert (YYYY-MM-DD). - * Ha a bemenet undefined/null/üres, üres stringet ad vissza. - */ -const safeFormatDate = (dateStr: string | undefined | null): string => { - if (!dateStr) return '' - return dateStr.split('T')[0].substring(0, 10) -} - watch(() => props.editCost, (cost) => { if (!cost) return - - const editData = cost - const data = editData.data || {} - - // ── Date: slice T for HTML5 date input ── - // Ensure we always get a valid YYYY-MM-DD string or empty string - form.date = safeFormatDate(editData.date) - - // ── Invoice Number: check BOTH top-level and JSONB ── - form.invoiceNumber = editData.invoice_number || data.invoice_number || '' - - // ── Category ── - if (editData.category_id) { - form.mainCategory = String(editData.category_id) - } - if (data.main_category_id) { - form.mainCategory = String(data.main_category_id) - } - if (data.sub_category_id) { - form.subCategory = String(data.sub_category_id) - } - - // ── Financial ── - if (editData.amount_gross) { - form.grossAmount = Number(editData.amount_gross) - } - if (data.net_amount) { - form.netAmount = Number(data.net_amount) - } - if (data.vat_rate) { - form.vatRate = Number(data.vat_rate) - } - if (editData.currency) { - form.currency = editData.currency - } - - // ── Dates (P0 FIX: robust hydration for YYYY-MM-DD) ── - // invoiceDate: extract from JSONB data.invoice_date or top-level - form.invoiceDate = safeFormatDate(data.invoice_date || editData.invoice_date) - - // paymentDeadline: extract from JSONB data.payment_deadline or top-level - form.paymentDeadline = safeFormatDate(data.payment_deadline || editData.payment_deadline) - - // ── Payment Method ── - if (data.payment_method) { - form.paymentMethod = data.payment_method - } - - // ── Vendor Hydration: check BOTH top-level and JSONB ── - const vendorOrgId = editData.vendor_organization_id || data.vendor_id || null - const vendorName = editData.external_vendor_name || data.vendor_name || '' - - if (vendorOrgId) { - // Try to find the vendor in the loaded list - const vendor = vendors.value.find( - (v) => v.organization_id === vendorOrgId - ) - if (vendor) { - selectedVendor.value = vendor - vendorSearch.value = vendor.display_name || vendor.name - } else { - // Reconstruct from raw data so the UI shows the saved provider - selectedVendor.value = { - id: vendorOrgId, - organization_id: vendorOrgId, - name: vendorName, - display_name: vendorName || null, - tax_number: null, - } as any - vendorSearch.value = vendorName - } - } else if (vendorName) { - vendorSearch.value = vendorName - } - - // ── Mileage & Liters ── - if (editData.mileage_at_cost || data.mileage_at_cost) { - form.mileageAtCost = Number(editData.mileage_at_cost || data.mileage_at_cost) - } - if (data.liters) { - form.liters = Number(data.liters) - } - - // Reset step to beginning for editing - currentStep.value = 0 - errorMessage.value = null -}, { immediate: true }) - -// ── Gross-to-Net Auto Calculation ── -// When the user types in the Gross Amount, automatically calculate and set the Net Amount -watch([() => form.grossAmount, () => form.vatRate], ([newGross, newVat]) => { - if (newGross > 0 && newVat > 0) { - form.netAmount = Math.round((newGross / (1 + newVat / 100)) * 100) / 100 - } + // Hydrate form from existing expense data + form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString() + form.mainCategory = String(cost.category_id || '') + form.subCategory = String(cost.sub_category_id || '') + form.netAmount = cost.data?.net_amount || cost.amount_net || 0 + form.vatRate = cost.data?.vat_rate || 27 + form.grossAmount = cost.amount_gross || 0 + form.currency = cost.currency || 'HUF' + form.invoiceNumber = cost.data?.invoice_number || '' + form.invoiceDate = cost.data?.invoice_date || '' + form.paymentDeadline = cost.data?.payment_deadline || '' + form.paymentMethod = cost.data?.payment_method || 'TRANSFER' + form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null + form.liters = cost.data?.liters || null }) +// ── File Methods ── +function triggerFileInput() { + fileInputRef.value?.click() +} + +function onFileDrop(e: DragEvent) { + isDragOver.value = false + const files = e.dataTransfer?.files + if (files) { + attachedFiles.value.push(...Array.from(files)) + } +} + +function onFileSelect(e: Event) { + const input = e.target as HTMLInputElement + if (input.files) { + attachedFiles.value.push(...Array.from(input.files)) + } +} + +function removeFile(idx: number) { + attachedFiles.value.splice(idx, 1) +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B' + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' + return (bytes / (1024 * 1024)).toFixed(1) + ' MB' +} + // ── Lifecycle ── -watch(() => props.isOpen, (open) => { - if (open) { - currentStep.value = 0 - form.date = getLocalDateString() - errorMessage.value = null - fetchCategories() - // Load all vendors by default (no org_type filter) - fetchVendors() - } +onMounted(() => { + fetchCategories() + fetchUserVehicles() }) -onMounted(() => { - if (props.isOpen) { +// Fetch odometer hint when vehicle changes +watch(effectiveVehicle, () => { + fetchLastOdometer() +}) + +// Reset form when modal opens +watch(() => props.isOpen, (open) => { + if (open) { + if (!props.editCost) { + resetForm() + } + fetchUserVehicles() fetchCategories() - fetchVendors() } }) + \ No newline at end of file diff --git a/frontend_app/src/components/dashboard/ComplexExpenseModal.vue b/frontend_app/src/components/dashboard/ComplexExpenseModal.vue index babd7a5..adbfb81 100644 --- a/frontend_app/src/components/dashboard/ComplexExpenseModal.vue +++ b/frontend_app/src/components/dashboard/ComplexExpenseModal.vue @@ -22,6 +22,17 @@
+ +
+ + +

Válaszd ki a szolgáltatót, vagy hagyd üresen

+
+
{{ vehicleLabel }} @@ -150,6 +161,8 @@ import { useCostStore } from '../../stores/cost' import api from '../../api/axios' import type { Vehicle } from '../../stores/vehicle' import { getLocalDateString } from '../../services/dateUtils' +import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue' +import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue' const props = defineProps<{ isOpen: boolean @@ -177,6 +190,17 @@ const form = reactive({ const errorMessage = ref(null) +// ── Provider State ── +const selectedProvider = ref(null) + +function onProviderSelect(provider: ProviderSearchResult) { + selectedProvider.value = provider +} + +function onProviderClear() { + selectedProvider.value = null +} + // ── Category Cascade State ── interface CategoryOption { id: number @@ -237,6 +261,26 @@ function onMainCategoryChange() { ) } +// ── Gamification Toast ── +function showGamificationToast() { + const toast = document.createElement('div') + toast.className = + 'fixed top-6 right-6 z-[99999] bg-emerald-600 text-white px-6 py-4 rounded-xl shadow-2xl text-sm font-medium animate-slide-in flex items-center gap-3' + toast.innerHTML = ` + 🎉 +
+

Költség sikeresen rögzítve!

+

A pontok jóváírásra kerültek.

+
+ ` + document.body.appendChild(toast) + + setTimeout(() => { + toast.classList.add('animate-slide-out') + setTimeout(() => toast.remove(), 300) + }, 4000) +} + async function handleSubmit() { if (!props.vehicle) { errorMessage.value = 'Nincs kiválasztva jármű.' @@ -261,7 +305,7 @@ async function handleSubmit() { categoryId = Number(form.subCategory || form.mainCategory) || 10 } - const payload = { + const payload: Record = { asset_id: props.vehicle.id, // organization_id is omitted — backend auto-resolves from asset category_id: categoryId, @@ -271,17 +315,23 @@ async function handleSubmit() { date: new Date(form.date).toISOString(), mileage_at_cost: null, description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'), + // P0 FIX: vendor fields at root level + vendor_organization_id: selectedProvider.value?.id || null, + external_vendor_name: selectedProvider.value?.name || null, data: { is_recurring: form.isRecurring, category_id: categoryId, main_category_id: form.mainCategory || null, sub_category_id: form.subCategory || null, + vendor_id: selectedProvider.value?.id || null, + vendor_name: selectedProvider.value?.name || null, }, } const result = await costStore.addExpense(payload) if (result) { + showGamificationToast() // Reset form form.date = getLocalDateString() form.amount = 0 @@ -289,6 +339,7 @@ async function handleSubmit() { form.subCategory = '' form.description = '' form.isRecurring = false + selectedProvider.value = null emit('saved') } else { errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.' @@ -304,6 +355,7 @@ watch(() => props.isOpen, (open) => { if (open) { form.date = getLocalDateString() errorMessage.value = null + selectedProvider.value = null } }) diff --git a/frontend_app/src/components/dashboard/SimpleFuelModal.vue b/frontend_app/src/components/dashboard/SimpleFuelModal.vue index 9018a6b..b02039d 100644 --- a/frontend_app/src/components/dashboard/SimpleFuelModal.vue +++ b/frontend_app/src/components/dashboard/SimpleFuelModal.vue @@ -21,6 +21,17 @@ + +
+ + +

Válaszd ki a szolgáltatót, vagy hagyd üresen

+
+
{{ vehicleLabel }} @@ -37,35 +48,68 @@ />
- -
- - + + + +
+

💰 Ár háromszög — tölts ki kettőt, a harmadik automatikusan számolódik

+ + +
+ +
+ + + +
+
+ + +
+ + +
+ + +
+ + +
- -
- - -
- - +
+ +

+ 📏 Utolsó rögzített km óra állás: {{ odometerHint.toLocaleString() }} km +

+

Km óra állás betöltése...

@@ -106,6 +155,16 @@
+ + + + + @@ -115,6 +174,9 @@ import { useCostStore } from '../../stores/cost' import api from '../../api/axios' import type { Vehicle } from '../../stores/vehicle' import { getLocalDateString } from '../../services/dateUtils' +import ProviderAutocomplete from '../provider/ProviderAutocomplete.vue' +import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue' +import type { ProviderSearchResult } from '../provider/ProviderAutocomplete.vue' const props = defineProps<{ isOpen: boolean @@ -128,15 +190,181 @@ const emit = defineEmits<{ const costStore = useCostStore() +// ── Form State ── const form = reactive({ date: getLocalDateString(), - amount: 0, + amountGross: 0, + unitPrice: 0, quantity: 0, odometer: 0, + currency: 'HUF' as string, }) +// ── Magic Triangle: Track which field was last manually edited ── +// P0 BUGFIX (2026-07-10): @blur events instead of @input events. +// A lastEditedField csak @blur-kor frissül, így nincs végtelen rekurzió. +const lastEditedField = ref(null) + const errorMessage = ref(null) +// ── Provider State ── +const selectedProvider = ref(null) + +// P0 BUGFIX (2026-07-10): Fix stale provider state. +// A ProviderAutocomplete @select eseménye tiszta overwrite-öt végez. +// A selectedProvider ref teljesen felülíródik az új provider adataival. +function onProviderSelect(provider: ProviderSearchResult) { + selectedProvider.value = provider +} + +function onProviderClear() { + selectedProvider.value = null +} + +// ── Odometer Hint ── +const odometerHint = ref(null) +const odometerLoading = ref(false) + +// P0 BUGFIX (2026-07-10): Fix odometer hint — fetch from asset/vehicle endpoint +// instead of /expenses/{asset_id}. The asset endpoint returns current_mileage +// which is the Source of Truth (synced from odometer_readings on expense creation). +async function fetchLastOdometer() { + if (!props.vehicle?.id) { + odometerHint.value = null + return + } + + odometerLoading.value = true + try { + // Fetch the asset/vehicle data which contains current_mileage + // This is the performant read-cache — synced automatically when expenses are created. + const res = await api.get(`/assets/vehicles/${props.vehicle.id}`) + // The response is the Vehicle object with current_mileage field + const vehicle = res.data as { current_mileage?: number | null } + if (vehicle.current_mileage != null && vehicle.current_mileage > 0) { + odometerHint.value = vehicle.current_mileage + } else { + odometerHint.value = null + } + } catch { + // Silently fail — odometer hint is a nice-to-have + odometerHint.value = null + } finally { + odometerLoading.value = false + } +} + +// ── Magic Triangle: Blur Handlers ── +// P0 BUGFIX (2026-07-10): @blur events instead of @input events. +// A @input események végtelen rekurziót okoztak, mert a számítás +// visszaírta az értékeket, ami újabb @input eseményt triggerelt. +// A @blur esemény csak akkor fut le, amikor a user elhagyja a mezőt, +// így nincs rekurzió. + +/** + * Kerekítés 2 tizedesjegyre. + * A Magic Triangle minden számítását ezzel végezzük, + * hogy elkerüljük a lebegőpontos pontatlanságokat. + */ +function roundTo2Decimals(val: number): number { + return Math.round(val * 100) / 100 +} + +/** + * @blur handler az Összeg (amountGross) mezőhöz. + * Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t. + */ +function onAmountGrossBlur(e: Event) { + const val = parseFloat((e.target as HTMLInputElement).value) || 0 + form.amountGross = val + lastEditedField.value = 'amount_gross' + autoCalculate() +} + +/** + * @blur handler az Egységár (unitPrice) mezőhöz. + * Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t. + */ +function onUnitPriceBlur(e: Event) { + const val = parseFloat((e.target as HTMLInputElement).value) || 0 + form.unitPrice = val + lastEditedField.value = 'unit_price' + autoCalculate() +} + +/** + * @blur handler a Mennyiség (quantity) mezőhöz. + * Beállítja a lastEditedField értékét, majd meghívja az autoCalculate()-t. + */ +function onQuantityBlur(e: Event) { + const val = parseFloat((e.target as HTMLInputElement).value) || 0 + form.quantity = val + lastEditedField.value = 'quantity' + autoCalculate() +} + +/** + * Magic Triangle: Automatikus számítás a három mező között. + * + * Logika: + * - A user mindig 2 mezőt tölt ki, a harmadik automatikusan számolódik. + * - A lastEditedField alapján tudjuk, melyik mezőt szerkesztette a user. + * - Ha a másik két mező közül legalább az egyik > 0, kiszámoljuk a harmadikat. + * - Prioritás: ha mindkét másik mező > 0, akkor a lastEditedField-et + * számoljuk újra a másik kettőből (pl. user javítja a quantity-t, + * akkor amountGross = unitPrice * quantity). + * - Ha csak az egyik másik mező > 0, akkor a hiányzó harmadikat számoljuk. + * + * Képletek: + * amountGross = unitPrice * quantity + * unitPrice = amountGross / quantity + * quantity = amountGross / unitPrice + * + * P0 BUGFIX (2026-07-10): @blur events — nincs rekurzió. + */ +function autoCalculate() { + const a = form.amountGross + const p = form.unitPrice + const q = form.quantity + + // Hány mezőnek van értéke > 0? + const filledCount = [a > 0, p > 0, q > 0].filter(Boolean).length + + // Ha már mindhárom ki van töltve, nincs mit számolni + if (filledCount >= 3) return + + const edited = lastEditedField.value + + if (edited === 'amount_gross') { + // User az összeget szerkesztette → számoljuk az egységárat VAGY a mennyiséget + if (q > 0 && a > 0) { + // Van mennyiség és összeg → egységár = összeg / mennyiség + form.unitPrice = roundTo2Decimals(a / q) + } else if (p > 0 && a > 0) { + // Van egységár és összeg → mennyiség = összeg / egységár + form.quantity = roundTo2Decimals(a / p) + } + } else if (edited === 'unit_price') { + // User az egységárat szerkesztette → számoljuk az összeget VAGY a mennyiséget + if (q > 0 && p > 0) { + // Van mennyiség és egységár → összeg = egységár * mennyiség + form.amountGross = roundTo2Decimals(p * q) + } else if (a > 0 && p > 0) { + // Van összeg és egységár → mennyiség = összeg / egységár + form.quantity = roundTo2Decimals(a / p) + } + } else if (edited === 'quantity') { + // User a mennyiséget szerkesztette → számoljuk az összeget VAGY az egységárat + if (p > 0 && q > 0) { + // Van egységár és mennyiség → összeg = egységár * mennyiség + form.amountGross = roundTo2Decimals(p * q) + } else if (a > 0 && q > 0) { + // Van összeg és mennyiség → egységár = összeg / mennyiség + form.unitPrice = roundTo2Decimals(a / q) + } + } +} + // ── Category Resolution ── const fuelCategoryId = ref(1) // Default fallback @@ -161,7 +389,16 @@ async function resolveFuelCategory() { watch(() => props.isOpen, (open) => { if (open) { form.date = getLocalDateString() + form.amountGross = 0 + form.unitPrice = 0 + form.quantity = 0 + form.odometer = 0 + form.currency = 'HUF' + lastEditedField.value = null errorMessage.value = null + selectedProvider.value = null + // Fetch odometer hint on open + fetchLastOdometer() } }) @@ -177,6 +414,30 @@ const vehicleLabel = computed(() => { return `${plate} (${brand} ${model})`.trim() }) +// ── Delayed New Provider State ── +const showQuickAddModal = ref(false) +const pendingNewProviderName = ref('') + +// ── Gamification Toast ── +function showGamificationToast() { + const toast = document.createElement('div') + toast.className = + 'fixed top-6 right-6 z-[99999] bg-emerald-600 text-white px-6 py-4 rounded-xl shadow-2xl text-sm font-medium animate-slide-in flex items-center gap-3' + toast.innerHTML = ` + 🎉 +
+

Tankolás sikeresen rögzítve!

+

A pontok jóváírásra kerültek.

+
+ ` + document.body.appendChild(toast) + + setTimeout(() => { + toast.classList.add('animate-slide-out') + setTimeout(() => toast.remove(), 300) + }, 4000) +} + async function handleSubmit() { if (!props.vehicle) { errorMessage.value = 'Nincs kiválasztva jármű.' @@ -185,36 +446,103 @@ async function handleSubmit() { errorMessage.value = null + // Determine if we have a selected provider or a raw typed name + const hasSelectedProvider = selectedProvider.value?.id != null && selectedProvider.value.id > 0 + const rawVendorName = selectedProvider.value?.name || null + + // P0 BUGFIX (2026-07-10): Fix stale provider state. + // A vendor_organization_id-t csak akkor küldjük, ha a provider forrása 'verified_org'. + // Ha a forrás 'crowd_added', akkor service_provider_id-t küldünk. + // A backend FK-safe validációja is véd, de itt a frontend is helyesen küldi. + const providerSource = selectedProvider.value?.source || null + // Build payload: category auto-set to FUEL (resolved dynamically by code) - // organization_id is omitted — backend auto-resolves from asset - const payload = { + const payload: Record = { asset_id: props.vehicle.id, category_id: fuelCategoryId.value, // FUEL category (resolved by code) /** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */ - amount_gross: form.amount, - currency: 'HUF' as const, + amount_gross: form.amountGross, + currency: form.currency, date: new Date(form.date).toISOString(), mileage_at_cost: form.odometer > 0 ? form.odometer : null, description: `Tankolás: ${form.quantity} L/kWh`, + // P0 BUGFIX: vendor fields at root level - source-aware routing + vendor_organization_id: hasSelectedProvider && providerSource === 'verified_org' ? selectedProvider.value!.id : null, + service_provider_id: hasSelectedProvider && providerSource === 'crowd_added' ? selectedProvider.value!.id : null, + external_vendor_name: hasSelectedProvider ? null : rawVendorName, data: { quantity: form.quantity, + unit_price: form.unitPrice, fuel_type: props.vehicle.fuel_type || null, + vendor_id: selectedProvider.value?.id || null, + vendor_name: selectedProvider.value?.name || null, + vendor_source: providerSource, }, } const result = await costStore.addExpense(payload) if (result) { + // ── DELAYED NEW PROVIDER FLOW ── + // If the user typed a raw string (no selected provider with valid ID), + // save the expense first, then open ProviderQuickAddModal pre-filled + if (!hasSelectedProvider && rawVendorName && rawVendorName.trim().length >= 2) { + pendingNewProviderName.value = rawVendorName.trim() + showQuickAddModal.value = true + // Don't emit 'saved' yet — wait for the quick-add flow to complete + return + } + + showGamificationToast() // Reset form form.date = getLocalDateString() - form.amount = 0 + form.amountGross = 0 + form.unitPrice = 0 form.quantity = 0 form.odometer = 0 + form.currency = 'HUF' + lastEditedField.value = null + selectedProvider.value = null emit('saved') } else { errorMessage.value = costStore.lastError || 'Ismeretlen hiba történt.' } } + +// ── Delayed New Provider: QuickAdd Modal Handlers ── +function onQuickAddClose() { + showQuickAddModal.value = false + pendingNewProviderName.value = '' + // The expense was already saved, so emit saved + showGamificationToast() + // Reset form + form.date = getLocalDateString() + form.amountGross = 0 + form.unitPrice = 0 + form.quantity = 0 + form.odometer = 0 + form.currency = 'HUF' + lastEditedField.value = null + selectedProvider.value = null + emit('saved') +} + +function onQuickAddSaved(provider: { id: number; name: string }) { + showQuickAddModal.value = false + pendingNewProviderName.value = '' + // The expense was already saved, and now the provider is created too + showGamificationToast() + // Reset form + form.date = getLocalDateString() + form.amountGross = 0 + form.unitPrice = 0 + form.quantity = 0 + form.odometer = 0 + form.currency = 'HUF' + lastEditedField.value = null + selectedProvider.value = null + emit('saved') +} diff --git a/frontend_app/src/components/provider/ProviderAutocomplete.vue b/frontend_app/src/components/provider/ProviderAutocomplete.vue index 6feb08c..25b0d57 100644 --- a/frontend_app/src/components/provider/ProviderAutocomplete.vue +++ b/frontend_app/src/components/provider/ProviderAutocomplete.vue @@ -124,7 +124,7 @@

{{ t('provider.autocompleteNoResults') }}

@@ -221,7 +221,7 @@ function sourceLabel(source: string): string { // ── Search API ── async function performSearch(query: string) { - if (query.length < 2) { + if (query.length < 3) { results.value = [] showDropdown.value = false return @@ -229,14 +229,28 @@ async function performSearch(query: string) { isSearching.value = true try { + // P0 BUGFIX (2026-07-09): Enhanced error logging for debugging. + // The axios baseURL already includes /api/v1, so 'providers/search' + // correctly resolves to /api/v1/providers/search. const response = await api.get('providers/search', { params: { q: query, limit: 10 }, }) results.value = response.data.results || [] highlightIndex.value = 0 showDropdown.value = true - } catch (error) { + } catch (error: any) { console.error('[ProviderAutocomplete] Search error:', error) + // P0 BUGFIX (2026-07-09): Log detailed error info for debugging + if (error.response) { + // Server responded with error status (4xx, 5xx) + console.warn( + `[ProviderAutocomplete] API Error: status=${error.response.status}, data=`, + error.response.data + ) + } else if (error.request) { + // Request was made but no response received (network/CORS issue) + console.warn('[ProviderAutocomplete] Network error: no response received') + } results.value = [] showDropdown.value = false } finally { diff --git a/frontend_app/src/components/provider/ProviderQuickAddModal.vue b/frontend_app/src/components/provider/ProviderQuickAddModal.vue index 952f7c6..b8c4c63 100644 --- a/frontend_app/src/components/provider/ProviderQuickAddModal.vue +++ b/frontend_app/src/components/provider/ProviderQuickAddModal.vue @@ -539,6 +539,9 @@ interface SelectedTag { // ── Props ── const props = defineProps<{ isOpen: boolean + /** P0 UI/UX OVERHAUL (2026-07-09): Pre-fill the provider name when opening + * from the Delayed New Provider flow in SimpleFuelModal. */ + prefillName?: string }>() // ── Emits ── @@ -931,6 +934,10 @@ watch( (open) => { if (open) { resetForm() + // P0 UI/UX OVERHAUL (2026-07-09): Pre-fill provider name if provided + if (props.prefillName) { + form.name = props.prefillName + } fetchCategories() loadCategoryTree() } diff --git a/frontend_app/src/i18n/en.ts b/frontend_app/src/i18n/en.ts index 2fd79a2..5f29e6d 100644 --- a/frontend_app/src/i18n/en.ts +++ b/frontend_app/src/i18n/en.ts @@ -342,13 +342,13 @@ export default { }, costWizard: { title: '🧾 Detailed Invoice', - step1: 'Basic Data', + step1: 'Provider & Basic Data', step2: 'Financials', - step3: 'Admin & Supplier', + step3: 'Invoice Details', step4: 'Files', - step1Desc: 'Vehicle, category & date', + step1Desc: 'Provider, vehicle, category & date', step2Desc: 'Amount, VAT & currency', - step3Desc: 'Supplier, invoice & deadline', + step3Desc: 'Invoice number, payment & deadline', step4Desc: 'Attach documents', selectVehicle: 'Select Vehicle', selectCategory: 'Select Category', @@ -358,11 +358,17 @@ export default { vatRate: 'VAT Rate', grossAmount: 'Gross Amount', currency: 'Currency', + provider: 'Provider', + providerHint: 'Search for a provider, or leave empty', vendor: 'Supplier', vendorPlaceholder: 'Search or type supplier name...', invoiceNumber: 'Invoice Number', invoiceDate: 'Invoice Date', paymentDeadline: 'Payment Deadline', + paymentMethod: 'Payment Method', + paymentTransfer: 'Bank Transfer', + paymentCash: 'Cash', + paymentCard: 'Card', attachFiles: 'Attach Files', dragDrop: 'Drag & drop files here, or click to browse', noFiles: 'No files attached', diff --git a/frontend_app/src/i18n/hu.ts b/frontend_app/src/i18n/hu.ts index 7153fd8..254908c 100644 --- a/frontend_app/src/i18n/hu.ts +++ b/frontend_app/src/i18n/hu.ts @@ -339,13 +339,13 @@ export default { }, costWizard: { title: '🧾 Részletes Számla', - step1: 'Alapadatok', + step1: 'Szolgáltató & Alapadatok', step2: 'Pénzügyek', - step3: 'Admin & Szállító', + step3: 'Számla adatok', step4: 'Fájlok', - step1Desc: 'Jármű, kategória & dátum', + step1Desc: 'Szolgáltató, jármű, kategória & dátum', step2Desc: 'Összeg, ÁFA & pénznem', - step3Desc: 'Szállító, számla & határidő', + step3Desc: 'Számlaszám, fizetési mód & határidő', step4Desc: 'Dokumentumok csatolása', selectVehicle: 'Jármű kiválasztása', selectCategory: 'Kategória kiválasztása', @@ -355,11 +355,17 @@ export default { vatRate: 'ÁFA kulcs', grossAmount: 'Bruttó összeg', currency: 'Pénznem', + provider: 'Szolgáltató', + providerHint: 'Keresd meg a szolgáltatót, vagy hagyd üresen', vendor: 'Szállító', vendorPlaceholder: 'Keresés vagy szállító nevének beírása...', invoiceNumber: 'Számlaszám', invoiceDate: 'Számla dátuma', paymentDeadline: 'Fizetési határidő', + paymentMethod: 'Fizetési mód', + paymentTransfer: 'Átutalás', + paymentCash: 'Készpénz', + paymentCard: 'Bankkártya', attachFiles: 'Fájlok csatolása', dragDrop: 'Húzd ide a fájlokat, vagy kattints a tallózáshoz', noFiles: 'Nincs fájl csatolva', diff --git a/plans/logic_spec_service_finder_3_level_architecture.md b/plans/logic_spec_service_finder_3_level_architecture.md new file mode 100644 index 0000000..351cece --- /dev/null +++ b/plans/logic_spec_service_finder_3_level_architecture.md @@ -0,0 +1,626 @@ +# 🏗️ Logic Spec: Service Finder 3-Level Platform Architecture + +**Verzió:** 1.0 +**Dátum:** 2026-07-09 +**Állapot:** Tervezet (Architect Review) +**Hatáskör:** Teljes platform — Admin (Mission Control), User (Provider Hub), Robot/AI (Intelligence Layer) + +--- + +## 1. 🎯 Modul Célja és Masterbook 2.0 Illeszkedés + +### Cél +A Service Finder platform három architekturális szintre bomlik, amelyek együtt alkotják a teljes ökoszisztémát: + +| Szint | Elnevezés | Elsődleges Cél | +|-------|-----------|----------------| +| **Szint 1** | 🏛️ **Admin — Mission Control** | Flottavezetők, moderátorok, superadminok irányítóközpontja | +| **Szint 2** | 👤 **User — Provider Hub** | Szolgáltatók és flottatulajdonosok önkiszolgáló felülete | +| **Szint 3** | 🤖 **Robot/AI — Intelligence Layer** | Automatikus adatkinyerés, intelligens rangsorolás, szemantikus keresés | + +### Masterbook 2.0 Illeszkedés +- **Dual Entity (Person/User):** Minden jogi entitás két szinten kezelve — hús-vér személy vs. technikai fiók +- **Triple Wallet:** Vásárolt, keresett és szolgáltatási kreditek elkülönített kezelése +- **Dual Control (Moderation):** Kétlépcsős jóváhagyási folyamat minden szolgáltatói rekordhoz +- **2-Step Asset Flow:** Draft → Active életciklus minden jármű és eszköz esetében +- **4-Level Expertise Taxonomy:** Vehicle Type → Industry → Profession → Specific Task +- **P0 Hybrid Vendor Refactor:** Három párhuzamos szolgáltatói adatforrás egyesítése + +--- + +## 2. 📊 Jelenlegi Rendszer Állapota (Audit Summary) + +### 2.1 Adatbázis Modellek — Teljes Körkép + +#### identity séma +| Tábla | Státusz | Fájl | +|-------|---------|------| +| `users` | ✅ KÉSZ | [`social.py`](backend/app/models/identity/social.py:26) | +| `persons` | ✅ KÉSZ | — | +| `wallets` | ✅ KÉSZ | Triple Wallet (4 zseb) | +| `service_providers` | ✅ KÉSZ | Közösségi szolgáltatók moderációval | +| `provider_validations` | ✅ KÉSZ | Validációs rekordok súlyozással | +| `votes` | ✅ KÉSZ | +1/-1 közösségi szavazás | +| `user_scores` | ✅ KÉSZ | Pontszámok | +| `service_reviews` | ✅ KÉSZ | Értékelés + transaction_id | +| `competitions` | ✅ KÉSZ | Versenyek | + +#### marketplace séma +| Tábla | Státusz | Fájl | +|-------|---------|------| +| `service_profiles` | ✅ KÉSZ | [`service.py`](backend/app/models/marketplace/service.py:21) | +| `expertise_tags` | ✅ KÉSZ | 4-level hierarchy (parent_id, path, level) | +| `service_expertise` | ✅ KÉSZ | Junction table | +| `service_staging` | ✅ KÉSZ | Robot-hunted providers | +| `discovery_parameters` | ✅ KÉSZ | Felfedezési paraméterek | +| `costs` | ✅ KÉSZ | Költség főkönyv | +| `organizations` | ✅ KÉSZ | Hivatalos garázs/szerviz szervezetek | +| `organization_members` | ✅ KÉSZ | Tagok szerepkörökkel | +| `branches` | ✅ KÉSZ | PostGIS location, opening_hours | +| `organization_financials` | ✅ KÉSZ | Pénzügyi adatok | + +#### ❌ HIÁNYZÓ modellek +| Modell | Tervezett Tábla | Ok | +|--------|-----------------|----| +| `KYCVerification` | `identity.kyc_verifications` | KYC dokumentumok és státusz követése | +| `ServicePricing` | `marketplace.service_pricing` | Szolgáltatás árazás (alkatrész + munkadíj) | +| `ServiceBooking` | `marketplace.service_bookings` | Foglalási napló | +| `VeszélyesZónaGatekeeper` | `system.gatekeeper_log` | Hard/soft delete tranzakció-ellenőrzés | + +### 2.2 Service Layer + +| Service | Fájl | Funkciók | +|---------|------|----------| +| `search_providers()` | [`provider_service.py:125`](backend/app/services/provider_service.py:125) | Unified search, geo-filter, trust score | +| `quick_add_provider()` | [`provider_service.py:578`](backend/app/services/provider_service.py:578) | Gyors létrehozás + gamification | +| `update_provider()` | [`provider_service.py:909`](backend/app/services/provider_service.py:909) | Teljes frissítés (category sync, expertise) | +| `_award_provider_points()` | [`provider_service.py:63`](backend/app/services/provider_service.py:63) | Gamification pontozás | +| `_sync_service_expertises()` | [`provider_service.py:742`](backend/app/services/provider_service.py:742) | 4-level hybrid mentés | +| `find_nearby_services()` | [`search_service.py:24`](backend/app/services/search_service.py:24) | PostGIS + weighted ranking | +| `AIService._execute_ai_call()` | [`ai_service.py:23`](backend/app/services/ai_service.py:23) | Ollama → Groq/Gemini fallback | +| `AIService._fallback_ai_call()` | [`ai_service.py:81`](backend/app/services/ai_service.py:81) | Cloud fallback (Gemini → Groq) | +| `AIService.process_ocr_document()` | [`ai_service.py:174`](backend/app/services/ai_service.py:174) | OCR dokumentum feldolgozás | +| `CostService.record_cost()` | [`cost_service.py`](backend/app/services/cost_service.py) | Költségrögzítés | + +### 2.3 API Végpontok — Állapot + +| Végpont | Metódus | Státusz | Leírás | +|---------|---------|---------|--------| +| `/admin/providers` | GET | ✅ KÉSZ | Unified provider list (3 tábla UNION ALL) | +| `/admin/providers/stats` | GET | ✅ KÉSZ | Statisztikák | +| `/admin/providers/{id}` | GET | ✅ KÉSZ | Detail + history + validations | +| `/admin/providers/{id}/approve` | POST | ✅ KÉSZ | Jóváhagyás + Promotion Engine | +| `/admin/providers/{id}/reject` | POST | ✅ KÉSZ | Elutasítás okával | +| `/admin/providers/{id}/restore` | POST | ✅ KÉSZ | Visszaállítás | +| `/admin/providers/{id}/flag` | POST | ✅ KÉSZ | Megjelölés (audit) | +| `/admin/providers/{id}` | DELETE | ✅ KÉSZ | Soft delete | +| `/admin/providers/{id}` | PATCH | ✅ KÉSZ | Teljes update + ServiceProfile auto-create | +| `/admin/providers/{id}/validations` | GET | ✅ KÉSZ | Validációs rekordok | +| `/admin/providers/{id}/history` | GET | ✅ KÉSZ | Audit log | +| `/search/match` | GET | ✅ KÉSZ | PostGIS geofencing search | + +#### ❌ HIÁNYZÓ Végpontok +| Végpont | Metódus | Prioritás | +|---------|---------|-----------| +| `/admin/gatekeeper/hard-delete` | POST | **P0** — Veszélyes zóna hard delete | +| `/admin/gatekeeper/log` | GET | **P0** — Gatekeeper audit log | +| `/admin/kyc/{user_id}` | GET/PUT | **P1** — KYC dokumentumok kezelése | +| `/admin/kyc/pending` | GET | **P1** — Függő KYC kérelmek | +| `/admin/subscriptions` | CRUD | **P1** — Előfizetések kezelése | +| `/admin/analytics/tco` | GET | **P1** — TCO aggregáció | +| `/admin/analytics/provider-rankings` | GET | **P2** — Szolgáltató rangsor analitika | +| `/providers/me/profile` | GET/PUT | **P1** — Szolgáltató saját profil | +| `/providers/me/services` | CRUD | **P1** — Saját szolgáltatások | +| `/providers/me/bookings` | GET | **P2** — Saját foglalások | +| `/search/semantic` | POST | **P2** — NLP alapú keresés | +| `/search/ai-rank` | POST | **P2** — AI rangsorolás | + +--- + +## 3. 🏛️ Admin Level — Mission Control + +### 3.1 Jóváhagyási Munkafolyamat (✅ KÉSZ) + +``` +flowchart LR + A[Függő Szolgáltató] --> B{Admin Döntés} + B -->|Approve| C[Promotion Engine] + B -->|Reject| D[Elutasítás Indoklással] + C --> E[ServiceProvider létrehozva] + C --> F[ServiceProfile auto-generated] + C --> G[Gamification XP jóváírva] + C --> H[AuditLog naplózva] + D --> I[ProviderValidation rekord] + D --> J[Status: rejected] + B -->|Flag| K[Audit megjelölés] + K --> L[Manuális felülvizsgálat] +``` + +### 3.2 KYC Verifikáció (❌ HIÁNYZIK — P1) + +**Tervezett Modell:** +```python +class KYCVerification(Base): + __tablename__ = "kyc_verifications" + __table_args__ = {"schema": "identity"} + id: int (PK) + user_id: int (FK → users.id) + person_id: bigint (FK → persons.id) + documents: dict (JSONB) — feltöltött dokumentumok + status: enum(pending, verified, rejected) + verified_by: int (FK → users.id, nullable) + verified_at: datetime + notes: text + expires_at: datetime + created_at: datetime + updated_at: datetime +``` + +### 3.3 Kategória Menedzsment (✅ RÉSZBEN) + +**Meglévő:** +- ✅ 4-level ExpertiseTag hierarchia (parent_id, path, level, name_i18n, search_keywords) +- ✅ Seed szkriptek: `seed_expertise_tags.py`, `seed_expertise_taxonomy.py` +- ✅ 136 kategória (utolsó seed: 768) +- ✅ `_sync_service_expertises()` — 4-level hybrid mentés +- ✅ `_create_new_tags()` — Új címkék automatikus létrehozása + +**Hiányzó:** +- ❌ Kategória kezelő admin UI (CRUD + átrendezés) +- ❌ Kategória láthatósági szabályok (Global/Country/Region) + +### 3.4 Pénzügyi & Előfizetés Kontroll (❌ HIÁNYZIK — P1) + +**Meglévő:** +- ✅ Triple Wallet modell (`identity.wallets`) +- ✅ FinancialLedger (`audit.financial_ledger`) +- ✅ Stripe webhook (Double Lock protokoll) +- ✅ Billing Engine (vízesés modell) +- ✅ `Organization.subscription_tier_id`, `subscription_plan`, `subscription_tier` kapcsolat + +**Hiányzó:** +- ❌ Admin előfizetés kezelő UI +- ❌ Előfizetés számlázási adatok admin felület +- ❌ Wallet egyenlegek admin áttekintése + +### 3.5 Veszélyes Zóna — Gatekeeper (❌ HIÁNYZIK — P0 KRITIKUS) + +**Tervezett architektúra:** +```python +# system.gatekeeper_log tábla +class GatekeeperLog(Base): + __tablename__ = "gatekeeper_log" + __table_args__ = {"schema": "system"} + id: int (PK) + action: enum(soft_delete, hard_delete, restore, purge) + target_table: str + target_id: int + initiated_by: int (FK → users.id) + approved_by: int (FK → users.id, nullable) + status: enum(pending, approved, rejected, executed) + reason: text + backup_snapshot: dict (JSONB) — hard delete előtti adatmentés + executed_at: datetime + created_at: datetime +``` + +**Hard Delete Folyamat:** +1. Admin kiválaszt egy rekordot hard delete-re +2. GatekeeperLog létrehozása `pending` státusszal +3. Backup snapshot JSONB-ben (mentés az adatról) +4. Második admin jóváhagyása szükséges (Dual Control) +5. Atomi tranzakció: DELETE + AuditLog + GatekeeperLog.status=executed +6. Rollback lehetőség a backup snapshot-ból + +**Referencia:** [`test_hard_delete.py`](backend/test_hard_delete.py:7) — létezik referencia implementáció + +--- + +## 4. 👤 User Level — Provider Hub + +### 4.1 Profil Menedzsment (✅ RÉSZBEN) + +**Meglévő:** +- ✅ `ProviderUpdateInput` séma (category_ids, supported_vehicle_classes, specializations, opening_hours) +- ✅ `update_provider()` — Teljes provider frissítés +- ✅ `quick_add_provider()` — Gyors létrehozás gamification hook-kal +- ✅ AddressManager integráció (P0 Unified Address Refactor) +- ✅ Frontend_admin: [`providers/[id]/edit.vue`](frontend_admin/pages/providers/[id]/edit.vue) — Teljes admin szerkesztő + +**Hiányzó:** +- ❌ Szolgáltatói self-service frontend (public felületen) +- ❌ Provider dashboard (saját statisztikák, értékelések) +- ❌ Multi-language profil szerkesztés (name_i18n) + +### 4.2 Szolgáltatás Katalógus (❌ HIÁNYZIK — P1) + +**Tervezett Modell:** +```python +class ServicePricing(Base): + __tablename__ = "service_pricing" + __table_args__ = {"schema": "marketplace"} + id: int (PK) + service_profile_id: int (FK → marketplace.service_profiles.id) + category_id: int (FK → marketplace.expertise_tags.id) + name: str + name_i18n: dict (JSONB) — Twin-technika + description: str + description_i18n: dict (JSONB) + labor_cost: Decimal(18,4) + parts_cost: Decimal(18,4) + currency: str (default: "HUF") + is_flat_rate: bool + estimated_duration_minutes: int + is_active: bool + valid_from: datetime + valid_until: datetime (nullable) +``` + +### 4.3 Értékelések & Gamifikáció (✅ RÉSZBEN) + +**Meglévő:** +- ✅ `ServiceReview` modell — rating, review_text, transaction_id +- ✅ `PointRule` — gamification pontszabályok +- ✅ `_award_provider_points()` — Provider pontozás +- ✅ Gamification admin frontend: [`gamification/`](frontend_admin/pages/gamification/) +- ✅ Leaderboard, badges, competitions, seasons + +**Hiányzó:** +- ❌ Review moderációs admin UI (flag-elt review-k) +- ❌ Gamification widget a provider dashboardon +- ❌ Pontszabályok vizuális szerkesztője + +### 4.4 Lead Menedzsment (❌ HIÁNYZIK — P2) + +**Meglévő:** +- ✅ `ServiceRequest` kapcsolat a `Branch` modellben + +**Hiányzó:** +- ❌ Teljes foglalási munkafolyamat (ServiceBooking model) +- ❌ Geofenced broadcast (körzetes értesítés) +- ❌ Értesítési csatornák (email, push, in-app) + +--- + +## 5. 🤖 Robot/AI Level — Intelligence Layer + +### 5.1 Szemantikus Keresés (❌ HIÁNYZIK — P2) + +**Meglévő:** +- ✅ PostGIS geofencing (`ST_DWithin`, `ST_DistanceSphere`) +- ✅ Config-driven weighted ranking (ad_weight, partner_weight, trust_weight, dist_penalty, pref_weight) +- ✅ Hierarchical ranking config (SystemParameter) + +**Tervezett NLP Search:** +```python +class SemanticSearchEngine: + """ + Tervezett motor a meglévő SearchService kiterjesztésére. + """ + @classmethod + async def semantic_search(cls, query: str, lat: float, lng: float, radius_km: float): + # 1. Query embedding (Ollama / local) + query_embedding = await AIService.get_embedding(query) + + # 2. Hybrid search: PostGIS + embedding similarity + results = await cls._hybrid_search(query_embedding, lat, lng, radius_km) + + # 3. AI re-ranking + reranked = await AIService.rerank_results(query, results) + + return reranked +``` + +### 5.2 Intelligens Rangsorolás (✅ RÉSZBEN) + +**Weighted Scoring Formula** ([`search_service.py:92`](backend/app/services/search_service.py:92)): +``` +score = (ad_weight * is_advertiser) + + (partner_weight * is_partner) + + (trust_weight * trust_score) - + (dist_penalty * dist_km) + + (pref_weight * is_preferred) +``` + +**Hiányzó:** +- ❌ ML-alapú rangsorolás (felhasználói viselkedés alapján) +- ❌ A/B tesztelési keretrendszer +- ❌ Rangsorolási metrikák admin dashboard + +### 5.3 Automatikus Adatkinyerés (✅ RÉSZBEN) + +**Meglévő:** +- ✅ [`AIService`](backend/app/services/ai_service.py) — Multi-Agent gateway +- ✅ Ollama local → Groq/Gemini cloud fallback +- ✅ `process_ocr_document()` — OCR dokumentum feldolgozás +- ✅ `get_gold_data_from_research()` — Arany adatkinyerés +- ✅ `get_clean_vehicle_data()` — Adattisztítás +- ✅ `robot_1_ocr_processor.py` — OCR robot + +**Hiányzó:** +- ❌ Document pipeline admin UI (OCR eredmények ellenőrzése) +- ❌ AI prompt konfigurációs admin panel +- ❌ Fallback monitoring és naplózás + +--- + +## 6. 🔧 Adatmodell Terv — Részletes + +### 6.1 Meglévő Modellek Összefoglalója + +| Séma | Tábla | Oszlopok | Kulcs (PK/FK) | JSONB | i18n | Soft Delete | +|------|-------|----------|---------------|-------|------|-------------| +| `identity` | `users` | 20+ | PK, FK→persons | preferences | — | `deleted_at` | +| `identity` | `persons` | 22+ | PK, FK→users, FK→address | identity_docs, ice_contact | — | `deleted_at` | +| `identity` | `service_providers` | 12+ | PK, FK→address | — | — | `status=rejected` | +| `identity` | `wallets` | 10+ | PK, FK→users | — | — | — | +| `marketplace` | `service_profiles` | 18+ | PK, FK→service_providers | opening_hours, specializations | — | — | +| `marketplace` | `expertise_tags` | 10+ | PK, FK→self (parent) | name_i18n, search_keywords | ✅ 4 mező | — | +| `marketplace` | `organizations` | 25+ | PK, FK→address | custom_features, visual_settings, settings, aliases, tags | — | `is_deleted` | +| `marketplace` | `branches` | 12+ | PK, FK→organization, FK→address | opening_hours | — | `is_deleted` | +| `marketplace` | `service_staging` | 12+ | PK | raw_data | — | — | +| `audit` | `financial_ledger` | 8+ | PK, FK→users | details | — | — | + +### 6.2 Twin-technika (Többnyelvűség) + +**Meglévő implementáció:** +- `ExpertiseTag.name_i18n` (JSONB) — {"hu": "Olajcsere", "en": "Oil Change", "de": "Ölwechsel"} +- `ExpertiseTag.description_i18n` (JSONB) +- `ExpertiseTag.search_keywords` (JSONB) — {"hu": ["olaj", "motorolaj"], "en": ["oil", "motor oil"]} + +**Kötelező minden új i18n mezőre:** +```python +name_i18n: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) +``` + +### 6.3 Soft-delete Logika + +| Tábla | Soft Delete Mező | Típus | +|-------|------------------|-------| +| `users` | `deleted_at` | DateTime (nullable) | +| `persons` | `deleted_at` | DateTime (nullable) | +| `organizations` | `is_deleted` | Boolean | +| `branches` | `is_deleted` | Boolean | +| `service_providers` | `status = rejected` | Enum | +| `organization_members` | `status != active` | Enum | + +**Hiányzó Gatekeeper:** A Veszélyes Zóna funkcióhoz dedikált `gatekeeper_log` tábla szükséges (Lásd 3.5) + +### 6.4 Hiányzó Modellek — Alembic Migration Terv + +| # | Modell | Tábla | Típus | Migráció | +|---|--------|-------|-------|----------| +| 1 | `GatekeeperLog` | `system.gatekeeper_log` | **P0** | `gatekeeper_log_v1` | +| 2 | `KYCVerification` | `identity.kyc_verifications` | P1 | `kyc_verification_v1` | +| 3 | `ServicePricing` | `marketplace.service_pricing` | P1 | `service_pricing_v1` | +| 4 | `ServiceBooking` | `marketplace.service_bookings` | P2 | `service_booking_v1` | +| 5 | `AIRequestLog` | `audit.ai_request_logs` | P2 | `ai_request_log_v1` | + +--- + +## 7. ⚙️ Admin Kontroll Rendszerváltozók + +### 7.1 Hierarchikus Config Változók + +A rendszer négy szinten kezeli a konfigurációs változókat: + +| Szint | Tábla | Hatáskör | +|-------|-------|-----------| +| **Global** | `system.system_parameters` | Az összes régióra és felhasználóra | +| **Country** | `system.system_parameters` (scope=country) | Adott országra | +| **Region** | `system.system_parameters` (scope=region) | Adott régióra | +| **User** | `identity.users.preferences` | Egyedi felhasználóra | + +### 7.2 Meglévő Rendszerváltozók + +| Kulcs | Típus | Scope | Érték | +|-------|-------|-------|-------| +| `GAMIFICATION_MASTER_CONFIG` | JSONB | Global | Gamification master config | +| `AI_REQUEST_DELAY` | Float | Global | Késleltetés AI hívások között | +| `AI_TEMPERATURE` | Float | Global | AI hőmérséklet paraméter | +| `AI_TIMEOUT_LOCAL` | Integer | Global | Helyi AI timeout (sec) | +| `ENABLE_AI_FALLBACK` | Boolean | Global | Cloud fallback engedélyezése | +| `AI_MODEL_TEXT` | String | Global | Szöveg AI modell neve | +| `AI_MODEL_VISION` | String | Global | Kép AI modell neve | +| `DVLA_DAILY_LIMIT` | Integer | Global | Napi DVLA API limit | + +### 7.3 Tervezett Új Változók + +| Kulcs | Típus | Scope | Cél | +|-------|-------|-------|-----| +| `SEARCH_RANKING_CONFIG` | JSONB | Global | Súlyozási mátrix (ad/partner/trust/dist/pref) | +| `GATEKEEPER_REQUIRED_APPROVALS` | Integer | Global | Hány admin kell hard delete-hez | +| `KYC_REQUIRED_LEVELS` | JSONB | Country | Országonkénti KYC követelmények | +| `PROVIDER_AUTO_APPROVE` | Boolean | Country | Automatikus jóváhagyás engedélyezése | +| `REVIEW_MODERATION_ENABLED` | Boolean | Global | Review moderáció bekapcsolása | + +--- + +## 8. 🌍 Geo-Logika és Rangsorolás + +### 8.1 PostGIS Alapú Keresés + +A [`search_service.py`](backend/app/services/search_service.py:24) a `find_nearby_services()` metódussal PostGIS `ST_DWithin` és `ST_DistanceSphere` függvényeket használ: + +```sql +-- Generált SQL (lekérdezés logika) +SELECT o.id, o.name, gpc.city, + b.branch_rating, + ST_DistanceSphere(b.location, ST_MakePoint(:lng, :lat)) AS distance_meters +FROM marketplace.organizations o +JOIN marketplace.branches b ON o.id = b.organization_id +LEFT JOIN system.addresses a ON a.id = b.address_id +LEFT JOIN system.geo_postal_codes gpc ON gpc.id = a.postal_code_id +WHERE o.is_active = true + AND b.is_deleted = false + AND ST_DWithin(b.location, ST_MakePoint(:lng, :lat), :radius_meters) +ORDER BY distance_meters ASC; +``` + +### 8.2 Súlyozott Rangsorolás + +A [`SearchService`](backend/app/services/search_service.py) config-driven súlyozást használ: + +```python +score = ( + r["ad_weight"] * is_advertiser + # Hirdetési súly (default: 10) + r["partner_weight"] * is_partner + # Partner súly (default: 5) + r["trust_weight"] * trust_score - # Bizalmi súly (default: 2) + r["dist_penalty"] * dist_km + # Távolság büntetés (default: 0.5) + r["pref_weight"] * is_preferred # Preferált súly (default: 3) +) +``` + +### 8.3 Csomag Szintek Szerinti Rangsorolás + +| Csomag Szint | ad_weight | partner_weight | max_distance_km | Prioritás | +|-------------|-----------|----------------|-----------------|-----------| +| **Free** | 0 | 0 | 20 | Alacsony | +| **Basic** | 2 | 1 | 50 | Közepes | +| **Premium** | 5 | 3 | 100 | Magas | +| **Enterprise** | 10 | 5 | 200 | Legmagasabb | + +--- + +## 9. 📅 Implementációs Sorrend és Függőségek + +### 9.1 Függőségi Gráf + +```mermaid +flowchart TD + P0[P0: Veszélyes Zóna Gatekeeper] --> P1a[P1: KYC Verifikáció] + P0 --> P1b[P1: Előfizetés Admin UI] + P1a --> P2a[P2: Szolgáltató Self-Service] + P1b --> P2b[P2: Foglalási Rendszer] + P2a --> P2c[P2: NLP Szemantikus Keresés] + P2b --> P2d[P2: AI Rangsorolás] + + style P0 fill:#ff4444,color:#fff + style P1a fill:#ffaa00,color:#000 + style P1b fill:#ffaa00,color:#000 + style P2a fill:#44aa44,color:#fff + style P2b fill:#44aa44,color:#fff + style P2c fill:#44aa44,color:#fff + style P2d fill:#44aa44,color:#fff +``` + +### 9.2 Prioritási Sorrend + +#### P0 — Kritikus (Hiányzó biztonsági funkciók) +| # | Feladat | Érintett Fájlok | +|---|---------|-----------------| +| 1 | **Gatekeeper hard/soft delete** — Tranzakció-ellenőrzés Dual Control-lal | [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1109), [`test_hard_delete.py`](backend/test_hard_delete.py) | +| 2 | **Gatekeeper admin API** — DELETE jogosultság ellenőrzés + AuditLog | új végpont | + +#### P1 — Magas Prioritás (Hiányzó üzleti funkciók) +| # | Feladat | Érintett Fájlok | +|---|---------|-----------------| +| 3 | **KYCVerification modell + API** — Dokumentumfeltöltés és státusz | új modell + endpoint | +| 4 | **KYC admin UI** — Függő kérelmek kezelése | új frontend oldal | +| 5 | **ServicePricing modell + API** — Árazási adatok | új modell + endpoint | +| 6 | **Előfizetés admin UI** — Subscription CRUD | új frontend oldal | +| 7 | **Szolgáltatói self-service API** — Saját profil kezelés | új végpontok | + +#### P2 — Fejlesztési Cél (Intelligens funkciók) +| # | Feladat | Érintett Fájlok | +|---|---------|-----------------| +| 8 | **NLP szemantikus keresés** — Embedding + hybrid search | [`search_service.py`](backend/app/services/search_service.py) kiterjesztése | +| 9 | **AI rangsorolás** — ML-based ranking | [`ai_service.py`](backend/app/services/ai_service.py) kiterjesztése | +| 10 | **Foglalási rendszer** — ServiceBooking flow | új modell + endpoint | +| 11 | **Lead management** — Értesítési csatornák | új service | +| 12 | **Document pipeline admin UI** — OCR eredmények | új frontend oldal | + +### 9.3 Implementációs Szekvencia + +**Fázis 1 — Biztonsági Alapok (P0)** +1. `GatekeeperLog` modell létrehozása (`system` séma) +2. Admin hard-delete API végpont (Dual Control) +3. Admin gatekeeper log UI +4. Meglévő DELETE végpontok gatekeeper-en keresztül vezetése + +**Fázis 2 — Admin Kibővítés (P1)** +5. `KYCVerification` modell + API +6. KYC admin frontend +7. `ServicePricing` modell + API +8. Előfizetés admin UI + +**Fázis 3 — Önkiszolgálás (P1)** +9. Provider self-service API (CRUD) +10. Provider dashboard frontend +11. Multi-language profil szerkesztés + +**Fázis 4 — Intelligens Réteg (P2)** +12. NLP szemantikus keresőmotor +13. AI rangsorolás +14. Foglalási rendszer +15. Lead management + +--- + +## 10. 🔗 Érintett Fájlok Teljes Listája + +### Backend — Modellek +- [`social.py`](backend/app/models/identity/social.py) — ServiceProvider, ProviderValidation, Vote, ServiceReview +- [`service.py`](backend/app/models/marketplace/service.py) — ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging +- [`organization.py`](backend/app/models/marketplace/organization.py) — Organization, Branch, OrganizationMember +- [`audit.py`](backend/app/models/audit.py) — AuditLog, FinancialLedger + +### Backend — API Végpontok +- [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py) — Admin provider moderáció (1224+ sor) +- [`providers.py`](backend/app/api/v1/endpoints/providers.py) — Public provider API +- [`search.py`](backend/app/api/v1/endpoints/search.py) — Geofencing kereső (99 sor) + +### Backend — Service Layer +- [`provider_service.py`](backend/app/services/provider_service.py) — Provider CRUD, gamification, search +- [`search_service.py`](backend/app/services/search_service.py) — PostGIS + weighted ranking +- [`ai_service.py`](backend/app/services/ai_service.py) — Multi-Agent AI gateway (Ollama → Groq/Gemini) +- [`cost_service.py`](backend/app/services/cost_service.py) — Költségrögzítés + +### Frontend — Admin (Nuxt) +- [`providers/index.vue`](frontend_admin/pages/providers/index.vue) — Provider lista +- [`providers/pending.vue`](frontend_admin/pages/providers/pending.vue) — Függő jóváhagyások +- [`providers/[id]/index.vue`](frontend_admin/pages/providers/[id]/index.vue) — Provider részletek +- [`providers/[id]/edit.vue`](frontend_admin/pages/providers/[id]/edit.vue) — Provider szerkesztés +- [`gamification/`](frontend_admin/pages/gamification/) — Gamification admin felület + +### Frontend — Public (Vue+Vite) +- [`provider/ProviderEditModal.vue`](frontend_app/src/components/provider/ProviderEditModal.vue) — Provider szerkesztés +- [`provider/ProviderDetailModal.vue`](frontend_app/src/components/provider/ProviderDetailModal.vue) — Provider részletek +- [`provider/ProviderQuickAddModal.vue`](frontend_app/src/components/provider/ProviderQuickAddModal.vue) — Gyors hozzáadás +- [`dashboard/ServiceFinderCard.vue`](frontend_app/src/components/dashboard/ServiceFinderCard.vue) — Kereső widget + +### Dokumentációk +- [`admin_providers_root_cause_analysis.md`](docs/admin_providers_root_cause_analysis.md) — Root cause analysis +- [`epic_10_admin_frontend_spec.md`](docs/sf/epic_10_admin_frontend_spec.md) — Epic 10 spec +- [`Masterbook_2.0_Status.md`](docs/Masterbook_2.0_Status.md) — Projekt státusz +- [`full_db_schema_blueprint.md`](docs/full_db_schema_blueprint.md) — Teljes séma blueprint + +--- + +## ✅ Verifikációs Checklist + +### Admin Level +- [ ] Admin provider moderáció (approve/reject/flag/restore) — ✅ KÉSZ +- [ ] UNION ALL query (3 tábla egyesítése) — ✅ KÉSZ +- [ ] Promotion Engine (Staging → Live) — ✅ KÉSZ +- [ ] Veszélyes Zóna Gatekeeper — ❌ HIÁNYZIK (P0) +- [ ] KYC Verifikáció — ❌ HIÁNYZIK (P1) +- [ ] Előfizetés admin UI — ❌ HIÁNYZIK (P1) +- [ ] Kategória menedzsment admin UI — ❌ HIÁNYZIK (P1) + +### User Level +- [ ] Provider CRUD (quick_add, update) — ✅ KÉSZ +- [ ] Geofencing kereső (PostGIS) — ✅ KÉSZ +- [ ] Szolgáltatói self-service — ❌ HIÁNYZIK (P1) +- [ ] ServicePricing modell — ❌ HIÁNYZIK (P1) +- [ ] Foglalási rendszer — ❌ HIÁNYZIK (P2) +- [ ] Értékelés és gamifikáció — ✅ RÉSZBEN + +### Robot/AI Level +- [ ] Multi-Agent AI Gateway (Ollama → Groq/Gemini) — ✅ KÉSZ +- [ ] OCR dokumentum feldolgozás — ✅ KÉSZ +- [ ] Config-driven weighted ranking — ✅ KÉSZ +- [ ] NLP szemantikus keresés — ❌ HIÁNYZIK (P2) +- [ ] ML-alapú rangsorolás — ❌ HIÁNYZIK (P2) +- [ ] AI prompt admin panel — ❌ HIÁNYZIK (P2) +