diff --git a/.roo/history.md b/.roo/history.md
index 2a46e24..ae20921 100644
--- a/.roo/history.md
+++ b/.roo/history.md
@@ -1,5 +1,78 @@
# Service Finder Fejlesztési Történet
+## 2026-06-14 - Admin Search Bugs Fix & Sticky UX
+
+### 🎯 Cél
+Backend SQL hibák javítása a GET /admin/users végponton (Address outerjoin, phone search), valamint frontend UX fejlesztések (sticky bulk action bar, clear/X gomb, üres állapot üzenet, HeaderProfile az AdminLayout-ban).
+
+### 🔧 Változtatások
+
+**1. BACKEND - [`admin.py`](backend/app/api/v1/endpoints/admin.py:502):**
+- **Kritikus SQL javítás:** A `selectinload` helyett explicit `outerjoin` + `contains_eager` használata a `Person` és `Address` táblákhoz. A `selectinload` külön query-ben tölti be a kapcsolódó adatokat, így a `WHERE` feltételek a `Person.phone`, `Person.first_name` stb. oszlopokra nem működtek (500-as hiba).
+- **Address keresés javítás:** Az `Address.zip` és `Address.city` Python property-k, nem DB oszlopok. A tényleges SQL lekérdezésben a `GeoPostalCode.zip_code` és `GeoPostalCode.city` oszlopokat kell használni. Ehhez egy 3. outerjoin került a `GeoPostalCode` táblára.
+- **Phone search:** A `query = query.where(Person.phone.ilike(...))` értékadás most már működik, mert a `Person` tábla explicit outerjoin-olva van.
+- **404 hiba:** Nem volt 404-es hiba az üres találatnál - a végpont helyesen 200 OK-val és üres listával tér vissza.
+
+**2. FRONTEND - [`AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue):**
+- **Sticky Bulk Action Bar:** `sticky top-0 z-20 bg-gray-800 shadow-lg` osztályok hozzáadva, hogy görgetésnél a táblázat tetején tapadjon.
+- **Clear/X gomb:** A kereső input mezőbe egy X gomb került (csak akkor látszik, ha van szöveg), ami üríti a `searchQuery`-t és alapállapotba állítja a listát.
+- **clearSearch() metódus:** Új metódus, ami nullázza a keresési paramétereket és újratölti a listát.
+- **Üres állapot:** Barátságos üzenet ikonnal és "Keresés törlése" gombbal, ha nincs találat.
+
+**3. FRONTEND - [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue):**
+- **HeaderProfile komponens:** Importálva és elhelyezve a top bar jobb oldalán a `LanguageSwitcher` és `ModeSwitcher` mellett. Ezzel az admin felületen is látszik a felhasználó neve, profilba lépés és kilépés lehetősége.
+
+### ✅ Eredmény
+- Python szintaxis ellenőrzés: OK
+- Backend SQL lekérdezések: outerjoin-ök helyesen használva, nincs 500-as hiba keresésnél
+- Frontend: sticky, clear gomb, üres állapot, HeaderProfile mind implementálva
+
+## 2026-06-14 - 405 DELETE Debugging & Fix
+
+### 🎯 Cél
+A `DELETE /api/v1/users/me` végpont 405 Method Not Allowed hibát dobott. Felderíteni a hiba okát és javítani.
+
+### 🔍 Vizsgálat lépései
+
+**1. LÉPÉS - Végpont fizikai ellenőrzése ([`users.py`](backend/app/api/v1/endpoints/users.py:502)):**
+- A `@router.delete("/me", status_code=200)` dekorátor létezik, helyes útvonallal (`/me`, nincs perjel a végén)
+- A végpont implementálva van, meghívja az `AuthService.soft_delete_user()` függvényt
+- A router listában a DELETE `/me` regisztrálva van
+
+**2. LÉPÉS - CORS és Proxy ellenőrzése:**
+- CORS: `allow_methods=["*"]` - minden metódus engedélyezve
+- Nginx proxy: `location /api/` blokk minden kérést továbbít, nincs metódus-szűrés
+- Router bekötés: `users.router` a `/users` prefix alá kötve
+
+**3. LÉPÉS - Belső tesztelés:**
+- Közvetlen teszt a konténerben (`localhost:8000`, proxy-t megkerülve) szintén 405-öt adott
+- A konténer `--reload` nélkül indult, így a kód módosításai nem léptek életbe
+- **Megoldás:** `docker compose restart sf_api` után a DELETE `/me` **200 OK** választ adott
+
+### ✅ Eredmény
+- A 405-ös hibát **nem a kód**, hanem a konténer újraindításának hiánya okozta
+- A `DELETE /api/v1/users/me` végpont a `tester_pro2@profibot.hu` fiókkal tesztelve **200 OK** választ ad
+- A soft-delete funkció helyesen anonimizálja az email címet és inaktiválja a felhasználót
+- Teszt után a `tester_pro2@profibot.hu` fiók visszaállításra került
+
+## 2026-06-13 - Emergency Fix: Vehicle Creation 500 Error (FK Violation on catalog_id)
+
+### 🎯 Cél
+A `POST /api/v1/assets/vehicles` végpont 500 Internal Server Error-t dobott új jármű rögzítésekor. A hiba oka az `AssetMatcherService` által visszaadott `VehicleModelDefinition.id` közvetlen hozzárendelése volt az `Asset.catalog_id` mezőhöz, amelynek FK-ja a `vehicle.vehicle_catalog` (`AssetCatalog`) táblára mutat, nem a `vehicle.vehicle_model_definitions` táblára.
+
+### 🔧 Változtatások
+
+**1. `backend/app/services/asset_service.py` (228-255. sorok):**
+- A matcher integrációs blokkban a `new_asset.catalog_id = matched_def.id` sor kicserélve.
+- Az új logika először megkeresi a `VehicleModelDefinition`-hez tartozó `AssetCatalog` rekordot (`master_definition_id` alapján).
+- Ha nem létezik, automatikusan létrehozza a megfelelő `AssetCatalog` bejegyzést a definíció adataiból.
+- Csak ezután állítja be a `catalog_id`-t a valódi `AssetCatalog.id`-ra.
+
+### ✅ Eredmény
+- A teszt `POST /api/v1/assets/vehicles` kérés `201 Created` státusszal tért vissza.
+- A jármű `catalog_id: 1312975` (AssetCatalog ID) értékkel jött létre, adatgazdagítással (`engine_capacity: 176`, `power_kw: 13`, `data_status: enriched`).
+- Nincs több ForeignKeyViolationError a logokban.
+
## 2026-06-08 - F5 Refresh Logout Bug Fix & HeaderLogo Contextual Navigation
### 🎯 Cél
@@ -18,625 +91,571 @@ Két frontend hiba javítása: (1) F5 oldalfrissítéskor a Vue Router guard ham
**3. `frontend/src/components/header/HeaderLogo.vue`:**
- `useRoute()` importálva a Vue Router-ből.
-- `targetRoute` computed property: ha a jelenlegi útvonal `/organization/`-t tartalmaz, akkor a logó a jelenlegi útvonalra navigál (önmagát tölti újra), egyébként `/dashboard`.
-- A prop-alapú `to` helyett a `:to="targetRoute"` használata a template-ben.
+- Quick Action gombok: már nem néma hibásak, figyelmeztetnek ha nincs jármű
+- FUEL kategória: dinamikus feloldás API-ból, nem hardcoded ID
+- OdometerReading tábla létrehozva a `vehicle` sémában
+- CostCategory.min_tier oszlop hozzáadva `'free'` default értékkel
+- subscription_service.py elkészítve (Gitea #239)
+- AssetCost.document_id oszlop hozzáadva
+- 20 CostCategory rekord tier beállítva
+
+## 2026-06-12 - Dynamic Parameters Check & Centralized Odometer Implementation
+
+### 🎯 Cél
+1. Rendszerparaméter-tábla vizsgálata (no hardcoding elv érvényesítése)
+2. Odometer (kilométeróra) automatikus bekötése a költségrögzítésbe
+3. Valós km állás visszaadása a frontend felé
+
+### 🔍 1. Rendszerparaméterek Vizsgálata
+
+**Megállapítás:** A `system.system_parameters` tábla létezik és használatban van az alábbi struktúrával:
+- `key` (VARCHAR) - paraméter kulcs
+- `category` (VARCHAR) - kategória (pl. vehicle, finance, security)
+- `value` (JSONB) - érték (tetszőleges JSON struktúra)
+- `scope_level` (ENUM: global, organization, user) - hatókör
+- `scope_id` (VARCHAR) - opcionális hatókör azonosító
+- `is_active` (BOOLEAN) - aktív/inaktív flag
+
+**Példa bejegyzések:** `VEHICLE_DRAFT_MAX_EXPENSES`, `VEHICLE_DRAFT_MAX_DAYS`, `VEHICLE_LIMIT`, `EXCHANGE_RATE_EUR_HUF`, `auth_min_password_length`, stb.
+
+**Következtetés:** A 30 napos trial logika dinamikus paraméterezéséhez a `system.system_parameters` tábla használható. A trial periódus bevezetése előtt létre kell hozni egy `TRIAL_PERIOD_DAYS` kulcsú rekordot ebben a táblában.
+
+### 🔧 2. Backend: Odometer bekötése (expenses.py)
+
+**Módosított fájlok:**
+- [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) - Odometer integráció hozzáadva
+- [`backend/app/models/__init__.py`](backend/app/models/__init__.py) - `OdometerReading` export hozzáadva
+- [`backend/app/models/vehicle/__init__.py`](backend/app/models/vehicle/__init__.py) - `OdometerReading` export hozzáadva
+
+**Implementáció részletei:**
+- Az `AssetCostCreate` séma már tartalmazza a `mileage_at_cost` mezőt
+- A `create_expense` végpont most:
+ 1. Létrehoz egy `OdometerReading` rekordot (`source="cost_entry"`, `cost_id=new_cost.id`) ha `mileage_at_cost` meg van adva
+ 2. Frissíti az `Asset.current_mileage` mezőt, ha az új km állás nagyobb, mint a jelenlegi
+- Az `OdometerReading` modell már létezett a `vehicle.odometer_readings` táblában (korábban nem volt exportálva a modell `__init__`-ből)
+
+### ✅ 3. Frontend / API: Valós Km állás Visszaadása
+
+**Megállapítás:** A teljes frontend-backend lánc már működik:
+- `AssetResponse.current_mileage` (Pydantic schema) - tartalmazza a mezőt
+- `GET /api/v1/assets/{id}` végpont - visszaadja a `current_mileage`-t
+- `VehicleData` TypeScript típus - definiálja a `current_mileage` mezőt
+- `VehicleDetailModal.vue` - "Alapadatok" fülön megjeleníti (`vehicle.current_mileage`)
+- `VehicleCardStandard.vue` - kártyanézetben is megjelenik
+
+### 📊 Összefoglalás
+- A rendszerparaméterek dinamikus kezelése biztosított a `system.system_parameters` táblán keresztül
+- Az OdometerReading automatikusan rögzítésre kerül minden költségbejegyzésnél, ahol `mileage_at_cost` meg van adva
+- Az `Asset.current_mileage` mindig a legmagasabb rögzített km állást tükrözi
+- A frontend valós időben látja a frissített km állást
+
+## 2026-06-12 - Auth Registration 500 Error Fix & Container Startup Repair
+
+### Cél
+A `POST /api/v1/auth/register` végpont 500 Internal Server Error hibájának kijavítása. A hiba oka a PostgreSQL `audit.log_severity` enum és a Python `LogSeverity` enum közötti mismatch volt.
+
+### Változtatások
+
+**1. `backend/app/services/auth_service.py`:**
+- `LogSeverity` enum import hozzáadva
+- `severity="INFO"` → `severity=LogSeverity.info` (line 149)
+- `severity="WARNING"` → `severity=LogSeverity.warning` (line 494)
+
+**2. `backend/app/models/vehicle/vehicle.py`:**
+- `CostCategory.__table_args__`: `extend_existing=True` hozzáadva
+- `VehicleUserRating.id`: `UUID` → `PG_UUID` javítás (NameError)
+
+**3. `backend/app/scripts/unified_db_sync.py`:**
+- `dynamic_import_models()` átírva: egyszerű `import app.models`
+
+**4. `backend/app/models/__init__.py`:**
+- Import sorrend javítva: Organization/Branch a Rating elé került
+
+### Eredmény
+- `POST /api/v1/auth/register` → **201 Created** sikeres regisztráció
+- `GET /health` → **200 OK** (database connected)
+- Container stabilan fut, nincs restart loop
+
+## 2026-06-12 - KYC Flow Bugfix & Repair (verify_email + Gamification commit)
+
+### 🎯 Cél
+Két kritikus hiba javítása a KYC (Know Your Customer) folyamatban, ami miatt a `gy.krisztina76@gmail.com` felhasználó nem tudott aktiválódni. A light regisztráció → email verifikáció → KYC kitöltés után nem jöttek létre a szükséges adatbázis kapcsolatok (Organization, Wallet, Branch, OrgMember, UserStats).
+
+### 🔧 Változtatások
+
+**1. `backend/app/services/auth_service.py` - `verify_email()` metódus (320-359. sor):**
+- **Bug:** A Person rekord keresése `Person.user_id == user.id` alapján történt, de `Person.user_id` NULL (ez egy külön back-reference FK, ami sosem lett beállítva regisztrációnál).
+- **Fix:** Módosítva `Person.id == user.person_id`-re, ami a `User.person_id` FK-n keresztül helyesen megtalálja a Person rekordot.
+- Plusz: `person.user_id = user.id` beállítás a back-reference konzisztencia fenntartásához.
+
+**2. `backend/app/services/gamification_service.py` - `award_points()` és `process_activity()` (20-123. sor):**
+- **Bug:** `process_activity()` belső `await db.commit()`-et hívott, ami konfliktusba került a `complete_kyc()` külső tranzakciójával. A gamification commit részlegesen elmentette az adatokat (Org, Wallet, stb.), de ha a külső metódusban hiba történt, a rollback már nem tudta visszavonni a commitált adatokat.
+- **Fix:** `commit: bool = True` paraméter hozzáadva mindkét metódushoz. Amikor `commit=False`, a metódus nem hív `db.commit()`-ot, így a hívó fél (pl. `complete_kyc()`) kezeli a tranzakciót atomikusan.
+
+**3. `backend/app/services/auth_service.py` - `complete_kyc()` (289. sor):**
+- `commit=False` átadva a `GamificationService.award_points()` hívásoknak, hogy ne legyen dupla commit.
+
+### 🛠️ Repair Script
+- `backend/scripts/repair_kyc_user.py` - Létrehozva a meglévő `gy.krisztina76@gmail.com` (ID 100) user javítására:
+ - Person aktiválása (`is_active=True`, `user_id=100`)
+ - Organization létrehozása ("Gyöngyössy Flotta", ID 46)
+ - Branch létrehozása ("Home Base")
+ - OrganizationMember létrehozása (OWNER)
+ - Wallet létrehozása (HUF)
+ - UserStats létrehozása (500 XP KYC bónusz)
+ - `user.scope_id` beállítása
### ✅ Eredmény
-- Vite build hiba nélkül lefutott (136 modul, 3.23s).
-- Nincs több F5 utáni hamis kijelentkezés.
-- A logó szervezeti oldalon nem dobja ki a felhasználót a dashboard-ra.
+- E2E teszt (`backend/scripts/test_kyc_e2e.py`) sikeresen lefutott: **6/6 teszt pass, 0 failed**
+- Teljes flow: Lite Registration → Email Verification → Login → KYC Complete → Infrastructure Verify
+- Minden szükséges adatbázis kapcsolat létrejön: Person, Organization, Branch, OrgMember, Wallet, UserStats, scope_id
+- A javított user (`gy.krisztina76@gmail.com`) adatai helyreállítva
-
-## 2026-06-08 - Issue #237: API Endpoints for visual_settings
+## 2026-06-12 - Emergency Regression Fix: Vehicle Creation 500 Error (UnboundLocalError)
### 🎯 Cél
-Backend API végpontok felkészítése a `visual_settings` módosítására. Pydantic sémák (`OrganizationUpdate`, `OrganizationResponse`) létrehozása a `visual_settings` mezővel, valamint `PATCH /organizations/{org_id}/visual-settings` végpont implementálása JSONB merge logikával.
-
-### ✅ Implementáció
-
-#### 1. Pydantic Sémák
-- **`OrganizationUpdate`**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:40) - Új séma `visual_settings: Optional[dict]`, `display_name`, `default_currency`, `language` mezőkkel
-- **`OrganizationResponse`**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:49) - Új DTO séma `visual_settings: Optional[dict]` mezővel, `from_attributes=True` konfigurációval
-
-#### 2. API Végpont
-- **`PATCH /organizations/{org_id}/visual-settings`**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:207) - Új végpont JSONB merge logikával:
- - OWNER/ADMIN jogosultság ellenőrzés
- - Mély merge: a meglévő `visual_settings` kulcsok megmaradnak, csak a kapottak frissülnek
- - Pl. `{"theme": "dark"}` küldése nem írja felül a `wall_logo_url`-t
- - Egyéb mezők (`display_name`, `language`, `default_currency`) is frissíthetők
-
-#### 3. Meglévő Végpontok Bővítése
-- **`GET /organizations/my`**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:203) - `visual_settings` mező hozzáadva a response-hoz
-- **`PATCH /users/me/preferences`**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:333) - Már támogatja a `visual_settings`-et a generikus `exclude_unset=True` logikával
-
-#### 4. Verifikáció
-- Python syntax check: minden fájl hibátlanul lefordul
-- Import teszt: `OrganizationUpdate`, `OrganizationResponse`, `UserUpdate`, `UserResponse` mindegyike tartalmazza a `visual_settings` mezőt
-
-
-## 2026-06-08 - Issue #228: Add visual_settings to Models
-
-### 🎯 Cél
-`visual_settings` JSONB mező hozzáadása a User és Organization modellekhez, a Pydantic sémák frissítése, majd az adatbázis szinkronizálása a sync_engine segítségével.
-
-### ✅ Implementáció
-
-#### 1. SQLAlchemy Modellek
-- **User modell**: [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:157) - `visual_settings` JSONB oszlop hozzáadva a `ui_mode` után, `server_default` JSONB default értékkel (`{"theme": "default", "primary_color": null, "wall_logo_url": null}`)
-- **Organization modell**: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:109) - `visual_settings` JSONB oszlop hozzáadva az `external_integration_config` után, azonos default értékkel
-
-#### 2. Pydantic Sémák
-- **UserResponse**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:62) - `visual_settings: Optional[dict]` mező hozzáadva default értékkel
-- **UserUpdate**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:107) - `visual_settings: Optional[dict]` mező hozzáadva
-
-#### 3. Adatbázis Szinkronizálás
-- `sync_engine` sikeresen lefuttatva: 2 hiányzó oszlopot észlelt és hozzáadott (`identity.users.visual_settings` és `fleet.organizations.visual_settings`)
-- Verifikáció: mindkét oszlop `jsonb` típusként létezik az adatbázisban
-
-
-## 2026-06-04 (B) - Magic Link Implementáció (Auto-Login Email Verification)
-
-### 🎯 Cél
-Az email verifikációs folyamat átalakítása "Magic Link" élménnyé. A felhasználónak NEM kell manuálisan bejelentkeznie az aktiválás után - a verifikációs link automatikusan belépteti!
-
-### ✅ Implementáció
-
-#### 1. Backend: JWT Token Generálás Verifikációnál
-**Fájl**: [`backend/app/api/v1/endpoints/auth.py`](backend/app/api/v1/endpoints/auth.py:93)
-- A `/verify-email` végpont most `response_model=Token`-t ad vissza
-- Sikeres verifikáció után a backend meghívja az [`AuthService.verify_email()`](backend/app/services/auth_service.py:316) metódust
-- Az `AuthService.verify_email()` mostantól User objektumot ad vissza (nem csak boolean-t)
-- A végpont generál JWT access és refresh tokent ugyanúgy, mint a `/login` endpoint
-- A tokenek HTTP-only cookie-ban és JSON response-ban is megjelennek
-
-**Változtatások**:
-```python
-# AuthService.verify_email() módosítás
-- return True # RÉGI
-+ return user # ÚJ: visszaadja az aktivált User objektumot
-
-# auth.py verify_email végpont
-+ user = await AuthService.verify_email(db, request.token)
-+ if not user:
-+ raise HTTPException(...)
-+
-+ # JWT token generálás (ugyanaz a logika, mint login-nál)
-+ token_data = {"sub": str(user.id), "role": ..., "rank": ..., ...}
-```
-
-#### 2. Frontend Magic Link Handler
-**Fájl**: [`frontend/src/router/index.js`](frontend/src/router/index.js:1)
-- Új route: `/magic-link` a verify email oldalhoz
-- A route guard ellenőrzi a `token` query paramétert
-- Sikeres verifikáció után a kapott tokeneket elmenti és átirányít a dashboardra
-
-#### 3. Teszt Script
-**Fájl**: [`backend/test_auth_e2e.py`](backend/test_auth_e2e.py:1)
-- Teljes E2E teszt: Regisztráció → Email verifikáció → Magic Link → Dashboard elérés
-- Ellenőrzi, hogy a Magic Link után a felhasználó már hitelesítve van
-
-### 🔗 Gitea
-- **Issue**: #201 - Magic Link: Auto-Login on Email Verification
-- **Státusz**: Completed
-
----
-
-## 2026-06-05 - Soft KYC, Trust Profile Extension & Device Hub
-
-### 🎯 Cél
-A KYC (Know Your Customer) folyamat kiterjesztése "Soft KYC" módra, a Trust Profile kibővítése identity score-ral, valamint a Device Hub (Device Fingerprinting) bevezetése.
-
-### ✅ Implementáció
-
-#### 1. Soft KYC Backend Végpontok
-**Fájl**: [`backend/app/api/v1/endpoints/kyc.py`](backend/app/api/v1/endpoints/kyc.py:1)
-- Új `POST /kyc/soft` végpont: lehetővé teszi a részleges KYC adatok mentését
-- Új `GET /kyc/status` végpont: visszaadja a KYC státuszt és a hiányzó mezőket
-- A `PUT /users/me/person` végpont most már fogadja a `mothers_last_name`, `mothers_first_name`, `birth_place`, `birth_date` mezőket
-
-#### 2. Trust Profile Extension
-**Fájl**: [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:271)
-- `UserTrustProfile` modell bővítése: `identity_score`, `verification_level`, `verified_channels`, `identity_risk_flag` mezőkkel
-- Automatikus trust score újraszámítás KYC adatok módosításakor
-
-#### 3. Device Hub (Device Fingerprinting)
-**Fájl**: [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:302)
-- Új `Device` modell: `fingerprint_hash`, `risk_score`, `is_banned` mezőkkel
-- Új `UserDeviceLink` kapcsolótábla: `user_id`, `device_hash`, `first_seen_at`, `last_seen_at`, `login_count`
-- API végpontok: `POST /devices/register`, `GET /devices/my`
-
-#### 4. Adatbázis Migráció
-- Új táblák: `identity.devices`, `identity.user_device_links`
-- `UserTrustProfile` bővítése új oszlopokkal
-- `sync_engine` futtatva: 1017/1017 elem szinkronban
-
-### 🔗 Gitea
-- **Issue**: #202 - Soft KYC, Trust Profile Extension & Device Hub
-- **Státusz**: Completed
-
----
-
-## 2026-06-05 - Fix KYC Data Binding & Magic Link Device Registration
-
-### 🎯 Cél
-A KYC adatok (address, identity_docs) nem jelentek meg a profilban a hibás adatkötés miatt. Emellett a Magic Link folyamatból hiányzott a Device Hub regisztráció.
-
-### ✅ Javítás
-
-#### 1. ProfileView Address Binding Fix
-**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:1)
-- A `person.address` mezőből hiányzott a `stairwell`, `floor`, `door` kiolvasása
-- Hozzáadva: `addressStairwell`, `addressFloor`, `addressDoor` computed property-k
-- A `handleSave` metódus most már ezeket is elküldi a `PUT /users/me/person` végpontnak
-
-#### 2. Magic Link Device Registration
-**Fájl**: [`frontend/src/views/MagicLinkView.vue`](frontend/src/views/MagicLinkView.vue:1)
-- Sikeres Magic Link belépés után a frontend meghívja a `POST /devices/register` végpontot
-- A device fingerprint hash-t a `fingerprintjs` library generálja
-
-### 🔗 Gitea
-- **Issue**: #204 - Fix KYC Data Binding & Magic Link Device Registration
-- **Státusz**: Completed
-
----
-
-## 2026-06-05 - Fix: SQLAlchemy JSON mutation for identity_docs
-
-### 🎯 Cél
-A `PUT /users/me/person` végponton keresztül küldött `identity_docs` JSON mező módosításai nem perzisztálódtak az adatbázisban, mert az SQLAlchemy nem detektálta a JSON mutációt.
-
-### ✅ Javítás
-
-#### 1. Végpont javítása
-**Fájl**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:223)
-- A `identity_docs` mező módosításakor most `flag_modified(person, "identity_docs")` hívással jelezzük az SQLAlchemy-nek, hogy a JSON mező megváltozott
-- Mély másolat (deep copy) készítése a meglévő adatokról a referencia integritás megőrzéséhez
-
-### 🔗 Gitea
-- **Issue**: #207 - Fix: SQLAlchemy JSON mutation for identity_docs
-- **Státusz**: Completed
-
----
-
-## 2026-06-05 - Internal Zip Lookup & Identity Docs Payload Fix
-
-### 🎯 Cél
-A frontend által használt zip-lookup (irányítószám → város) végpont áttérése a külső zippopotam.us API-ról a belső backend végpontra. Emellett az identity_docs payload hibás szerializációjának javítása.
-
-### ✅ Implementáció
-
-#### 1. Backend: Új Zip Lookup Végpont
-**Fájl**: [`backend/app/api/v1/endpoints/geo.py`](backend/app/api/v1/endpoints/geo.py:1)
-- Új `GET /geo/zip-lookup/{zip_code}` végpont
-- Ellenőrzi a `system.geo_postal_codes` táblát a megadott irányítószámra
-- Válasz: `{"zip": "1111", "city": "Budapest"}` vagy 404
-
-#### 2. Frontend: Zip Lookup Átállítás
-- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:1) - zippopotam.us → internal API
-- [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:1) - Debounced zip lookup watcher
-
-### 🧪 Tesztelési Eredmény
-1. ✅ Backend zip-lookup endpoint működik: `1111` → `{"city": "Budapest"}` (200)
-2. ✅ Ismeretlen irányítószám: 404 hibakód megfelelő üzenettel
-3. ✅ Frontend CompleteKycView belső API-t használ
-4. ✅ Frontend ProfileView debounced zip lookup implementálva
-5. ✅ identity_docs payload már helyesen működik (nem kellett javítás)
-
-### 🔗 Gitea
-- **Issue**: #208 - Internal Zip Lookup & Identity Docs Payload Fix
-- **Státusz**: Completed
-
----
-
-## 2026-06-05 - Fix Missing Address Fields (stairwell/floor/door) in GeoService Pipeline
-
-### 🎯 Cél
-A `PUT /users/me/person` végponton keresztül küldött `address_stairwell`, `address_floor` és `address_door` mezők nem mentődtek az adatbázisba, mert a GeoService hívásból hiányoztak ezek a paraméterek.
-
-### ✅ Javítás
-
-#### 1. Végpont javítása
-**Fájl**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:238)
-- A `GeoService.get_or_create_full_address()` hívásból hiányzott a `stairwell`, `floor` és `door` paraméterek átadása
-- Hozzáadva: `stairwell=update_dict.get("address_stairwell")`, `floor=update_dict.get("address_floor")`, `door=update_dict.get("address_door")`
-
-#### 2. Ellenőrzött fájlok (nem volt szükség módosításra)
-- [`backend/app/services/geo_service.py`](backend/app/services/geo_service.py:46) - A `get_or_create_full_address` metódus szignatúrája már tartalmazta a `stairwell`, `floor`, `door` paramétereket, és az Address entitásba is mentette őket
-- [`backend/app/schemas/user.py`](backend/app/schemas/user.py:87) - A `PersonUpdate` séma már deklarálta az `address_stairwell`, `address_floor`, `address_door` mezőket
-
-### 🔍 Root Cause
-A `PUT /users/me/person` végpont a `GeoService.get_or_create_full_address()` híváskor nem adta át a `stairwell`, `floor` és `door` paramétereket, így azok mindig `None`-ként kerültek az adatbázisba, függetlenül attól, hogy a frontend mit küldött.
-
-### 🔍 További javítás: full_address_text formátum javítása
-
-A `GeoService.get_or_create_full_address()` metódusban a `full_address_text` generátor a `door` paraméterhez `ajtó` szöveget fűzött, de a magyar címírási konvenció szerint `a.` (ajtó rövidítése) a helyes.
-
-**Javítás**:
-- [`backend/app/services/geo_service.py`](backend/app/services/geo_service.py:136) - `{door}. ajtó` → `{door}. a.`
-
-**Példa a helyes formátumra**: `2120 Dunakeszi, Határ utca 8. A. lph. 5. em. 10. a.`
-
-**Ellenőrzés**: A `sync_engine` futása után a rendszer 1017/1017 elemmel szinkronban van.
-
----
-
-## 2026-06-05 - E2E Test Browserless Container Support
-
-### 🎯 Cél
-Az [`tests/active/e2e_profile_address.py`](tests/active/e2e_profile_address.py) Playwright E2E teszt frissítése, hogy támogassa a távoli Browserless/Chrome Docker konténerhez való csatlakozást.
-
-### ✅ Implementáció
-- **Dinamikus böngésző indítás**: A statikus `pw.chromium.launch()` hívás helyett a kód most ellenőrzi a `BROWSERLESS_URL` környezeti változót.
- - Ha be van állítva → `pw.chromium.connect_over_cdp(browserless_url)` segítségével csatlakozik a távoli konténerhez.
- - Ha nincs beállítva → lokális `pw.chromium.launch()` fallback, a `HEADLESS` env var figyelembevételével.
-- **Gitea Issue**: #215 - létrehozva, elindítva és lezárva.
-
-## 2026-06-07 (C) - NAV API UX & Adatbázis Finomhangolás
-
-### 🎯 Cél
-A NAV API működik, de a felhasználói élmény és az adatbázis szigorúsága finomhangolást igényelt.
-
-### ✅ Implementáció
-
-#### 1. Cégjegyzékszám (company_registry_number) opcionálissá tétele
-- **Modell**: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:89) - `reg_number: Mapped[Optional[str]]` már eleve nullable volt
-- **Schema**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:17) - `reg_number: Optional[str] = None` már eleve opcionális volt
-- **Adatbázis**: A `sync_engine` audit szerint a séma tökéletesen szinkronban van (1017/1017 elem OK)
-
-#### 2. Duplikáció szűrése a NAV lekérdezés előtt
-- **Végpont**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:124) - `GET /lookup-tax/{tax_number}`
-- Injektált `db: AsyncSession = Depends(get_db)` függőség
-- Mielőtt a NAV-hoz fordul, ellenőrzi az adatbázist az adószám első 8 számjegye (törzsszám) alapján
-- Ha létezik aktív cég (`is_deleted == False`), azonnal `HTTPException(status_code=409, detail="Ez az adószám már regisztrálva van a rendszerben.")` hibát dob
-
-#### 3. Rövid név okosítása (filler words filtering)
-- **Service**: [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py:105) - `_format_company_names()` metódus
-- `filler_words` lista: `["és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó", "ipari", "építőipari", "termelő", "fejlesztő", "kivitelező", "általános"]`
-- A rövid név generálásakor a név szavakra bontva, csak a nem-töltelék szavak maradnak meg
-- A cégforma rövidítés (pl. "Kft.") a legvégére kerül hozzáfűzésre
-- Fallback: ha minden szó töltelék volt, az eredeti név marad
-
-### ✅ Verifikáció
-- Python import check: `nav_service.py` és `organizations.py` sikeresen importálódott
-- `sync_engine` audit: 1017/1017 elem OK, 0 hiba, teljes szinkron
-
-## 2026-06-08 - UI/UX Navigációs Hibák Javítása (3 Bugfix)
-
-### 🎯 Cél
-Három UI/UX és navigációs hiba javítása a Vue 3 frontendben: (1) cégnév/"Cégem" gomb navigáció `/company/garage`-ba, (2) Profil oldal bezárása backdrop overlay-re kattintva, (3) Avatar dropdown bezárása kattintáskor az elemen kívül.
-
-### ✅ Implementáció
-
-#### 1. ProfileView.vue - Backdrop Overlay
-**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:14)
-- Teljes oldalt lefedő backdrop overlay (`fixed inset-0 z-10 bg-black/30 backdrop-blur-[2px]`) a profil kártya körül
-- `@click="closeProfile"` a backdrop-on → kattintásra `router.push('/dashboard')`
-- `@click.stop` a belső kártya `div`-en → megakadályozza a bezáródást a kártyán belüli kattintáskor
-- `handleEscapeKey()`: Escape billentyűre bezárja a profilt (vagy a jelszó módosító modalt, ha az nyitva van)
-- `onMounted`/`onUnmounted`: `keydown` eseményfigyelő regisztráció/törlés
-
-#### 2. DashboardHeader.vue - Cégnév Navigáció
-**Fájl**: [`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:157)
-- Cégnév (`activeCompanyName`) ``-ként jelenik meg, `@click="goToCompanyGarage"`
-- `goToCompanyGarage()`: bezárja az összes dropdown-t, majd `router.push('/company/garage')`
-- `handleCompanySelect(orgId)`: ha a szervezet már aktív, akkor is navigál `/company/garage`-ba (nem tér vissza korán)
-
-#### 3. DashboardHeader.vue - Avatar Dropdown Click-Outside
-**Fájl**: [`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:394)
-- `avatarContainerRef` template ref az avatar konténeren
-- `handleOutsideClick()`: `avatarContainerRef.value.contains(target)` ellenőrzés → ha a kattintás az avatar konténeren kívül van, bezárja a `isUserMenuOpen` dropdown-t
-
-#### 4. auth.ts - Store Navigáció Eltávolítása
-**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:524)
-- `switchOrganization()` már nem tartalmaz `router.push()` hívásokat
-- A navigáció kizárólag a komponensek felelőssége (Separation of Concerns)
-
-### ✅ Verifikáció
-- Vite production build: 121 module transformed, 0 hiba, 3.35s alatt
-
-## 2026-06-08 - Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer Mérföldkő Létrehozása
-
-### 🎯 Cél
-Új Gitea mérföldkő (ID: 26) létrehozása a frontend architektúrális átalakításához, valamint a hozzá tartozó 8 issue (ID: #228-#236) létrehozása a `gitea_manager.py` script segítségével.
-
-### ✅ Elkészült elemek
-
-#### Mérföldkő
-- **Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer** (ID: 26)
-
-#### 1. FÁZIS: Adatbázis és Backend Alapozás
-- **#228** - 1.1: Adatbázis sémák bővítése vizuális konfigurációval (Skins/Themes) - JSONB mezők User és Organization táblákhoz
-- **#229** - 1.2: Engedélyezési szintek és Funkciókapcsolók (Feature Flags / RBAC) - ABAC kiterjesztés
-- **#230** - 1.3: Dinamikus (Adatbázis) és Statikus fordítási rendszer alapjai - Translation tábla
-
-#### 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás
-- **#231** - 2.1: Független Layout komponensek felépítése (Vue 3) - PrivateLayout & OrganizationLayout
-- **#232** - 2.2: Vue Router átírás (Nested Routing) - Hierarchikus útvonalak
-
-#### 3. FÁZIS: Többnyelvűség (i18n) & Vizuális Témaváltó (Theming Engine)
-- **#233** - 3.1: i18n audit & Szövegek kiszervezése nyelvi kulcsokra
-- **#234** - 3.2: Tailwind CSS Változók és Témakezelő implementálása (AppThemeStore)
-
-#### 4. FÁZIS: Animált UI Készlet
-- **#235** - 4.1: "App Store" típusú kiterjedő kártya (Expansion Card)
-- **#236** - 4.2: Kontextuális Vészjelző Rendszer (Villogó Hibajelző Lámpa)
-
-## 2026-06-08 - Frontend Architektúra 2.0 - Megvalósíthatósági Elemzés
-
-### 🎯 Cél
-A létrehozott 8 Gitea issue (#228-#236) kód-szintű felmérése: mi az, ami már létezik, mi hiányzik, és milyen függőségi sorrendben kell implementálni.
-
-### ✅ Elemzés eredménye
-
-#### Készültségi Tábla
-| Issue | Kártya | Kész | Megjegyzés |
-|-------|--------|------|------------|
-| #228 | JSONB visual_settings | **0%** | Nincs visual_settings mező semelyik modellben |
-| #229 | RBAC/ABAC | **40%** | UserRole, OrgUserRole, subscription_plan létezik; feature flag-ok hiányoznak |
-| #230 | Dinamikus Fordítás | **80%** | Translation modell, vue-i18n, 6 nyelvi fájl, translationService kész |
-| #231 | Layout komponensek | **60%** | 3 layout létezik, de ` `-ot használnak ` ` helyett |
-| #232 | Nested Routing | **50%** | 8 route létezik, de flat struktúrában, nincs layout nesting |
-| #233 | i18n audit | **30%** | 17+ hardcoded szöveg azonosítva 5 komponensben |
-| #234 | Tailwind Témaváltó | **0%** | Nincs CSS variable, nincs AppThemeStore, nincsenek `dark:` osztályok |
-| #235 | Expansion Card | **0%** | Nincs dedikált komponens |
-| #236 | Vészjelző Rendszer | **10%** | hotspot-pulse/ring animációk léteznek, de nincs ContextAlert komponens |
-
-#### Feltárt fájlok (18+ darab)
-- Backend: [`identity.py`](backend/app/models/identity/identity.py:122), [`organization.py`](backend/app/models/marketplace/organization.py:35), [`translation.py`](backend/app/models/system/translation.py:8)
-- Frontend Store: [`auth.ts`](frontend/src/stores/auth.ts:54), [`appModeStore.ts`](frontend/src/stores/appModeStore.ts:6)
-- Frontend Layout: [`ConsumerLayout.vue`](frontend/src/layouts/ConsumerLayout.vue:1), [`CorporateLayout.vue`](frontend/src/layouts/CorporateLayout.vue:1), [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue:1)
-- Frontend Router: [`index.ts`](frontend/src/router/index.ts:1)
-- Frontend i18n: [`hu.ts`](frontend/src/i18n/hu.ts:1), [`en.ts`](frontend/src/i18n/en.ts:1), [`translationService.ts`](frontend/src/services/translationService.ts:5)
-- Frontend Komponens: [`DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:1), [`DashboardView.vue`](frontend/src/views/DashboardView.vue:1), [`CompanyGarageView.vue`](frontend/src/views/organization/CompanyGarageView.vue:1)
-- Frontend Config: [`tailwind.config.js`](frontend/tailwind.config.js:1), [`main.css`](frontend/src/assets/main.css:1), [`main.ts`](frontend/src/main.ts:30)
-- Frontend Types: [`organization.ts`](frontend/src/types/organization.ts:5)
-
-#### Ajánlott implementációs sorrend
-1. **#228** (JSONB) → **#229** (RBAC) → **#234** (Témaváltó)
-2. **#230** (Fordítás) → **#233** (i18n audit)
-3. **#231** (Layout) → **#232** (Router) → **#235** (Expansion) → **#236** (Alert)
-
-#### Dokumentáció
-- Teljes elemzés: [`docs/frontend_architecture_2_0_feasibility_analysis.md`](docs/frontend_architecture_2_0_feasibility_analysis.md)
-
-## 2026-06-08 - Frontend Architektúra 2.0 - Hiányzó Alkártyák Létrehozása
-
-### 🎯 Cél
-Az elemzés alapján azonosított hiányzó komponensekhez 14 új Gitea alkártya (#237-#250) létrehozása, majd hozzárendelése a "Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer" mérföldkőhöz (ID: 26).
-
-### ✅ Elkészült elemek
-
-#### 1. FÁZIS: Adatbázis és Backend Alapozás (Alkártyák)
-- **#237** - 1.1-A: Backend API végpontok visual_settings kezelésére (GET/PUT /users/me/visual-settings, GET/PUT /organizations/{id}/visual-settings)
-- **#238** - 1.1-B: Frontend useVisualSettings() composable + UserProfile/OrganizationItem interface bővítés visual_settings mezővel
-- **#239** - 1.2-A: Backend subscription_service.py + SUBSCRIPTION_FEATURES config dict (allow_custom_skin, allow_advanced_animations)
-- **#240** - 1.2-B: Frontend useFeatureFlag() composable + auth store bővítés subscription_features mezővel
-
-#### 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás (Alkártyák)
-- **#241** - 2.1-A: PrivateLayout komponens kialakítása (ConsumerLayout átnevezés)
-- **#242** - 2.1-B: OrganizationLayout komponens kialakítása (CorporateLayout átnevezés)
-- **#243** - 2.2-A: Nested Router struktúra kialakítása (layout → child route)
-- **#244** - 2.2-B: Route transition guard + breadcrumb komponens
-
-#### 3. FÁZIS: Többnyelvűség & Témaváltó (Alkártyák)
-- **#245** - 3.1-A: i18n audit - hardcoded szövegek kiszervezése
-- **#246** - 3.1-B: i18n audit - hiányzó nyelvi kulcsok pótlása
-- **#247** - 3.2-A: AppThemeStore implementálása (CSS variable binding)
-- **#248** - 3.2-B: Tailwind CSS dark mode + skin class binding
-
-#### 4. FÁZIS: Animált UI Készlet (Alkártyák)
-- **#249** - 4.1: ExpansionCard komponens (App Store típusú kártya)
-- **#250** - 4.2: ContextAlert komponens (Vészjelző Rendszer)
-
-### 🔗 Gitea
-- **Mérföldkő**: Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (ID: 26)
-- **Státusz**: Alkártyák létrehozva, mérföldkőhöz rendelve
-
----
-
-## 2026-06-09 - Széf→Garázs Terminológia Javítás & Regisztrációs Hiba Javítás
-
-### 🎯 Cél
-Két kritikus hiba javítása a járművek csatlakoztatása előtt: (1) "Széf" terminológia cseréje "Garázs"-ra a backend kódban, (2) Regisztrációs hiba javítása, ahol az opcionális címmezők kitöltetlenül hagyása csendes hibát okozott.
-
-### ✅ Javítások
-
-#### 1. Terminológia Javítás ("Széf" → "Garázs")
-
-**Elsődleges - Backend organization létrehozás**:
-- **Fájl**: [`backend/app/services/auth_service.py`](backend/app/services/auth_service.py:253)
-- **Hiba**: A `complete_kyc()` metódusban a személyes garázs organization `name` mezője `f"{p.last_name} Széfe"` volt hardkódolva
-- **Javítás**: `f"{p.last_name} Széfe"` → `f"{p.last_name} Garázsa"`
-- **Hatás**: A regisztráció után a felhasználó a sárga gombban "{Vezetéknév} Garázsa" feliratot látja
-
-**Másodlagos - Dokumentum archiválás üzenet**:
-- **Fájl**: [`backend/app/api/v1/endpoints/documents.py`](backend/app/api/v1/endpoints/documents.py:63)
-- **Hiba**: `"Dokumentum sikeresen archiválva a széfben."`
-- **Javítás**: `"Dokumentum sikeresen archiválva a garázsban."`
-
-**Ellenőrzött frontend fájlok (nem volt szükség módosításra)**:
-- [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts:38) - Már "garázs"-t használ (`personalGarage`, `privateGarage`, `companyGarageLabel`, `personalGarageLabel`)
-- [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts:41) - Már "garage"-t használ helyesen
-- [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:42) - i18n kulcsokat használ, nincs hardkódolt "széf"
-- [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:10) - Már "garázsa" szerepel a template-ben
-
-#### 2. Regisztrációs Csendes Hiba Javítása
-
-**Gyökér Ok**:
-- **Fájl**: [`backend/app/services/geo_service.py`](backend/app/services/geo_service.py:50)
-- A `get_or_create_full_address()` metódusban a `street_name`, `street_type` és `house_number` paraméterek **kötelezőek** voltak (`str` típus, nem `Optional[str]`)
-- A KYC űrlapról ezek a mezők `None` értékkel érkezhetnek (opcionális mezők)
-- A `UserKYCComplete` Pydantic séma helyesen `Optional[str] = None`-ként deklarálta őket
-- A frontend helyesen kihagyta az üres mezőket a payload-ból
-- **A hiba**: A `GeoService.get_or_create_full_address()` nem fogadott el `None` értéket ezekre a paraméterekre
-
-**Javítás**:
-- `street_name: str` → `street_name: Optional[str] = None`
-- `street_type: str` → `street_type: Optional[str] = None`
-- `house_number: str` → `house_number: Optional[str] = None`
-
-**Ellenőrzött fájlok (nem volt szükség módosításra)**:
-- [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py:28) - `UserKYCComplete` séma helyesen `Optional[str] = None`-t használ
-- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:411) - A `handleSubmit()` helyesen szűri az üres stringeket
-
-### ✅ Verifikáció
-- Vite production build: 121 module transformed, 0 hiba, 3.56s alatt
-- Python syntax check: minden módosított fájl hibátlan
-- `sync_engine` futtatva: séma konzisztens
-
-
-## 2026-06-10 - is_primary (Elsődleges Jármű) Flag Implementáció
-
-### 🎯 Cél
-`is_primary` mező bevezetése a járművek számára, amely lehetővé teszi a felhasználók számára, hogy megjelöljék elsődleges járművüket. A mező az `individual_equipment` JSONB oszlopban tárolódik, így nincs szükség adatbázis migrációra.
+Két kritikus végpont 500-as hibájának elhárítása a legutóbbi architekturális változtatások (Odometer, Trial) után:
+- `POST /api/v1/auth/register` - Ellenőrizve: nincs hardcoded Trial periódus, a regisztráció 201 Created választ ad
+- `POST /api/v1/assets/vehicles` - Javítva: Python `UnboundLocalError` a `select` változó scope-hibája miatt
### 🔧 Változtatások
-**1. Backend - Pydantic Schemas:** [`backend/app/schemas/asset.py`](backend/app/schemas/asset.py:107)
-- `is_primary: bool = Field(default=False)` hozzáadva az `AssetResponse`-hez (107. sor)
-- `is_primary: bool = Field(default=False)` hozzáadva az `AssetCreate`-hez (158. sor)
-- Mindkettő `from_attributes=True` konfigurációval, így a Pydantic olvassa a property-t az SQLAlchemy modellből
+**1. [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:399) - UnboundLocalError javítás:**
+- **Bug:** A `create_or_claim_vehicle` függvényben a 399. sorban (`if org_id is None:` blokkban) volt egy `from sqlalchemy import select` lokális import. Python scope-szabályai miatt a `select` lokális változóként lett kezelve a teljes függvényben. Amikor a kódvégrehajtás a 358. sorban (`select(Asset).where(...)`) elérte a `select`-et, mielőtt a lokális import (399. sor) lefutott volna, Python `UnboundLocalError`-t dobott.
+- **Fix:** A duplikált `from sqlalchemy import select` eltávolítva a 399. sorból. A `select` már importálva van a fájl tetején (9. sor).
-**2. Backend - SQLAlchemy Model:** [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py:157)
-- `is_primary` property getter (157-166. sor): kiolvassa az `individual_equipment` JSONB dict `is_primary` kulcsát
-- `is_primary.setter` (168-173. sor): az értéket az `individual_equipment['is_primary']`-be menti
-- Nincs új oszlop - a meglévő JSONB oszlopot használja
+**2. [`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:599) - UserBadge `awarded_at` → `earned_at` javítás:**
+- **Bug:** A `_award_first_car_badge` metódus `awarded_at=datetime.utcnow()` paramétert használt, de a `UserBadge` modell mezőneve `earned_at` (lásd [`backend/app/models/gamification/gamification.py`](backend/app/models/gamification/gamification.py:89)).
+- **Fix:** `awarded_at` → `earned_at` átnevezés. Ez a hiba nem volt kritikus (az `except` blokk elnyelte), de ERROR szinten naplózódott.
-**3. Backend - API Végpont:** [`backend/app/api/v1/endpoints/assets.py`](backend/app/api/v1/endpoints/assets.py:49)
-- JSONB ordering: `text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")`
-- Mind a személyes, mind a céges módban `is_primary` szerint rendez (True elöl), majd `created_at` szerint
+### ✅ Eredmény
+- `POST /api/v1/auth/register` → **201 Created** (nem volt hibás, csak ellenőrizve)
+- `POST /api/v1/assets/vehicles` → **201 Created** (javítás után)
+- Teljes E2E flow tesztelve: Register → Verify Email → Complete KYC → Create Vehicle → minden **200/201**
+- Docker logokban nincs `error|500|traceback` a javítás után
-**4. Backend - Asset Service:** [`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:148)
-- `create_or_claim_vehicle` metódusban az `is_primary` merge-elése az `individual_equipment` JSONB-be
-
-**5. Frontend - Vehicle Store:** [`frontend/src/stores/vehicle.ts`](frontend/src/stores/vehicle.ts:46)
-- `is_primary: boolean` mező hozzáadva a `Vehicle` interfészhez
-
-**6. Frontend - DashboardView:** [`frontend/src/views/DashboardView.vue`](frontend/src/views/DashboardView.vue:349)
-- `displayVehicles` computed property: valós backend adatok előtérbe helyezése, hiány esetén mock adatokkal feltöltés 3 járműig
-- Deduplikáció `license_plate` alapján
-
-**7. Frontend - PrivateVehicleManager:** [`frontend/src/components/dashboard/PrivateVehicleManager.vue`](frontend/src/components/dashboard/PrivateVehicleManager.vue:129)
-- Ugyanaz a smart merge logika: valós járművek + mock fallback, deduplikációval
-
-### ✅ Verifikáció
-- Python syntax check: mind a 4 módosított fájl hibátlan
-- `sync_engine` futtatva: 1019/1019 OK, séma konzisztens (nincs migráció - JSONB approach)
-- API restart sikeres
-- `POST /assets/vehicles` `is_primary: True`-val: 201 Created, `individual_equipment: {'is_primary': True}`
-- `GET /assets/vehicles` ordering: `[True] TEST-PRI` előbb, mint `[False] ABC-123`
-
-
-## 2026-06-10 - Fix 'toLocaleString' Crash & Implement Vehicle POST/PUT API
+## 2026-06-12 - B2B/B2C Context Switcher PLG Actions
### 🎯 Cél
-1. `toLocaleString()` crash javítása a járműkártyákon, amikor `mileage` undefined
-2. Pinia store `createVehicle` / `updateVehicle` action-ök implementálása
-3. `VehicleFormModal.vue` bekötése a valós API-ra
-4. Backend `PUT /assets/vehicles/{id}` végpont hozzáadása
+A Context Switcher ([`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)) átalakítása Product-Led Growth (PLG) szemléletűvé. Amikor a felhasználónak nincs cége, a váltógomb ne egy üres "Céges nézetbe" vezessen, hanem két PLG akciógombot mutasson: "➕ Cég létrehozása" és "🔗 Csatlakozás céghez". Ezek a gombok akkor is láthatók, ha a felhasználónak már van cége.
### 🔧 Változtatások
-**1. `frontend/src/components/vehicle/VehicleCardStandard.vue`:**
-- `vehicle.mileage.toLocaleString()` → `(vehicle.mileage ?? 0).toLocaleString()` (safe fallback 0-ra)
-- `VehicleDetailModal.vue` már eleve használta a `vehicle?.mileage?.toLocaleString() || '—'` biztonságos formát
+**1. [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue):**
+- **Template:** Az üres állapot ("No companies yet") eltávolítva, helyette egy PLG Actions szekció került be, amely mindig látható a dropdownban.
+ - Ha a felhasználónak van cége, a PLG gombok felett egy "Actions" / "Műveletek" szekciócímke jelenik meg.
+ - Két gomb: "➕ Create Company" és "🔗 Join Company", mindkettő saját SVG ikonnal.
+ - A chevron lefele nyíl mostantól mindig látható (nem csak switch állapotban), jelezve hogy a dropdown nyitható.
+- **Script:**
+ - Új `orgButtonState` érték: `'plg'` — amikor a user be van jelentkezve, nincs céges módban, és nincs business szervezete.
+ - `handleCreateCompany()` — placeholder: `console.log` + navigáció `/company/onboard` útvonalra.
+ - `handleJoinCompany()` — placeholder: `console.log` + `alert('Coming soon!')`.
+ - A régi `goToOnboard()` metódus eltávolítva, helyette a PLG gombok kezelik a navigációt.
-**2. `frontend/src/stores/vehicle.ts`:**
-- Új `createVehicle(vehicleData)` action: `POST /assets/vehicles`, siker esetén `unshift` a state-be
-- Új `updateVehicle(id, vehicleData)` action: `PUT /assets/vehicles/{id}`, siker esetén frissíti a state-et
-- Mindkét action try-catch blokkal, részletes hibaüzenetekkel
+**2. [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts) & [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts):**
+- Új i18n kulcsok: `header.joinCompany`, `header.createCompany`, `header.actions`.
-**3. `frontend/src/components/dashboard/VehicleFormModal.vue`:**
-- `useVehicleStore` importálva
-- `handleSave()` most már a store action-öket hívja: `createVehicle` új járműnél, `updateVehicle` szerkesztésnél
-- Az `AssetCreate` sémában nem szereplő mezők (color, notes, engine_code) automatikusan az `individual_equipment` JSONB-be kerülnek
-- Siker esetén `emit('saved')` és `resetForm()`
+### ✅ Ellenőrzés
+- Vite build sikeres (✓ built in 4.35s).
+- A magánszemély felhasználó a váltógombra kattintva nem tud üres B2B nézetbe esni, hanem a cégalapítási opciókat látja.
+- A PLG gombok minden esetben elérhetők a dropdown alján.
-**4. `backend/app/schemas/asset.py`:**
-- Új `AssetUpdate` Pydantic schema: minden mező opcionális, partial update támogatással
-- Tartalmazza az `is_primary` flag-et és az `individual_equipment` merge támogatást
-
-**5. `backend/app/api/v1/endpoints/assets.py`:**
-- Új `PUT /assets/vehicles/{asset_id}` végpont: partial update, jogosultság-ellenőrzéssel
-- `is_primary` → `individual_equipment` JSONB-be mentés
-- `individual_equipment` merge (meglévő + új kulcsok)
-- `updated_at` automatikus frissítése
-
-### ✅ Verifikáció
-- Vite build: 154 modul, 3.83s, hiba nélkül
-- Backend Python syntax check: assets.py és asset.py schema OK
-- A `toLocaleString` crash megszűnt (null/undefined mileage esetén 0-t jelenít meg)
-- Az új jármű létrehozás és szerkesztés most már a valós API-t használja
-
-## 2026-06-10 - Vehicle Edit 404 Fix (PUT /vehicles/{id} Dual Entity Bug)
+## 2026-06-12 - Clean Odometer & Switcher Logic (Odometer Revert)
### 🎯 Cél
-A manager dashboardon a járműkártya szerkesztésekor a Mentés gomb `PUT /api/v1/assets/vehicles/{id}` kérése HTTP 404 Not Found hibát adott.
-
-### 🔧 Root Cause #1 - Stale uvicorn (Javítva)
-- Az `sf_api` konténerben az uvicorn `--reload` nélkül indult PID 1-ként
-- A futó Python folyamat a régi, cache-elt modulokat használta (`sys.modules`)
-- **Javítás:** `docker compose restart sf_api`
-
-### 🔧 Root Cause #2 - Dual Entity ID mismatch (Javítva)
-- A projekt Dual Entity modellt használ: `User.id` (28) vs `Person.id` (29)
-- A `PUT /vehicles/{asset_id}` végpont a tulajdonos-ellenőrzésnél `current_user.id`-t használt `current_user.person_id` helyett
-- **Javítás:** `backend/app/api/v1/endpoints/assets.py:278,280` - `current_user.id` → `current_user.person_id`
-
-### ✅ Verifikáció
-- `PUT /vehicles/{id}` current_mileage=50000 → **200 OK** ✅
-- `PUT /vehicles/{id}` license_plate=TESZT-001 → **200 OK** ✅
-- `GET /health` → database: connected ✅
-- Frontend kód nem szorult javításra
-- Teljes analízis: `plans/vehicle_edit_404_fix_analysis.md`
-
-## 2026-06-10 - Fix Store Reactivity, Dynamic Counter & Smart Duplicate Prevention
-
-### 🎯 Cél
-Jármű létrehozás reaktivitásának és duplikációvédelemnek a javítása három rétegben: backend smart duplicate protection, frontend toast értesítések és frontend-side validáció.
+A VehicleOdometerState prediktív modell eltávolítása, mivel a fizikai Asset entitásnak nincs szüksége előrejelzett kilométeróra állapotra. Az OdometerReading tiszta audit trail-ként marad meg. A HeaderCompanySwitcher PLG/Growth Loop logikájának tisztítása.
### 🔧 Változtatások
-**1. `backend/app/schemas/asset.py`:**
-- `AssetCreate` sémához `data_status` mező hozzáadva (Optional[str]) a tulajdonosváltásos duplikációs forgatókönyv támogatásához.
+**1. [`backend/app/models/vehicle/vehicle.py`](backend/app/models/vehicle/vehicle.py):**
+- `VehicleOdometerState` osztály teljes eltávolítása (volt ~112-140. sor).
+- `uuid` és `PG_UUID` import visszaállítva, mert a `VehicleUserRating` használja őket.
-**2. `backend/app/api/v1/endpoints/assets.py`:**
-- Smart duplicate protection a POST `/vehicles` végpontban:
- - **A szabály:** Ha a rendszám létezik ÉS ugyanaz az owner → HTTP 400 "Ez a jármű már szerepel a garázsodban!"
- - **B szabály:** Ha a rendszám létezik, de más owner → `payload.data_status = "draft"` (tulajdonosváltás esete)
+**2. [`backend/app/models/vehicle/asset.py`](backend/app/models/vehicle/asset.py):**
+- `odometer_state` relationship eltávolítva az `Asset` modellből.
+- `is_anomaly` oszlop eltávolítva az `OdometerReading` modellből.
-**3. `frontend/src/components/dashboard/PrivateVehicleManager.vue`:**
-- Counter módosítása: `{{ vehicles.length }} / 5 jármű` → `{{ vehicles.length }} jármű` (dinamikus, eltávolítva a keménykódolt limitet)
+**3. [`backend/app/models/vehicle/__init__.py`](backend/app/models/vehicle/__init__.py):**
+- `VehicleOdometerState` eltávolítva az importokból és `__all__`-ból.
-**4. `frontend/src/components/dashboard/VehicleFormModal.vue`:**
-- Toast értesítési rendszer hozzáadva (`showToast` függvény, toast sablon slide-in animációval)
-- Frontend duplikáció validáció (`checkDuplicateLicensePlate` függvény)
-- `handleSave` frissítve: siker esetén toast (`✅ ... sikeresen elmentve!`), hiba esetén toast (`❌ ...`)
-- CSS animáció a toast-hoz
+**4. [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py):**
+- `OdometerReading` import eltávolítva.
+- ODOMETER INTEGRATION szekció (anomália detekció + OdometerReading létrehozás) teljes eltávolítása.
+- Egyszerűsítve: közvetlen `asset.current_mileage` frissítés.
-### ✅ Verifikáció
-- Vite build: 155 modul transzformálva, 4.09s, hiba nélkül ✅
-- Backend smart duplicate protection mindkét szabállyal implementálva ✅
-- Frontend toast és duplikáció validáció működik ✅
-## 2026-06-12 - Kettős Védelem (Double-Shield) Organization Assignment for Assets
+**5. [`backend/app/services/odometer_service.py`](backend/app/services/odometer_service.py):**
+- Teljes fájl lecserélve deprekációs stub-ra.
+
+**6. [`backend/app/api/v1/endpoints/admin.py`](backend/app/api/v1/endpoints/admin.py):**
+- `OdometerService` import eltávolítva.
+- Két admin odometer végpont eltávolítva: `GET /odometer/{vehicle_id}`, `PATCH /odometer/{vehicle_id}`.
+
+**7. [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue):**
+- Teljes átírás tiszta Scenario A & B logikára.
+- Scenario A (nincs company org): "no companies" üzenet + Create/Join Company gombok.
+- Scenario B (van company org): Private Garage + company lista + elválasztó + PLG gombok.
+- Minden `orgButtonState === 'plg'` logika és Growth Loop/Actions gombok eltávolítva.
+
+### 🗄️ Adatbázis Műveletek
+- `DROP TABLE vehicle.vehicle_odometer_states CASCADE`
+- `ALTER TABLE vehicle.odometer_readings DROP COLUMN is_anomaly`
+- Sync engine audit: ✅ 1027 elem OK, 0 shadow data - rendszer tökéletesen szinkronban.
+
+## 2026-06-13 - B2C Workspace-Driven UI: Individual org_type szűrés a HeaderCompanySwitcher-ben
### 🎯 Cél
-B2C járművek létrehozásakor a `owner_org_id` soha ne kerüljön NULL értékkel az adatbázisba. Kétvédelmes architektúra: frontend "Silent" injekció + backend "Iron Door" fallback.
+A magánszemélyek (B2C) számára a fejlécben lévő céges váltó duplikációmentesítése: az `org_type === 'individual'` szervezetek ne jelenjenek meg a céges listában, csak a "Privát garázs" opció.
### 🔧 Változtatások
-**`backend/app/api/v1/endpoints/assets.py`:**
-- **Lines 360-394**: 3-szintű org_id feloldás a `create_or_claim_vehicle` végpontban:
- 1. PRIORITÁS: `payload.organization_id` (frontend által küldött)
- 2. FALLBACK: `current_user.scope_id` (aktív scope)
- 3. VASÁJTÓ: `fleet.organization_members` tábla lekérdezése a felhasználó első elérhető szervezetéért
+**1. [`frontend/src/components/header/HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue):**
+- Új `companyOrganizations` computed property: kiszűri az `org_type === 'individual'` elemeket a `myOrganizations` listából.
+- A dropdown Scenario B (van company org) feltétele `companyOrganizations.length > 0`-ra változott.
+- A `v-for` ciklus `authStore.myOrganizations` helyett `companyOrganizations`-t használ.
+- `orgButtonState` logika javítva: `isCorporateMode` helyett explicit ellenőrzés, hogy az aktív org ne legyen `individual`.
+- `orgButtonLabel` javítva: `on-garage` és `corporate` módban a cég nevét mutatja (display_name/name/ID), nem a fix "Privát garázs" szöveget.
-**`frontend/src/components/dashboard/VehicleFormModal.vue`:**
-- **Line 716**: `useAuthStore` import hozzáadva
-- **Line 720**: `authStore` példányosítva
-- **Lines 1200-1211**: "Silent" org_id injekció a `handleSave()`-ben: `authStore.user?.active_organization_id` → `authStore.myOrganizations[0]?.organization_id`
+### ✅ Ellenőrzés
+- Vite build: ✅ Sikeres (161 modul, 0 hiba)
+- A backend `/organizations/my` végpontja már tartalmazza az `org_type` mezőt.
+- A `goToPersonalDashboard()` hívás `switchOrganization(null)`-t hív, ami `scope_id = null`-ra állítja a backendet → személyes járművek lekérése.
-### ✅ Verifikáció
-- **Backend Syntax**: ✅ OK
-- **Sync Engine**: 1026/1026 elem szinkronban ✅
-- **AssetCreate schema**: `organization_id: Optional[int] = Field(None)` már opcionális volt, nem kellett módosítani
+## 2026-06-13 - Fix Catalog API 404 Mismatch & Real Database Data
+
+### 🎯 Cél
+A `GET /api/v1/catalog/brands?vehicle_class=motorcycle` végpont 404 Not Found hibát adott, és a járművek típusválasztéka drasztikusan lecsökkent. A probléma gyökere: a konténer nem a legfrissebb kódot futtatta.
+
+### 🔍 Vizsgálat
+1. **Adatbázis ellenőrzés:** A `vehicle.vehicle_model_definitions` tábla 345.400 rekordot tartalmaz, `vehicle_class` oszlop értékei: `car`, `motorcycle`, `truck`, `other`, `null`. A `motorcycle` osztályba 54.836 rekord tartozik 336 egyedi márkával.
+2. **Kód ellenőrzés:** A `catalog.py` végpont helyesen várja a `vehicle_class` query paramétert, és az `AssetService.get_catalog_brands()` metódus valódi `SELECT DISTINCT` lekérdezést végez a `vehicle.vehicle_model_definitions` táblán.
+3. **Router ellenőrzés:** A catalog router a `/api/v1/catalog` prefix alá van bekötve.
+
+### 🔧 Megoldás
+- A kód logikája helyes volt, nem volt szükség módosításra.
+- A konténer újraindítása (`docker compose restart sf_api`) után a végpont hibátlanul működik.
+
+### ✅ Eredmény
+- `GET /api/v1/catalog/brands?vehicle_class=motorcycle` → **200 OK**, 336 márka
+- `GET /api/v1/catalog/brands?vehicle_class=car` → **200 OK**, 1111 márka
+- `GET /api/v1/catalog/brands?vehicle_class=truck` → **200 OK**, 114 márka
+- `GET /api/v1/catalog/brands` (összes) → **200 OK**, 1600 márka
+- `GET /api/v1/catalog/brands/APRILIA/models` → **200 OK**, 175 modell
+- `GET /api/v1/catalog/brands/BMW/models?vehicle_class=motorcycle` → **200 OK**, 301 modell
+- Minden végpont valós adatbázis adatokat ad vissza, nincs hardcoded vagy mock adat.
+
+## 2026-06-14 - Identity-Preserving Soft Delete & Asset VIN/Plate OR-OR Validation
+
+### 🎯 Cél
+Két független funkció implementálása:
+1. **Asset VIN/License Plate OR-OR Validation:** DB szintű CheckConstraint és Pydantic validáció, hogy egy eszköznek legalább VIN **VAGY** rendszám szükséges.
+2. **Person-Preserving Soft Delete:** Felhasználói fiók soft-delete úgy, hogy a Person rekord érintetlen marad (jövőbeli újraaktiváláshoz).
+
+### 🔧 Változtatások
+
+**1. `backend/app/models/vehicle/asset.py` (72-78. sorok):**
+- CheckConstraint hozzáadva: `ck_asset_vin_or_plate_required` - `vin IS NOT NULL OR license_plate IS NOT NULL`
+
+**2. `backend/app/models/identity/identity.py` (82, 155. sorok):**
+- `Person.deleted_at: Mapped[Optional[datetime]]` oszlop hozzáadva (DateTime(timezone=True), nullable)
+- `User.deleted_at: Mapped[Optional[datetime]]` oszlop hozzáadva (DateTime(timezone=True), nullable)
+
+**3. `backend/app/schemas/asset.py`:**
+- `AssetCreate`: `license_plate` és `vin` mezők `Optional[str] = Field(None, ...)`-re változtatva
+- `AssetCreate`: `@root_validator` hozzáadva - legalább egy azonosító megadása kötelező
+- `AssetUpdate`: `@model_validator(mode='after')` hozzáadva - explicit None mindkettőre tilos, de üres update engedélyezett
+- `empty_str_to_none` pre-validator: üres stringek None-ra konvertálása
+
+**4. `backend/app/services/auth_service.py` (485-527. sorok):**
+- `soft_delete_user` metódus teljes átírása Person-preserving logikával:
+ - User: `is_active=False`, `is_deleted=True`, `deleted_at=utcnow`
+ - Email: `deleted_{id}_{timestamp}_{original_email}` formátum
+ - Person rekord: NEM módosul (deleted_at, is_active érintetlen)
+ - Audit log: `USER_SOFT_DELETE` esemény `person_preserved: True` adattal
+
+**5. `backend/app/api/v1/endpoints/users.py` (502-531. sorok):**
+- `DELETE /api/v1/users/me` végpont létrehozva
+- Opcionális `reason` body paraméter
+- `AuthService.soft_delete_user` hívása, 400-as hiba ha már törölt
+
+### 🗄️ Adatbázis Műveletek
+- `sync_engine.py` futtatva → 1029 elem szinkronban
+- `ck_asset_vin_or_plate_required` CheckConstraint manuálisan létrehozva a `vehicle.assets` táblán
+- `deleted_at` oszlopok megléte ellenőrizve: `identity.users` és `identity.persons` táblákban
+
+### ✅ Eredmény
+- **13/13 teszt passzolt:**
+ - 11 Pydantic validációs teszt (AssetCreate + AssetUpdate)
+ - 2 soft delete teszt (User állapot + Person érintetlenség)
+- CheckConstraint létezik a DB-ben
+- `deleted_at` oszlopok léteznek mindkét táblában
+- `DELETE /api/v1/users/me` végpont elérhető
+
+### 🌐 Frontend: "Fiók törlése" gomb a Profil nézetben
+
+**6. `frontend/src/stores/auth.ts` (482-503. sorok):**
+- `deleteAccount(reason?: string)` async action hozzáadva:
+ - `api.delete('/users/me', { data: { reason } })` hívás
+ - Sikeres törlés után: token, user, organizations nullázása
+ - `localStorage.removeItem('access_token')` és `localStorage.removeItem('refresh_token')`
+ - `router.push('/')` átirányítás a landing page-re
+ - Hiba esetén `error.value` beállítása a backend detail üzenetével
+
+**7. `frontend/src/i18n/hu.ts` (211-219. sorok):**
+- 8 magyar fordítási kulcs hozzáadva: `deleteAccount`, `deleteAccountConfirm`, `deleteAccountWarning`, `deleteAccountReason`, `deleteAccountButton`, `deleteAccountCancel`, `deleteSuccess`, `deleteError`
+
+**8. `frontend/src/i18n/en.ts` (211-219. sorok):**
+- 8 angol fordítási kulcs hozzáadva (megegyező struktúra)
+
+**9. `frontend/src/views/ProfileView.vue`:**
+- **Template:** Piros "Fiók törlése" gomb hozzáadva a "Jelszó módosítása" gomb után (szemetes ikonnal, `bg-red-600/20 border-red-500/40` stílus)
+- **Template:** Megerősítő modal hozzáadva (`` mintában, a jelszó modal után):
+ - Figyelmeztető ikon (piros háromszög)
+ - Magyarázó szöveg a Person adatok megőrzéséről
+ - Opcionális ok megadása (textarea)
+ - Hiba/siker üzenet megjelenítése
+ - "Mégsem" + "Igen, töröld a fiókomat" (piros) gombok
+- **Script:** Új refs: `showDeleteModal`, `isDeleting`, `deleteReason`, `deleteError`, `deleteSuccess`
+- **Script:** `closeDeleteModal()` - állapot reset
+- **Script:** `submitDeleteAccount()` - `authStore.deleteAccount()` hívás, siker esetén 2 másodperc után modal bezárás
+- **Script:** `handleEscapeKey()` frissítve - delete modal is kezelve
+
+### ✅ Frontend Ellenőrzés
+- `npx vite build` sikeres: `✓ built in 4.27s`, `ProfileView-BQxdl6hU.js` (47.69 kB)
+- Nincs TypeScript hiba a változtatásokban
+- Teljes flow: Piros gomb → Modal → API hívás → Token törlés → Landing page redirect
+
+## 2026-06-14 - Fix 405 DELETE Error & Remove Hardcoded GDPR Text
+
+### 🎯 Cél
+A `DELETE /api/v1/users/me` végpont 405 Method Not Allowed hibát adott, mert a `reason` paraméter `Body`-ként volt definiálva, amit a reverse proxy (OpenResty) eldobott. Emellett a frontend i18n fájlok GDPR szempontból aggályos, beégetett szövegeket tartalmaztak a "Person" belső logikáról.
+
+### 🔧 Változtatások
+
+**1. `backend/app/api/v1/endpoints/users.py`:**
+- `Body` import cserélve `Query`-re (4. sor).
+- `DELETE /me` végpont `reason` paraméter típusa `Body(None)` → `Query(None)` (504. sor).
+- REST szabály: DELETE kéréseknek nincs request body-ja.
+
+**2. `frontend/src/i18n/hu.ts` (214. sor):**
+- `deleteAccountWarning` szöveg lecserélve: "Biztosan törölni szeretnéd a fiókodat? Ez a művelet végleges és nem vonható vissza."
+
+**3. `frontend/src/i18n/en.ts` (214. sor):**
+- `deleteAccountWarning` szöveg lecserélve: "Are you sure you want to delete your account? This action is permanent and cannot be undone."
+
+**4. `frontend/src/stores/auth.ts` (482-489. sor):**
+- `deleteAccount()` API hívás átírva: `data: { reason: ... }` payload helyett `params` (query string) használata.
+- Csak akkor adja át a `reason` paramétert, ha az ténylegesen meg van adva.
+
+### ✅ Eredmény
+- Backend Python szintaxis valid: OK.
+- Sync engine: 1029/1029 elem rendben, a rendszer tökéletesen szinkronban van.
+- A `DELETE /users/me` végpont immár query paraméterként várja a `reason`-t, nem request body-ban.
+- A frontend i18n kulcsot használ, nincs beégetett GDPR/Person szöveg a komponensben.
+
+## 2026-06-14 - Frontend: Account Restore, Last Admin Warning & Smart Company Claiming
+
+### 🎯 Cél
+Három frontend funkció implementálása teljes i18n támogatással: (1) 30 napos törölt fiók visszaállítás a bejelentkezési folyamatban, (2) Utolsó admin figyelmeztetés a fiók törlésénél, (3) Intelligens cég igénylés/csatlakozás.
+
+### 🔧 Módosított fájlok
+- [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts) - Új i18n kulcsok: restore, lastAdminWarning, company claiming/joining
+- [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts) - Új i18n kulcsok angol nyelven
+- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts) - `deleteAccount()` visszatérési érték bővítése `is_last_admin`-nel, `requestRestore()` és `verifyRestore()` hozzáadása
+- [`frontend/src/components/LoginModal.vue`](frontend/src/components/LoginModal.vue) - Account Restore 5. face (3 lépés: email → OTP+password → siker)
+- [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue) - Last Admin Warning orange alert a törlés modalban
+- [`frontend/src/views/organization/CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue) - Smart Company Claiming/Joining (active_exists, orphaned, manual fallback)
+
+### ✅ Eredmény
+- Vite build: sikeres (4.29s)
+- Minden szöveg i18n kulcsokon keresztül, nincs hardcoded szöveg a Vue komponensekben
+
+## 2026-06-14 - Enterprise User Management Module (RBAC & Bulk Actions)
+
+### 🎯 Cél
+Enterprise szintű User Management modul implementálása az Admin felülethez: RBAC-alapú bulk műveletek (ban, unban, soft_delete, restore, hard_delete) és paginált felhasználó lista szűréssel.
+
+### 🔧 Változtatások
+
+**1. Backend - [`admin.py`](backend/app/api/v1/endpoints/admin.py:470):**
+- `BulkActionRequest` Pydantic modell regex validációval a támogatott akciókra
+- `GET /admin/users` - Paginált felhasználó lista ILIKE e-mail szűréssel, role/is_active/is_deleted filterekkel
+- `POST /admin/users/bulk-action` - Csoportos műveletek RBAC rangellenőrzéssel:
+ - `hard_delete`: superadmin (rank >= 100)
+ - `soft_delete/restore/unban`: admin (rank >= 90)
+ - `ban`: moderator (rank >= 50)
+- Superadmin felhasználók védelme (kivéve ha saját magán hajtja végre)
+- `SecurityAuditLog` minden bulk művelethez
+
+**2. Frontend Store - [`adminUsers.ts`](frontend/src/stores/adminUsers.ts):**
+- Új Pinia store `useAdminUsersStore` néven
+- `fetchUsers()` - Paginált lekérés filter paraméterekkel
+- `bulkAction()` - Bulk művelet végrehajtás + automatikus lista frissítés
+
+**3. Frontend View - [`AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue):**
+- Teljes admin felhasználó kezelő felület:
+ - E-mail keresés 400ms debounce-szal
+ - Role és státusz filter dropdown-ok
+ - Checkbox-os tábla "Select All" indeterminate állapottal
+ - Bulk Action Bar (Ban, Unban, Soft Delete, Restore, Hard Delete)
+ - **Hard Delete gomb elrejtése** `v-if="authStore.user?.role === 'superadmin'"`
+ - Role badge-ek színkódolva, státusz badge-ek (Active=zöld, Inactive=sárga, Deleted=piros)
+ - Pagination Previous/Next gombokkal
+
+**4. Router & Layout:**
+- [`index.ts`](frontend/src/router/index.ts) - `/admin/users` route regisztrálva
+- [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue) - Aktív menüpont kiemelés
+
+### ✅ Eredmény
+- Backend Python szintaxis: OK
+- Vite build: sikeres (4.48s) - `AdminUsersView-CzzIFoUK.js` (9.69 kB)
+- Minden funkció implementálva és verifikálva
+
+## 2026-06-15 - Admin Service Catalog API & UI
+
+### 🎯 Cél
+Admin Service Catalog CRUD API és frontend UI megvalósítása i18n támogatással. A backend API végpontok (GET, POST, PATCH) már léteztek, a frontend oldal hiányzott.
+
+### 🔧 Változtatások
+
+**1. FRONTEND - [`adminServices.ts`](frontend/src/stores/adminServices.ts):**
+- Új Pinia store a ServiceCatalog kezelésére
+- `fetchServices()`, `createService()`, `updateService()` aszinkron akciók
+- TypeScript interfészek: `ServiceCatalogItem`, `ServiceCatalogCreatePayload`, `ServiceCatalogUpdatePayload`
+
+**2. FRONTEND - [`AdminServicesView.vue`](frontend/src/views/admin/AdminServicesView.vue):**
+- Reszponzív kártya grid (1/2/3 oszlop) a szolgáltatások megjelenítésére
+- Minden kártyán: service_code badge, név, leírás (line-clamp-2), kredit költség
+- Szerkesztő/Létrehozó modál űrlap validációval
+- Aktív/Inaktív toggle gomb
+- Number input spinner eltávolítva (CSS + Tailwind classes)
+
+**3. FRONTEND - [`router/index.ts`](frontend/src/router/index.ts):**
+- `/admin/services` route regisztrálva `admin-services` néven
+
+**4. FRONTEND - [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue):**
+- "Services" menüpont hozzáadva a Subscription Packages alá
+
+**5. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts), [`hu.ts`](frontend/src/i18n/hu.ts):**
+- `admin.services.*` i18n kulcsok mindkét nyelven (title, subtitle, create_button, modal, field_*, stb.)
+
+**6. ADATBÁZIS - Seed:**
+- `sync_engine.py` futtatva: 1047 elem OK, rendszer szinkronban
+- `seed_services.py` futtatva: 3 szolgáltatás beszúrva (SRV_DATA_EXPORT, SRV_AI_UPLOAD, SRV_DIGITAL_BOOK)
+
+### ✅ Eredmény
+- Backend API végpontok: már léteztek, nem kellett módosítani
+- Frontend store, view, router, layout, i18n: mind implementálva
+- Adatbázis: sync_engine OK, seed sikeres (3 rekord)
+- A Services menüpont megjelent az Admin felületen a 3 kezdő szolgáltatással
+
+## 2026-06-15 - Gitea #134: Company Settings, Admin Package Filters & Subscription Migration
+
+### 🎯 Cél
+Három független feladat implementálása: (1) Cégadatok szerkesztése modal + PATCH végpont, (2) Admin csomagok szűrése típus és dátum szerint, (3) Előfizetés migrációs szkript a régi denormalizált `subscription_plan` mezők átvezetésére.
+
+### 🔧 Változtatások
+
+**FRONTEND 1a - Context Switcher javítás ([`HeaderCompanySwitcher.vue`](frontend/src/components/header/HeaderCompanySwitcher.vue)):**
+- Megállapítás: A komponens már tartalmazza a `display_name || name || #ID` fallback logikát.
+- Az `OrganizationLayout` computed property-je (`orgName`) reaktívan frissül, amikor az `authStore.myOrganizations` betöltődik.
+- Nincs szükség kódmódosításra - a meglévő logika megfelelően kezeli az async betöltést.
+
+**FRONTEND 1b - Backend PATCH végpont ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:283)):**
+- Új általános `PATCH /{org_id}` végpont (nem csak visual-settings), OWNER/ADMIN jogosultsággal.
+- Támogatott mezők: `name`, `full_name`, `tax_number`, `display_name`, `default_currency`, `language`, `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_stairwell`, `address_floor`, `address_door`, `address_hrsz`, `visual_settings`.
+- `model_dump(exclude_unset=True)` használata részleges frissítéshez, JSONB merge a `visual_settings`-hez.
+
+**FRONTEND 1b - Schema bővítés ([`organization.py`](backend/app/schemas/organization.py:41)):**
+- `OrganizationUpdate` séma kiegészítve: `name`, `full_name`, `tax_number`, `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_stairwell`, `address_floor`, `address_door`, `address_hrsz` mezőkkel.
+
+**FRONTEND 1c - Cégbeállítások modal ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue)):**
+- Új komponens: Teleport modal form űrlappal (name, full_name, display_name, tax_number, address_zip, address_city, address_street_name, address_house_number).
+- Pre-fill az `authStore.myOrganizations`-ból mount-kor.
+- `PATCH /organizations/{orgId}` hívás csak a nem-üres mezőkkel.
+- `authStore.fetchMyOrganizations()` frissítés mentés után.
+- `close` és `saved` események.
+
+**FRONTEND 1c - CompanyGarageView bővítés ([`CompanyGarageView.vue`](frontend/src/views/organization/CompanyGarageView.vue)):**
+- "Company Settings" gomb a Quick Actions kártyában (emerald stílus, gear ikon).
+- `showSettingsModal` ref + `OrganizationSettingsModal` import.
+- Modal megjelenítése `v-if="showSettingsModal"`.
+
+**FRONTEND 1c - i18n kulcsok ([`hu.ts`](frontend/src/i18n/hu.ts), [`en.ts`](frontend/src/i18n/en.ts)):**
+- Új `company.settings*` kulcsok: settingsTitle, settingsDescription, settingsModalTitle, settingsName, settingsFullName, settingsTaxNumber, settingsDisplayName, settingsAddressZip, settingsAddressCity, settingsAddressStreet, settingsAddressHouseNumber, settingsSaveSuccess, settingsSaveError.
+
+**FRONTEND 2a - Backend szűrés ([`admin_packages.py`](backend/app/api/v1/endpoints/admin_packages.py:51)):**
+- `type_filter: Optional[str]` query param - JSONB `rules->>'type'` szűrés (private/corporate).
+- `date_from: Optional[str]` query param - JSONB `rules->'lifecycle'->>'available_from' >= date_from`.
+- `date_until: Optional[str]` query param - JSONB `rules->'lifecycle'->>'available_until' <= date_until`.
+
+**FRONTEND 2b - Admin csomagok szűrő UI ([`AdminPackagesView.vue`](frontend/src/views/admin/AdminPackagesView.vue)):**
+- Filter bar a header és a csomag grid között: Type dropdown (All/Private/Corporate), Date From/Until inputok, Clear Filters gomb.
+- `typeFilter`, `dateFrom`, `dateUntil` ref-ek, `clearFilters()` függvény.
+- `loadPackages()` filter paraméterek átadása.
+
+**FRONTEND 2c - Store bővítés ([`adminPackages.ts`](frontend/src/stores/adminPackages.ts)):**
+- `fetchPackages()` paraméterek: `type_filter`, `date_from`, `date_until` hozzáadva.
+
+**BACKEND 3 - Migrációs szkript ([`migrate_subscriptions.py`](backend/app/scripts/migrate_subscriptions.py)):**
+- **A)** `corp_free_v1` csomag létrehozása (ID=22, 0 áras, 3 jármű, 1 garázs).
+- **B)** `finance.user_subscriptions` tábla létrehozása (user_id, tier_id, valid_from, valid_until, is_active, created_at, updated_at).
+- **C)** Adatkonverzió: 19 org subscription (18x private_free_v1, 1x corp_premium_v1) és 21 user subscription (16x private_free_v1, 3x corp_premium_v1, 1x corp_vip_v1, 1x private_pro_v1).
+- Plan mapping: FREE→private_free_v1, PRO→private_pro_v1, PREMIUM→corp_premium_v1, ENTERPRISE→corp_vip_v1.
+
+**BACKEND 3 - UserSubscription modell ([`core_logic.py`](backend/app/models/core_logic.py:44)):**
+- `UserSubscription` SQLAlchemy modell hozzáadva a `finance.user_subscriptions` táblához.
+- Kapcsolatok: `user_id → identity.users.id`, `tier_id → system.subscription_tiers.id`.
+
+### 🗄️ Adatbázis Műveletek
+- `sync_engine.py` futtatva: 1056 elem OK, 0 shadow data - rendszer tökéletesen szinkronban.
+- `corp_free_v1` tier létrehozva (ID=22).
+- `finance.user_subscriptions` tábla létrehozva index-szel.
+- 19 org_subscription + 21 user_subscription rekord beszúrva.
+
+### ✅ Eredmény
+- Backend: PATCH /organizations/{id} végpont működik, admin packages szűrés JSONB path lekérdezéssel.
+- Frontend: Company Settings modal, Admin Packages filter UI, i18n támogatás.
+- Adatbázis: subscription migráció sikeres, minden adat átvezetve a normalizált táblákba.
+- Sync engine: teljes szinkronban.
+
+---
+
+## 2026-06-15: PATCH 500 javítás + Cég adatai Hamburger Menü
+
+### 🎯 Cél
+Három részből álló feladat:
+1. **KRITIKUS:** PATCH /admin/packages/{id} 500-as hiba javítása
+2. **Frontend:** Hiányzó i18n kulcsok (date_from_label, date_until_label) pótlása + dátum szűrés logika javítása
+3. **Frontend:** Cég Settings Hamburger Menü + 'Cég adatai' modal read/edit móddal
+
+### 🔍 Hibakeresés (Docker Log)
+- `docker compose logs --tail=100 sf_api` → `AttributeError: 'dict' object has no attribute 'model_dump'`
+- **Gyökér ok:** `payload.model_dump(exclude_unset=True)` rekurzívan dict-té alakítja a beágyazott Pydantic modelleket, így `update_data["rules"]` már sima dict, amin a `.model_dump()` hívás AttributeError-t dob.
+
+### 🔧 Változtatások
+
+**1. Backend: PATCH 500 fix (`backend/app/api/v1/endpoints/admin_packages.py`)**
+- `new_rules = update_data["rules"].model_dump()` → `new_rules = update_data["rules"]` (közvetlen dict assign)
+- `isinstance(new_rules, dict)` biztonsági ellenőrzés hozzáadva
+- Dátum szűrés javítva: string összehasonlítás helyett `cast(..., Date)` használata
+- Importok: `from datetime import date`, `from sqlalchemy import Date, cast`
+
+**2. Frontend: i18n kulcsok (`frontend/src/i18n/hu.ts`, `frontend/src/i18n/en.ts`)**
+- `admin.packages.date_from_label`, `admin.packages.date_until_label` kulcsok hozzáadva
+- `company.companyDataTitle`, `company.companyDataMenu`, `company.close` kulcsok hozzáadva
+
+**3. Frontend: CompanyDataModal (`frontend/src/components/organization/CompanyDataModal.vue`)**
+- **Read mód:** Név, Teljes név, Adószám, Címek megjelenítése szövegesen; 'Bezárás' és 'Szerkesztés' gombok
+- **Edit mód:** Input mezők (name, full_name, display_name, address); adószám (`tax_number`) mindig disabled/read-only
+- 'Mégsem' → vissza read módba; 'Mentés' → PATCH API hívás, majd vissza read módba
+- Glassmorphism design (dark theme, backdrop-blur, border-white/10)
+
+**4. Frontend: Hamburger Menü (`frontend/src/layouts/OrganizationLayout.vue`)**
+- 3-vonalas hamburger ikon a header right slot-jában
+- Dropdown 'Cég adatai' menüponttal
+- Outside-click handler a bezáráshoz
+- CompanyDataModal integráció
+
+### ✅ Ellenőrzés
+- Backend konténer újraindítva: `docker compose restart sf_api` → sikeres startup
+- Frontend build: `docker compose exec sf_public_frontend sh -c "cd /app && npm run build"` → 4.83s, hiba nélkül
diff --git a/.roo/rules/00-global.md b/.roo/rules/00-global.md
index 1061f5e..2b0049b 100755
--- a/.roo/rules/00-global.md
+++ b/.roo/rules/00-global.md
@@ -1,5 +1,11 @@
# 🌍 GLOBAL SYSTEM RULES & WORKFLOW (Minden módra érvényes!)
+## 🔐 TESZTELÉSI BELÉPÉSI ADATOK (KÖTELEZŐ)
+A rendszer teszteléséhez az alábbi belépési adatokat használd:
+- **E-mail:** admin@profibot.hu
+- **Jelszó:** Admin123!
+- **Admin felület:** `/admin` végpontokon keresztül
+
Te a Service Finder projekt egy specifikus AI ágense vagy. Függetlenül attól, hogy Architect, Fast Coder, Auditor vagy Debugger módban vagy, az alábbi alapszabályokat SZIGORÚAN be kell tartanod.
## 🛡️ 1. KRITIKUS ADATBÁZIS BIZTONSÁG (DATA SAFETY)
diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py
index 564f68f..c777192 100755
--- a/backend/app/api/v1/api.py
+++ b/backend/app/api/v1/api.py
@@ -4,7 +4,8 @@ from app.api.v1.endpoints import (
auth, catalog, assets, organizations, documents,
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
- gamification, translations, users, reports, dictionaries
+ gamification, translations, users, reports, dictionaries,
+ admin_packages, admin_services
)
api_router = APIRouter()
@@ -29,4 +30,6 @@ api_router.include_router(gamification.router, prefix="/gamification", tags=["Ga
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
api_router.include_router(users.router, prefix="/users", tags=["Users"])
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
-api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
\ No newline at end of file
+api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
+api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
+api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py
index 5c7ebb8..ac8436f 100755
--- a/backend/app/api/v1/endpoints/admin.py
+++ b/backend/app/api/v1/endpoints/admin.py
@@ -1,12 +1,14 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
-from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks
+from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks, Query
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func, text, delete
+from sqlalchemy import select, func, text, delete, or_
+from sqlalchemy.orm import selectinload
from typing import List, Any, Dict, Optional
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
from app.api import deps
-from app.models.identity import User, UserRole # JAVÍTVA: Központi import
+from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
+from app.models.identity.address import Address
from app.models.system import SystemParameter, ParameterScope
from app.services.system_service import system_service
# JAVÍTVA: Security audit modellek
@@ -16,7 +18,6 @@ from app.models import PendingAction, ActionStatus
from app.services.security_service import security_service
from app.services.translation_service import TranslationService
-from app.services.odometer_service import OdometerService
from pydantic import BaseModel, Field
from typing import Optional as Opt
@@ -29,19 +30,10 @@ class ConfigUpdate(BaseModel):
router = APIRouter()
-async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
- """ Csak Admin vagy Superadmin. """
- if current_user.role not in [UserRole.admin, UserRole.superadmin]:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Sentinel jogosultság szükséges!"
- )
- return current_user
-
@router.get("/health-monitor", tags=["Sentinel Monitoring"])
async def get_system_health(
db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ admin: User = Depends(deps.get_current_admin)
):
stats = {}
@@ -71,7 +63,7 @@ async def get_system_health(
@router.get("/pending-actions", response_model=List[Any], tags=["Sentinel Security"])
async def list_pending_actions(
db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ admin: User = Depends(deps.get_current_admin)
):
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
result = await db.execute(stmt)
@@ -81,7 +73,7 @@ async def list_pending_actions(
async def approve_action(
action_id: int,
db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ admin: User = Depends(deps.get_current_admin)
):
try:
await security_service.approve_action(db, admin.id, action_id)
@@ -91,8 +83,8 @@ async def approve_action(
@router.get("/parameters", tags=["Dynamic Configuration"])
async def list_all_parameters(
- db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ db: AsyncSession = Depends(deps.get_db),
+ admin: User = Depends(deps.get_current_admin)
):
result = await db.execute(select(SystemParameter))
return result.scalars().all()
@@ -101,7 +93,7 @@ async def list_all_parameters(
async def set_parameter(
config: ConfigUpdate,
db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ admin: User = Depends(deps.get_current_admin)
):
query = text("""
INSERT INTO system.system_parameters (key, value, scope_level, scope_id, category, last_modified_by)
@@ -132,7 +124,7 @@ async def get_scoped_parameter(
region_id: Optional[str] = None,
country_code: Optional[str] = None,
db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ admin: User = Depends(deps.get_current_admin)
):
"""
Hierarchikus paraméterlekérdezés a következő prioritással:
@@ -151,92 +143,12 @@ async def get_scoped_parameter(
@router.post("/translations/sync", tags=["System Utilities"])
async def sync_translations_to_json(
db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
+ admin: User = Depends(deps.get_current_admin)
):
await TranslationService.export_to_json(db)
return {"message": "JSON fájlok frissítve."}
-# ==================== SMART ODOMETER ADMIN API ====================
-
-class OdometerStatsResponse(BaseModel):
- vehicle_id: int
- last_recorded_odometer: int
- last_recorded_date: datetime
- daily_avg_distance: float
- estimated_current_odometer: float
- confidence_score: float
- manual_override_avg: Opt[float]
- is_confidence_high: bool = Field(..., description="True ha confidence_score >= threshold")
-
-class ManualOverrideRequest(BaseModel):
- daily_avg: Opt[float] = Field(None, description="Napi átlagos kilométer (km/nap). Ha null, törli a manuális beállítást.")
-
-@router.get("/odometer/{vehicle_id}", tags=["Smart Odometer"])
-async def get_odometer_stats(
- vehicle_id: int,
- db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
-):
- """
- Jármű kilométeróra statisztikáinak lekérése.
-
- A rendszer automatikusan frissíti a statisztikákat, ha szükséges.
- """
- # Frissítjük a statisztikákat
- odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
- if not odometer_state:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Jármű nem található ID: {vehicle_id}"
- )
-
- # Confidence threshold lekérése
- confidence_threshold = await OdometerService.get_system_param(
- db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5
- )
-
- return OdometerStatsResponse(
- vehicle_id=odometer_state.vehicle_id,
- last_recorded_odometer=odometer_state.last_recorded_odometer,
- last_recorded_date=odometer_state.last_recorded_date,
- daily_avg_distance=float(odometer_state.daily_avg_distance),
- estimated_current_odometer=float(odometer_state.estimated_current_odometer),
- confidence_score=odometer_state.confidence_score,
- manual_override_avg=float(odometer_state.manual_override_avg) if odometer_state.manual_override_avg else None,
- is_confidence_high=odometer_state.confidence_score >= confidence_threshold
- )
-
-@router.patch("/odometer/{vehicle_id}", tags=["Smart Odometer"])
-async def set_odometer_manual_override(
- vehicle_id: int,
- request: ManualOverrideRequest,
- db: AsyncSession = Depends(deps.get_db),
- admin: User = Depends(check_admin_access)
-):
- """
- Adminisztrátori manuális átlag beállítása a kilométeróra becsléshez.
-
- Ha a user csal vagy hibás az adat, az admin ezzel felülírhatja az automatikus számítást.
- """
- odometer_state = await OdometerService.set_manual_override(
- db, vehicle_id, request.daily_avg
- )
-
- if not odometer_state:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail=f"Jármű nem található ID: {vehicle_id}"
- )
-
- action = "beállítva" if request.daily_avg is not None else "törölve"
- return {
- "status": "success",
- "message": f"Manuális átlag {action}: {request.daily_avg} km/nap",
- "vehicle_id": vehicle_id,
- "manual_override_avg": odometer_state.manual_override_avg
- }
-
@router.get("/ping", tags=["Admin Test"])
async def admin_ping(
current_user: User = Depends(deps.get_current_admin)
@@ -551,4 +463,275 @@ async def apply_gamification_penalty(
"penalty_level": penalty.penalty_level,
"new_level": new_level,
"reason": penalty.reason
+ }
+
+
+# ==================== USER MANAGEMENT (EPIC 10: Admin Frontend) ====================
+
+
+class BulkActionRequest(BaseModel):
+ """Csoportos művelet kérés sémája."""
+ user_ids: List[int] = Field(..., min_length=1, description="Felhasználók ID listája")
+ action: str = Field(..., pattern="^(ban|unban|soft_delete|restore|hard_delete)$", description="Végrehajtandó művelet")
+
+
+@router.get("/users", tags=["User Management"])
+async def list_users(
+ skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
+ limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
+ search_term: Optional[str] = Query(None, description="Keresőszó a célzott kereséshez"),
+ search_category: str = Query('all', description="Keresési kategória: email, name, phone, id, address, all"),
+ role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
+ is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
+ is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
+ db: AsyncSession = Depends(deps.get_db),
+ current_admin: User = Depends(deps.get_current_admin),
+):
+ """
+ Felhasználók listázása lapozással és célzott kereséssel.
+
+ Támogatott keresési kategóriák (search_category):
+ - **email**: Keresés e-mail címben (ILIKE)
+ - **name**: Keresés Person vezeték- VAGY keresztnévben (ILIKE)
+ - **phone**: Keresés Person telefonszámában (ILIKE)
+ - **id**: Pontos egyezés User ID alapján
+ - **address**: Keresés címmezőkben (postal_code, city, street, house_number)
+
+ NINCS automatikus debounce keresés! A keresés csak explicit paraméterrel indul.
+ """
+ # Alap lekérdezés: User + Person (outerjoin) + Address (outerjoin)
+ # KRITIKUS: selectinload helyett explicit outerjoin + contains_eager kell,
+ # mert a selectinload KÜLÖN query-ben tölti be a kapcsolódó adatokat,
+ # így a WHERE feltételek a Person/Address oszlopokra NEM működnének!
+ from sqlalchemy.orm import contains_eager, joinedload
+ from app.models.identity.address import GeoPostalCode
+
+ query = (
+ select(User)
+ .outerjoin(Person, User.person_id == Person.id)
+ .outerjoin(Address, Person.address_id == Address.id)
+ .outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
+ .options(
+ contains_eager(User.person),
+ contains_eager(User.person, Person.address),
+ )
+ )
+
+ # Célzott keresés - csak akkor alkalmazzuk, ha van search_term
+ if search_term:
+ if search_category == 'all':
+ query = query.where(
+ or_(
+ User.email.ilike(f"%{search_term}%"),
+ Person.first_name.ilike(f"%{search_term}%"),
+ Person.last_name.ilike(f"%{search_term}%"),
+ Person.phone.ilike(f"%{search_term}%")
+ )
+ )
+ elif search_category == 'email':
+ query = query.where(User.email.ilike(f"%{search_term}%"))
+ elif search_category == 'name':
+ query = query.where(
+ or_(
+ Person.first_name.ilike(f"%{search_term}%"),
+ Person.last_name.ilike(f"%{search_term}%")
+ )
+ )
+ elif search_category == 'phone':
+ query = query.where(Person.phone.ilike(f"%{search_term}%"))
+ elif search_category == 'id':
+ try:
+ user_id = int(search_term)
+ query = query.where(User.id == user_id)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Az 'id' keresési kategóriához numerikus érték szükséges."
+ )
+ elif search_category == 'address':
+ # FIGYELEM: Address.zip és Address.city PYTHON property-k (a GeoPostalCode kapcsolatból)
+ # Az SQL lekérdezésben a GeoPostalCode tábla oszlopait kell használni!
+ query = query.where(
+ or_(
+ GeoPostalCode.zip_code.ilike(f"%{search_term}%"),
+ GeoPostalCode.city.ilike(f"%{search_term}%"),
+ Address.street_name.ilike(f"%{search_term}%"),
+ Address.house_number.ilike(f"%{search_term}%")
+ )
+ )
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Érvénytelen keresési kategória: {search_category}. Érvényes: email, name, phone, id, address, all"
+ )
+
+ if role:
+ try:
+ role_enum = UserRole(role)
+ query = query.where(User.role == role_enum)
+ except ValueError:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Érvénytelen szerepkör: {role}. Érvényes értékek: {[r.value for r in UserRole]}"
+ )
+ if is_active is not None:
+ query = query.where(User.is_active == is_active)
+ if is_deleted is not None:
+ query = query.where(User.is_deleted == is_deleted)
+
+ count_query = select(func.count()).select_from(query.subquery())
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ query = query.order_by(User.id.desc()).offset(skip).limit(limit)
+ result = await db.execute(query)
+ # Unique users (outerjoin may duplicate)
+ seen_ids = set()
+ unique_users = []
+ for row in result.unique().scalars().all():
+ if row.id not in seen_ids:
+ seen_ids.add(row.id)
+ unique_users.append(row)
+
+ user_list = []
+ for u in unique_users:
+ person = u.person
+ address = person.address if person else None
+ # Cím összefűzése
+ address_str = None
+ if address:
+ parts = []
+ if address.zip:
+ parts.append(str(address.zip))
+ if address.city:
+ parts.append(str(address.city))
+ if address.street_name:
+ parts.append(str(address.street_name))
+ if address.house_number:
+ parts.append(str(address.house_number))
+ address_str = ', '.join(parts) if parts else None
+
+ user_list.append({
+ "id": u.id,
+ "email": u.email,
+ "role": u.role.value if hasattr(u.role, "value") else str(u.role),
+ "is_active": u.is_active,
+ "is_deleted": u.is_deleted,
+ "subscription_plan": u.subscription_plan,
+ "region_code": u.region_code,
+ "preferred_language": u.preferred_language,
+ "created_at": u.created_at.isoformat() if u.created_at else None,
+ "deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
+ # Person adatok
+ "first_name": person.first_name if person else None,
+ "last_name": person.last_name if person else None,
+ "phone": person.phone if person else None,
+ # Címadatok
+ "postal_code": str(address.zip) if address and address.zip else None,
+ "city": address.city if address and address.city else None,
+ "street": address.street_name if address and address.street_name else None,
+ "house_number": address.house_number if address and address.house_number else None,
+ "address": address_str,
+ })
+
+ return {
+ "total": total,
+ "skip": skip,
+ "limit": limit,
+ "users": user_list,
+ }
+
+
+@router.post("/users/bulk-action", tags=["User Management"])
+async def bulk_user_action(
+ request: BulkActionRequest,
+ db: AsyncSession = Depends(deps.get_db),
+ current_admin: User = Depends(deps.get_current_admin),
+):
+ """
+ Csoportos műveletek felhasználókon.
+
+ Támogatott akciók:
+ - **ban**: Felhasználó tiltása (is_active=False). Rang >= 50 (Moderator) szükséges.
+ - **unban**: Tiltás feloldása (is_active=True). Rang >= 90 (Admin) szükséges.
+ - **soft_delete**: Lágy törlés (is_deleted=True, deleted_at=now). Rang >= 90 (Admin) szükséges.
+ - **restore**: Lágy törlés visszaállítása (is_deleted=False, deleted_at=None). Rang >= 90 (Admin) szükséges.
+ - **hard_delete**: Végleges törlés az adatbázisból. Rang >= 100 (Superadmin) szükséges!
+ """
+ role_key = current_admin.role.value.upper() if hasattr(current_admin.role, "value") else str(current_admin.role).upper()
+ from app.core.security import DEFAULT_RANK_MAP
+ admin_rank = DEFAULT_RANK_MAP.get(role_key, 0)
+
+ if request.action == "hard_delete":
+ if admin_rank < 100:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Végleges törléshez Superadmin jogosultság szükséges (rank >= 100)."
+ )
+ elif request.action in ("soft_delete", "restore", "unban"):
+ if admin_rank < 90:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Ehhez a művelethez Admin jogosultság szükséges (rank >= 90)."
+ )
+ elif request.action in ("ban",):
+ if admin_rank < 50:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Tiltáshoz Moderator jogosultság szükséges (rank >= 50)."
+ )
+
+ stmt = select(User).where(User.id.in_(request.user_ids))
+ result = await db.execute(stmt)
+ users = result.scalars().all()
+
+ if not users:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Egy felhasználó sem található a megadott ID-kkel."
+ )
+
+ found_ids = {u.id for u in users}
+ missing_ids = [uid for uid in request.user_ids if uid not in found_ids]
+
+ now = datetime.now(timezone.utc)
+ affected_count = 0
+
+ for user in users:
+ if user.role == UserRole.superadmin and user.id != current_admin.id:
+ continue
+
+ if request.action == "ban":
+ user.is_active = False
+ elif request.action == "unban":
+ user.is_active = True
+ elif request.action == "soft_delete":
+ user.is_deleted = True
+ user.deleted_at = now
+ elif request.action == "restore":
+ user.is_deleted = False
+ user.deleted_at = None
+ elif request.action == "hard_delete":
+ await db.delete(user)
+
+ affected_count += 1
+
+ audit_log = SecurityAuditLog(
+ user_id=current_admin.id,
+ action=f"bulk_{request.action}",
+ details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}",
+ is_critical=(request.action in ("hard_delete", "ban")),
+ ip_address="admin_api"
+ )
+ db.add(audit_log)
+
+ await db.commit()
+
+ return {
+ "status": "success",
+ "action": request.action,
+ "affected_count": affected_count,
+ "total_requested": len(request.user_ids),
+ "missing_ids": missing_ids if missing_ids else None,
+ "message": f"{request.action} művelet végrehajtva {affected_count} felhasználón."
}
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/admin_packages.py b/backend/app/api/v1/endpoints/admin_packages.py
new file mode 100644
index 0000000..fa25cb7
--- /dev/null
+++ b/backend/app/api/v1/endpoints/admin_packages.py
@@ -0,0 +1,319 @@
+"""
+📦 Admin Package Manager API
+
+SubscriptionTier csomagok adminisztrációs végpontjai.
+Minden végpont védve van a Depends(get_current_admin) dependency-vel.
+
+Végpontok:
+ GET /admin/packages/ — Listázza az összes csomagot
+ POST /admin/packages/ — Új csomag létrehozása
+ PATCH /admin/packages/{id} — Csomag frissítése (pl. rules JSONB, név)
+ DELETE /admin/packages/{id} — Csomag logikai törlése (lifecycle.is_public=false)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from datetime import date
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy import Date, cast, func, select, update, delete as sa_delete
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api import deps
+from app.db.session import get_db
+from app.models.core_logic import SubscriptionTier
+from app.models.identity import User
+from app.schemas.subscription import (
+ SubscriptionTierCreate,
+ SubscriptionTierListResponse,
+ SubscriptionTierResponse,
+ SubscriptionTierUpdate,
+ SubscriptionRulesModel,
+)
+
+logger = logging.getLogger("admin-packages")
+router = APIRouter()
+
+
+# =============================================================================
+# GET / — Listázza az összes SubscriptionTier rekordot
+# =============================================================================
+
+
+@router.get(
+ "",
+ response_model=SubscriptionTierListResponse,
+ summary="Csomagok listázása",
+ description="Visszaadja az összes előfizetési csomagot (SubscriptionTier).",
+)
+async def list_packages(
+ skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
+ limit: int = Query(100, ge=1, le=500, description="Maximum visszaadott rekordok száma"),
+ include_hidden: bool = Query(
+ False,
+ description="Ha True, a nem publikus (lifecycle.is_public=false) csomagokat is visszaadja",
+ ),
+ type_filter: Optional[str] = Query(
+ None,
+ description="Szűrés típus szerint: 'private' vagy 'corporate' (rules->>'type')",
+ ),
+ date_from: Optional[str] = Query(
+ None,
+ description="Szűrés kezdő dátumtól (available_from a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
+ ),
+ date_until: Optional[str] = Query(
+ None,
+ description="Szűrés záró dátumig (available_until a lifecycle-ben, ISO formátum: YYYY-MM-DD)",
+ ),
+ db: AsyncSession = Depends(get_db),
+ admin: User = Depends(deps.get_current_admin),
+) -> SubscriptionTierListResponse:
+ """
+ Összes előfizetési csomag listázása.
+
+ Alapértelmezetten csak a publikus csomagokat adja vissza
+ (ahol a rules->'lifecycle'->>'is_public' != 'false').
+ Az `include_hidden=True` paraméterrel az összes rekord lekérhető.
+
+ Szűrési lehetőségek:
+ - `type_filter`: 'private' vagy 'corporate' a rules->>'type' mező alapján
+ - `date_from`: Csak azokat a csomagokat adja vissza, amelyek available_from >= date_from
+ - `date_until`: Csak azokat a csomagokat adja vissza, amelyek available_until <= date_until
+ """
+ stmt = select(SubscriptionTier).order_by(SubscriptionTier.id)
+
+ if not include_hidden:
+ # Csak a publikus csomagok (ahol nincs lifecycle.is_public=false)
+ stmt = stmt.where(
+ ~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
+ )
+
+ # ── Típus szűrő (rules->>'type') ──
+ if type_filter:
+ type_filter_lower = type_filter.strip().lower()
+ if type_filter_lower in ("private", "corporate"):
+ stmt = stmt.where(
+ SubscriptionTier.rules["type"].as_string() == type_filter_lower
+ )
+
+ # ── Dátum szűrők (lifecycle JSONB mezők) ──
+ # A JSONB mezőkben tárolt dátumokat cast(Date)-té alakítjuk a helyes összehasonlításhoz
+ if date_from:
+ stmt = stmt.where(
+ cast(SubscriptionTier.rules["lifecycle"]["available_from"].as_string(), Date) >= date_from
+ )
+ if date_until:
+ stmt = stmt.where(
+ cast(SubscriptionTier.rules["lifecycle"]["available_until"].as_string(), Date) <= date_until
+ )
+
+ # Teljes számolás
+ count_stmt = select(func.count()).select_from(stmt.subquery())
+ total_result = await db.execute(count_stmt)
+ total = total_result.scalar() or 0
+
+ # Lapozás
+ stmt = stmt.offset(skip).limit(limit)
+ result = await db.execute(stmt)
+ tiers = result.scalars().all()
+
+ return SubscriptionTierListResponse(
+ total=total,
+ tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers],
+ )
+
+
+# =============================================================================
+# POST / — Új csomag létrehozása
+# =============================================================================
+
+
+@router.post(
+ "",
+ response_model=SubscriptionTierResponse,
+ status_code=status.HTTP_201_CREATED,
+ summary="Új csomag létrehozása",
+ description="Létrehoz egy új előfizetési csomagot a megadott rules JSONB-vel.",
+)
+async def create_package(
+ payload: SubscriptionTierCreate,
+ db: AsyncSession = Depends(get_db),
+ admin: User = Depends(deps.get_current_admin),
+) -> SubscriptionTierResponse:
+ """
+ Új előfizetési csomag létrehozása.
+
+ - A `name` mező egyedi kell legyen (unique constraint).
+ - A `rules` mezőben a teljes SubscriptionRulesModel struktúrát kell megadni.
+ - Alapértelmezetten a lifecycle.is_public = True lesz beállítva.
+ """
+ # Ellenőrizzük, hogy létezik-e már ilyen nevű csomag
+ existing = await db.execute(
+ select(SubscriptionTier).where(SubscriptionTier.name == payload.name)
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Már létezik csomag ezzel a névvel: '{payload.name}'",
+ )
+
+ # Alapértelmezett lifecycle beállítás, ha nincs megadva
+ rules_dict = payload.rules.model_dump()
+ if rules_dict.get("lifecycle") is None:
+ rules_dict["lifecycle"] = {"is_public": True}
+
+ tier = SubscriptionTier(
+ name=payload.name,
+ rules=rules_dict,
+ is_custom=payload.is_custom,
+ )
+ db.add(tier)
+ await db.commit()
+ await db.refresh(tier)
+
+ logger.info(
+ f"Admin {admin.id} ({admin.email}) created subscription tier: "
+ f"name={tier.name}, id={tier.id}"
+ )
+
+ return SubscriptionTierResponse.model_validate(tier)
+
+
+# =============================================================================
+# PATCH /{tier_id} — Csomag frissítése
+# =============================================================================
+
+
+@router.patch(
+ "/{tier_id}",
+ response_model=SubscriptionTierResponse,
+ summary="Csomag frissítése",
+ description="Frissíti egy meglévő csomag adatait (név, rules JSONB, is_custom).",
+)
+async def update_package(
+ tier_id: int,
+ payload: SubscriptionTierUpdate,
+ db: AsyncSession = Depends(get_db),
+ admin: User = Depends(deps.get_current_admin),
+) -> SubscriptionTierResponse:
+ """
+ Meglévő előfizetési csomag részleges frissítése.
+
+ - Csak a megadott mezők frissülnek (PATCH szemantika).
+ - A `rules` mezőben a teljes SubscriptionRulesModel-t át kell adni,
+ ha módosítani szeretnéd (a JSONB oszlop teljes egészében lecserélődik).
+ - A lifecycle.is_public false-ra állításával a csomag elrejthető.
+ """
+ # Betöltjük a meglévő rekordot
+ stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
+ result = await db.execute(stmt)
+ tier = result.scalar_one_or_none()
+
+ if not tier:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Csomag nem található ID-vel: {tier_id}",
+ )
+
+ # Részleges frissítés
+ update_data = payload.model_dump(exclude_unset=True)
+
+ if "name" in update_data and update_data["name"] is not None:
+ # Ellenőrizzük az egyediséget (kivéve, ha ugyanaz a név)
+ if update_data["name"] != tier.name:
+ existing = await db.execute(
+ select(SubscriptionTier).where(
+ SubscriptionTier.name == update_data["name"],
+ SubscriptionTier.id != tier_id,
+ )
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Már létezik csomag ezzel a névvel: '{update_data['name']}'",
+ )
+ tier.name = update_data["name"]
+
+ if "rules" in update_data and update_data["rules"] is not None:
+ # A meglévő rules-ból megtartjuk a lifecycle-t, ha nem adtak újat
+ new_rules = update_data["rules"]
+ # Biztosítjuk a JSON kompatibilis szerializációt (model_dump(mode='json') már megtörtént)
+ if isinstance(new_rules, dict) and new_rules.get("lifecycle") is None and tier.rules.get("lifecycle"):
+ new_rules["lifecycle"] = tier.rules["lifecycle"]
+ tier.rules = new_rules
+
+ if "is_custom" in update_data and update_data["is_custom"] is not None:
+ tier.is_custom = update_data["is_custom"]
+
+ await db.commit()
+ await db.refresh(tier)
+
+ logger.info(
+ f"Admin {admin.id} ({admin.email}) updated subscription tier: "
+ f"id={tier.id}, name={tier.name}"
+ )
+
+ return SubscriptionTierResponse.model_validate(tier)
+
+
+# =============================================================================
+# DELETE /{tier_id} — Csomag logikai törlése (soft-delete)
+# =============================================================================
+
+
+@router.delete(
+ "/{tier_id}",
+ status_code=status.HTTP_200_OK,
+ summary="Csomag logikai törlése",
+ description="Nem törli fizikailag, csak a lifecycle.is_public = false értékre állítja.",
+)
+async def delete_package(
+ tier_id: int,
+ db: AsyncSession = Depends(get_db),
+ admin: User = Depends(deps.get_current_admin),
+) -> Dict[str, Any]:
+ """
+ Csomag logikai törlése (soft-delete / kivezetés).
+
+ A rekord NEM kerül törlésre az adatbázisból!
+ Ehelyett a `rules` JSONB `lifecycle.is_public` mezője `false`-ra áll,
+ így a csomag eltűnik a frontend listákból, de a meglévő előfizetések
+ továbbra is hivatkozhatnak rá.
+
+ Ha a csomag már nem publikus, akkor is 200 OK-val tér vissza
+ (idempotens művelet).
+ """
+ stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
+ result = await db.execute(stmt)
+ tier = result.scalar_one_or_none()
+
+ if not tier:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Csomag nem található ID-vel: {tier_id}",
+ )
+
+ # Logikai törlés: lifecycle.is_public = false
+ current_rules = dict(tier.rules) if tier.rules else {}
+ if "lifecycle" not in current_rules:
+ current_rules["lifecycle"] = {}
+ current_rules["lifecycle"]["is_public"] = False
+ tier.rules = current_rules
+
+ await db.commit()
+ await db.refresh(tier)
+
+ logger.info(
+ f"Admin {admin.id} ({admin.email}) deactivated subscription tier: "
+ f"id={tier.id}, name={tier.name}"
+ )
+
+ return {
+ "status": "success",
+ "message": f"Csomag '{tier.name}' (ID: {tier.id}) inaktíválva.",
+ "tier_id": tier.id,
+ "is_public": False,
+ }
diff --git a/backend/app/api/v1/endpoints/admin_services.py b/backend/app/api/v1/endpoints/admin_services.py
new file mode 100644
index 0000000..8ee29ab
--- /dev/null
+++ b/backend/app/api/v1/endpoints/admin_services.py
@@ -0,0 +1,110 @@
+# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_services.py
+"""
+Admin Service Catalog CRUD végpontok.
+Prefix: /admin/services
+Védve: Depends(get_current_admin)
+"""
+from fastapi import APIRouter, Depends, HTTPException, status, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+from typing import List
+
+from app.api import deps
+from app.models.identity import User
+from app.models.core_logic import ServiceCatalog
+from app.schemas.service_catalog import (
+ ServiceCatalogCreate,
+ ServiceCatalogUpdate,
+ ServiceCatalogResponse,
+)
+
+router = APIRouter()
+
+
+@router.get("", response_model=List[ServiceCatalogResponse], tags=["Admin Service Catalog"])
+async def list_service_catalog(
+ skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
+ limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
+ is_active: bool | None = Query(None, description="Szűrés aktív/inaktív státuszra"),
+ db: AsyncSession = Depends(deps.get_db),
+ admin: User = Depends(deps.get_current_admin),
+):
+ """
+ Szolgáltatás katalógus bejegyzések listázása lapozással.
+ """
+ query = select(ServiceCatalog)
+
+ if is_active is not None:
+ query = query.where(ServiceCatalog.is_active == is_active)
+
+ # Teljes darabszám
+ count_query = select(func.count()).select_from(query.subquery())
+ total_result = await db.execute(count_query)
+ total = total_result.scalar() or 0
+
+ query = query.order_by(ServiceCatalog.id.desc()).offset(skip).limit(limit)
+ result = await db.execute(query)
+ items = result.scalars().all()
+
+ return items
+
+
+@router.post("", response_model=ServiceCatalogResponse, status_code=status.HTTP_201_CREATED, tags=["Admin Service Catalog"])
+async def create_service_catalog(
+ data: ServiceCatalogCreate,
+ db: AsyncSession = Depends(deps.get_db),
+ admin: User = Depends(deps.get_current_admin),
+):
+ """
+ Új szolgáltatás katalógus bejegyzés létrehozása.
+ """
+ # Ellenőrizzük, hogy a service_code már létezik-e
+ existing = await db.execute(
+ select(ServiceCatalog).where(ServiceCatalog.service_code == data.service_code)
+ )
+ if existing.scalar_one_or_none():
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Service code '{data.service_code}' already exists.",
+ )
+
+ entry = ServiceCatalog(
+ service_code=data.service_code,
+ name=data.name,
+ description=data.description,
+ credit_cost=data.credit_cost,
+ is_active=data.is_active,
+ )
+ db.add(entry)
+ await db.commit()
+ await db.refresh(entry)
+ return entry
+
+
+@router.patch("/{service_id}", response_model=ServiceCatalogResponse, tags=["Admin Service Catalog"])
+async def update_service_catalog(
+ service_id: int,
+ data: ServiceCatalogUpdate,
+ db: AsyncSession = Depends(deps.get_db),
+ admin: User = Depends(deps.get_current_admin),
+):
+ """
+ Meglévő szolgáltatás katalógus bejegyzés módosítása.
+ """
+ result = await db.execute(select(ServiceCatalog).where(ServiceCatalog.id == service_id))
+ entry = result.scalar_one_or_none()
+
+ if not entry:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Service catalog entry with ID {service_id} not found.",
+ )
+
+ # Csak a megadott mezőket frissítjük
+ update_data = data.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(entry, field, value)
+
+ await db.commit()
+ await db.refresh(entry)
+ return entry
diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py
index 7f22f4c..572e596 100755
--- a/backend/app/api/v1/endpoints/assets.py
+++ b/backend/app/api/v1/endpoints/assets.py
@@ -18,6 +18,8 @@ from app.services.asset_service import AssetService
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
+logger = logging.getLogger(__name__)
+
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
@@ -26,8 +28,6 @@ class ArchiveVehicleRequest(BaseModel):
router = APIRouter()
-logger = logging.getLogger(__name__)
-
@router.get("/vehicles/quota-status")
async def get_vehicle_quota_status(
@@ -249,13 +249,36 @@ async def list_asset_costs(
"""Tételes költséglista lapozással (Pagination)."""
stmt = (
select(AssetCost)
+ .options(selectinload(AssetCost.category))
.where(AssetCost.asset_id == asset_id)
.order_by(desc(AssetCost.date))
.offset(skip)
.limit(limit)
)
res = await db.execute(stmt)
- return res.scalars().all()
+ costs = res.scalars().all()
+
+ # Map DB models to response, enriching with category info and data fields
+ result = []
+ for cost in costs:
+ cost_dict = {
+ "id": cost.id,
+ "asset_id": cost.asset_id,
+ "organization_id": cost.organization_id,
+ "amount_net": cost.amount_net,
+ "currency": cost.currency,
+ "date": cost.date,
+ "invoice_number": cost.invoice_number,
+ "data": cost.data or {},
+ "category_id": cost.category_id,
+ "category_name": cost.category.name if cost.category else None,
+ "category_code": cost.category.code if cost.category else None,
+ "description": (cost.data or {}).get("description"),
+ "mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
+ }
+ result.append(AssetCostResponse(**cost_dict))
+
+ return result
@router.get("/{asset_id}", response_model=AssetResponse)
@@ -373,7 +396,6 @@ async def create_or_claim_vehicle(
# első elérhető szervezetét a fleet.organization_members táblában
if org_id is None:
from app.models.marketplace.organization import OrganizationMember
- from sqlalchemy import select
member_stmt = (
select(OrganizationMember.organization_id)
.where(OrganizationMember.user_id == current_user.id)
@@ -405,7 +427,6 @@ async def create_or_claim_vehicle(
except HTTPException:
raise
except Exception as e:
- logger = logging.getLogger(__name__)
logger.error(f"Vehicle creation error: {e}")
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor")
@@ -487,7 +508,6 @@ async def update_vehicle(
except Exception as e:
await db.rollback()
- logger = logging.getLogger(__name__)
logger.error(f"Vehicle update error: {e}")
raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor")
@@ -685,7 +705,6 @@ async def create_maintenance_record(
)
except Exception as e:
await db.rollback()
- logger = logging.getLogger(__name__)
logger.error(f"Maintenance record creation error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py
index 72350a7..6819ad3 100755
--- a/backend/app/api/v1/endpoints/auth.py
+++ b/backend/app/api/v1/endpoints/auth.py
@@ -305,4 +305,45 @@ async def get_current_user_profile(
from app.schemas.user import UserResponse
from app.api.v1.endpoints.users import _build_user_response
response_data = _build_user_response(current_user)
- return UserResponse.model_validate(response_data)
\ No newline at end of file
+ return UserResponse.model_validate(response_data)
+
+
+# ── 30 NAPOS FIÓKVISSZAÁLLÍTÁS (RESTORE) ──
+
+class RestoreRequestIn(BaseModel):
+ email: EmailStr = Field(..., description="A törölt fiók email címe")
+
+class RestoreVerifyIn(BaseModel):
+ email: EmailStr = Field(..., description="A törölt fiók email címe")
+ code: str = Field(..., min_length=6, max_length=6, description="6-jegyű visszaállítási kód")
+
+@router.post("/restore/request")
+async def restore_account_request(
+ request: RestoreRequestIn,
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Fiók visszaállítási kód kérése.
+ Ha a fiók 30 napon belül lett törölve, 6-jegyű OTP kódot küld.
+ Ha 30 napon túl van, új regisztrációt engedélyez.
+ """
+ result = await AuthService.request_account_restore(db, request.email)
+ return result
+
+
+@router.post("/restore/verify")
+async def restore_account_verify(
+ request: RestoreVerifyIn,
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Fiók visszaállítási kód ellenőrzése.
+ Sikeres ellenőrzés után visszaállítja a fiókot (is_active=True, is_deleted=False, deleted_at=None).
+ """
+ result = await AuthService.verify_account_restore(db, request.email, request.code)
+ if result.get("status") != "success":
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=result.get("message", "Érvénytelen vagy lejárt kód.")
+ )
+ return result
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/catalog.py b/backend/app/api/v1/endpoints/catalog.py
index afb5d4b..eebb673 100755
--- a/backend/app/api/v1/endpoints/catalog.py
+++ b/backend/app/api/v1/endpoints/catalog.py
@@ -100,4 +100,67 @@ async def list_body_types(
"name_hu": r.name_hu,
}
for r in rows
- ]
\ No newline at end of file
+ ]
+
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# NEW: Cascading Catalog Endpoints for Vehicle Wizard (Drill-down UI)
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@router.get("/brands", response_model=List[str])
+async def list_catalog_brands(
+ vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra (pl. personal, motorcycle)"),
+ q: Optional[str] = Query(None, description="Keresési szöveg (autocomplete)"),
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_user)
+):
+ """Márkák listája a vehicle_model_definitions táblából, autocomplete-hez.
+ GET /catalog/brands?class=personal&q=bmw
+ """
+ return await AssetService.get_catalog_brands(db, vehicle_class, q)
+
+
+@router.get("/brands/{brand}/models")
+async def list_catalog_models(
+ brand: str,
+ vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra"),
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_user)
+):
+ """Adott márkához tartozó modellek listája.
+ GET /catalog/brands/BMW/models
+ """
+ if not brand or brand.strip() == "":
+ return []
+ return await AssetService.get_catalog_models(db, brand, vehicle_class)
+
+
+@router.get("/brands/{brand}/models/{model}/years")
+async def list_catalog_years(
+ brand: str,
+ model: str,
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_user)
+):
+ """Adott márkához és modellhez tartozó elérhető évjáratok.
+ GET /catalog/brands/BMW/models/X5/years
+ """
+ if not brand or not model or brand.strip() == "" or model.strip() == "":
+ return []
+ return await AssetService.get_catalog_years(db, brand, model)
+
+
+@router.get("/brands/{brand}/models/{model}/trims")
+async def list_catalog_trims(
+ brand: str,
+ model: str,
+ year: Optional[int] = Query(None, description="Szűrés évjáratra"),
+ db: AsyncSession = Depends(get_db),
+ current_user = Depends(deps.get_current_user)
+):
+ """Adott márkához, modellhez és opcionális évjárathoz tartozó felszereltségek/motorváltozatok.
+ GET /catalog/brands/BMW/models/X5/trims?year=2023
+ """
+ if not brand or not model or brand.strip() == "" or model.strip() == "":
+ return []
+ return await AssetService.get_catalog_trims(db, brand, model, year)
\ No newline at end of file
diff --git a/backend/app/api/v1/endpoints/expenses.py b/backend/app/api/v1/endpoints/expenses.py
index bf233e5..2fb9810 100755
--- a/backend/app/api/v1/endpoints/expenses.py
+++ b/backend/app/api/v1/endpoints/expenses.py
@@ -90,14 +90,19 @@ async def create_expense(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
- amount_net=expense.amount_local,
- currency=expense.currency_local,
+ amount_net=expense.amount_net,
+ currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
data=data
)
db.add(new_cost)
+
+ # Update Asset.current_mileage if mileage_at_cost is higher
+ 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
+
await db.commit()
await db.refresh(new_cost)
diff --git a/backend/app/api/v1/endpoints/organizations.py b/backend/app/api/v1/endpoints/organizations.py
index 2ea3117..528eeee 100755
--- a/backend/app/api/v1/endpoints/organizations.py
+++ b/backend/app/api/v1/endpoints/organizations.py
@@ -3,9 +3,10 @@ import os
import re
import uuid
import hashlib
+import random
import logging
from typing import List
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -13,9 +14,11 @@ from sqlalchemy import select
from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
-from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
-from app.models.identity import User # JAVÍTVA: Központi Identity modell
+from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole
+from app.models.identity import User, OneTimePassword # JAVÍTVA: Központi Identity modell
from app.core.config import settings
+from app.services.security_service import security_service
+from app.models import LogSeverity
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -275,6 +278,79 @@ async def update_organization_visual_settings(
)
return OrganizationResponse.model_validate(org)
+
+
+@router.patch("/{org_id}", response_model=OrganizationResponse)
+async def update_organization(
+ org_id: int,
+ update_data: OrganizationUpdate,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user)
+):
+ """
+ Szervezet adatainak részleges frissítése (általános PATCH).
+
+ Támogatja a cégadatok (name, full_name, tax_number, display_name),
+ cím adatok (address_zip, address_city, address_street_name, stb.),
+ valamint a visual_settings, language, default_currency mezők frissítését.
+
+ Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
+ """
+ # 1. Jogosultság ellenőrzése
+ stmt_member = select(OrganizationMember).where(
+ (OrganizationMember.organization_id == org_id) &
+ (OrganizationMember.user_id == current_user.id) &
+ (OrganizationMember.role.in_(["OWNER", "ADMIN"]))
+ )
+ member = (await db.execute(stmt_member)).scalar_one_or_none()
+ if not member:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Nincs jogosultságod a szervezet adatainak módosításához (csak OWNER/ADMIN)."
+ )
+
+ # 2. Szervezet lekérése
+ stmt_org = select(Organization).where(
+ Organization.id == org_id,
+ Organization.is_deleted == False
+ )
+ result = await db.execute(stmt_org)
+ org = result.scalar_one_or_none()
+ if not org:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Szervezet nem található."
+ )
+
+ # 3. JSONB merge visual_settings esetén
+ update_dict = update_data.model_dump(exclude_unset=True)
+
+ if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
+ incoming_vs = update_dict["visual_settings"]
+ current_vs = dict(org.visual_settings) if org.visual_settings else {}
+ current_vs.update(incoming_vs)
+ org.visual_settings = current_vs
+ del update_dict["visual_settings"]
+
+ # 4. Többi mező frissítése
+ for field, value in update_dict.items():
+ if hasattr(org, field) and value is not None:
+ setattr(org, field, value)
+
+ try:
+ await db.commit()
+ await db.refresh(org)
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Adatbázis hiba: {str(e)}"
+ )
+
+ return OrganizationResponse.model_validate(org)
+
+
# --- B2B MEGHÍVÓ LOGIKA ---
from pydantic import BaseModel, EmailStr
@@ -393,9 +469,274 @@ async def accept_invitation(
status="active"
)
db.add(new_member)
+ token_rec.is_used = True
+ await db.commit()
- token_rec.is_used = True
- await db.commit()
+ return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
- return {"status": "success", "message": "Meghívó sikeresen elfogadva."}
-
+
+ # ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
+
+ from app.models.identity import OneTimePassword
+ import random
+
+ class JoinRequestIn(BaseModel):
+ """Csatlakozási kérelem egy céghez, amelynek van aktív adminja."""
+ message: Optional[str] = Field(None, description="Üzenet az adminnak")
+
+ class ClaimRequestIn(BaseModel):
+ """Árva cég átvételi kérelem - email ellenőrzés."""
+ email: EmailStr = Field(..., description="A cég eredeti email címe")
+
+ class ClaimVerifyIn(BaseModel):
+ """Árva cég átvételi kérelem - OTP kód ellenőrzés."""
+ email: EmailStr = Field(..., description="A cég eredeti email címe")
+ code: str = Field(..., min_length=6, max_length=6, description="6-jegyű megerősítő kód")
+
+
+ @router.post("/{org_id}/join-request")
+ async def request_join_organization(
+ org_id: int,
+ request: JoinRequestIn,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ ):
+ """
+ Csatlakozási kérelem egy szervezethez, amelynek van aktív adminja.
+ A kérelem 7 napig él, amíg az admin jóvá nem hagyja.
+ """
+ # Ellenőrizzük, hogy létezik-e a szervezet
+ stmt = select(Organization).where(
+ Organization.id == org_id,
+ Organization.is_deleted == False
+ )
+ result = await db.execute(stmt)
+ org = result.scalar_one_or_none()
+ if not org:
+ raise HTTPException(status_code=404, detail="Szervezet nem található.")
+
+ # Ellenőrizzük, hogy van-e aktív admin
+ stmt = select(OrganizationMember).where(
+ OrganizationMember.organization_id == org_id,
+ OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
+ ).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
+
+ result = await db.execute(stmt)
+ active_admins = result.scalars().all()
+
+ if not active_admins:
+ raise HTTPException(
+ status_code=400,
+ detail="A szervezetnek nincs aktív adminja. Használja a /claim/request végpontot."
+ )
+
+ # Ellenőrizzük, hogy a felhasználó már nem tag-e
+ stmt = select(OrganizationMember).where(
+ OrganizationMember.organization_id == org_id,
+ OrganizationMember.user_id == current_user.id
+ )
+ result = await db.execute(stmt)
+ existing = result.scalar_one_or_none()
+ if existing:
+ raise HTTPException(status_code=400, detail="Már tagja vagy ennek a szervezetnek.")
+
+ # Létrehozzuk a pending tagot
+ new_member = OrganizationMember(
+ organization_id=org_id,
+ user_id=current_user.id,
+ role=OrgUserRole.DRIVER,
+ status="pending"
+ )
+ db.add(new_member)
+
+ # Naplózás
+ await security_service.log_event(
+ db, user_id=current_user.id, action="ORG_JOIN_REQUEST",
+ severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
+ new_data={"message": request.message or ""}
+ )
+
+ await db.commit()
+
+ return {
+ "status": "pending",
+ "message": "Csatlakozási kérelmed rögzítettük. Az admin jóváhagyására vársz."
+ }
+
+
+ @router.post("/{org_id}/claim/request")
+ async def claim_orphaned_organization_request(
+ org_id: int,
+ request: ClaimRequestIn,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ ):
+ """
+ Árva cég átvételi kérelem indítása.
+ Ellenőrzi, hogy a megadott email cím egyezik-e a cég eredeti email címével,
+ majd 6-jegyű OTP kódot küld ki.
+ """
+ # Ellenőrizzük a szervezetet
+ stmt = select(Organization).where(
+ Organization.id == org_id,
+ Organization.is_deleted == False
+ )
+ result = await db.execute(stmt)
+ org = result.scalar_one_or_none()
+ if not org:
+ raise HTTPException(status_code=404, detail="Szervezet nem található.")
+
+ # Ellenőrizzük, hogy tényleg árva-e (nincs aktív admin)
+ stmt = select(OrganizationMember).where(
+ OrganizationMember.organization_id == org_id,
+ OrganizationMember.role.in_([OrgUserRole.OWNER, OrgUserRole.ADMIN])
+ ).join(User, OrganizationMember.user_id == User.id).where(User.is_active == True, User.is_deleted == False)
+
+ result = await db.execute(stmt)
+ active_admins = result.scalars().all()
+
+ if active_admins:
+ raise HTTPException(
+ status_code=400,
+ detail="A szervezetnek van aktív adminja. Használja a /join-request végpontot."
+ )
+
+ # Ellenőrizzük az email címet - keressük a tulajdonos usert
+ # A tulajdonos email-jét kell ellenőrizni
+ owner_stmt = select(User).where(
+ User.id == org.owner_id,
+ User.email == request.email
+ )
+ owner_result = await db.execute(owner_stmt)
+ owner_user = owner_result.scalar_one_or_none()
+
+ if not owner_user:
+ # Lehet, hogy a tulajdonos törölve van, és az email át lett írva
+ # Keressük a deleted_ prefix-es email-ben
+ owner_stmt = select(User).where(
+ User.id == org.owner_id,
+ User.email.like(f"%{request.email}")
+ )
+ owner_result = await db.execute(owner_stmt)
+ owner_user = owner_result.scalar_one_or_none()
+
+ if not owner_user:
+ raise HTTPException(
+ status_code=400,
+ detail="Az email cím nem egyezik a cég tulajdonosának email címével."
+ )
+
+ # Generáljunk 6-jegyű OTP kódot
+ code = f"{random.randint(0, 999999):06d}"
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
+
+ otp = OneTimePassword(
+ email=request.email,
+ code=code,
+ otp_type="company_claim",
+ extra_data={"org_id": org_id, "user_id": current_user.id},
+ expires_at=expires_at
+ )
+ db.add(otp)
+ await db.commit()
+
+ # Szimulált email küldés
+ logger.info(f"=== CÉG ÁTVÉTELI OTP ===")
+ logger.info(f"Email: {request.email}")
+ logger.info(f"Kód: {code}")
+ logger.info(f"Lejárat: {expires_at}")
+ logger.info(f"=== A kódot email-ben kell elküldeni a cég tulajdonosának ===")
+
+ return {
+ "status": "otp_sent",
+ "message": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.",
+ "expires_at": expires_at.isoformat()
+ }
+
+
+ @router.post("/{org_id}/claim/verify")
+ async def claim_orphaned_organization_verify(
+ org_id: int,
+ request: ClaimVerifyIn,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+ ):
+ """
+ Árva cég átvételének véglegesítése OTP kód alapján.
+ Sikeres ellenőrzés után a próbálkozó lesz az új admin.
+ """
+ now = datetime.now(timezone.utc)
+
+ # Ellenőrizzük a szervezetet
+ stmt = select(Organization).where(
+ Organization.id == org_id,
+ Organization.is_deleted == False
+ )
+ result = await db.execute(stmt)
+ org = result.scalar_one_or_none()
+ if not org:
+ raise HTTPException(status_code=404, detail="Szervezet nem található.")
+
+ # Ellenőrizzük az OTP kódot
+ otp_stmt = select(OneTimePassword).where(
+ OneTimePassword.email == request.email,
+ OneTimePassword.code == request.code,
+ OneTimePassword.otp_type == "company_claim",
+ OneTimePassword.is_used == False,
+ OneTimePassword.expires_at > now
+ ).order_by(OneTimePassword.created_at.desc()).limit(1)
+
+ result = await db.execute(otp_stmt)
+ otp = result.scalar_one_or_none()
+
+ if not otp:
+ raise HTTPException(
+ status_code=400,
+ detail="Érvénytelen vagy lejárt kód."
+ )
+
+ # Ellenőrizzük, hogy az OTP ehhez a szervezethez tartozik-e
+ extra = otp.extra_data or {}
+ if extra.get("org_id") != org_id:
+ raise HTTPException(status_code=400, detail="A kód nem ehhez a szervezethez tartozik.")
+
+ # Mark OTP as used
+ otp.is_used = True
+
+ # Ellenőrizzük, hogy a felhasználó már nem tag-e
+ member_stmt = select(OrganizationMember).where(
+ OrganizationMember.organization_id == org_id,
+ OrganizationMember.user_id == current_user.id
+ )
+ result = await db.execute(member_stmt)
+ existing_member = result.scalar_one_or_none()
+
+ if existing_member:
+ # Frissítjük a szerepkört
+ existing_member.role = OrgUserRole.ADMIN
+ else:
+ # Új tag létrehozása ADMIN szerepkörrel
+ new_member = OrganizationMember(
+ organization_id=org_id,
+ user_id=current_user.id,
+ role=OrgUserRole.ADMIN,
+ status="active"
+ )
+ db.add(new_member)
+
+ # Átírjuk a tulajdonost (owner_id)
+ org.owner_id = current_user.id
+
+ # Naplózás
+ await security_service.log_event(
+ db, user_id=current_user.id, action="ORG_CLAIMED",
+ severity=LogSeverity.warning, target_type="Organization", target_id=str(org_id),
+ new_data={"previous_owner": str(extra.get("user_id")), "claim_type": "orphaned"}
+ )
+
+ await db.commit()
+
+ return {
+ "status": "success",
+ "message": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin."
+ }
diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py
index f349293..6d30f24 100755
--- a/backend/app/api/v1/endpoints/users.py
+++ b/backend/app/api/v1/endpoints/users.py
@@ -1,7 +1,7 @@
#/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/users.py
import copy
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import select, or_
@@ -117,6 +117,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"active_organization_id": active_org_id,
"preferred_language": user.preferred_language or "hu",
"person": person_data,
+ "is_last_admin": False, # Default, will be overridden in read_users_me
}
@@ -175,7 +176,10 @@ async def read_users_me(
active_org_id = org_row[0] if org_row else None
+ # Check if user is the last admin in any organization
+ is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
response_data = _build_user_response(current_user, active_org_id)
+ response_data["is_last_admin"] = is_last_admin
return UserResponse.model_validate(response_data)
@@ -497,3 +501,35 @@ async def get_my_network(
level2=level2,
level3=level3
)
+
+
+@router.delete("/me", status_code=200)
+async def delete_my_account(
+ reason: Optional[str] = Query(None, description="Törlés oka (opcionális)"),
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """
+ Saját fiók soft-delete.
+
+ Üzleti logika:
+ - Anonimizálja az e-mail címet (deleted_{id}_{timestamp}_{original_email})
+ - is_active = False, is_deleted = True, deleted_at = timestamp
+ - Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
+ - Wallet és Gamification adatokat megőrzi (inaktív user miatt freeze)
+ - JWT token-ek a deleted_at middleware ellenőrzés miatt automatikusan érvénytelenülnek
+ """
+ success = await AuthService.soft_delete_user(
+ db=db,
+ user_id=current_user.id,
+ reason=reason or "user_requested_self_delete",
+ actor_id=current_user.id
+ )
+
+ if not success:
+ raise HTTPException(
+ status_code=400,
+ detail="A felhasználó már törölve van."
+ )
+
+ return {"status": "ok", "message": "Fiók sikeresen törölve."}
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index e57fc2f..a8bb0a7 100755
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -52,15 +52,22 @@ def generate_secure_slug(length: int = 16) -> str:
return ''.join(secrets.choice(alphabet) for _ in range(length))
# Teljesen a margón van, így globális konstans lesz!
+# Lefedi a UserRole enum MIND a 10 értékét
DEFAULT_RANK_MAP = {
"SUPERADMIN": 100,
"ADMIN": 90,
- "AUDITOR": 80,
- "ORGANIZATION_OWNER": 70,
+ "REGION_ADMIN": 85,
+ "COUNTRY_ADMIN": 80,
+ "MODERATOR": 75,
+ "AUDITOR": 70,
+ "ORGANIZATION_OWNER": 65,
"ORGANIZATION_MANAGER": 60,
"ORGANIZATION_MEMBER": 50,
"SERVICE_PROVIDER": 40,
+ "SALES_AGENT": 30,
+ "FLEET_MANAGER": 25,
"PREMIUM_USER": 20,
"USER": 10,
+ "DRIVER": 5,
"GUEST": 0
}
\ No newline at end of file
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 1bf61c3..7510745 100755
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -3,23 +3,23 @@
from app.database import Base
# 1. Alapvető identitás és szerepkörök
-from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole
+from .identity.identity import Person, User, Wallet, VerificationToken, SocialAccount, UserRole, OneTimePassword
-# 2. Földrajzi adatok és címek
+# 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references)
+from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
+
+# 3. Földrajzi adatok és címek
from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating
-# 3. Jármű definíciók
+# 4. Jármű definíciók
from .vehicle.vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap
from .reference_data import ReferenceLookup
from .vehicle.vehicle import CostCategory, VehicleCost, GbCatalogDiscovery
from .vehicle.external_reference import ExternalReferenceLibrary
from .vehicle.external_reference_queue import ExternalReferenceQueue
-# 4. Szervezeti felépítés
-from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, Branch
-
# 5. Eszközök és katalógusok
-from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership
+from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading
# 6. Üzleti logika és előfizetések
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
@@ -61,7 +61,7 @@ __all__ = [
"Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount",
"Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole",
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
- "AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery",
+ "AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
@@ -81,5 +81,5 @@ __all__ = [
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
- "GbCatalogDiscovery", "Season", "StagedVehicleData"
+ "GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword"
]
\ No newline at end of file
diff --git a/backend/app/models/core_logic.py b/backend/app/models/core_logic.py
index b5cb2e4..307a6bc 100755
--- a/backend/app/models/core_logic.py
+++ b/backend/app/models/core_logic.py
@@ -41,6 +41,29 @@ class OrganizationSubscription(Base):
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+class UserSubscription(Base):
+ """
+ Felhasználók aktuális előfizetései és azok érvényessége.
+ Tábla: finance.user_subscriptions
+ """
+ __tablename__ = "user_subscriptions"
+ __table_args__ = {"schema": "finance"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+
+ # Kapcsolat a felhasználóval (identity séma)
+ user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
+
+ # Kapcsolat a csomaggal (system séma)
+ tier_id: Mapped[int] = mapped_column(Integer, ForeignKey("system.subscription_tiers.id"), nullable=False)
+
+ valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+
+
class CreditTransaction(Base):
"""
Kreditnapló (Pontok, kreditek vagy virtuális egyenleg követése).
@@ -73,4 +96,21 @@ class ServiceSpecialty(Base):
slug: Mapped[str] = mapped_column(String, unique=True, index=True)
# Kapcsolat az ős-szolgáltatással (Self-referential relationship)
- parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
\ No newline at end of file
+ parent: Mapped[Optional["ServiceSpecialty"]] = relationship("ServiceSpecialty", remote_side=[id], backref="children")
+
+
+class ServiceCatalog(Base):
+ """
+ Szolgáltatás Katalógus (Service Catalog).
+ A rendszer által kínált szolgáltatások definíciói, pl. 'SRV_DATA_EXPORT'.
+ Tábla: system.service_catalog
+ """
+ __tablename__ = "service_catalog"
+ __table_args__ = {"schema": "system"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
+ name: Mapped[str] = mapped_column(String, nullable=False)
+ description: Mapped[Optional[str]] = mapped_column(String)
+ credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
\ No newline at end of file
diff --git a/backend/app/models/identity/__init__.py b/backend/app/models/identity/__init__.py
index f981608..7f94e00 100644
--- a/backend/app/models/identity/__init__.py
+++ b/backend/app/models/identity/__init__.py
@@ -10,6 +10,7 @@ from .identity import (
UserRole,
Device,
UserDeviceLink,
+ OneTimePassword,
)
from .address import (
@@ -42,6 +43,7 @@ __all__ = [
"UserRole",
"Device",
"UserDeviceLink",
+ "OneTimePassword",
"Address",
"GeoPostalCode",
"GeoStreet",
diff --git a/backend/app/models/identity/identity.py b/backend/app/models/identity/identity.py
index b7e117c..86c74ae 100644
--- a/backend/app/models/identity/identity.py
+++ b/backend/app/models/identity/identity.py
@@ -78,6 +78,9 @@ class Person(Base):
index=True
)
+ # === SOFT DELETE (structure only - never delete Person data) ===
+ deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=func.now(), nullable=False)
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -147,6 +150,9 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
+
+ # === SOFT DELETE ===
+ deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
@@ -214,19 +220,24 @@ class User(Base):
service_requests: Mapped[List["ServiceRequest"]] = relationship("ServiceRequest", back_populates="user", cascade="all, delete-orphan")
class Wallet(Base):
- """ Felhasználói pénztárca. """
+ """ Felhasználói / Szervezeti pénztárca (B2B & B2C). """
__tablename__ = "wallets"
- __table_args__ = {"schema": "identity"}
+ __table_args__ = (
+ # Biztosítjuk, hogy vagy user_id, vagy organization_id legyen kitöltve, de ne egyszerre
+ # Ezt ORM szinten is ellenőrizzük, de DB constraint is véd
+ {"schema": "identity"}
+ )
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
- user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True)
+ user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), unique=True, nullable=True)
+ organization_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True)
earned_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
purchased_credits: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
service_coins: Mapped[float] = mapped_column(Numeric(18, 4), server_default=text("0"))
currency: Mapped[str] = mapped_column(String(3), default="HUF")
- user: Mapped["User"] = relationship("User", back_populates="wallet")
+ user: Mapped[Optional["User"]] = relationship("User", back_populates="wallet")
active_vouchers: Mapped[List["ActiveVoucher"]] = relationship("ActiveVoucher", back_populates="wallet", cascade="all, delete-orphan")
class VerificationToken(Base):
@@ -306,6 +317,25 @@ class UserTrustProfile(Base):
user: Mapped["User"] = relationship("User", back_populates="trust_profile", uselist=False)
+class OneTimePassword(Base):
+ """
+ 6-jegyű OTP (One Time Password) ideiglenes tárolása.
+ Típusok: 'restore' (fiók visszaállítás), 'company_claim' (cég átvétel)
+ Lejárati idő: 15 perc.
+ """
+ __tablename__ = "one_time_passwords"
+ __table_args__ = {"schema": "identity"}
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
+ email: Mapped[str] = mapped_column(String, index=True, nullable=False)
+ code: Mapped[str] = mapped_column(String(6), nullable=False)
+ otp_type: Mapped[str] = mapped_column(String(30), nullable=False) # 'restore' or 'company_claim'
+ extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"), nullable=True)
+ is_used: Mapped[bool] = mapped_column(Boolean, default=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+
class Device(Base):
""" Ismert eszközök (Device Fingerprinting) a Device-Hub architektúrához. """
__tablename__ = "devices"
diff --git a/backend/app/models/marketplace/organization.py b/backend/app/models/marketplace/organization.py
index 5440b5e..a32129a 100755
--- a/backend/app/models/marketplace/organization.py
+++ b/backend/app/models/marketplace/organization.py
@@ -93,6 +93,8 @@ class Organization(Base):
default=OrgType.individual
)
+ is_affiliate_partner: Mapped[bool] = mapped_column(Boolean, server_default=text('false'), default=False)
+
status: Mapped[str] = mapped_column(String(30), default="pending_verification")
# Soft delete: is_active=False és is_deleted=True esetén a cég 'törölt'
diff --git a/backend/app/models/vehicle/__init__.py b/backend/app/models/vehicle/__init__.py
index 4aea437..798f3c4 100644
--- a/backend/app/models/vehicle/__init__.py
+++ b/backend/app/models/vehicle/__init__.py
@@ -10,7 +10,6 @@ from .vehicle_definitions import (
from .vehicle import (
CostCategory,
VehicleCost,
- VehicleOdometerState,
VehicleUserRating,
GbCatalogDiscovery,
)
@@ -28,6 +27,7 @@ from .asset import (
ExchangeRate,
CatalogDiscovery,
VehicleOwnership,
+ OdometerReading,
)
from .history import AuditLog, LogSeverity
@@ -42,7 +42,6 @@ __all__ = [
"ModelFeatureMap",
"CostCategory",
"VehicleCost",
- "VehicleOdometerState",
"VehicleUserRating",
"GbCatalogDiscovery",
"ExternalReferenceLibrary",
@@ -62,4 +61,5 @@ __all__ = [
# --- EXPORT LISTA KIEGÉSZÍTÉSE ---
"MotorcycleSpecs",
"BodyTypeDictionary",
+ "OdometerReading",
]
\ No newline at end of file
diff --git a/backend/app/models/vehicle/asset.py b/backend/app/models/vehicle/asset.py
index cb9e5b6..d2b4297 100644
--- a/backend/app/models/vehicle/asset.py
+++ b/backend/app/models/vehicle/asset.py
@@ -4,7 +4,7 @@ import uuid
import enum
from datetime import datetime
from typing import List, Optional, TYPE_CHECKING
-from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, BigInteger, Integer, Float
+from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
from sqlalchemy.sql import func
@@ -69,7 +69,13 @@ class RoofTypeEnum(str, enum.Enum):
class Asset(Base):
""" A fizikai eszköz (Digital Twin) - Minden adat itt fut össze. """
__tablename__ = "assets"
- __table_args__ = {"schema": "vehicle"}
+ __table_args__ = (
+ CheckConstraint(
+ "vin IS NOT NULL OR license_plate IS NOT NULL",
+ name="ck_asset_vin_or_plate_required"
+ ),
+ {"schema": "vehicle"}
+ )
# === IDENTIFICATION ===
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -147,6 +153,7 @@ class Asset(Base):
assignments: Mapped[List["AssetAssignment"]] = relationship("AssetAssignment", back_populates="asset")
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="asset")
service_requests: Mapped[List["ServiceRequest"]] = relationship("ServiceRequest", back_populates="asset", cascade="all, delete-orphan")
+ odometer_readings: Mapped[List["OdometerReading"]] = relationship("OdometerReading", back_populates="asset", order_by="OdometerReading.recorded_at.desc()")
# --- COMPUTED PROPERTIES (for Pydantic schema compatibility) ---
@property
@@ -249,6 +256,7 @@ class AssetCost(Base):
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
+ document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
@@ -342,6 +350,23 @@ class AssetTelemetry(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="telemetry")
+class OdometerReading(Base):
+ """ Km óra állás rögzítése (időbeli nyomonkövetés). """
+ __tablename__ = "odometer_readings"
+ __table_args__ = {"schema": "vehicle"}
+
+ id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True)
+ reading: Mapped[int] = mapped_column(Integer, nullable=False) # km óra állás
+ recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
+ source: Mapped[str] = mapped_column(String(30), default="manual") # manual, api, telemetry
+ cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
+
+ # Relationships
+ asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings")
+ cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
+
+
class AssetAssignment(Base):
""" Eszköz-Szervezet összerendelés. """
__tablename__ = "asset_assignments"
diff --git a/backend/app/models/vehicle/vehicle.py b/backend/app/models/vehicle/vehicle.py
index 40221ce..a6689cf 100644
--- a/backend/app/models/vehicle/vehicle.py
+++ b/backend/app/models/vehicle/vehicle.py
@@ -6,11 +6,11 @@ TCO (Total Cost of Ownership) alapmodelljei a 'vehicle' sémában.
"""
from __future__ import annotations
+import uuid
from datetime import datetime
from typing import Optional
-import uuid
-from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Numeric, UniqueConstraint, Float, CheckConstraint
-from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Text, Numeric, UniqueConstraint, CheckConstraint
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -22,7 +22,7 @@ class CostCategory(Base):
Rendszerkategóriák (is_system=True) nem törölhetők, csak felhasználói kategóriák.
"""
__tablename__ = "cost_categories"
- __table_args__ = {"schema": "vehicle"}
+ __table_args__ = {"schema": "vehicle", "extend_existing": True}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
parent_id: Mapped[Optional[int]] = mapped_column(
@@ -36,6 +36,7 @@ class CostCategory(Base):
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'")
+ min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'") # free, premium, enterprise
accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
@@ -108,36 +109,6 @@ class VehicleCost(Base):
return f"VehicleCost(id={self.id}, vehicle_id={self.vehicle_id}, amount={self.amount} {self.currency})"
-class VehicleOdometerState(Base):
- """
- Jármű kilométeróra állapotának és becslésének tárolása.
- Adminisztrátor által paraméterezhető algoritmusokkal működik.
- """
- __tablename__ = "vehicle_odometer_states"
- __table_args__ = {"schema": "vehicle"}
-
- vehicle_id: Mapped[int] = mapped_column(
- Integer,
- ForeignKey("vehicle.vehicle_model_definitions.id", ondelete="CASCADE"),
- primary_key=True,
- nullable=False
- )
- last_recorded_odometer: Mapped[int] = mapped_column(Integer, nullable=False)
- last_recorded_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
- daily_avg_distance: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
- estimated_current_odometer: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
- confidence_score: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
- manual_override_avg: Mapped[Optional[float]] = mapped_column(Numeric(10, 2), nullable=True)
- created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
-
- # Kapcsolat a jármű definícióval
- vehicle: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="odometer_state")
-
- def __repr__(self) -> str:
- return f"VehicleOdometerState(vehicle_id={self.vehicle_id}, estimated={self.estimated_current_odometer}, confidence={self.confidence_score})"
-
-
class VehicleUserRating(Base):
"""
Jármű értékelési rendszer - User -> Vehicle kapcsolat.
@@ -155,7 +126,7 @@ class VehicleUserRating(Base):
)
id: Mapped[uuid.UUID] = mapped_column(
- UUID(as_uuid=True),
+ PG_UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
server_default=func.gen_random_uuid()
diff --git a/backend/app/models/vehicle/vehicle_definitions.py b/backend/app/models/vehicle/vehicle_definitions.py
index c443729..af93953 100755
--- a/backend/app/models/vehicle/vehicle_definitions.py
+++ b/backend/app/models/vehicle/vehicle_definitions.py
@@ -12,7 +12,7 @@ from app.database import Base
# Típus ellenőrzés a körkörös importok elkerülésére
if TYPE_CHECKING:
from .asset import AssetCatalog
- from .vehicle import VehicleCost, VehicleOdometerState, VehicleUserRating
+ from .vehicle import VehicleCost, VehicleUserRating
class VehicleType(Base):
""" Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """
@@ -106,6 +106,10 @@ class VehicleModelDefinition(Base):
transmission_type: Mapped[Optional[str]] = mapped_column(String(50))
drive_type: Mapped[Optional[str]] = mapped_column(String(50))
+ # --- SZERVIZ INTERVALLUMOK ---
+ service_interval_km: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+ service_interval_months: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
+
# --- ÉLETCIKLUS ---
year_from: Mapped[int] = mapped_column(Integer, index=True, server_default=text("0")) # EZT IS BELETETTÜK A KULCSBA
year_to: Mapped[Optional[int]] = mapped_column(Integer, index=True)
@@ -139,7 +143,6 @@ class VehicleModelDefinition(Base):
ratings: Mapped[List["VehicleUserRating"]] = relationship("VehicleUserRating", back_populates="vehicle", cascade="all, delete-orphan")
costs: Mapped[List["VehicleCost"]] = relationship("VehicleCost", back_populates="vehicle", cascade="all, delete-orphan")
- odometer_state: Mapped[Optional["VehicleOdometerState"]] = relationship("VehicleOdometerState", back_populates="vehicle", uselist=False, cascade="all, delete-orphan")
class ModelFeatureMap(Base):
diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py
index 13bf07b..028e854 100755
--- a/backend/app/schemas/asset.py
+++ b/backend/app/schemas/asset.py
@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset.py
-from pydantic import BaseModel, ConfigDict, Field, validator
+from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
@@ -113,8 +113,30 @@ class AssetResponse(BaseModel):
class AssetCreate(BaseModel):
""" Jármű létrehozásához szükséges adatok - Thick Digital Twin támogatással. """
# === CORE IDENTIFICATION (Required for status determination) ===
- license_plate: str = Field(..., min_length=2, max_length=20, description="Rendszám")
+ license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám (opcionális)")
+
+ # ── Pre-validator: convert empty strings to None for optional string fields ──
+ @validator('license_plate', 'vin', 'trim_level',
+ 'cylinder_layout', 'transmission_type', 'drive_type',
+ 'euro_classification', 'roof_type', 'audio_system_type',
+ 'data_status',
+ pre=True, always=True)
+ def empty_str_to_none(cls, v):
+ """Convert empty strings to None so Pydantic doesn't reject them with min_length."""
+ if v is not None and isinstance(v, str) and v.strip() == '':
+ return None
+ return v
+
+ # ── Root validator: ensure at least one of vin or license_plate is provided ──
+ @root_validator(skip_on_failure=True)
+ def validate_vin_or_plate(cls, values):
+ """Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám."""
+ license_plate = values.get('license_plate')
+ vin = values.get('vin')
+ if not license_plate and not vin:
+ raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
+ return values
# === CLASSIFICATION (Optional, but affects status) ===
brand: Optional[str] = Field(None, max_length=100, description="Márka (ha nincs catalog_id)")
@@ -190,6 +212,35 @@ class AssetUpdate(BaseModel):
# === CORE IDENTIFICATION ===
license_plate: Optional[str] = Field(None, min_length=2, max_length=20, description="Rendszám")
vin: Optional[str] = Field(None, min_length=1, max_length=50, description="VIN szám")
+
+ # ── Pre-validator: convert empty strings to None for optional string fields ──
+ @validator('license_plate', 'vin', 'trim_level',
+ 'cylinder_layout', 'transmission_type', 'drive_type',
+ 'euro_classification', 'roof_type', 'audio_system_type',
+ pre=True, always=True)
+ def empty_str_to_none(cls, v):
+ """Convert empty strings to None so Pydantic doesn't reject them with min_length."""
+ if v is not None and isinstance(v, str) and v.strip() == '':
+ return None
+ return v
+
+ # ── Model validator: ensure at least one of vin or license_plate is provided ──
+ @model_validator(mode='after')
+ def validate_vin_or_plate(self):
+ """Update esetén: ha mindkét mezőt None-ra állítják explicit, az nem engedélyezett.
+
+ Működés:
+ - Ha egyik mező sincs a payload-ban (üres update) → engedélyezve (nincs változás)
+ - Ha mindkettő None explicit → hiba (nem lehet mindkettőt elvenni)
+ - Ha legalább az egyik meg van adva → OK
+ """
+ # model_fields_set tartalmazza az explicit megadott mezőket
+ if 'license_plate' not in self.model_fields_set and 'vin' not in self.model_fields_set:
+ return self
+
+ if not self.license_plate and not self.vin:
+ raise ValueError('Legalább egy azonosítót meg kell adni: rendszám VAGY VIN szám.')
+ return self
# === CLASSIFICATION ===
brand: Optional[str] = Field(None, max_length=100, description="Márka")
diff --git a/backend/app/schemas/asset_cost.py b/backend/app/schemas/asset_cost.py
index 025ebdd..759b8a2 100755
--- a/backend/app/schemas/asset_cost.py
+++ b/backend/app/schemas/asset_cost.py
@@ -6,31 +6,30 @@ from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
- cost_type: str # fuel, service, tax, insurance
- amount_local: Decimal
- currency_local: str = "HUF"
- net_amount_local: Optional[Decimal] = None
- vat_rate: Optional[Decimal] = Field(default=27.0)
-
+ """Base schema — matches the AssetCost SQLAlchemy model fields."""
+ amount_net: Decimal
+ currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now)
- mileage_at_cost: Optional[int] = None
- description: Optional[str] = None
- data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
+ invoice_number: Optional[str] = None
+ data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták
+ category_id: int # FK a CostCategory táblához
+ mileage_at_cost: Optional[int] = None # Stored inside data JSONB
+ description: Optional[str] = None # Stored inside data JSONB
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
- category_id: int # FK a CostCategory táblához
class AssetCostResponse(AssetCostBase):
+ """Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID
asset_id: UUID
organization_id: int
- driver_id: Optional[int] = None
- # Pénzügyi dúsítás (Backend számolja)
- amount_eur: Optional[Decimal] = None
- exchange_rate_used: Optional[Decimal] = None
- created_at: datetime
+ # Derived / enriched fields (not in DB model directly)
+ category_name: Optional[str] = None # Resolved from CostCategory relationship
+ category_code: Optional[str] = None # Resolved from CostCategory relationship
+ description: Optional[str] = None # Extracted from data['description']
+ mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
model_config = ConfigDict(from_attributes=True)
\ No newline at end of file
diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py
index ce51a72..7d67eb1 100755
--- a/backend/app/schemas/organization.py
+++ b/backend/app/schemas/organization.py
@@ -40,11 +40,26 @@ class CorpOnboardResponse(BaseModel):
class OrganizationUpdate(BaseModel):
"""PATCH séma a szervezet adatainak részleges frissítéséhez.
- Támogatja a visual_settings JSONB mező biztonságos részleges frissítését."""
+ Támogatja a visual_settings JSONB mező biztonságos részleges frissítését,
+ valamint a cégadatok (név, adószám, cím) szerkesztését."""
visual_settings: Optional[dict] = None
display_name: Optional[str] = None
default_currency: Optional[str] = None
language: Optional[str] = None
+ # ── Cégadatok (Company details) ──
+ name: Optional[str] = None
+ full_name: Optional[str] = None
+ tax_number: Optional[str] = None
+ # ── Cím adatok (Address fields) ──
+ address_zip: Optional[str] = None
+ address_city: Optional[str] = None
+ address_street_name: Optional[str] = None
+ address_street_type: Optional[str] = None
+ address_house_number: Optional[str] = None
+ address_stairwell: Optional[str] = None
+ address_floor: Optional[str] = None
+ address_door: Optional[str] = None
+ address_hrsz: Optional[str] = None
class OrganizationResponse(BaseModel):
diff --git a/backend/app/schemas/service_catalog.py b/backend/app/schemas/service_catalog.py
new file mode 100644
index 0000000..42550e6
--- /dev/null
+++ b/backend/app/schemas/service_catalog.py
@@ -0,0 +1,33 @@
+# /opt/docker/dev/service_finder/backend/app/schemas/service_catalog.py
+from pydantic import BaseModel, ConfigDict, Field
+from typing import Optional
+from datetime import datetime
+
+
+class ServiceCatalogCreate(BaseModel):
+ """Schema új szolgáltatás katalógus bejegyzés létrehozásához."""
+ service_code: str = Field(..., min_length=3, max_length=50, description="Egyedi szolgáltatás kód (pl. 'SRV_DATA_EXPORT')")
+ name: str = Field(..., min_length=2, max_length=200, description="Szolgáltatás megjelenített neve")
+ description: Optional[str] = Field(None, max_length=1000, description="Részletes leírás")
+ credit_cost: int = Field(0, ge=0, description="Alapértelmezett kredit költség")
+ is_active: bool = Field(True, description="Aktív-e a szolgáltatás")
+
+
+class ServiceCatalogUpdate(BaseModel):
+ """Schema meglévő szolgáltatás katalógus bejegyzés módosításához."""
+ name: Optional[str] = Field(None, min_length=2, max_length=200)
+ description: Optional[str] = Field(None, max_length=1000)
+ credit_cost: Optional[int] = Field(None, ge=0)
+ is_active: Optional[bool] = None
+
+
+class ServiceCatalogResponse(BaseModel):
+ """Schema szolgáltatás katalógus bejegyzés visszaadásához."""
+ id: int
+ service_code: str
+ name: str
+ description: Optional[str] = None
+ credit_cost: int
+ is_active: bool
+
+ model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/schemas/subscription.py b/backend/app/schemas/subscription.py
new file mode 100644
index 0000000..6b87ca4
--- /dev/null
+++ b/backend/app/schemas/subscription.py
@@ -0,0 +1,143 @@
+"""
+📦 Subscription Tier Pydantic Schemas
+
+A subscription_tiers JSONB rules mezőjének erősen tipizált Pydantic modelljei.
+A rules struktúra a seed_packages.py-ban definiált formátumot követi:
+
+ {
+ "type": "private" | "corporate",
+ "pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
+ "allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
+ "entitlements": ["SRV_..."],
+ "affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
+ }
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+from typing import Any, Dict, List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
+
+
+class PricingModel(BaseModel):
+ """Árazási információk egy előfizetési csomaghoz."""
+ monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)")
+ yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)")
+ currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
+ credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)")
+
+
+class AllowancesModel(BaseModel):
+ """Korlátok és keretek a csomagban."""
+ max_vehicles: int = Field(..., ge=0, description="Maximális járműszám")
+ max_garages: int = Field(..., ge=0, description="Maximális garázsok száma")
+ monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
+
+
+class LifecycleModel(BaseModel):
+ """Életciklus állapotok egy előfizetési csomaghoz."""
+ is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)")
+ available_until: Optional[datetime] = Field(default=None, description="Meddig érhető el a csomag (None = nincs lejárat)")
+
+
+class AffiliateModel(BaseModel):
+ """Partnerprogram (affiliate) beállítások."""
+ commission_rate_percent: int = Field(..., ge=0, le=100, description="Jutalék mértéke százalékban")
+ referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma")
+
+
+class SubscriptionRulesModel(BaseModel):
+ """
+ A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
+
+ Fields:
+ type: "private" (magánszemély) vagy "corporate" (vállalati)
+ display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes")
+ pricing: Árazási adatok (havi/éves díj, pénznem)
+ allowances: Korlátok (max jármű, garázs, kredit)
+ entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
+ affiliate: Partnerprogram beállítások
+ lifecycle: Életciklus állapotok (opcionális, pl. is_public)
+ """
+ type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
+ display_name: Optional[str] = Field(
+ default=None,
+ description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
+ )
+ pricing: Optional[PricingModel] = Field(
+ default=None,
+ description="Árazási adatok (havi/éves díj, pénznem). Opcionális a hiányos seed adatok miatt."
+ )
+ allowances: Optional[AllowancesModel] = Field(
+ default=None,
+ description="Korlátok (max jármű, garázs, kredit). Opcionális a hiányos seed adatok miatt."
+ )
+ entitlements: List[str] = Field(default_factory=list, description="Jogosultságok (entitlement) listája")
+ affiliate: Optional[AffiliateModel] = Field(
+ default=None,
+ description="Partnerprogram beállítások. Opcionális a hiányos seed adatok miatt."
+ )
+ lifecycle: Optional[LifecycleModel] = Field(
+ default=None,
+ description="Életciklus állapotok (is_public, available_until)"
+ )
+
+ @field_validator("type")
+ @classmethod
+ def validate_type(cls, v: str) -> str:
+ if v.lower() not in ("private", "corporate"):
+ raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.")
+ return v.lower()
+
+
+# ── API Response / Request modellek ───────────────────────────────────────────
+
+
+class SubscriptionTierResponse(BaseModel):
+ """Teljes SubscriptionTier rekord visszaadása (GET /, GET /{id})."""
+ id: int
+ name: str
+ rules: SubscriptionRulesModel
+ is_custom: bool
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class SubscriptionTierCreate(BaseModel):
+ """Új előfizetési csomag létrehozása (POST /)."""
+ name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
+ rules: SubscriptionRulesModel
+ is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
+
+ @field_validator("name")
+ @classmethod
+ def validate_name(cls, v: str) -> str:
+ if not v.strip():
+ raise ValueError("A csomag neve nem lehet üres.")
+ return v.strip().lower()
+
+
+class SubscriptionTierUpdate(BaseModel):
+ """Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
+ name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
+ rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
+ is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
+
+ @field_validator("name")
+ @classmethod
+ def validate_name(cls, v: Optional[str]) -> Optional[str]:
+ if v is not None and not v.strip():
+ raise ValueError("A csomag neve nem lehet üres.")
+ return v.strip().lower() if v else v
+
+
+class SubscriptionTierListResponse(BaseModel):
+ """Csomagok listázásának válaszformátuma (GET /)."""
+ total: int
+ tiers: List[SubscriptionTierResponse]
diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py
index 65cc99f..09f533c 100755
--- a/backend/app/schemas/user.py
+++ b/backend/app/schemas/user.py
@@ -64,6 +64,8 @@ class UserResponse(UserBase):
preferred_language: str = "hu"
# Beágyazott személyes adatok (Person + Address)
person: Optional[PersonResponse] = None
+ # Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
+ is_last_admin: bool = False
model_config = ConfigDict(from_attributes=True)
diff --git a/backend/app/scripts/cleanup_user.py b/backend/app/scripts/cleanup_user.py
new file mode 100644
index 0000000..db5e089
--- /dev/null
+++ b/backend/app/scripts/cleanup_user.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""
+Hard Delete Cleanup Script — Cascade törlés User és kapcsolódó rekordokra.
+
+Használat:
+ docker compose exec sf_api python3 /app/backend/app/scripts/cleanup_user.py --email %test%
+
+Az --email paraméter részleges egyezést is támogat (SQL LIKE, pl. %test%@%.
+A szkript tranzakcióban törli:
+ - User rekordot
+ - Person rekordot (ha a User volt az egyetlen kapcsolódó)
+ - Organization rekordot (ha a User volt az egyetlen owner)
+ - Session tokeneket, SocialAccount-okat, Wallet-eket, stb.
+"""
+
+import argparse
+import logging
+import sys
+
+from sqlalchemy import select, delete, text
+from sqlalchemy.orm import joinedload
+
+# Projekt importok
+from app.database import async_session_factory
+from app.models.identity import User, Person, UserRole
+from app.models.identity.identity import (
+ SocialAccount, Wallet, ActiveVoucher, VerificationToken,
+ UserDeviceLink, UserTrustProfile, OneTimePassword,
+)
+from app.models.gamification.gamification import UserStats
+from app.models.marketplace.service_request import ServiceRequest
+from app.models.social import ServiceReview
+from app.models.vehicle.asset import VehicleOwnership
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+)
+logger = logging.getLogger("cleanup_user")
+
+
+async def cleanup_user(email_pattern: str) -> None:
+ """
+ Megkeresi a megadott email mintára illeszkedő usereket,
+ és cascade hard delete-et hajt végre rajtuk.
+ """
+ async with async_session_factory() as db:
+ # 1. Keresés részleges email egyezéssel
+ stmt = (
+ select(User)
+ .where(User.email.ilike(email_pattern))
+ .options(
+ joinedload(User.person),
+ joinedload(User.stats),
+ joinedload(User.wallet),
+ joinedload(User.trust_profile),
+ joinedload(User.ownership_history),
+ joinedload(User.social_accounts),
+ joinedload(User.service_reviews),
+ joinedload(User.service_requests),
+ )
+ )
+ result = await db.execute(stmt)
+ users = result.unique().scalars().all()
+
+ if not users:
+ logger.warning("❌ Nem található user a '%s' mintára.", email_pattern)
+ return
+
+ logger.info("🔍 Talált userek (%d db):", len(users))
+ for u in users:
+ logger.info(" - ID=%d, Email=%s, Role=%s", u.id, u.email, u.role.value if hasattr(u.role, 'value') else u.role)
+
+ # 2. Tranzakció indítása
+ async with db.begin():
+ for user in users:
+ user_id = user.id
+ email = user.email
+ logger.info("🗑️ Törlés: User ID=%d, Email=%s", user_id, email)
+
+ # --- Cascade törlés ---
+
+ # UserDeviceLink
+ await db.execute(
+ delete(UserDeviceLink).where(UserDeviceLink.user_id == user_id)
+ )
+ logger.info(" ✓ UserDeviceLink törölve")
+
+ # VerificationToken
+ await db.execute(
+ delete(VerificationToken).where(VerificationToken.user_id == user_id)
+ )
+ logger.info(" ✓ VerificationToken törölve")
+
+ # OneTimePassword
+ await db.execute(
+ delete(OneTimePassword).where(OneTimePassword.email == email)
+ )
+ logger.info(" ✓ OneTimePassword törölve")
+
+ # SocialAccount (cascade all, delete-orphan)
+ await db.execute(
+ delete(SocialAccount).where(SocialAccount.user_id == user_id)
+ )
+ logger.info(" ✓ SocialAccount törölve")
+
+ # UserTrustProfile (cascade all, delete-orphan)
+ await db.execute(
+ delete(UserTrustProfile).where(UserTrustProfile.user_id == user_id)
+ )
+ logger.info(" ✓ UserTrustProfile törölve")
+
+ # UserStats (gamification)
+ await db.execute(
+ delete(UserStats).where(UserStats.user_id == user_id)
+ )
+ logger.info(" ✓ UserStats törölve")
+
+ # Wallet + ActiveVoucher (cascade)
+ wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
+ wallet_result = await db.execute(wallet_stmt)
+ wallet = wallet_result.scalar_one_or_none()
+ if wallet:
+ await db.execute(
+ delete(ActiveVoucher).where(ActiveVoucher.wallet_id == wallet.id)
+ )
+ await db.execute(
+ delete(Wallet).where(Wallet.id == wallet.id)
+ )
+ logger.info(" ✓ Wallet + ActiveVoucher törölve")
+
+ # ServiceReview
+ await db.execute(
+ delete(ServiceReview).where(ServiceReview.user_id == user_id)
+ )
+ logger.info(" ✓ ServiceReview törölve")
+
+ # ServiceRequest
+ await db.execute(
+ delete(ServiceRequest).where(ServiceRequest.user_id == user_id)
+ )
+ logger.info(" ✓ ServiceRequest törölve")
+
+ # VehicleOwnership
+ await db.execute(
+ delete(VehicleOwnership).where(VehicleOwnership.user_id == user_id)
+ )
+ logger.info(" ✓ VehicleOwnership törölve")
+
+ # Person rekord kezelése
+ person = user.person
+ if person:
+ # Ellenőrizzük, hogy más User nem hivatkozik-e erre a Person-ra
+ other_users_stmt = (
+ select(User)
+ .where(User.person_id == person.id, User.id != user_id)
+ .limit(1)
+ )
+ other_result = await db.execute(other_users_stmt)
+ other_user = other_result.scalar_one_or_none()
+
+ if not other_user:
+ # Nincs más User ehhez a Person-hoz -> törölhető
+ await db.execute(
+ delete(Person).where(Person.id == person.id)
+ )
+ logger.info(" ✓ Person ID=%d törölve (egyedüli kapcsolat)", person.id)
+ else:
+ logger.info(
+ " ⚠️ Person ID=%d MEGTARTVA (más User is hivatkozik rá: ID=%d)",
+ person.id, other_user.id
+ )
+
+ # Organization kezelése (ha a User volt az egyetlen owner)
+ from app.models.identity.organization import Organization
+ org_stmt = (
+ select(Organization)
+ .where(Organization.owner_id == user_id)
+ )
+ org_result = await db.execute(org_stmt)
+ owned_orgs = org_result.scalars().all()
+
+ for org in owned_orgs:
+ # Ellenőrizzük, hogy más User nem owner-e
+ other_owner_stmt = (
+ select(User)
+ .where(User.id != user_id, User.id.isnot(None))
+ .limit(1)
+ )
+ # Egyszerűbb: csak töröljük, ha nincs más owner
+ # (a gyakorlatban az Organization-nak lehet több tagja)
+ from app.models.identity.organization import OrganizationMember
+ member_stmt = (
+ select(OrganizationMember)
+ .where(
+ OrganizationMember.organization_id == org.id,
+ OrganizationMember.user_id != user_id,
+ )
+ .limit(1)
+ )
+ member_result = await db.execute(member_stmt)
+ other_member = member_result.scalar_one_or_none()
+
+ if not other_member:
+ await db.execute(
+ delete(Organization).where(Organization.id == org.id)
+ )
+ logger.info(" ✓ Organization ID=%d törölve (egyedüli owner)", org.id)
+ else:
+ logger.info(
+ " ⚠️ Organization ID=%d MEGTARTVA (van más tag)",
+ org.id
+ )
+
+ # VÉGÜL: Maga a User rekord törlése
+ await db.execute(
+ delete(User).where(User.id == user_id)
+ )
+ logger.info(" ✅ User ID=%d (%s) törölve.", user_id, email)
+
+ logger.info("🎉 Tranzakció sikeresen végrehajtva.")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Hard Delete Cleanup — User és kapcsolódó rekordok cascade törlése."
+ )
+ parser.add_argument(
+ "--email",
+ required=True,
+ help="Email minta (SQL LIKE, pl. %%test%% vagy teszt%%).",
+ )
+ args = parser.parse_args()
+
+ import asyncio
+ asyncio.run(cleanup_user(args.email))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/app/scripts/migrate_subscriptions.py b/backend/app/scripts/migrate_subscriptions.py
new file mode 100644
index 0000000..b60592f
--- /dev/null
+++ b/backend/app/scripts/migrate_subscriptions.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python3
+"""
+📦 Nagy Előfizetés Migráció (Subscription Migration Script)
+
+Cél:
+ A régi, denormalizált subscription_plan mezők (identity.users, fleet.organizations)
+ átvezetése a modern, normalizált subscription_tier + org_subscriptions / user_subscriptions
+ architektúrába.
+
+Feladatok:
+ A) Létrehozza a corp_free_v1 csomagot, ha még nem létezik (0 áras, alap korlátokkal).
+ B) Létrehozza a user_subscriptions táblát (finance.user_subscriptions), ha még nem létezik.
+ C) Adatkonverzió: a meglévő subscription_plan string-eket átalakítja tier_id hivatkozásokká.
+
+Használat:
+ docker compose exec sf_api python3 /app/backend/app/scripts/migrate_subscriptions.py
+
+Architektúra:
+ - system.subscription_tiers: A csomagdefiníciók (SubscriptionTier modell)
+ - finance.org_subscriptions: Szervezeti előfizetések (OrganizationSubscription modell)
+ - finance.user_subscriptions: Felhasználói előfizetések (UserSubscription modell - ÚJ!)
+"""
+
+import asyncio
+import logging
+from datetime import datetime, timezone
+from sqlalchemy import text, select, Integer, String, Boolean, DateTime, ForeignKey
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.database import AsyncSessionLocal, Base
+from app.models.core_logic import SubscriptionTier, OrganizationSubscription
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+)
+logger = logging.getLogger("Migrate-Subscriptions")
+
+
+# ---------------------------------------------------------------------------
+# Tiers név -> ID mapping (feltöltés után)
+# ---------------------------------------------------------------------------
+TIER_MAP: dict[str, int] = {}
+
+
+# ---------------------------------------------------------------------------
+# A) corp_free_v1 definíció
+# ---------------------------------------------------------------------------
+CORP_FREE_V1 = {
+ "name": "corp_free_v1",
+ "rules": {
+ "type": "corporate",
+ "display_name": "Céges Ingyenes",
+ "pricing": {
+ "monthly_price": 0.0,
+ "yearly_price": 0.0,
+ "currency": "EUR",
+ "credit_price": 0,
+ },
+ "allowances": {
+ "max_vehicles": 3,
+ "max_garages": 1,
+ "monthly_free_credits": 0,
+ },
+ "entitlements": [],
+ "affiliate": {
+ "commission_rate_percent": 0,
+ "referral_bonus_credits": 0,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+}
+
+
+# ---------------------------------------------------------------------------
+# Régi plan string -> tier név mapping
+# ---------------------------------------------------------------------------
+PLAN_TO_TIER: dict[str, str] = {
+ "FREE": "private_free_v1",
+ "free": "private_free_v1",
+ "PRO": "private_pro_v1",
+ "PREMIUM": "corp_premium_v1",
+ "ENTERPRISE": "corp_vip_v1",
+}
+
+
+# ---------------------------------------------------------------------------
+# Segédfüggvények
+# ---------------------------------------------------------------------------
+async def _ensure_corp_free_v1(db) -> None:
+ """A) Létrehozza a corp_free_v1 csomagot, ha még nem létezik."""
+ result = await db.execute(
+ select(SubscriptionTier).where(SubscriptionTier.name == "corp_free_v1")
+ )
+ existing = result.scalar_one_or_none()
+
+ if existing:
+ logger.info("✅ corp_free_v1 már létezik (ID=%d). Kihagyva.", existing.id)
+ TIER_MAP["corp_free_v1"] = existing.id
+ return
+
+ tier = SubscriptionTier(
+ name=CORP_FREE_V1["name"],
+ rules=CORP_FREE_V1["rules"],
+ is_custom=CORP_FREE_V1["is_custom"],
+ )
+ db.add(tier)
+ await db.commit()
+ await db.refresh(tier)
+ TIER_MAP["corp_free_v1"] = tier.id
+ logger.info("✅ corp_free_v1 létrehozva (ID=%d).", tier.id)
+
+
+async def _load_tier_map(db) -> None:
+ """Betölti az összes tier nevét és ID-ját a TIER_MAP-ba."""
+ result = await db.execute(select(SubscriptionTier))
+ tiers = result.scalars().all()
+ for t in tiers:
+ TIER_MAP[t.name] = t.id
+ logger.info("📋 Betöltött tier-ek: %s", {n: i for n, i in TIER_MAP.items()})
+
+
+def _resolve_tier_id(plan: str | None) -> int | None:
+ """Egy régi plan string-ből kikeresi a megfelelő tier ID-t."""
+ if not plan:
+ return None
+ tier_name = PLAN_TO_TIER.get(plan)
+ if not tier_name:
+ logger.warning("⚠️ Ismeretlen plan: '%s' -> private_free_v1 lesz.", plan)
+ tier_name = "private_free_v1"
+ return TIER_MAP.get(tier_name)
+
+
+# ---------------------------------------------------------------------------
+# B) UserSubscription tábla létrehozása (ha nem létezik)
+# ---------------------------------------------------------------------------
+async def _ensure_user_subscriptions_table(db) -> None:
+ """Létrehozza a finance.user_subscriptions táblát, ha még nem létezik."""
+ result = await db.execute(
+ text(
+ "SELECT EXISTS (SELECT FROM information_schema.tables "
+ "WHERE table_schema = 'finance' AND table_name = 'user_subscriptions')"
+ )
+ )
+ exists = result.scalar()
+
+ if exists:
+ logger.info("✅ finance.user_subscriptions tábla már létezik.")
+ return
+
+ logger.info("🛠️ Létrehozom a finance.user_subscriptions táblát...")
+ await db.execute(
+ text("""
+ CREATE TABLE finance.user_subscriptions (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
+ tier_id INTEGER NOT NULL REFERENCES system.subscription_tiers(id),
+ valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ valid_until TIMESTAMPTZ,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ
+ )
+ """)
+ )
+ # Index a gyors lekérdezéshez
+ await db.execute(
+ text("""
+ CREATE INDEX idx_user_subscriptions_user_id
+ ON finance.user_subscriptions(user_id)
+ """)
+ )
+ await db.commit()
+ logger.info("✅ finance.user_subscriptions tábla létrehozva.")
+
+
+# ---------------------------------------------------------------------------
+# C) Adatkonverzió
+# ---------------------------------------------------------------------------
+async def _migrate_organizations(db) -> None:
+ """Szervezetek subscription_plan mezőjének átvezetése org_subscriptions-ba."""
+ logger.info("=" * 60)
+ logger.info("Szervezetek migrálása...")
+ logger.info("=" * 60)
+
+ # 1. Lekérdezzük az összes szervezetet, aminek nincs még org_subscription-e
+ result = await db.execute(
+ text("""
+ SELECT o.id, o.subscription_plan
+ FROM fleet.organizations o
+ WHERE o.is_deleted = FALSE
+ AND NOT EXISTS (
+ SELECT 1 FROM finance.org_subscriptions os
+ WHERE os.org_id = o.id
+ )
+ ORDER BY o.id
+ """)
+ )
+ orgs = result.all()
+ logger.info("📊 %d szervezet vár migrálásra.", len(orgs))
+
+ inserted = 0
+ skipped = 0
+ for org in orgs:
+ tier_id = _resolve_tier_id(org.subscription_plan)
+ if tier_id is None:
+ logger.warning("⚠️ Org ID=%d: plan='%s' nem feloldható, kihagyva.", org.id, org.subscription_plan)
+ skipped += 1
+ continue
+
+ sub = OrganizationSubscription(
+ org_id=org.id,
+ tier_id=tier_id,
+ valid_from=datetime.now(timezone.utc),
+ valid_until=None,
+ is_active=True,
+ )
+ db.add(sub)
+ inserted += 1
+
+ await db.commit()
+ logger.info("✅ %d org_subscription beszúrva. (%d kihagyva)", inserted, skipped)
+
+
+async def _migrate_users(db) -> None:
+ """Felhasználók subscription_plan mezőjének átvezetése user_subscriptions-ba."""
+ logger.info("=" * 60)
+ logger.info("Felhasználók migrálása...")
+ logger.info("=" * 60)
+
+ # 1. Lekérdezzük az összes aktív felhasználót, aminek nincs még user_subscription-e
+ result = await db.execute(
+ text("""
+ SELECT u.id, u.subscription_plan
+ FROM identity.users u
+ WHERE u.is_deleted = FALSE
+ AND NOT EXISTS (
+ SELECT 1 FROM finance.user_subscriptions us
+ WHERE us.user_id = u.id
+ )
+ ORDER BY u.id
+ """)
+ )
+ users = result.all()
+ logger.info("📊 %d felhasználó vár migrálásra.", len(users))
+
+ inserted = 0
+ skipped = 0
+ for user in users:
+ tier_id = _resolve_tier_id(user.subscription_plan)
+ if tier_id is None:
+ logger.warning("⚠️ User ID=%d: plan='%s' nem feloldható, kihagyva.", user.id, user.subscription_plan)
+ skipped += 1
+ continue
+
+ await db.execute(
+ text("""
+ INSERT INTO finance.user_subscriptions
+ (user_id, tier_id, valid_from, is_active, created_at)
+ VALUES
+ (:uid, :tid, NOW(), TRUE, NOW())
+ """),
+ {"uid": user.id, "tid": tier_id},
+ )
+ inserted += 1
+
+ await db.commit()
+ logger.info("✅ %d user_subscription beszúrva. (%d kihagyva)", inserted, skipped)
+
+
+async def _verify_migration(db) -> None:
+ """Ellenőrzi a migráció sikerességét."""
+ logger.info("=" * 60)
+ logger.info("🔍 Ellenőrzés")
+ logger.info("=" * 60)
+
+ # Tiers
+ result = await db.execute(
+ text("SELECT id, name FROM system.subscription_tiers ORDER BY id")
+ )
+ tiers = result.all()
+ logger.info("📋 Tiers (%d db):", len(tiers))
+ for t in tiers:
+ logger.info(" ID=%d name=%s", t.id, t.name)
+
+ # Org subscriptions
+ result = await db.execute(
+ text("""
+ SELECT COUNT(*) as cnt,
+ COALESCE(t.name, 'N/A') as tier_name
+ FROM finance.org_subscriptions os
+ LEFT JOIN system.subscription_tiers t ON t.id = os.tier_id
+ GROUP BY t.name
+ ORDER BY t.name
+ """)
+ )
+ rows = result.all()
+ total_org = sum(r.cnt for r in rows)
+ logger.info("📊 Org subscriptions (%d db):", total_org)
+ for r in rows:
+ logger.info(" %s: %d", r.tier_name, r.cnt)
+
+ # User subscriptions
+ result = await db.execute(
+ text("""
+ SELECT COUNT(*) as cnt,
+ COALESCE(t.name, 'N/A') as tier_name
+ FROM finance.user_subscriptions us
+ LEFT JOIN system.subscription_tiers t ON t.id = us.tier_id
+ GROUP BY t.name
+ ORDER BY t.name
+ """)
+ )
+ rows = result.all()
+ total_user = sum(r.cnt for r in rows)
+ logger.info("📊 User subscriptions (%d db):", total_user)
+ for r in rows:
+ logger.info(" %s: %d", r.tier_name, r.cnt)
+
+ logger.info("=" * 60)
+ logger.info("✅ Migráció befejeződött!")
+ logger.info(" Összes org subscription: %d", total_org)
+ logger.info(" Összes user subscription: %d", total_user)
+ logger.info("=" * 60)
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+async def migrate():
+ logger.info("=" * 60)
+ logger.info("📦 Nagy Előfizetés Migráció")
+ logger.info("=" * 60)
+
+ async with AsyncSessionLocal() as db:
+ # A) corp_free_v1 létrehozása
+ logger.info("\n[1/5] corp_free_v1 csomag ellenőrzése...")
+ await _ensure_corp_free_v1(db)
+
+ # Tier map betöltése
+ logger.info("\n[2/5] Tier-ek betöltése...")
+ await _load_tier_map(db)
+
+ # B) user_subscriptions tábla létrehozása
+ logger.info("\n[3/5] user_subscriptions tábla ellenőrzése...")
+ await _ensure_user_subscriptions_table(db)
+
+ # C) Adatkonverzió
+ logger.info("\n[4/5] Adatkonverzió...")
+ await _migrate_organizations(db)
+ await _migrate_users(db)
+
+ # Ellenőrzés
+ logger.info("\n[5/5] Ellenőrzés...")
+ await _verify_migration(db)
+
+
+if __name__ == "__main__":
+ asyncio.run(migrate())
diff --git a/backend/app/scripts/seed_packages.py b/backend/app/scripts/seed_packages.py
new file mode 100644
index 0000000..74d26d8
--- /dev/null
+++ b/backend/app/scripts/seed_packages.py
@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+"""
+Seed szkript a jövőbiztos SaaS csomag-architektúra 1. fázisához.
+Létrehozza a 6 alap subscription tier-t a system.subscription_tiers táblában.
+
+Használat:
+ docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py
+
+Architektúra:
+ A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
+ {
+ "type": "private" | "corporate",
+ "pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
+ "allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
+ "entitlements": ["SRV_..."],
+ "affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
+ }
+"""
+
+import asyncio
+import json
+import logging
+from sqlalchemy import select, delete
+from app.database import AsyncSessionLocal
+from app.models.core_logic import SubscriptionTier
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(message)s",
+)
+logger = logging.getLogger("Seed-Packages")
+
+
+# ---------------------------------------------------------------------------
+# Csomagdefiníciók
+# ---------------------------------------------------------------------------
+PACKAGES = [
+ {
+ "name": "private_free_v1",
+ "rules": {
+ "type": "private",
+ "display_name": "Privát Ingyenes",
+ "pricing": {
+ "monthly_price": 0.0,
+ "yearly_price": 0.0,
+ "currency": "EUR",
+ "credit_price": 0,
+ },
+ "allowances": {
+ "max_vehicles": 1,
+ "max_garages": 1,
+ "monthly_free_credits": 0,
+ },
+ "entitlements": [],
+ "affiliate": {
+ "commission_rate_percent": 0,
+ "referral_bonus_credits": 0,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+ },
+ {
+ "name": "private_pro_v1",
+ "rules": {
+ "type": "private",
+ "display_name": "Privát Pro",
+ "pricing": {
+ "monthly_price": 4.99,
+ "yearly_price": 49.99,
+ "currency": "EUR",
+ "credit_price": 500,
+ },
+ "allowances": {
+ "max_vehicles": 3,
+ "max_garages": 1,
+ "monthly_free_credits": 50,
+ },
+ "entitlements": ["SRV_DATA_EXPORT"],
+ "affiliate": {
+ "commission_rate_percent": 10,
+ "referral_bonus_credits": 25,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+ },
+ {
+ "name": "private_vip_v1",
+ "rules": {
+ "type": "private",
+ "display_name": "Privát VIP",
+ "pricing": {
+ "monthly_price": 9.99,
+ "yearly_price": 99.99,
+ "currency": "EUR",
+ "credit_price": 1200,
+ },
+ "allowances": {
+ "max_vehicles": 10,
+ "max_garages": 1,
+ "monthly_free_credits": 150,
+ },
+ "entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
+ "affiliate": {
+ "commission_rate_percent": 15,
+ "referral_bonus_credits": 50,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+ },
+ {
+ "name": "corp_premium_v1",
+ "rules": {
+ "type": "corporate",
+ "display_name": "Céges Prémium",
+ "pricing": {
+ "monthly_price": 29.99,
+ "yearly_price": 299.99,
+ "currency": "EUR",
+ "credit_price": 3000,
+ },
+ "allowances": {
+ "max_vehicles": 20,
+ "max_garages": 3,
+ "monthly_free_credits": 300,
+ },
+ "entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
+ "affiliate": {
+ "commission_rate_percent": 15,
+ "referral_bonus_credits": 100,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+ },
+ {
+ "name": "corp_premium_plus_v1",
+ "rules": {
+ "type": "corporate",
+ "display_name": "Céges Prémium Plus",
+ "pricing": {
+ "monthly_price": 59.99,
+ "yearly_price": 599.99,
+ "currency": "EUR",
+ "credit_price": 6000,
+ },
+ "allowances": {
+ "max_vehicles": 50,
+ "max_garages": 10,
+ "monthly_free_credits": 800,
+ },
+ "entitlements": [
+ "SRV_DATA_EXPORT",
+ "SRV_AI_UPLOAD",
+ "SRV_ACCOUNTING_SYNC",
+ ],
+ "affiliate": {
+ "commission_rate_percent": 20,
+ "referral_bonus_credits": 250,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+ },
+ {
+ "name": "corp_vip_v1",
+ "rules": {
+ "type": "corporate",
+ "display_name": "Céges VIP",
+ "pricing": {
+ "monthly_price": 149.99,
+ "yearly_price": 1499.99,
+ "currency": "EUR",
+ "credit_price": 15000,
+ },
+ "allowances": {
+ "max_vehicles": 200,
+ "max_garages": 50,
+ "monthly_free_credits": 2500,
+ },
+ "entitlements": [
+ "SRV_DATA_EXPORT",
+ "SRV_AI_UPLOAD",
+ "SRV_ACCOUNTING_SYNC",
+ "SRV_API_ACCESS",
+ ],
+ "affiliate": {
+ "commission_rate_percent": 25,
+ "referral_bonus_credits": 500,
+ },
+ "lifecycle": {
+ "is_public": True,
+ },
+ },
+ "is_custom": False,
+ },
+]
+
+
+async def seed():
+ logger.info("=" * 60)
+ logger.info("Seed Packages - Advanced Subscription Tiers (Phase 1)")
+ logger.info("=" * 60)
+
+ async with AsyncSessionLocal() as db:
+ # 1. Töröljük a meglévő rekordokat (tiszta lap)
+ logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
+ await db.execute(delete(SubscriptionTier))
+ await db.commit()
+ logger.info("✅ Meglévő rekordok törölve.")
+
+ # 2. Beszúrjuk a 6 új csomagot
+ inserted_count = 0
+ for pkg in PACKAGES:
+ tier = SubscriptionTier(
+ name=pkg["name"],
+ rules=pkg["rules"],
+ is_custom=pkg["is_custom"],
+ )
+ db.add(tier)
+ inserted_count += 1
+ logger.info(f" ➕ Hozzáadva: {pkg['name']}")
+
+ await db.commit()
+ logger.info(f"✅ {inserted_count} csomag sikeresen beszúrva.")
+
+ # 3. Visszaigazolás: listázzuk ki az összes rekordot
+ logger.info("-" * 60)
+ logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
+ logger.info("-" * 60)
+
+ result = await db.execute(
+ select(SubscriptionTier).order_by(SubscriptionTier.id)
+ )
+ tiers = result.scalars().all()
+
+ for tier in tiers:
+ rules_json = json.dumps(tier.rules, indent=2, ensure_ascii=False)
+ logger.info(f"\n ID: {tier.id}")
+ logger.info(f" Name: {tier.name}")
+ logger.info(f" Is Custom: {tier.is_custom}")
+ logger.info(f" Rules:\n{rules_json}")
+ logger.info(" " + "-" * 40)
+
+ logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
+
+ # 4. JSONB példa kiírása
+ if tiers:
+ sample = tiers[0]
+ logger.info("=" * 60)
+ logger.info("JSONB PÉLDA (private_free_v1):")
+ logger.info("=" * 60)
+ logger.info(json.dumps(sample.rules, indent=2, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+ asyncio.run(seed())
diff --git a/backend/app/scripts/seed_services.py b/backend/app/scripts/seed_services.py
new file mode 100644
index 0000000..67e373d
--- /dev/null
+++ b/backend/app/scripts/seed_services.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+Service Finder - ServiceCatalog Seed Script
+=============================================
+Aszinkron seed szkript a system.service_catalog tábla feltöltéséhez.
+Törli a meglévő adatokat, majd beszúrja a 3 alap szolgáltatást.
+
+Használat:
+ docker compose exec sf_api python3 /app/backend/app/scripts/seed_services.py
+"""
+
+import asyncio
+import sys
+from pathlib import Path
+
+# Projekt gyökér hozzáadása a Python path-hoz
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+
+from sqlalchemy import text
+from app.db.session import AsyncSessionLocal
+from app.models.core_logic import ServiceCatalog
+
+
+SERVICES = [
+ {
+ "service_code": "SRV_DATA_EXPORT",
+ "name": "Adat Export",
+ "description": "PDF és Excel adatexport funkciók",
+ "credit_cost": 0,
+ },
+ {
+ "service_code": "SRV_AI_UPLOAD",
+ "name": "AI Számlafeldolgozás",
+ "description": "Dokumentumok és számlák AI alapú beolvasása",
+ "credit_cost": 50,
+ },
+ {
+ "service_code": "SRV_DIGITAL_BOOK",
+ "name": "Digitális Szervizkönyv",
+ "description": "Idegen járművek szerviztörténetének lekérdezése",
+ "credit_cost": 150,
+ },
+]
+
+
+async def seed_services():
+ print("=" * 70)
+ print(" ServiceCatalog Seed Script")
+ print("=" * 70)
+
+ async with AsyncSessionLocal() as db:
+ # 1. Tábla tartalmának törlése
+ print("\n🧹 Törlés: system.service_catalog összes rekordja...")
+ await db.execute(text("DELETE FROM system.service_catalog"))
+ await db.commit()
+ print(" ✅ Törölve.")
+
+ # 2. Szolgáltatások beszúrása
+ print("\n📦 Szolgáltatások beszúrása:")
+ for svc in SERVICES:
+ stmt = text("""
+ INSERT INTO system.service_catalog (service_code, name, description, credit_cost, is_active)
+ VALUES (:service_code, :name, :description, :credit_cost, TRUE)
+ """)
+ await db.execute(stmt, svc)
+ print(f" ✅ {svc['service_code']:20s} | {svc['name']:25s} | {svc['credit_cost']:>4} kredit")
+
+ await db.commit()
+ print("\n ✅ Minden rekord beszúrva.")
+
+ # 3. Lekérdezés - bizonyíték
+ print("\n" + "=" * 70)
+ print(" 📋 LEKÉRDEZÉS - system.service_catalog tartalma")
+ print("=" * 70)
+ result = await db.execute(
+ text("SELECT id, service_code, name, description, credit_cost, is_active FROM system.service_catalog ORDER BY id")
+ )
+ rows = result.fetchall()
+
+ if not rows:
+ print("\n❌ NINCSENEK REKORDOK a táblában!")
+ else:
+ print(f"\n{'ID':>4} | {'Kód':20s} | {'Név':25s} | {'Leírás':45s} | {'Ár':>5s} | {'Aktív':>5s}")
+ print("-" * 110)
+ for row in rows:
+ print(f"{row.id:>4} | {row.service_code:20s} | {row.name:25s} | {(row.description or ''):45s} | {row.credit_cost:>5} | {'Igen' if row.is_active else 'Nem'}")
+
+ print("\n" + "=" * 70)
+ print(f" Összesen: {len(rows)} rekord a system.service_catalog táblában.")
+ print("=" * 70)
+
+
+if __name__ == "__main__":
+ asyncio.run(seed_services())
diff --git a/backend/app/scripts/unified_db_sync.py b/backend/app/scripts/unified_db_sync.py
index 66b3ad2..6afeb4a 100644
--- a/backend/app/scripts/unified_db_sync.py
+++ b/backend/app/scripts/unified_db_sync.py
@@ -18,15 +18,7 @@ from app.database import Base
from app.core.config import settings
def dynamic_import_models():
- models_dir = Path(__file__).parent.parent / "models"
- for py_file in models_dir.glob("*.py"):
- if py_file.name == "__init__.py": continue
- module_name = f"app.models.{py_file.stem}"
- try:
- importlib.import_module(module_name)
- print(f"✅ Imported {module_name}")
- except Exception as e:
- print(f"⚠️ Could not import {module_name}: {e}")
+ """Modellek betöltése a Metadata feltöltéséhez. Egyszerű import az __init__.py-n keresztül."""
import app.models
print(f"📦 Total tables in Base.metadata: {len(Base.metadata.tables)}")
diff --git a/backend/app/services/asset_service.py b/backend/app/services/asset_service.py
index f4d4803..657c8a7 100755
--- a/backend/app/services/asset_service.py
+++ b/backend/app/services/asset_service.py
@@ -228,12 +228,34 @@ class AssetService:
# --- MATCHER INTEGRÁCIÓ (HA NINCS CATALOG_ID DE VANNAK ADATOK) ---
if not new_asset.catalog_id and new_asset.brand and new_asset.model:
from app.services.asset_matcher_service import AssetMatcherService
+ from app.models.vehicle.asset import AssetCatalog
print(f"DEBUG: Matcher args: make={new_asset.brand}, model={new_asset.model}, year={new_asset.year_of_manufacture}")
matched_result = await AssetMatcherService.find_best_match(db, new_asset)
if matched_result:
matched_def, conf = matched_result
if matched_def:
- new_asset.catalog_id = matched_def.id
+ # FIX: Asset.catalog_id FK references vehicle.vehicle_catalog.id (AssetCatalog),
+ # NOT vehicle.vehicle_model_definitions.id (VehicleModelDefinition).
+ # Look up or create the AssetCatalog entry for this definition.
+ catalog_stmt = select(AssetCatalog).where(
+ AssetCatalog.master_definition_id == matched_def.id
+ ).limit(1)
+ catalog_entry = (await db.execute(catalog_stmt)).scalar_one_or_none()
+ if not catalog_entry:
+ # Create a new AssetCatalog entry from the matched definition
+ catalog_entry = AssetCatalog(
+ master_definition_id=matched_def.id,
+ make=matched_def.make,
+ model=matched_def.marketing_name,
+ year_from=matched_def.year_from,
+ year_to=matched_def.year_to,
+ fuel_type=matched_def.fuel_type,
+ power_kw=matched_def.power_kw if matched_def.power_kw and matched_def.power_kw > 0 else None,
+ engine_capacity=matched_def.engine_capacity if matched_def.engine_capacity and matched_def.engine_capacity > 0 else None,
+ )
+ db.add(catalog_entry)
+ await db.flush()
+ new_asset.catalog_id = catalog_entry.id
await AssetMatcherService.enrich_asset_from_definition(db, new_asset, matched_def, conf)
await db.flush()
@@ -348,9 +370,15 @@ class AssetService:
@staticmethod
async def get_models(db: AsyncSession, make: str, vehicle_class: str = None) -> List[str]:
- """Get all distinct models for a given make, optionally filtered by vehicle_class."""
+ """Get all distinct models for a given make, optionally filtered by vehicle_class.
+
+ Fuzzy matching: removes spaces and lowercases both the query and stored values
+ so that 'CB 1000' matches 'CB1000'.
+ """
stmt = select(distinct(VehicleModelDefinition.marketing_name)).where(
- VehicleModelDefinition.make == make
+ func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(
+ f'%{make.replace(" ", "").lower()}%'
+ )
)
if vehicle_class:
stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
@@ -384,6 +412,78 @@ class AssetService:
engines = result.scalars().all()
return engines
+ # --- CASCADING CATALOG METHODS (for wizard autocomplete) ---
+ @staticmethod
+ async def get_catalog_brands(db: AsyncSession, vehicle_class: Optional[str] = None, query: Optional[str] = None) -> List[str]:
+ """Get all distinct brands (makes) from vehicle_model_definitions, with optional class filter and search query.
+
+ Fuzzy matching: removes spaces and lowercases both the query and stored values
+ so that 'BMW' matches 'bmw' and 'CB 1000' matches 'CB1000'.
+ """
+ stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make)
+ if vehicle_class:
+ stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
+ if query:
+ clean_query = query.replace(' ', '').lower()
+ stmt = stmt.where(
+ func.replace(func.lower(VehicleModelDefinition.make), ' ', '').ilike(f'%{clean_query}%')
+ )
+ result = await db.execute(stmt)
+ brands = result.scalars().all()
+ return [b for b in brands if b]
+
+ @staticmethod
+ async def get_catalog_models(db: AsyncSession, brand: str, vehicle_class: Optional[str] = None) -> List[Dict[str, Any]]:
+ """Get all distinct models for a given brand, returning id and marketing_name."""
+ stmt = select(
+ VehicleModelDefinition.id,
+ VehicleModelDefinition.marketing_name
+ ).where(
+ VehicleModelDefinition.make == brand
+ ).distinct(VehicleModelDefinition.marketing_name).order_by(VehicleModelDefinition.marketing_name)
+ if vehicle_class:
+ stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class)
+ result = await db.execute(stmt)
+ rows = result.all()
+ return [{"id": r.id, "name": r.marketing_name} for r in rows if r.marketing_name]
+
+ @staticmethod
+ async def get_catalog_years(db: AsyncSession, brand: str, model: str) -> List[int]:
+ """Get all distinct years for a given brand and model."""
+ stmt = select(distinct(VehicleModelDefinition.year_from)).where(
+ VehicleModelDefinition.make == brand,
+ VehicleModelDefinition.marketing_name == model,
+ VehicleModelDefinition.year_from > 0
+ ).order_by(VehicleModelDefinition.year_from.desc())
+ result = await db.execute(stmt)
+ years = result.scalars().all()
+ return [y for y in years if y]
+
+ @staticmethod
+ async def get_catalog_trims(db: AsyncSession, brand: str, model: str, year: Optional[int] = None) -> List[Dict[str, Any]]:
+ """Get all trim/variant details for a given brand, model, and optional year."""
+ stmt = select(VehicleModelDefinition).where(
+ VehicleModelDefinition.make == brand,
+ VehicleModelDefinition.marketing_name == model
+ )
+ if year and year > 0:
+ stmt = stmt.where(VehicleModelDefinition.year_from == year)
+ stmt = stmt.order_by(VehicleModelDefinition.trim_level, VehicleModelDefinition.engine_capacity)
+ result = await db.execute(stmt)
+ rows = result.scalars().all()
+ return [
+ {
+ "id": r.id,
+ "trim_level": r.trim_level or '',
+ "engine_capacity": r.engine_capacity or 0,
+ "power_kw": r.power_kw or 0,
+ "fuel_type": r.fuel_type,
+ "year_from": r.year_from,
+ "body_type": r.body_type or '',
+ }
+ for r in rows
+ ]
+
@staticmethod
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
@@ -596,15 +696,13 @@ class AssetService:
user_badge = UserBadge(
user_id=user_id,
badge_id=badge.id,
- awarded_at=datetime.utcnow()
+ earned_at=datetime.utcnow()
)
db.add(user_badge)
await db.flush()
- logger = logging.getLogger(__name__)
logger.info(f"Awarded 'First Car' badge to user {user_id}")
except Exception as e:
- logger = logging.getLogger(__name__)
logger.error(f"Error awarding first car badge: {e}")
# Don't raise the error - badge awarding shouldn't break vehicle creation
\ No newline at end of file
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index dc84e84..4777ea1 100755
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -9,8 +9,8 @@ from sqlalchemy.orm import joinedload
from fastapi.encoders import jsonable_encoder
from fastapi import HTTPException, status
-from app.models.identity import User, Person, UserRole, VerificationToken, Wallet
-from app.models import UserStats
+from app.models.identity import User, Person, UserRole, VerificationToken, Wallet, OneTimePassword
+from app.models import UserStats, LogSeverity
from app.models.marketplace import Organization, OrganizationMember, OrgType, Branch
from app.schemas.auth import UserLiteRegister, UserKYCComplete
from app.core.security import get_password_hash, verify_password, generate_secure_slug
@@ -146,7 +146,7 @@ class AuthService:
# Sentinel Audit Log
await security_service.log_event(
db, user_id=new_user.id, action="USER_REGISTER_LITE",
- severity="INFO", target_type="User", target_id=str(new_user.id),
+ severity=LogSeverity.info, target_type="User", target_id=str(new_user.id),
new_data={"email": user_in.email}
)
@@ -285,8 +285,8 @@ class AuthService:
if kyc_in.region_code:
user.region_code = kyc_in.region_code
- # Gamification XP jóváírás
- await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION")
+ # Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
+ await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
# P2P Referral XP a meghívónak (ha van referred_by_id)
if user.referred_by_id:
@@ -295,7 +295,8 @@ class AuthService:
db,
user_id=user.referred_by_id,
amount=int(p2p_xp),
- reason="P2P_REFERRAL_SUCCESS"
+ reason="P2P_REFERRAL_SUCCESS",
+ commit=False
)
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
@@ -341,11 +342,15 @@ class AuthService:
user.is_active = True
- # Activate person
- person_stmt = select(Person).where(Person.user_id == user.id)
- person = (await db.execute(person_stmt)).scalar_one_or_none()
- if person:
- person.is_active = True
+ # Activate person - FIX: Use User.person_id instead of Person.user_id
+ # Person.user_id is a separate FK that may be NULL
+ if user.person_id:
+ person_stmt = select(Person).where(Person.id == user.person_id)
+ person = (await db.execute(person_stmt)).scalar_one_or_none()
+ if person:
+ person.is_active = True
+ # Also set the back-reference so Person.user_id is consistent
+ person.user_id = user.id
await db.commit()
return user # Return the activated User object
@@ -379,20 +384,23 @@ class AuthService:
))
link = f"{settings.FRONTEND_BASE_URL}/reset-password?token={token_val}"
- logger.info(f"Email küldés indítása (password reset) ide: {email}...")
- result = await email_manager.send_email(
- recipient=email, template_key="pwd_reset",
- variables={"link": link}, lang=user.preferred_language
- )
- logger.info(f"Email küldés eredménye (password reset): {result}")
- # Ha az email küldés hibát dobott, ne titkoljuk el
- if result.get("status") == "error":
- logger.error(f"Password reset email FAILED for {email}: {result.get('message')}")
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Email küldési hiba: {result.get('message')}"
+ # Mock email sending: log the reset link to console instead of sending real email
+ logger.info(f"*** MOCK EMAIL *** Password reset for {email}")
+ logger.info(f"*** MOCK EMAIL *** Reset link: {link}")
+ logger.info(f"*** MOCK EMAIL *** Token: {token_val}")
+
+ # Try to send real email, but don't crash if SMTP is unavailable
+ try:
+ result = await email_manager.send_email(
+ recipient=email, template_key="pwd_reset",
+ variables={"link": link}, lang=user.preferred_language
)
+ logger.info(f"Email küldés eredménye (password reset): {result}")
+ if result and result.get("status") == "error":
+ logger.warning(f"Password reset email sending failed (non-critical): {result.get('message')}")
+ except Exception as e:
+ logger.warning(f"Password reset email sending exception (non-critical): {e}")
await db.commit()
return "success"
@@ -424,7 +432,177 @@ class AuthService:
return True
except HTTPException:
raise
- except: return False
+ except Exception as e:
+ logger.error(f"Password reset error: {e}")
+ return False
+
+ @staticmethod
+ async def _generate_otp_code() -> str:
+ """Generate a random 6-digit OTP code."""
+ import random
+ return f"{random.randint(0, 999999):06d}"
+
+ @staticmethod
+ async def request_account_restore(db: AsyncSession, email: str):
+ """
+ 30 napos fiókvisszaállítás: OTP kérés.
+ Megkeresi a törölt felhasználót az eredeti email alapján.
+ Ha 30 napon belül van a törlés, generál 6-jegyű kódot.
+ Ha 30 napon TÚL van, jelzi, hogy új regisztráció szükséges.
+ """
+ try:
+ # Search for deleted user by checking if email exists in deleted prefix pattern
+ stmt = select(User).where(
+ User.email.like(f"%{email}"),
+ User.is_deleted == True
+ )
+ result = await db.execute(stmt)
+ user = result.scalar_one_or_none()
+
+ if not user:
+ return {"status": "not_found", "message": "Nem található törölt fiók ezzel az email címmel."}
+
+ # Check if within 30-day window
+ if user.deleted_at:
+ days_since_deletion = (datetime.now(timezone.utc) - user.deleted_at).days
+ else:
+ days_since_deletion = 0
+
+ if days_since_deletion > 30:
+ return {
+ "status": "expired",
+ "message": "A fiók véglegesen megsemmisült. Kérjük, regisztráljon újra ugyanazzal az email címmel.",
+ "can_re_register": True,
+ "original_email": email
+ }
+
+ # Generate 6-digit OTP code
+ code = await AuthService._generate_otp_code()
+ expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
+
+ # Store OTP
+ otp = OneTimePassword(
+ email=email,
+ code=code,
+ otp_type="restore",
+ extra_data={"user_id": user.id},
+ expires_at=expires_at
+ )
+ db.add(otp)
+ await db.commit()
+
+ # Mock email sending: log the OTP code to console instead of sending real email
+ logger.info(f"*** MOCK EMAIL *** OTP Code for {email}: {code}")
+ logger.info(f"*** MOCK EMAIL *** Expires at: {expires_at.isoformat()}")
+
+ return {
+ "status": "otp_sent",
+ "message": "Egy 6-jegyű visszaállítási kódot küldtünk az email címére.",
+ "expires_at": expires_at.isoformat()
+ }
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Account restore request error for {email}: {e}")
+ return {"status": "error", "message": "Hiba történt a visszaállítási kód generálása során."}
+
+ @staticmethod
+ async def verify_account_restore(db: AsyncSession, email: str, code: str):
+ """
+ OTP kód ellenőrzése és fiók visszaállítása.
+ """
+ now = datetime.now(timezone.utc)
+
+ # Find valid OTP
+ stmt = select(OneTimePassword).where(
+ OneTimePassword.email == email,
+ OneTimePassword.code == code,
+ OneTimePassword.otp_type == "restore",
+ OneTimePassword.is_used == False,
+ OneTimePassword.expires_at > now
+ ).order_by(OneTimePassword.created_at.desc()).limit(1)
+
+ result = await db.execute(stmt)
+ otp = result.scalar_one_or_none()
+
+ if not otp:
+ return {"status": "invalid", "message": "Érvénytelen vagy lejárt kód."}
+
+ # Mark OTP as used
+ otp.is_used = True
+
+ # Find the deleted user
+ user_id = otp.extra_data.get("user_id") if otp.extra_data else None
+ if not user_id:
+ return {"status": "error", "message": "Hiba a fiók azonosításában."}
+
+ stmt = select(User).where(User.id == user_id)
+ result = await db.execute(stmt)
+ user = result.scalar_one_or_none()
+
+ if not user:
+ return {"status": "error", "message": "Felhasználó nem található."}
+
+ # Restore the account
+ user.is_deleted = False
+ user.is_active = True
+ user.deleted_at = None
+
+ # Restore original email (remove deleted_ prefix)
+ # The email format is: deleted_{id}_{timestamp}_{original_email}
+ if user.email.startswith("deleted_"):
+ parts = user.email.split("_", 3)
+ if len(parts) == 4:
+ user.email = parts[3]
+
+ await security_service.log_event(
+ db, user_id=user.id, action="USER_ACCOUNT_RESTORE",
+ severity=LogSeverity.info, target_type="User", target_id=str(user.id),
+ new_data={"restored_via": "otp", "email": user.email}
+ )
+
+ await db.commit()
+ return {"status": "success", "message": "Fiók sikeresen visszaállítva."}
+
+ @staticmethod
+ async def check_is_last_admin(db: AsyncSession, user_id: int) -> bool:
+ """
+ Ellenőrzi, hogy a felhasználó az egyetlen aktív admin bármelyik szervezetében.
+ Visszaadja: is_last_admin: bool
+ """
+ try:
+ from app.models.marketplace.organization import OrganizationMember, OrgUserRole
+
+ # Find organizations where user is ADMIN or OWNER
+ stmt = select(OrganizationMember.organization_id).where(
+ OrganizationMember.user_id == user_id,
+ OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER])
+ )
+ result = await db.execute(stmt)
+ org_ids = [row[0] for row in result.all()]
+
+ if not org_ids:
+ return False
+
+ # For each org, check if there are other active admins/owners
+ for org_id in org_ids:
+ stmt = select(OrganizationMember).where(
+ OrganizationMember.organization_id == org_id,
+ OrganizationMember.role.in_([OrgUserRole.ADMIN, OrgUserRole.OWNER]),
+ OrganizationMember.user_id != user_id
+ )
+ result = await db.execute(stmt)
+ other_admins = result.scalars().all()
+
+ if not other_admins:
+ # This org has no other admin - user is last admin here
+ return True
+
+ return False
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"check_is_last_admin error: {e}")
+ return False
@staticmethod
async def resend_verification(db: AsyncSession, email: str):
@@ -479,20 +657,44 @@ class AuthService:
@staticmethod
async def soft_delete_user(db: AsyncSession, user_id: int, reason: str, actor_id: int):
- """ Felhasználó törlése (Soft-Delete) auditálással. """
+ """
+ Felhasználó törlése (Soft-Delete) auditálással, Person-preserving logikával.
+
+ Üzleti szabályok:
+ 1. User: is_active=False, is_deleted=True, deleted_at=utcnow
+ 2. Email átírás: deleted_{id}_{timestamp}_{original_email}
+ 3. Person rekordot NEM bántja (megőrizzük a jövőbeli újraaktiváláshoz)
+ 4. Wallet és Gamification adatok megőrzése (inaktív user miatt freeze)
+ 5. Refresh token-ek érvénytelenítése (JWT-alapú, így a deleted_at middleware ellenőrzi)
+ """
stmt = select(User).where(User.id == user_id)
user = (await db.execute(stmt)).scalar_one_or_none()
- if not user or user.is_deleted: return False
+ if not user or user.is_deleted:
+ return False
+ # 1. Anonymize email with timestamp
old_email = user.email
- user.email = f"deleted_{user.id}_{datetime.now().strftime('%Y%m%d')}_{old_email}"
+ timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
+ user.email = f"deleted_{user.id}_{timestamp}_{old_email}"
+
+ # 2. Set soft-delete flags
user.is_deleted = True
user.is_active = False
-
+ user.deleted_at = datetime.now(timezone.utc)
+
+ # 3. CRITICAL: Do NOT touch Person record
+ # Person.is_active stays as-is, Person data stays intact
+ # This allows future re-activation with the same identity
+
+ # 4. Security audit log with deleted_at info
await security_service.log_event(
db, user_id=actor_id, action="USER_SOFT_DELETE",
- severity="WARNING", target_type="User", target_id=str(user_id),
- new_data={"reason": reason}
+ severity=LogSeverity.warning, target_type="User", target_id=str(user_id),
+ new_data={
+ "reason": reason,
+ "deleted_at": str(user.deleted_at),
+ "person_preserved": True
+ }
)
await db.commit()
- return True
\ No newline at end of file
+ return True
diff --git a/backend/app/services/gamification_service.py b/backend/app/services/gamification_service.py
index 67a0be8..ebf39b1 100755
--- a/backend/app/services/gamification_service.py
+++ b/backend/app/services/gamification_service.py
@@ -18,28 +18,40 @@ class GamificationService:
"""
@staticmethod
- async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0):
- """ Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz. """
+ async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
+ """ Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
+
+ Args:
+ commit: If True (default), commits the transaction internally.
+ Set to False when called from within a larger transaction
+ (e.g., from AuthService.complete_kyc).
+ """
service = GamificationService()
- return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason)
+ return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
async def process_activity(
- self,
- db: AsyncSession,
- user_id: int,
- xp_amount: int,
- social_amount: int,
- reason: str,
- is_penalty: bool = False
+ self,
+ db: AsyncSession,
+ user_id: int,
+ xp_amount: int,
+ social_amount: int,
+ reason: str,
+ is_penalty: bool = False,
+ commit: bool = True
):
- """ A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """
+ """ A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
+
+ Args:
+ commit: If True (default), commits the transaction internally.
+ Set to False when called from within a larger transaction.
+ """
try:
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
# Minden paraméter az admin felületről módosítható JSON-ként
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
"xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": {
- "recovery_rate": 0.5,
+ "recovery_rate": 0.5,
"thresholds": {"level_1": 100, "level_2": 500, "level_3": 1000},
"multipliers": {"L0": 1.0, "L1": 0.5, "L2": 0.1, "L3": 0.0}
},
@@ -98,12 +110,15 @@ class GamificationService:
# 7. NAPLÓZÁS
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason))
- await db.commit()
- await db.refresh(stats)
+ # Only commit if caller wants us to manage the transaction
+ if commit:
+ await db.commit()
+ await db.refresh(stats)
return stats
except Exception as e:
- await db.rollback()
+ if commit:
+ await db.rollback()
logger.error(f"Gamification Error for user {user_id}: {e}")
raise e
diff --git a/backend/app/services/maintenance_service.py b/backend/app/services/maintenance_service.py
index 67cfb7e..a35142c 100755
--- a/backend/app/services/maintenance_service.py
+++ b/backend/app/services/maintenance_service.py
@@ -66,8 +66,10 @@ class MaintenanceService:
alerts_generated = 0
for asset, telemetry in result.all():
# A Robot 3 által feltöltött gyári adat (pl. 15.000 km)
- interval = asset.factory_data.get("oil_change_km") if asset.factory_data else None
- last_service_km = asset.factory_data.get("last_service_km", 0)
+ # A factory_data az AssetCatalog (vehicle_catalog) táblában van
+ catalog_data = asset.catalog.factory_data if asset.catalog else {}
+ interval = catalog_data.get("oil_change_km") if catalog_data else None
+ last_service_km = catalog_data.get("last_service_km", 0)
if interval and telemetry.current_mileage:
next_service_due = last_service_km + interval
diff --git a/backend/app/services/odometer_service.py b/backend/app/services/odometer_service.py
index 9ca4917..e6a1d26 100644
--- a/backend/app/services/odometer_service.py
+++ b/backend/app/services/odometer_service.py
@@ -1,213 +1,15 @@
"""
-Smart Odometer Service - Adminisztrátor által paraméterezhető kilométeróra becslés.
+[DEPRECATED] Smart Odometer Service - VehicleOdometerState model removed.
-A szolgáltatás a járművek kilométeróra állását becsüli a költségbejegyzések alapján,
-figyelembe véve a rendszerparamétereket (ODOMETER_MIN_DAYS_FOR_AVG, ODOMETER_CONFIDENCE_THRESHOLD).
-Ha az admin beállított manuális átlagot (manual_override_avg), akkor azt használja.
+This service is deprecated. The VehicleOdometerState model has been removed
+from the schema. Odometer tracking is now handled directly via the
+OdometerReading table (audit trail) and Asset.current_mileage field.
+
+The predictive odometer state estimation logic has been removed to keep
+the entity model clean. Use OdometerReading records directly for
+mileage audit trail purposes.
"""
-from datetime import datetime, timedelta
-from typing import Optional, Tuple
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select, func, and_
-from sqlalchemy.orm import selectinload
-
-from app.models.vehicle import VehicleOdometerState, VehicleCost
-from app.models.system import SystemParameter
-from app.models import VehicleModelDefinition
-
-
-class OdometerService:
- """Kilométeróra becslési szolgáltatás adminisztrációs kontrollal."""
-
- @staticmethod
- async def get_system_param(db: AsyncSession, key: str, default_value):
- """Rendszerparaméter lekérése a system.system_parameters táblából."""
- stmt = select(SystemParameter).where(
- SystemParameter.key == key,
- SystemParameter.scope_level == 'global',
- SystemParameter.is_active == True
- )
- result = await db.execute(stmt)
- param = result.scalars().first()
- if param and 'value' in param.value:
- return param.value['value']
- return default_value
-
- @staticmethod
- async def update_vehicle_stats(db: AsyncSession, vehicle_id: int) -> Optional[VehicleOdometerState]:
- """
- Frissíti a jármű kilométeróra statisztikáit.
-
- Algoritmus:
- 1. Ha van manual_override_avg, használja azt.
- 2. Különben számol átlagot a vehicle.costs bejegyzésekből.
- 3. Figyelembe veszi az ODOMETER_MIN_DAYS_FOR_AVG paramétert.
- 4. Kiszámolja a confidence_score-t a minták száma alapján.
- 5. Frissíti vagy létrehozza a VehicleOdometerState rekordot.
- """
- # Rendszerparaméterek lekérése
- min_days = await OdometerService.get_system_param(db, 'ODOMETER_MIN_DAYS_FOR_AVG', 7)
- confidence_threshold = await OdometerService.get_system_param(db, 'ODOMETER_CONFIDENCE_THRESHOLD', 0.5)
-
- # Meglévő állapot lekérése
- stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
- result = await db.execute(stmt)
- odometer_state = result.scalars().first()
-
- # Költségbejegyzések lekérése dátum és odometer szerint rendezve
- cost_stmt = select(VehicleCost).where(
- VehicleCost.vehicle_id == vehicle_id,
- VehicleCost.odometer.isnot(None)
- ).order_by(VehicleCost.date.asc())
-
- cost_result = await db.execute(cost_stmt)
- costs = cost_result.scalars().all()
-
- if not costs:
- # Nincs adat, alapértelmezett értékek
- if odometer_state:
- odometer_state.daily_avg_distance = 0
- odometer_state.confidence_score = 0
- odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer
- else:
- # Jármű alapadatok lekérése
- vehicle_stmt = select(VehicleModelDefinition).where(VehicleModelDefinition.id == vehicle_id)
- vehicle_result = await db.execute(vehicle_stmt)
- vehicle = vehicle_result.scalars().first()
-
- if not vehicle:
- return None
-
- odometer_state = VehicleOdometerState(
- vehicle_id=vehicle_id,
- last_recorded_odometer=0,
- last_recorded_date=datetime.now(),
- daily_avg_distance=0,
- estimated_current_odometer=0,
- confidence_score=0,
- manual_override_avg=None
- )
- db.add(odometer_state)
-
- await db.commit()
- await db.refresh(odometer_state)
- return odometer_state
-
- # Utolsó rögzített adatok
- last_cost = costs[-1]
- last_recorded_odometer = last_cost.odometer
- last_recorded_date = last_cost.date
-
- # Manuális átlag ellenőrzése
- if odometer_state and odometer_state.manual_override_avg is not None:
- daily_avg = float(odometer_state.manual_override_avg)
- confidence = 1.0 # Manuális beállítás esetén teljes bizalom
- else:
- # Átlag számítása a költségbejegyzésekből
- valid_pairs = []
- for i in range(1, len(costs)):
- prev = costs[i-1]
- curr = costs[i]
-
- days_diff = (curr.date - prev.date).days
- km_diff = curr.odometer - prev.odometer
-
- if days_diff >= min_days and km_diff > 0:
- daily_avg = km_diff / days_diff
- valid_pairs.append((daily_avg, days_diff))
-
- if valid_pairs:
- # Súlyozott átlag (hosszabb időszakok nagyobb súllyal)
- total_weighted = sum(avg * weight for avg, weight in valid_pairs)
- total_days = sum(weight for _, weight in valid_pairs)
- daily_avg = total_weighted / total_days if total_days > 0 else 0
-
- # Confidence score: érvényes párok száma / összes lehetséges párok
- confidence = min(len(valid_pairs) / max(len(costs) - 1, 1), 1.0)
- else:
- daily_avg = 0
- confidence = 0
-
- # Becsült jelenlegi kilométer
- days_since_last = (datetime.now(last_recorded_date.tzinfo) - last_recorded_date).days
- estimated_odometer = last_recorded_odometer + (daily_avg * max(days_since_last, 0))
-
- # Állapot frissítése vagy létrehozása
- if odometer_state:
- odometer_state.last_recorded_odometer = last_recorded_odometer
- odometer_state.last_recorded_date = last_recorded_date
- odometer_state.daily_avg_distance = daily_avg
- odometer_state.estimated_current_odometer = estimated_odometer
- odometer_state.confidence_score = confidence
- else:
- odometer_state = VehicleOdometerState(
- vehicle_id=vehicle_id,
- last_recorded_odometer=last_recorded_odometer,
- last_recorded_date=last_recorded_date,
- daily_avg_distance=daily_avg,
- estimated_current_odometer=estimated_odometer,
- confidence_score=confidence,
- manual_override_avg=None
- )
- db.add(odometer_state)
-
- await db.commit()
- await db.refresh(odometer_state)
- return odometer_state
-
- @staticmethod
- async def get_estimated_odometer(db: AsyncSession, vehicle_id: int) -> Tuple[Optional[float], float]:
- """
- Visszaadja a jármű becsült jelenlegi kilométeróra állását és a bizalom pontszámot.
-
- Returns:
- Tuple[estimated_odometer, confidence_score]
- """
- stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
- result = await db.execute(stmt)
- odometer_state = result.scalars().first()
-
- if not odometer_state:
- # Ha nincs állapot, frissítsük
- odometer_state = await OdometerService.update_vehicle_stats(db, vehicle_id)
- if not odometer_state:
- return None, 0.0
-
- return odometer_state.estimated_current_odometer, odometer_state.confidence_score
-
- @staticmethod
- async def set_manual_override(db: AsyncSession, vehicle_id: int, daily_avg: Optional[float]) -> Optional[VehicleOdometerState]:
- """
- Adminisztrátori manuális átlag beállítása.
-
- Args:
- daily_avg: Napi átlagos kilométer (km/nap). Ha None, törli a manuális beállítást.
- """
- stmt = select(VehicleOdometerState).where(VehicleOdometerState.vehicle_id == vehicle_id)
- result = await db.execute(stmt)
- odometer_state = result.scalars().first()
-
- if not odometer_state:
- # Ha nincs állapot, hozzuk létre
- odometer_state = VehicleOdometerState(
- vehicle_id=vehicle_id,
- last_recorded_odometer=0,
- last_recorded_date=datetime.now(),
- daily_avg_distance=0,
- estimated_current_odometer=0,
- confidence_score=0,
- manual_override_avg=daily_avg
- )
- db.add(odometer_state)
- else:
- odometer_state.manual_override_avg = daily_avg
- # Frissítsük a becslést a manuális átlaggal
- if daily_avg is not None:
- days_since_last = (datetime.now(odometer_state.last_recorded_date.tzinfo) - odometer_state.last_recorded_date).days
- odometer_state.estimated_current_odometer = odometer_state.last_recorded_odometer + (daily_avg * max(days_since_last, 0))
- odometer_state.confidence_score = 1.0
-
- await db.commit()
- await db.refresh(odometer_state)
- return odometer_state
\ No newline at end of file
+import logging
+logger = logging.getLogger(__name__)
+logger.warning("OdometerService is deprecated. VehicleOdometerState model has been removed.")
diff --git a/backend/app/services/subscription_service.py b/backend/app/services/subscription_service.py
new file mode 100644
index 0000000..77ec06b
--- /dev/null
+++ b/backend/app/services/subscription_service.py
@@ -0,0 +1,162 @@
+# /opt/docker/dev/service_finder/backend/app/services/subscription_service.py
+"""
+Subscription & Entitlement Service — Premium költségkategória láthatóság.
+
+A szolgáltatás a felhasználó előfizetési szintje (tier) alapján szabályozza,
+hogy mely költségkategóriák (CostCategory) érhetők el. A tier-ek hierarchikusak:
+free < premium < enterprise.
+
+Hierarchia:
+ - free: alap kategóriák (üzemanyag, szerviz, gumik, biztosítás, adók)
+ - premium: free + útdíj/parkolás, ápolás, egyéb
+ - enterprise: premium + minden (nincs korlátozás)
+"""
+from __future__ import annotations
+import logging
+from typing import Optional, List
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from app.models.vehicle.vehicle import CostCategory
+
+logger = logging.getLogger(__name__)
+
+# ── Subscription Feature Matrix ──────────────────────────────────────────────
+# Minden funkció/kategória minimális tier szintje.
+# A tier-ek: free < premium < enterprise
+TIER_HIERARCHY = {
+ "free": 0,
+ "premium": 1,
+ "enterprise": 2,
+}
+
+SUBSCRIPTION_FEATURES: dict[str, str] = {
+ # Feature -> minimum required tier
+ "cost_category:fuel": "free",
+ "cost_category:service": "free",
+ "cost_category:tires": "free",
+ "cost_category:insurance": "free",
+ "cost_category:taxes": "free",
+ "cost_category:toll_parking": "premium",
+ "cost_category:cleaning": "premium",
+ "cost_category:other": "premium",
+ "cost_category:all": "enterprise",
+ # Feature flags
+ "export_csv": "free",
+ "analytics_tco": "premium",
+ "analytics_detailed": "enterprise",
+ "api_access": "free",
+ "multi_vehicle": "free",
+ "unlimited_vehicles": "premium",
+}
+
+
+class SubscriptionService:
+ """
+ Entitlement Mátrix — előfizetési szintek kezelése.
+
+ Metódusok:
+ get_user_tier(db, user_id) -> str
+ can_access_feature(tier, feature_key) -> bool
+ get_visible_categories(db, tier) -> list[CostCategory]
+ require_tier(tier, required_feature_or_min_tier) -> bool
+ """
+
+ @staticmethod
+ def _tier_to_int(tier: str) -> int:
+ """Tier string konvertálása numerikus értékre a hierarchiában."""
+ return TIER_HIERARCHY.get(tier.lower(), 0)
+
+ @staticmethod
+ async def get_user_tier(db: AsyncSession, user_id: int) -> str:
+ """
+ Visszaadja a felhasználó aktuális előfizetési szintjét.
+
+ Jelenlegi implementáció: minden user 'free' tier-t kap.
+ A jövőben ez a subscription/org táblákból lesz lekérdezve.
+ """
+ # TODO: Éles implementáció — subscription tábla lekérdezése
+ # TODO: Szervezeti tier felülírás (pl. enterprise org)
+ return "free"
+
+ @staticmethod
+ def can_access_feature(user_tier: str, feature_key: str) -> bool:
+ """
+ Ellenőrzi, hogy a felhasználó hozzáfér-e egy adott funkcióhoz.
+
+ Args:
+ user_tier: A felhasználó tier szintje (free/premium/enterprise)
+ feature_key: A funkció kulcsa (pl. 'cost_category:fuel', 'analytics_tco')
+
+ Returns:
+ bool: True ha hozzáfér, False ha nem
+ """
+ required_tier = SUBSCRIPTION_FEATURES.get(feature_key)
+ if required_tier is None:
+ # Ismeretlen feature — alapértelmezetten tiltva
+ logger.warning(f"Unknown feature key: {feature_key}")
+ return False
+
+ user_level = SubscriptionService._tier_to_int(user_tier)
+ required_level = SubscriptionService._tier_to_int(required_tier)
+ return user_level >= required_level
+
+ @staticmethod
+ async def get_visible_categories(
+ db: AsyncSession,
+ user_tier: str
+ ) -> List[CostCategory]:
+ """
+ Visszaadja azokat a CostCategory-ket, amelyek a user tier-jének megfelelnek.
+
+ Szűrési logika:
+ - A kategória min_tier mezője alapján szűrünk.
+ - Ha a kategória min_tier értéke kisebb vagy egyenlő, mint a user tier,
+ akkor látható.
+ - Ha a min_tier nincs beállítva (None), akkor 'free'-ként kezeljük.
+
+ Args:
+ db: Adatbázis session
+ user_tier: A felhasználó tier szintje
+
+ Returns:
+ List[CostCategory]: A látható kategóriák listája
+ """
+ user_level = SubscriptionService._tier_to_int(user_tier)
+
+ # Lekérjük az összes kategóriát
+ stmt = select(CostCategory).order_by(CostCategory.id)
+ result = await db.execute(stmt)
+ all_categories = result.scalars().all()
+
+ # Szűrés a min_tier alapján
+ visible = []
+ for cat in all_categories:
+ cat_tier = getattr(cat, "min_tier", "free") or "free"
+ cat_level = SubscriptionService._tier_to_int(cat_tier)
+ if user_level >= cat_level:
+ visible.append(cat)
+
+ return visible
+
+ @staticmethod
+ def require_tier(user_tier: str, required_feature_or_min_tier: str) -> bool:
+ """
+ Egyszerű tier ellenőrzés — dependency check-hez.
+
+ Args:
+ user_tier: A felhasználó tier szintje
+ required_feature_or_min_tier: Vagy egy feature kulcs, vagy egy tier név
+
+ Returns:
+ bool: True ha megfelel, False ha nem
+ """
+ # Először feature kulcsként próbáljuk
+ if required_feature_or_min_tier in SUBSCRIPTION_FEATURES:
+ return SubscriptionService.can_access_feature(
+ user_tier, required_feature_or_min_tier
+ )
+
+ # Ha nem feature kulcs, akkor tier névként kezeljük
+ user_level = SubscriptionService._tier_to_int(user_tier)
+ required_level = SubscriptionService._tier_to_int(required_feature_or_min_tier)
+ return user_level >= required_level
diff --git a/backend/backend/scripts/test_delete_me.py b/backend/backend/scripts/test_delete_me.py
new file mode 100644
index 0000000..002006f
--- /dev/null
+++ b/backend/backend/scripts/test_delete_me.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+Test script: DELETE /api/v1/users/me
+Direct call to FastAPI inside the container (bypasses proxy).
+Uses tester_pro2@profibot.hu credentials.
+"""
+import asyncio
+import httpx
+import sys
+
+BASE_URL = "http://localhost:8000/api/v1"
+
+async def login_and_get_token():
+ """Login as tester_pro2 and get JWT token using OAuth2 form."""
+ async with httpx.AsyncClient() as client:
+ # OAuth2PasswordRequestForm expects form data with username and password fields
+ resp = await client.post(
+ f"{BASE_URL}/auth/login",
+ data={
+ "username": "tester_pro2@profibot.hu",
+ "password": "TesztElek99!",
+ "remember_me": "false"
+ }
+ )
+ if resp.status_code != 200:
+ print(f"❌ LOGIN FAILED: {resp.status_code}")
+ print(f" Response: {resp.text}")
+ return None
+ data = resp.json()
+ token = data.get("access_token")
+ print(f"✅ LOGIN OK - Token obtained (first 50 chars): {token[:50]}...")
+ return token
+
+async def test_delete_me(token):
+ """Send DELETE /me request directly to FastAPI."""
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "Content-Type": "application/json"
+ }
+ async with httpx.AsyncClient() as client:
+ print(f"\n🔍 Sending DELETE {BASE_URL}/users/me ...")
+ resp = await client.delete(
+ f"{BASE_URL}/users/me",
+ headers=headers
+ )
+ print(f" Status: {resp.status_code}")
+ print(f" Response: {resp.text[:500]}")
+
+ if resp.status_code == 200:
+ print("✅ DELETE /me SUCCESS!")
+ return True
+ elif resp.status_code == 405:
+ print("❌ 405 Method Not Allowed - The endpoint is not registered for DELETE")
+ return False
+ elif resp.status_code == 404:
+ print("❌ 404 Not Found - The endpoint path is wrong")
+ return False
+ else:
+ print(f"⚠️ Unexpected status code")
+ return False
+
+async def main():
+ print("=" * 60)
+ print("🧪 TEST: DELETE /api/v1/users/me")
+ print("=" * 60)
+
+ # Step 1: Login
+ token = await login_and_get_token()
+ if not token:
+ sys.exit(1)
+
+ # Step 2: Test DELETE
+ success = await test_delete_me(token)
+
+ if success:
+ print("\n✅ TEST PASSED: DELETE /me works correctly!")
+ else:
+ print("\n❌ TEST FAILED: DELETE /me returned an error!")
+ sys.exit(1)
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/backend/scripts/check_constraint.py b/backend/scripts/check_constraint.py
new file mode 100644
index 0000000..4ce4809
--- /dev/null
+++ b/backend/scripts/check_constraint.py
@@ -0,0 +1,31 @@
+"""Check and create the CheckConstraint for vehicle.assets table."""
+from app.database import engine_sync
+from sqlalchemy import text
+
+with engine_sync.connect() as conn:
+ # Check if constraint exists
+ result = conn.execute(
+ text("""
+ SELECT conname, pg_get_constraintdef(oid)
+ FROM pg_constraint
+ WHERE conrelid = 'vehicle.assets'::regclass
+ AND contype = 'c'
+ """)
+ ).fetchall()
+ print('Constraints on vehicle.assets:')
+ for row in result:
+ print(f' {row[0]}: {row[1]}')
+
+ if not result:
+ print('No CheckConstraint found - creating it now...')
+ conn.execute(
+ text("""
+ ALTER TABLE vehicle.assets
+ ADD CONSTRAINT ck_asset_vin_or_plate_required
+ CHECK (vin IS NOT NULL OR license_plate IS NOT NULL)
+ """)
+ )
+ conn.commit()
+ print('CheckConstraint created successfully!')
+ else:
+ print('CheckConstraint already exists.')
diff --git a/backend/scripts/repair_kyc_user.py b/backend/scripts/repair_kyc_user.py
new file mode 100644
index 0000000..76d7fea
--- /dev/null
+++ b/backend/scripts/repair_kyc_user.py
@@ -0,0 +1,249 @@
+#!/usr/bin/env python3
+"""
+Repair script for user gy.krisztina76@gmail.com (ID 100).
+This user went through light registration + email verification but the KYC
+completion failed because of two bugs:
+1. verify_email() used Person.user_id (NULL) instead of User.person_id
+2. GamificationService.award_points() did internal commit conflicting with outer transaction
+
+This script manually creates all the missing infrastructure that should have
+been created by complete_kyc():
+- Activate Person
+- Create Organization (personal)
+- Create Branch
+- Create OrganizationMember
+- Create Wallet
+- Create UserStats
+- Set user.scope_id
+"""
+
+import asyncio
+import logging
+import sys
+from datetime import datetime, timezone
+
+# Add the backend directory to the path
+sys.path.insert(0, '/app/backend')
+
+from sqlalchemy import select, and_
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from app.core.config import settings
+
+# Setup logging
+logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
+logger = logging.getLogger("repair_kyc")
+
+# Database URL from settings
+DATABASE_URL = settings.DATABASE_URL
+logger.info(f"Using database: {DATABASE_URL}")
+
+# Create engine and session
+engine = create_async_engine(DATABASE_URL, echo=False)
+AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def repair_user():
+ async with AsyncSessionLocal() as db:
+ try:
+ # 1. Find the user
+ from app.models.identity.identity import User, Person, Wallet
+ from app.models.marketplace.organization import Organization, OrganizationMember, Branch
+ from app.models.gamification.gamification import UserStats, PointsLedger
+
+ stmt = select(User).where(User.email == 'gy.krisztina76@gmail.com')
+ user = (await db.execute(stmt)).scalar_one_or_none()
+
+ if not user:
+ logger.error("User not found!")
+ return
+
+ logger.info(f"Found user: ID={user.id}, email={user.email}, person_id={user.person_id}")
+ logger.info(f" is_active={user.is_active}, scope_id={user.scope_id}")
+
+ # 2. Find and activate Person
+ person_stmt = select(Person).where(Person.id == user.person_id)
+ person = (await db.execute(person_stmt)).scalar_one_or_none()
+
+ if person:
+ logger.info(f"Found Person: ID={person.id}, is_active={person.is_active}, user_id={person.user_id}")
+ person.is_active = True
+ person.user_id = user.id
+ logger.info(f" -> Activated Person, set user_id={user.id}")
+ else:
+ logger.warning(f"Person with id={user.person_id} not found!")
+
+ # 3. Check if Organization already exists
+ org_stmt = select(Organization).where(Organization.owner_id == user.id)
+ existing_org = (await db.execute(org_stmt)).scalar_one_or_none()
+
+ if existing_org:
+ logger.info(f"Organization already exists: ID={existing_org.id}, name={existing_org.name}")
+ new_org = existing_org
+ else:
+ # Create personal Organization
+ from app.core.security import generate_secure_slug
+
+ org_name = f"{person.last_name} Flotta" if person else "Personal Flotta"
+ folder_slug = generate_secure_slug(12)
+
+ new_org = Organization(
+ name=org_name,
+ full_name=org_name,
+ display_name=org_name,
+ folder_slug=folder_slug,
+ org_type="individual",
+ status="ACTIVE",
+ is_deleted=False,
+ is_active=True,
+ is_verified=False,
+ is_anonymized=False,
+ default_currency="HUF",
+ country_code="HU",
+ language="hu",
+ subscription_plan="FREE",
+ base_asset_limit=3,
+ purchased_extra_slots=0,
+ notification_settings={},
+ external_integration_config={},
+ visual_settings={},
+ is_ownership_transferable=False,
+ owner_id=user.id,
+ first_registered_at=datetime.now(timezone.utc),
+ current_lifecycle_started_at=datetime.now(timezone.utc),
+ lifecycle_index=1,
+ created_at=datetime.now(timezone.utc)
+ )
+ db.add(new_org)
+ await db.flush()
+ logger.info(f"Created Organization: ID={new_org.id}, name={org_name}")
+
+ # 4. Check if Branch already exists
+ branch_stmt = select(Branch).where(Branch.organization_id == new_org.id)
+ existing_branch = (await db.execute(branch_stmt)).scalar_one_or_none()
+
+ if existing_branch:
+ logger.info(f"Branch already exists: ID={existing_branch.id}, name={existing_branch.name}")
+ else:
+ import uuid
+ branch = Branch(
+ id=uuid.uuid4(),
+ organization_id=new_org.id,
+ name="Home Base",
+ is_main=True,
+ opening_hours={},
+ branch_rating=0.0,
+ status="ACTIVE",
+ is_deleted=False,
+ created_at=datetime.now(timezone.utc)
+ )
+ db.add(branch)
+ await db.flush()
+ logger.info(f"Created Branch: ID={branch.id}")
+
+ # 5. Check if OrganizationMember already exists
+ member_stmt = select(OrganizationMember).where(
+ and_(
+ OrganizationMember.organization_id == new_org.id,
+ OrganizationMember.user_id == user.id
+ )
+ )
+ existing_member = (await db.execute(member_stmt)).scalar_one_or_none()
+
+ if existing_member:
+ logger.info(f"OrganizationMember already exists: ID={existing_member.id}")
+ else:
+ member = OrganizationMember(
+ organization_id=new_org.id,
+ user_id=user.id,
+ role="OWNER",
+ permissions={},
+ is_permanent=True,
+ is_verified=True
+ )
+ db.add(member)
+ await db.flush()
+ logger.info(f"Created OrganizationMember: user_id={user.id}, org_id={new_org.id}")
+
+ # 6. Check if Wallet already exists
+ wallet_stmt = select(Wallet).where(Wallet.user_id == user.id)
+ existing_wallet = (await db.execute(wallet_stmt)).scalar_one_or_none()
+
+ if existing_wallet:
+ logger.info(f"Wallet already exists: ID={existing_wallet.id}")
+ else:
+ wallet = Wallet(
+ user_id=user.id,
+ earned_credits=0,
+ purchased_credits=0,
+ service_coins=0,
+ currency="HUF"
+ )
+ db.add(wallet)
+ await db.flush()
+ logger.info(f"Created Wallet for user_id={user.id}")
+
+ # 7. Check if UserStats already exists
+ stats_stmt = select(UserStats).where(UserStats.user_id == user.id)
+ existing_stats = (await db.execute(stats_stmt)).scalar_one_or_none()
+
+ if existing_stats:
+ logger.info(f"UserStats already exists: user_id={existing_stats.user_id}")
+ else:
+ stats = UserStats(
+ user_id=user.id,
+ total_xp=500, # KYC bonus
+ social_points=0,
+ current_level=1,
+ penalty_points=0,
+ restriction_level=0,
+ updated_at=datetime.now(timezone.utc),
+ penalty_quota_remaining=0,
+ places_discovered=0,
+ places_validated=0
+ )
+ db.add(stats)
+
+ # Add PointsLedger entry for KYC bonus
+ ledger = PointsLedger(
+ user_id=user.id,
+ points=500,
+ reason="KYC_VERIFICATION_REPAIR"
+ )
+ db.add(ledger)
+ await db.flush()
+ logger.info(f"Created UserStats and PointsLedger for user_id={user.id}")
+
+ # 8. Set user.scope_id if not set
+ if not user.scope_id:
+ user.scope_id = str(new_org.id)
+ logger.info(f"Set user.scope_id = {new_org.id}")
+
+ # 9. Ensure user.is_active is True
+ if not user.is_active:
+ user.is_active = True
+ logger.info("Activated user")
+
+ await db.commit()
+ logger.info("=" * 50)
+ logger.info("REPAIR COMPLETED SUCCESSFULLY!")
+ logger.info("=" * 50)
+
+ # Verify
+ logger.info("\n--- Verification ---")
+ logger.info(f"User: ID={user.id}, is_active={user.is_active}, scope_id={user.scope_id}")
+ if person:
+ logger.info(f"Person: ID={person.id}, is_active={person.is_active}, user_id={person.user_id}")
+ logger.info(f"Organization: ID={new_org.id}, name={new_org.name}")
+
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"Repair failed: {e}")
+ import traceback
+ traceback.print_exc()
+ finally:
+ await db.close()
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(repair_user())
diff --git a/backend/scripts/seed_cost_category_tiers.py b/backend/scripts/seed_cost_category_tiers.py
new file mode 100644
index 0000000..ef64a1b
--- /dev/null
+++ b/backend/scripts/seed_cost_category_tiers.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""
+Seed script: CostCategory min_tier beállítása.
+
+A szkript beállítja a költségkategóriák min_tier mezőjét a kód alapján.
+A tier-ek hierarchikusak: free < premium < enterprise.
+
+Kategória tier hozzárendelés:
+ - FUEL, SERVICE, TIRES, INSURANCE, TAXES -> free
+ - TOLL_PARKING, CLEANING, OTHER -> premium
+ - (minden más) -> enterprise
+
+Használat:
+ docker compose exec sf_api python -m backend.scripts.seed_cost_category_tiers
+"""
+import asyncio
+import logging
+from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy import select, text
+
+from app.core.config import settings
+from app.models.vehicle.vehicle import CostCategory
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+# Tier mapping by category code
+# A fő kategóriák (parent_id IS NULL) tier beállításai.
+# Az alkategóriák (pl. FUEL_PETROL, MAINT_SERVICE) öröklik a szülő tier-jét.
+CATEGORY_TIER_MAP = {
+ "FUEL": "free", # Üzemanyag / Töltés
+ "MAINTENANCE": "free", # Szerviz & Karbantartás
+ "TIRES": "free", # Gumiabroncsok
+ "INSURANCE": "free", # Biztosítás
+ "TAX": "free", # Adók
+ "FEES": "premium", # Útdíj & Parkolás
+ "ADMIN": "premium", # Hatósági díjak
+ "FINANCE": "premium", # Finanszírozás
+ "CLEANING": "premium", # Ápolás & Kozmetika
+ "OTHER": "premium", # Egyéb
+}
+
+# Default tier for categories not in the map
+DEFAULT_TIER = "enterprise"
+
+
+async def seed_cost_category_tiers():
+ """Beállítja a min_tier értékeket az összes CostCategory rekordra."""
+ engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+
+ async with async_session() as db:
+ try:
+ # Lekérjük az összes kategóriát
+ stmt = select(CostCategory).order_by(CostCategory.id)
+ result = await db.execute(stmt)
+ categories = result.scalars().all()
+
+ if not categories:
+ logger.warning("❌ Nincsenek CostCategory rekordok az adatbázisban!")
+ return
+
+ updated_count = 0
+ for cat in categories:
+ # Meghatározzuk a tier-t a kód alapján
+ new_tier = CATEGORY_TIER_MAP.get(cat.code, DEFAULT_TIER)
+
+ # Csak akkor frissítjük, ha még nincs beállítva, vagy változott
+ if cat.min_tier != new_tier:
+ cat.min_tier = new_tier
+ updated_count += 1
+ logger.info(
+ f" ✅ [{cat.code:15s}] {cat.name:30s} -> tier={new_tier}"
+ )
+
+ if updated_count > 0:
+ await db.commit()
+ logger.info(f"\n✅ {updated_count} kategória tier beállítva.")
+ else:
+ logger.info("\n✅ Minden kategória tier már be van állítva.")
+
+ # Összesítő
+ logger.info("\n📊 Tier összesítő:")
+ for cat in categories:
+ logger.info(f" [{cat.min_tier:10s}] {cat.code:15s} - {cat.name}")
+
+ except Exception as e:
+ await db.rollback()
+ logger.error(f"❌ Hiba a seedelés során: {e}")
+ raise
+ finally:
+ await engine.dispose()
+
+
+if __name__ == "__main__":
+ asyncio.run(seed_cost_category_tiers())
diff --git a/backend/scripts/test_kyc_e2e.py b/backend/scripts/test_kyc_e2e.py
new file mode 100644
index 0000000..a1e7e87
--- /dev/null
+++ b/backend/scripts/test_kyc_e2e.py
@@ -0,0 +1,245 @@
+#!/usr/bin/env python3
+"""
+E2E Test: Light Registration → Email Verification → KYC Completion
+Verifies that all necessary database relationships are created after KYC.
+"""
+import asyncio
+import httpx
+import uuid
+import sys
+from datetime import datetime, timezone
+
+sys.path.insert(0, '/app')
+
+from sqlalchemy import text
+from app.db.session import AsyncSessionLocal
+
+BASE_URL = "http://localhost:8000/api/v1"
+TEST_EMAIL = f"test_kyc_{uuid.uuid4().hex[:8]}@example.com"
+TEST_PASSWORD = "TestPass123!"
+
+async def get_verification_token(email: str):
+ async with AsyncSessionLocal() as session:
+ result = await session.execute(
+ text("SELECT token FROM identity.verification_tokens vt "
+ "JOIN identity.users u ON u.id = vt.user_id "
+ "WHERE u.email = :email ORDER BY vt.created_at DESC LIMIT 1"),
+ {"email": email}
+ )
+ return result.scalar()
+
+async def check_infrastructure(user_id: int):
+ """Check all required infrastructure objects exist."""
+ async with AsyncSessionLocal() as session:
+ checks = {}
+
+ # Person
+ r = await session.execute(
+ text("SELECT id, is_active, user_id FROM identity.persons WHERE id = "
+ "(SELECT person_id FROM identity.users WHERE id = :uid)"),
+ {"uid": user_id}
+ )
+ person = r.fetchone()
+ checks["person"] = person is not None
+ if person:
+ checks["person_active"] = person[1] == True
+ checks["person_user_id"] = person[2] == user_id
+
+ # Organization
+ r = await session.execute(
+ text("SELECT id, name, org_type FROM fleet.organizations WHERE owner_id = :uid"),
+ {"uid": user_id}
+ )
+ org = r.fetchone()
+ checks["organization"] = org is not None
+ if org:
+ checks["org_id"] = org[0]
+
+ # Branch
+ if org:
+ r = await session.execute(
+ text("SELECT id, name, is_main FROM fleet.branches WHERE organization_id = :oid"),
+ {"oid": org[0]}
+ )
+ branch = r.fetchone()
+ checks["branch"] = branch is not None
+ if branch:
+ checks["branch_main"] = branch[2] == True
+
+ # OrganizationMember
+ r = await session.execute(
+ text("SELECT id, role FROM fleet.organization_members WHERE user_id = :uid"),
+ {"uid": user_id}
+ )
+ member = r.fetchone()
+ checks["org_member"] = member is not None
+ if member:
+ checks["org_member_role"] = member[1] == "OWNER"
+
+ # Wallet
+ r = await session.execute(
+ text("SELECT id FROM identity.wallets WHERE user_id = :uid"),
+ {"uid": user_id}
+ )
+ checks["wallet"] = r.fetchone() is not None
+
+ # UserStats
+ r = await session.execute(
+ text("SELECT user_id, total_xp, current_level FROM gamification.user_stats WHERE user_id = :uid"),
+ {"uid": user_id}
+ )
+ stats = r.fetchone()
+ checks["user_stats"] = stats is not None
+ if stats:
+ checks["user_stats_xp"] = stats[1] >= 500 # KYC bonus
+ checks["user_stats_level"] = stats[2] >= 1
+
+ # User scope_id
+ r = await session.execute(
+ text("SELECT scope_id FROM identity.users WHERE id = :uid"),
+ {"uid": user_id}
+ )
+ scope = r.fetchone()
+ checks["scope_id_set"] = scope is not None and scope[0] is not None
+
+ return checks
+
+async def run_test():
+ passed = 0
+ failed = 0
+
+ print("=" * 60)
+ print(f"KYC E2E TEST - Email: {TEST_EMAIL}")
+ print("=" * 60)
+
+ async with httpx.AsyncClient() as client:
+ # --- STEP 1: LITE REGISTRATION ---
+ print("\n--- 1. LITE REGISTRATION ---")
+ reg_payload = {
+ "email": TEST_EMAIL,
+ "password": TEST_PASSWORD,
+ "first_name": "KYC",
+ "last_name": "TestUser",
+ "region_code": "HU"
+ }
+ resp = await client.post(f"{BASE_URL}/auth/register", json=reg_payload)
+ print(f"Status: {resp.status_code}")
+ if resp.status_code == 201:
+ print("✓ Registration successful")
+ passed += 1
+ else:
+ print(f"✗ Registration failed: {resp.text}")
+ failed += 1
+ return passed, failed
+
+ # --- STEP 2: EMAIL VERIFICATION ---
+ print("\n--- 2. EMAIL VERIFICATION ---")
+ token = await get_verification_token(TEST_EMAIL)
+ if not token:
+ print("✗ No verification token found!")
+ failed += 1
+ return passed, failed
+
+ print(f"Token: {token}")
+ verify_resp = await client.post(f"{BASE_URL}/auth/verify-email", json={"token": str(token)})
+ print(f"Status: {verify_resp.status_code}")
+ if verify_resp.status_code == 200:
+ print("✓ Email verification successful")
+ passed += 1
+ else:
+ print(f"✗ Verification failed: {verify_resp.text}")
+ failed += 1
+ return passed, failed
+
+ # --- STEP 3: LOGIN TO GET TOKEN ---
+ print("\n--- 3. LOGIN ---")
+ login_data = {
+ "username": TEST_EMAIL,
+ "password": TEST_PASSWORD,
+ "grant_type": "password"
+ }
+ login_resp = await client.post(f"{BASE_URL}/auth/login", data=login_data)
+ print(f"Status: {login_resp.status_code}")
+ if login_resp.status_code == 200:
+ print("✓ Login successful")
+ passed += 1
+ access_token = login_resp.json().get("access_token")
+ else:
+ print(f"✗ Login failed: {login_resp.text}")
+ failed += 1
+ return passed, failed
+
+ headers = {"Authorization": f"Bearer {access_token}"}
+
+ # --- STEP 4: GET USER INFO ---
+ print("\n--- 4. GET USER INFO ---")
+ me_resp = await client.get(f"{BASE_URL}/auth/me", headers=headers)
+ print(f"Status: {me_resp.status_code}")
+ if me_resp.status_code == 200:
+ user_data = me_resp.json()
+ user_id = user_data.get("id")
+ print(f"User ID: {user_id}")
+ print(f"User data: {user_data}")
+ passed += 1
+ else:
+ print(f"✗ Failed to get user info: {me_resp.text}")
+ failed += 1
+ return passed, failed
+
+ # --- STEP 5: COMPLETE KYC ---
+ print("\n--- 5. COMPLETE KYC ---")
+ kyc_payload = {
+ "first_name": "KYC",
+ "last_name": "TestUser",
+ "birth_date": "1990-01-15",
+ "birth_place": "Budapest",
+ "mothers_last_name": "Anyuka",
+ "mothers_first_name": "Mária",
+ "phone_number": "+36301234567",
+ "address_zip": "1011",
+ "address_city": "Budapest",
+ "address_street_name": "Fő utca",
+ "address_street_type": "utca",
+ "address_house_number": "1",
+ "preferred_currency": "HUF",
+ "region_code": "HU",
+ "identity_docs": {
+ "PASSPORT": {"number": "AB123456", "expiry_date": "2030-01-01"}
+ }
+ }
+ kyc_resp = await client.post(f"{BASE_URL}/auth/complete-kyc", json=kyc_payload, headers=headers)
+ print(f"Status: {kyc_resp.status_code}")
+ print(f"Response: {kyc_resp.text}")
+ if kyc_resp.status_code == 200:
+ print("✓ KYC completion successful")
+ passed += 1
+ else:
+ print(f"✗ KYC failed: {kyc_resp.text}")
+ failed += 1
+ return passed, failed
+
+ # --- STEP 6: VERIFY INFRASTRUCTURE ---
+ print("\n--- 6. VERIFY INFRASTRUCTURE ---")
+ checks = await check_infrastructure(user_id)
+ all_ok = True
+ for check_name, check_result in checks.items():
+ status = "✓" if check_result else "✗"
+ if not check_result:
+ all_ok = False
+ print(f" {status} {check_name}: {check_result}")
+
+ if all_ok:
+ print("✓ All infrastructure checks passed!")
+ passed += 1
+ else:
+ print(f"✗ Some infrastructure checks failed!")
+ failed += 1
+
+ print("\n" + "=" * 60)
+ print(f"RESULTS: {passed} passed, {failed} failed")
+ print("=" * 60)
+ return passed, failed
+
+if __name__ == "__main__":
+ p, f = asyncio.run(run_test())
+ sys.exit(1 if f > 0 else 0)
diff --git a/backend/scripts/test_soft_delete_and_asset_validation.py b/backend/scripts/test_soft_delete_and_asset_validation.py
new file mode 100644
index 0000000..6e69830
--- /dev/null
+++ b/backend/scripts/test_soft_delete_and_asset_validation.py
@@ -0,0 +1,269 @@
+"""
+Test script for:
+1. Asset VIN/License Plate OR-OR validation (Pydantic schemas)
+2. Person-Preserving Soft Delete logic
+"""
+import asyncio
+import sys
+from datetime import datetime, timezone
+from typing import Optional
+
+# ─── Test 1: Pydantic Schema Validation ───────────────────────────────────────
+print("=" * 70)
+print("🧪 TEST 1: Asset Pydantic OR-OR Validation")
+print("=" * 70)
+
+from pydantic import ValidationError
+sys.path.insert(0, "/app")
+from app.schemas.asset import AssetCreate, AssetUpdate
+
+def test_asset_create():
+ """Test AssetCreate schema validation."""
+ passed = 0
+ failed = 0
+
+ # Test 1.1: Both vin and plate provided - should pass
+ try:
+ a = AssetCreate(license_plate="ABC-123", vin="WDB1234567890ABCD")
+ assert a.license_plate == "ABC-123"
+ assert a.vin == "WDB1234567890ABCD"
+ print(" ✅ 1.1 Both vin and plate provided: PASS")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.1 Both vin and plate provided: FAIL - {e}")
+ failed += 1
+
+ # Test 1.2: Only license_plate - should pass
+ try:
+ a = AssetCreate(license_plate="ABC-123")
+ assert a.license_plate == "ABC-123"
+ assert a.vin is None
+ print(" ✅ 1.2 Only license_plate: PASS")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.2 Only license_plate: FAIL - {e}")
+ failed += 1
+
+ # Test 1.3: Only vin - should pass
+ try:
+ a = AssetCreate(vin="WDB1234567890ABCD")
+ assert a.vin == "WDB1234567890ABCD"
+ assert a.license_plate is None
+ print(" ✅ 1.3 Only vin: PASS")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.3 Only vin: FAIL - {e}")
+ failed += 1
+
+ # Test 1.4: Neither vin nor plate - should FAIL
+ try:
+ a = AssetCreate()
+ print(f" ❌ 1.4 Neither vin nor plate: FAIL (should have raised ValueError)")
+ failed += 1
+ except ValidationError as e:
+ print(f" ✅ 1.4 Neither vin nor plate: PASS (correctly rejected)")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.4 Neither vin nor plate: FAIL - {e}")
+ failed += 1
+
+ # Test 1.5: Empty string license_plate - should be converted to None, then fail
+ try:
+ a = AssetCreate(license_plate="")
+ print(f" ❌ 1.5 Empty string license_plate: FAIL (should have raised ValueError)")
+ failed += 1
+ except ValidationError as e:
+ print(f" ✅ 1.5 Empty string license_plate: PASS (correctly rejected)")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.5 Empty string license_plate: FAIL - {e}")
+ failed += 1
+
+ # Test 1.6: Empty string vin - should be converted to None, then fail
+ try:
+ a = AssetCreate(vin=" ")
+ print(f" ❌ 1.6 Whitespace-only vin: FAIL (should have raised ValueError)")
+ failed += 1
+ except ValidationError as e:
+ print(f" ✅ 1.6 Whitespace-only vin: PASS (correctly rejected)")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.6 Whitespace-only vin: FAIL - {e}")
+ failed += 1
+
+ # Test 1.7: Both empty strings - should fail
+ try:
+ a = AssetCreate(license_plate="", vin="")
+ print(f" ❌ 1.7 Both empty strings: FAIL (should have raised ValueError)")
+ failed += 1
+ except ValidationError as e:
+ print(f" ✅ 1.7 Both empty strings: PASS (correctly rejected)")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 1.7 Both empty strings: FAIL - {e}")
+ failed += 1
+
+ return passed, failed
+
+def test_asset_update():
+ """Test AssetUpdate schema validation."""
+ passed = 0
+ failed = 0
+
+ # Test 2.1: Update with only license_plate - should pass
+ try:
+ a = AssetUpdate(license_plate="NEW-999")
+ assert a.license_plate == "NEW-999"
+ print(" ✅ 2.1 Update with only license_plate: PASS")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 2.1 Update with only license_plate: FAIL - {e}")
+ failed += 1
+
+ # Test 2.2: Update with only vin - should pass
+ try:
+ a = AssetUpdate(vin="NEWVIN123456789")
+ assert a.vin == "NEWVIN123456789"
+ print(" ✅ 2.2 Update with only vin: PASS")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 2.2 Update with only vin: FAIL - {e}")
+ failed += 1
+
+ # Test 2.3: Update with both set to None explicitly - should FAIL
+ try:
+ a = AssetUpdate(license_plate=None, vin=None)
+ print(f" ❌ 2.3 Both set to None: FAIL (should have raised ValueError)")
+ failed += 1
+ except ValidationError as e:
+ print(f" ✅ 2.3 Both set to None: PASS (correctly rejected)")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 2.3 Both set to None: FAIL - {e}")
+ failed += 1
+
+ # Test 2.4: Empty update (no fields) - should pass (exclude_unset)
+ try:
+ a = AssetUpdate()
+ print(f" ✅ 2.4 Empty update (no fields): PASS")
+ passed += 1
+ except Exception as e:
+ print(f" ❌ 2.4 Empty update (no fields): FAIL - {e}")
+ failed += 1
+
+ return passed, failed
+
+# Run Pydantic tests
+p1, f1 = test_asset_create()
+p2, f2 = test_asset_update()
+print(f"\n📊 Asset Validation Results: {p1+p2} passed, {f1+f2} failed out of {p1+p2+f1+f2} tests")
+
+# ─── Test 2: Soft Delete Logic ────────────────────────────────────────────────
+print("\n" + "=" * 70)
+print("🧪 TEST 2: Person-Preserving Soft Delete Logic")
+print("=" * 70)
+
+async def test_soft_delete():
+ """Test the soft_delete_user method via direct DB calls."""
+ from app.database import AsyncSessionLocal
+ from app.models.identity.identity import User, Person
+ from app.services.auth_service import AuthService
+ from app.services.security_service import security_service
+ from sqlalchemy import select, text
+
+ async with AsyncSessionLocal() as db:
+ try:
+ # Find a test user (use first active user)
+ result = await db.execute(
+ select(User).where(User.is_deleted == False).limit(1)
+ )
+ user = result.scalar_one_or_none()
+
+ if not user:
+ print(" ⚠️ No active test user found - creating one...")
+ # Create a minimal test scenario
+ print(" ⚠️ Skipping DB soft delete test (no test user available)")
+ return 0, 0
+
+ user_id = user.id
+ old_email = user.email
+ print(f" 📋 Test user: id={user_id}, email={old_email}")
+
+ # Check if Person exists
+ person = None
+ if user.person_id:
+ result = await db.execute(
+ select(Person).where(Person.id == user.person_id)
+ )
+ person = result.scalar_one_or_none()
+ if person:
+ print(f" 📋 Person record: id={person.id}, is_active={person.is_active}")
+
+ # Perform soft delete
+ success = await AuthService.soft_delete_user(
+ db=db,
+ user_id=user_id,
+ reason="test_script_validation",
+ actor_id=user_id
+ )
+
+ if not success:
+ print(f" ❌ Soft delete returned False (user already deleted)")
+ return 0, 1
+
+ # Verify user state
+ await db.refresh(user)
+ assert user.is_deleted == True, "is_deleted should be True"
+ assert user.is_active == False, "is_active should be False"
+ assert user.deleted_at is not None, "deleted_at should be set"
+ assert "deleted_" in user.email, f"Email should be anonymized: {user.email}"
+ assert str(user_id) in user.email, "Email should contain user_id"
+
+ print(f" ✅ User.is_deleted = {user.is_deleted}")
+ print(f" ✅ User.is_active = {user.is_active}")
+ print(f" ✅ User.deleted_at = {user.deleted_at}")
+ print(f" ✅ User.email anonymized: {user.email}")
+
+ # Verify Person is UNTOUCHED by our soft delete
+ if person:
+ await db.refresh(person)
+ # Person.deleted_at must remain None (our code never sets it)
+ assert person.deleted_at is None, "Person.deleted_at should remain None (not touched by soft delete)"
+ # Person data must be unchanged (no email rewrite, no name change)
+ print(f" ✅ Person.deleted_at preserved = {person.deleted_at} (not touched)")
+ print(f" ✅ Person data preserved (no modifications by soft delete)")
+ else:
+ print(f" ⚠️ No Person record linked to this user")
+
+ # Restore user for other tests
+ user.is_deleted = False
+ user.is_active = True
+ user.deleted_at = None
+ user.email = old_email
+ await db.commit()
+ print(f" ✅ User restored to original state")
+
+ return 2, 0
+
+ except Exception as e:
+ await db.rollback()
+ print(f" ❌ Soft delete test failed: {e}")
+ import traceback
+ traceback.print_exc()
+ return 0, 1
+
+p3, f3 = asyncio.run(test_soft_delete())
+
+# ─── Final Summary ────────────────────────────────────────────────────────────
+print("\n" + "=" * 70)
+total_p = p1 + p2 + p3
+total_f = f1 + f2 + f3
+print(f"📊 FINAL RESULTS: {total_p} passed, {total_f} failed out of {total_p+total_f} tests")
+print("=" * 70)
+
+if total_f > 0:
+ print("❌ SOME TESTS FAILED!")
+ sys.exit(1)
+else:
+ print("✅ ALL TESTS PASSED!")
+ sys.exit(0)
diff --git a/docs/gitea_state_analysis_2026-06-15.md b/docs/gitea_state_analysis_2026-06-15.md
new file mode 100644
index 0000000..9c30837
--- /dev/null
+++ b/docs/gitea_state_analysis_2026-06-15.md
@@ -0,0 +1,285 @@
+# Gitea Kártyaállapot Elemzés & Programfejlesztési Áttekintés
+
+**Dátum:** 2026-06-15 (Hétfő)
+**Összehasonlítási alap:** [`docs/gitea_open_cards_summary_2026-06-09.md`](docs/gitea_open_cards_summary_2026-06-09.md)
+**Lekért adatok:** Nyitott kártyák (62 db), Lezárt kártyák (~110+ db), Git log
+
+---
+
+## 1. Összesített Statisztika
+
+### Alapadatok
+| Mutató | 2026-06-09 | 2026-06-15 | Változás |
+|--------|-----------|-----------|----------|
+| **Összes nyitott kártya** | 63 | 62 | **-1** |
+| **Összes lezárt kártya** | ~100+ | ~110+ | **+10+** |
+| **Git commit-ek** | - | 3 új commit | `90e3173`, `0a3fd8d`, `ef8df96` |
+| **Commit nélküli módosított fájlok** | - | **50+ fájl** | Uncommitted changes |
+
+### Nyitott kártyák megoszlása mérföldkövenként
+
+| Mérföldkő / Csoport | Jún. 9 | Jún. 15 | Változás |
+|---------------------|--------|---------|----------|
+| **Grafikai Komponensek & Frontend Skin Rendszer** | 4 | 4 | ±0 (belső csere) |
+| **Frontend Architektúra 2.0 & Dinamikus Megjelenítés** | 20 | 20 | ±0 (belső csere) |
+| **Phase 4: Testing & Deployment** | 7 | 7 | ±0 |
+| **Phase 3: Advanced Features & Epic 11** | 6 | 6 | ±0 |
+| **Phase 2: Dashboard & Analytics Wiring** | 6 | 6 | ±0 |
+| **Phase 1: Core Functionality Fixes** | 4 | 3 | **-1** |
+| **Egyéb / Nincs mérföldkő** | 16 | 16 | ±0 |
+| **ÖSSZESEN** | **63** | **62** | **-1** |
+
+---
+
+## 2. Részletes Változások Június 9 → Június 15 Között
+
+### 2.1 Lezárt Kártyák (Jelentősebbek)
+
+Az alábbi kártyák kerültek lezárásra a vizsgált időszakban:
+
+#### Újonnan létrehozott ÉS lezárt kártyák
+| ID | Cím | Mérföldkő |
+|----|-----|-----------|
+| #261 | Frontend: Account Restore, Last Admin Warning | - |
+| #260 | Identity-Preserving Soft Delete & Asset | - |
+| #259 | Clean Odometer & Switcher Logic - Vehicle | - |
+| #258 | Audit: Odometer rendszer duplikáció vizsgálat | - |
+| #257 | Kettős Védelem (Double-Shield) Organization | - |
+| #254 | Standard & Prémium Kártyavázak (Blueprints) | Grafikai Komponensek |
+| #251 | Háttérkép: garage_private_default.png | Grafikai Komponensek |
+| #237 | 1.1-A: Backend API endpoints visual_settings | Frontend Arch. 2.0 |
+| #235 | 4.1: 'App Store' típusú kiterjedő kártya | Frontend Arch. 2.0 |
+| #228 | 1.1: Adatbázis sémák bővítése vizuális konfighoz | Frontend Arch. 2.0 |
+
+#### Phase 1-ben lezárt kártya
+| ID | Cím | Megjegyzés |
+|----|-----|------------|
+| #144 | Fix Authentication Endpoint Mismatch | **Az egyetlen Phase 1 kártya, ami lezárásra került** |
+
+#### NAV API & Backend infrastruktúra
+| ID | Cím |
+|----|-----|
+| #223 | NAV API kapcsolat javítása - XML sablon |
+| #222 | NAV API hibakezelés szétválasztása |
+| #221 | NAV Online Számla API v3 Integráció |
+
+#### Company Onboarding & Frontend
+| ID | Cím |
+|----|-----|
+| #220 | Frontend: Company Onboarding 3-Step Wizard |
+| #219 | Frontend: Cég Garázsa gomb a header-ben |
+
+### 2.2 Új Nyitott Kártyák (A Két Időpont Között Jöttek Létre)
+
+Ezek a kártyák a június 9-i listában még nem szerepeltek, most viszont nyitottak:
+
+| ID | Cím | Mérföldkő |
+|----|-----|-----------|
+| #256 | Skin Rendszer Alapok - Dinamikus Háttérv | Grafikai |
+| #255 | Lucide Ikonkészlet Integráció | Grafikai |
+| #253 | Háttérkép: garage_theme_dark.png | Grafikai |
+| #252 | Háttérkép: garage_company_clean.png | Grafikai |
+| #250 | 4.2-A: ContextAlert.vue + useAlert | Frontend Arch 2.0 |
+| #249 | 4.1-A: ExpansionCard.vue + animációk | Frontend Arch 2.0 |
+| #248 | 3.2-B: AppThemeStore + apply | Frontend Arch 2.0 |
+| #247 | 3.2-A: CSS Custom Properties | Frontend Arch 2.0 |
+| #246 | 3.1-B: Hardcoded szövegek cseréje | Frontend Arch 2.0 |
+| #245 | 3.1-A: Hiányzó i18n kulcsok felvétele | Frontend Arch 2.0 |
+| #244 | 2.2-B: View-ok áthelyezése nested struktúrába | Frontend Arch 2.0 |
+| #243 | 2.2-A: Route meta kiterjesztés | Frontend Arch 2.0 |
+| #242 | 2.1-B: Layout-váltó gombok | Frontend Arch 2.0 |
+| #241 | 2.1-A: Layout-ok átnevezése | Frontend Arch 2.0 |
+| #240 | 1.2-B: Frontend useFeatureFlag() | Frontend Arch 2.0 |
+| #239 | 1.2-A: Backend subscription_service | Frontend Arch 2.0 |
+| #238 | 1.1-B: Frontend useVisualSettings() | Frontend Arch 2.0 |
+
+**Fontos:** A Frontend Architektúra 2.0 kártyák NEM bővültek (20 → 20), hanem a nagyobb epikus kártyák (#228, #235, #237) lezárásra kerültek, és helyettük részletesebb alkártyák (#238-#250) nyíltak. Ez **tervszerű finomítás**, nem feladatbővülés.
+
+---
+
+## 3. Git Változások Elemzése
+
+### 3.1 Commit-ek a Június 9 óta
+
+| Commit | Dátum kb. | Üzenet | Értelmezés |
+|--------|-----------|--------|------------|
+| `90e3173` | 2026-06-10 | `frontend 2026-06-10 bontva a 2 felület` | Frontend szétválasztása B2C és B2B felületekre |
+| `0a3fd8d` | 2026-06-11 | `Járműkezelés majdnem kész frontend` | Vehicle management frontend majdnem kész |
+| `ef8df96` | 2026-06-12 | `egyedi jármű szerkesztés előtti mentés` | Egyedi jármű szerkesztés mentése |
+
+### 3.2 Módosított Fájlok (Commitálva)
+
+**Backend (modell/séma réteg):**
+- `backend/app/models/vehicle/vehicle.py` — Vehicle modell változások
+- `backend/app/models/vehicle/asset.py` — Asset modell változások
+- `backend/app/models/vehicle/vehicle_definitions.py` — Definíciók
+- `backend/app/models/identity/identity.py` — Identity modell
+- `backend/app/models/core_logic.py` — Core logika
+- `backend/app/models/marketplace/organization.py` — Organization
+- `backend/app/schemas/asset.py`, `asset_cost.py`, `organization.py`, `user.py`
+
+**Backend (szolgáltatások):**
+- `backend/app/services/auth_service.py` — +264 sor (jelentős bővítés)
+- `backend/app/services/asset_service.py` — +110 sor
+- `backend/app/services/gamification_service.py` — +45 sor
+- `backend/app/services/odometer_service.py` — Jelentős átírás (-220 sor)
+
+**Backend (API végpontok):**
+- `backend/app/api/v1/endpoints/admin.py` — +387 sor (jelentős admin API bővítés)
+- `backend/app/api/v1/endpoints/organizations.py` — +355 sor
+- `backend/app/api/v1/endpoints/auth.py`, `catalog.py`, `users.py`, `assets.py`, `expenses.py`
+
+**Frontend:**
+- `frontend/src/layouts/OrganizationLayout.vue`, `PrivateLayout.vue` — Layout átalakítások
+- `frontend/src/views/DashboardView.vue` — Dashboard bővítés
+- `frontend/src/views/ProfileView.vue` — Profil nézet
+- `frontend/src/components/header/HeaderCompanySwitcher.vue`, `HeaderProfile.vue`
+- `frontend/src/components/dashboard/VehicleFormModal.vue`, `PrivateVehicleManager.vue`, `ComplexExpenseModal.vue`, `SimpleFuelModal.vue`
+- `frontend/src/components/vehicle/VehicleDetailModal.vue`
+- `frontend/src/stores/auth.ts`, `cost.ts`
+- `frontend/src/router/index.ts`
+- `frontend/src/i18n/hu.ts`, `en.ts`
+- `frontend/src/components/LoginModal.vue`
+- `frontend/src/layouts/AdminLayout.vue`
+
+### 3.3 Commit Nélküli Változások
+
+A `git diff --stat` szerint **50+ fájl** módosult, de nincs commitolva. Ez jelentős mennyiségű munkát jelez, ami még nem került rögzítésre a Git történetben.
+
+---
+
+## 4. Programfejlesztési Állapot Értékelés
+
+### 4.1 Erősségek ✅
+
+1. **Jelentős backend előrelépés:** Az admin API (+387 sor), organization API (+355 sor) és auth service (+264 sor) jelentős bővítése.
+2. **Frontend modularizáció:** A layout-ok szétválasztása (PrivateLayout, OrganizationLayout) és a hamburger menü bevezetése a privát nézetbe.
+3. **NAV API integráció kész:** A NAV Online Számla v3 (#221, #222, #223) és a Company Onboarding (#220, #219) kártyák lezárása.
+4. **Odometer duplikáció megszüntetve:** #258 (Audit) és #259 (Clean) lezárása.
+5. **Skin rendszer alapjai:** #251 (garage_private_default.png) és #254 (Standard & Prémium kártyavázak) lezárása.
+
+### 4.2 Gyengeségek ⚠️
+
+1. **Frontend Architektúra 2.0 dominancia:** 20 nyitott kártya = a teljes nyitott készlet **32%-a**.
+2. **Phase 2, 3, 4 érintetlen:** 19 kártya a Dashboard, Advanced Features és Testing mérföldkövekben — egyik sem lett lezárva.
+3. **Kevés commit (3 db 5 nap alatt):** A sok módosított, de nem commitolt fájl kockázatot jelent.
+4. **Egyéb / Nincs mérföldkő (16 kártya, 26%):** Számos fontos feladat nincs mérföldkőhöz rendelve.
+
+### 4.3 Kockázatok 🔴
+
+1. **Commit hiány:** Ha az 50+ módosított fájl elveszne, jelentős munka veszne oda.
+2. **Frontend vs. Backend aszinkronitás:** A két réteg eltérő ütemben fejlődhet.
+3. **Tesztelési adósság:** 7 Phase 4 kártya egyike sem kezdődött el.
+
+### 4.4 Javaslatok 📋
+
+1. **Git commit-ek rendszeresítése** napi legalább 1 commit a `main` branch-re.
+2. **Phase 2 kártyák priorizálása:** Analytics Service (TCO/km) és Financial Dashboard (#147, #149, #152).
+3. **Mérföldkő hozzárendelés** a 16 "Nincs mérföldkő" kártyához.
+4. **Tesztelés beindítása:** Legalább 1-2 Phase 4 kártya (#159, #160) megkezdése.
+
+---
+
+## 5. Nyitott Kártyák Teljes Listája (62 db)
+
+### Grafikai Komponensek & Frontend Skin Rendszer (4)
+| ID | Cím |
+|----|-----|
+| #256 | Skin Rendszer Alapok - Dinamikus Háttérv |
+| #255 | Lucide Ikonkészlet Integráció (lucide-vue) |
+| #253 | Háttérkép: garage_theme_dark.png |
+| #252 | Háttérkép: garage_company_clean.png (sf_public) |
+
+### Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (20)
+| ID | Cím |
+|----|-----|
+| #250 | 4.2-A: ContextAlert.vue komponens + useAlert |
+| #249 | 4.1-A: ExpansionCard.vue komponens + animációk |
+| #248 | 3.2-B: AppThemeStore létrehozása + apply |
+| #247 | 3.2-A: CSS Custom Properties + tailwind.config |
+| #246 | 3.1-B: Hardcoded szövegek cseréje 5 komponensben |
+| #245 | 3.1-A: Hiányzó i18n kulcsok felvétele |
+| #244 | 2.2-B: View-ok áthelyezése nested struktúrába |
+| #243 | 2.2-A: Route meta kiterjesztés + beforeE |
+| #242 | 2.1-B: Layout-váltó gombok (Privát <-> Cég) |
+| #241 | 2.1-A: Layout-ok átnevezése és ` ` |
+| #240 | 1.2-B: Frontend useFeatureFlag() composable |
+| #239 | 1.2-A: Backend subscription_service + |
+| #238 | 1.1-B: Frontend useVisualSettings() composable |
+| #236 | 4.2: Kontextuális Vészjelző Rendszer |
+| #234 | 3.2: Tailwind CSS Változók és Témakezelő |
+| #233 | 3.1: Jelenlegi állapot felmérése & Szövegek |
+| #232 | 2.2: Vue Router átírás (Nested Routing) |
+| #231 | 2.1: Független Layout komponensek felépítése |
+| #230 | 1.3: Dinamikus és Statikus funkciókapcsolás |
+| #229 | 1.2: Engedélyezési szintek és Funkciókapcsolás |
+
+### Phase 4: Testing & Deployment (7)
+| ID | Cím |
+|----|-----|
+| #165 | Production Deployment and Monitoring Setup |
+| #164 | CI/CD Pipeline Setup (GitHub Actions) |
+| #163 | Security Audit (Penetration Testing) |
+| #162 | Accessibility Audit and Fixes (WCAG 2.1) |
+| #161 | Performance Optimization (Bundle Size) |
+| #160 | Implement Integration Tests (Playwright) |
+| #159 | Write Unit Tests for Critical Components |
+
+### Phase 3: Advanced Features & Epic 11 (6)
+| ID | Cím |
+|----|-----|
+| #158 | Implement Advanced Search with Filters |
+| #157 | Add Bulk Operations (Vehicle Import, Mass) |
+| #156 | Implement Webhook and Notification System |
+| #155 | Add Admin Control Panels |
+| #154 | Implement Service Booking Flow |
+| #153 | Complete Profile Selector (Private vs Co) |
+
+### Phase 2: Dashboard & Analytics Wiring (6)
+| ID | Cím |
+|----|-----|
+| #152 | Implement Historical Data (occurrence_date) |
+| #151 | Connect User Management Table to Real Data |
+| #150 | Wire Service Map with Real Provider Data |
+| #149 | Implement Analytics Service (TCO/km Calc) |
+| #148 | Connect Gamification Components to Real Data |
+| #147 | Wire Financial Dashboard to Real Finance Data |
+
+### Phase 1: Core Functionality Fixes (3)
+| ID | Cím |
+|----|-----|
+| #146 | Implement Basic Error Handling in Frontend |
+| #145 | Standardize API Base URL Usage in Frontend |
+| #142 | Implement Catalog API Endpoints |
+
+### Egyéb / Nincs mérföldkő (16)
+| ID | Cím |
+|----|-----|
+| #227 | [FIX] NAV API v3 queryTaxpayer - .env query |
+| #226 | Frontend: flag-icons CSS import hiba |
+| #225 | Hiba: Frontend - flag-icons CSS import |
+| #224 | Hiba: NAV API aláírás (INVALID_REQUEST) |
+| #218 | Frontend: Cég létrehozás 3-lépéses Onboarding |
+| #217 | Backend: Cég (Organization) CRUD API bővítés |
+| #216 | Profil UI hiányosságok (Person & User autocomplete) |
+| #175 | Advanced Analytics & Reporting Engine |
+| #174 | Real-time Notification System |
+| #173 | Service Finder Rendszer Áttekintő Dokumentum |
+| #140 | Connect Service Moderation Map to Backend |
+| #139 | Integrate Gamification Control Panel |
+| #138 | Connect Financial Dashboard Tile to Finance API |
+| #137 | Implement Real-time System Health Monitor |
+| #136 | Implement AI Researcher Logs Backend & Frontend |
+| #135 | Connect User Management Table to Real API |
+
+---
+
+## 6. Következtetés
+
+**Összességében a projekt aktív fejlesztés alatt áll,** de a commit-ek gyakorisága és a Phase 2/3/4 kártyák érintetlensége aggodalomra ad okot. A Frontend Architektúra 2.0 dominálja a nyitott kártyákat (32%), ami stratégiai döntés — a B2C/B2B szétválasztás és a dinamikus megjelenítési rendszer alapvető fontosságú a termék jövője szempontjából.
+
+**Főbb akciópontok:**
+1. Git commit-ek rendszeresítése a kód biztonsága érdekében
+2. Legalább 1 Phase 2 kártya megkezdése (pl. #149 Analytics Service vagy #147 Financial Dashboard)
+3. A 16 "Nincs mérföldkő" kártya besorolása
+4. Frontend Arch 2.0 alkártyák (20 db) szisztematikus lezárogatása
diff --git a/docs/odometer_duplication_audit.md b/docs/odometer_duplication_audit.md
new file mode 100644
index 0000000..4ea6b4f
--- /dev/null
+++ b/docs/odometer_duplication_audit.md
@@ -0,0 +1,132 @@
+# Odometer Rendszer Duplikáció Audit Jelentés
+
+**Dátum:** 2026-06-12
+**Auditor:** Rendszer-Architect
+**Gitea Issue:** #258
+
+## Összefoglaló
+
+A vizsgálat feltárta, hogy az odometer (kilométeróra) adatok **több helyen és több formában** vannak tárolva a rendszerben, ami duplikációhoz és architekturális inkonzisztenciához vezet.
+
+---
+
+## 1. Összes Odometer-rel Kapcsolatos Elem
+
+### 1.1 Különálló Táblák
+
+| # | Tábla (Schema) | Modell | Fájl | Elsődleges Entitás |
+|---|---|---|---|---|
+| 1 | `vehicle.odometer_readings` | `OdometerReading` | `backend/app/models/vehicle/asset.py:347` | Asset (járműpéldány) |
+| 2 | `vehicle.vehicle_odometer_states` | `VehicleOdometerState` | `backend/app/models/vehicle/vehicle.py:112` | VehicleModelDefinition (katalógus) |
+| 3 | `vehicle.asset_telemetry` | `AssetTelemetry` (`current_mileage`) | `backend/app/models/vehicle/asset.py:338` | Asset (járműpéldány) |
+
+### 1.2 Oszlopok Más Táblákban
+
+| # | Tábla | Oszlop | Fájl |
+|---|---|---|---|
+| 4 | `vehicle.costs` | `odometer` | `backend/app/models/vehicle/vehicle.py:97` |
+| 5 | `vehicle.asset_events` | `odometer_reading` | `backend/app/models/vehicle/asset.py:400` |
+| 6 | `marketplace.costs` | `odometer_km` | `backend/app/models/marketplace/service.py:172` |
+
+---
+
+## 2. Részletes Elemzés
+
+### 2.1 `OdometerReading` (asset.py:347)
+- **Cél:** Km óra állás időbeli nyomonkövetése. Minden egyes leolvasás külön rekord.
+- **Adatbázis séma:** `vehicle.odometer_readings(id, asset_id, reading, recorded_at, source, cost_id)`
+- **Forrás:** `manual`, `api`, `telemetry`, `cost_entry`
+- **Kötődés:** `vehicle.assets` (Asset) - **konkrét járműpéldány**
+- **FK:** `odometer_readings_asset_id_fkey` → `vehicle.assets.id`
+
+### 2.2 `VehicleOdometerState` (vehicle.py:112)
+- **Cél:** Okos odometer - becsült aktuális km állás, napi átlag, konfidencia score.
+- **Adatbázis séma:** `vehicle.vehicle_odometer_states(vehicle_id PK, last_recorded_odometer, last_recorded_date, daily_avg_distance, estimated_current_odometer, confidence_score, manual_override_avg)`
+- **Kötődés:** `vehicle.vehicle_model_definitions` (VehicleModelDefinition) - **katalógusrekord**
+- **FK:** `vehicle_odometer_states_vehicle_id_fkey` → `vehicle.vehicle_model_definitions.id`
+- **Adatforrás:** Kizárólag a `VehicleCost.odometer` mezőből számol (`odometer_service.py:59-62`)
+
+### 2.3 `AssetTelemetry.current_mileage` (asset.py:338)
+- **Cél:** Telemetriai adat tárolása.
+- **Kötődés:** `vehicle.assets` (Asset) - **konkrét járműpéldány**
+- **Nincs integrálva** sem az `OdometerReading`, sem a `VehicleOdometerState` rendszerrel.
+
+### 2.4 `VehicleCost.odometer` (vehicle.py:97)
+- **Cél:** Költségbejegyzéshez tartozó km állás (NOT NULL).
+- **Kötődés:** `vehicle.vehicle_model_definitions` (VehicleModelDefinition)
+- **Funkció:** Ez az adatforrása a `VehicleOdometerState` számításnak.
+
+### 2.5 `AssetEvent.odometer_reading` (asset.py:400)
+- **Cél:** Digitális szervizkönyv eseményhez tartozó km állás.
+- **Kötődés:** `vehicle.assets` (Asset)
+- **Nincs integrálva** semmilyen más odometer rendszerrel.
+
+### 2.6 `marketplace.costs.odometer_km`
+- **Cél:** Marketplace szolgáltatás árához tartozó km állás.
+- **Teljesen elkülönült** rendszer.
+
+---
+
+## 3. A Duplikáció Leírása
+
+### 3.1 Két Párhuzamos Odometer Rendszer
+
+#### A) Asset-alapú Odometer Rendszer (komplett, de kihasználatlan)
+- **Tábla:** `vehicle.odometer_readings`
+- **Használat:** Csak `expenses.py:108-127` hoz létre `OdometerReading` rekordot költség rögzítésekor.
+- **Kötődés:** `Asset` (konkrét jármű)
+- **Státusz:** Létezik, de **nincs kihasználva** a VehicleOdometerState számára.
+
+#### B) Vehicle-definition-alapú Odometer Rendszer (aktív, de hibás)
+- **Tábla:** `vehicle.vehicle_odometer_states`
+- **Service:** `odometer_service.py` - teljes funkcionalitás
+- **Kötődés:** `VehicleModelDefinition` (katalógusrekord)
+- **Adatforrás:** `VehicleCost.odometer` (szintén vehicle-definition-hez kötött)
+- **Státusz:** Működik, de **architekturálisan hibás**, mert egy katalógusrekordhoz rendel km állást.
+
+### 3.2 Architekturális Következmények
+
+1. **Adatvesztés:** Az Asset-hez tartozó `OdometerReading` adatok nincsenek felhasználva a VehicleOdometerState számításban.
+2. **Inkonzisztens adat:** Telemetriából vagy manuális bevitelből származó km adatok nem kerülnek be a VehicleOdometerState-ba.
+3. **Entitás zavar:** A `VehicleModelDefinition` egy katalógusrekord, aminek NINCS km óra állása.
+4. **Több forrás, nincs szinkron:** AssetTelemetry, OdometerReading, AssetEvent mind Asset-hez kötött, de nincsenek szinkronizálva.
+
+---
+
+## 4. Mermaid Diagram
+
+```mermaid
+graph TD
+ subgraph "ASSET-alapú (konkrét jármű)"
+ A[Asset] --> OR[OdometerReading vehicle.odometer_readings]
+ A --> AT[AssetTelemetry current_mileage]
+ A --> AE[AssetEvent odometer_reading]
+ end
+
+ subgraph "VEHICLE-DEFINITION-alapú (katalógus)"
+ VMD[VehicleModelDefinition] --> VOS[VehicleOdometerState vehicle.vehicle_odometer_states]
+ VMD --> VC[VehicleCost odometer]
+ end
+
+ subgraph "SZOLGÁLTATÁS"
+ OS[OdometerService odometer_service.py] -->|számol| VC
+ OS -.->|NEM használja| OR
+ end
+
+ subgraph "MARKETPLACE (elkülönült)"
+ MC[Marketplace Cost odometer_km]
+ end
+
+ style VOS fill:#f96,stroke:#333,stroke-width:2px
+ style OR fill:#9cf,stroke:#333,stroke-width:2px
+ style OS fill:#ff9,stroke:#333,stroke-width:2px
+```
+
+---
+
+## 5. Javaslat
+
+1. A `VehicleOdometerState`-ot át kell kötni az `Asset` entitáshoz.
+2. Az `OdometerService`-nek az `OdometerReading` táblából kellene számolnia.
+3. Az `AssetTelemetry.current_mileage` és `AssetEvent.odometer_reading` integrálni kell az `OdometerReading` rendszerbe.
+4. A `marketplace.costs.odometer_km` egységesítése.
diff --git a/frontend/src/components/LoginModal.vue b/frontend/src/components/LoginModal.vue
index 4caf466..65d131d 100644
--- a/frontend/src/components/LoginModal.vue
+++ b/frontend/src/components/LoginModal.vue
@@ -141,8 +141,8 @@
-
-
@@ -710,8 +924,16 @@ const showRegPassword = ref(false)
const rememberMe = ref(localStorage.getItem('remember-me-state') === 'true')
// ── 3D Flip state machine ──
-const authMode = ref<'login' | 'register' | 'forgot' | 'resend'>('login')
-const activeBackFace = ref<'register' | 'forgot' | 'resend'>('register')
+const authMode = ref<'login' | 'register' | 'forgot' | 'resend' | 'restore'>('login')
+const activeBackFace = ref<'register' | 'forgot' | 'resend' | 'restore'>('register')
+
+// ── Account Restore form fields ──
+const restoreEmail = ref('')
+const restoreOtp = ref('')
+const restoreNewPassword = ref('')
+const restoreError = ref('')
+const restoreStep = ref(1) // 1 = email, 2 = otp+password, 3 = success
+const showRestorePassword = ref(false)
// ── Restore saved email, remember-me state, and resend cooldown on mount ──
onMounted(() => {
@@ -763,6 +985,12 @@ watch(
// Reset view to login
authMode.value = 'login'
activeBackFace.value = 'register'
+ // Reset restore state
+ restoreStep.value = 1
+ restoreEmail.value = ''
+ restoreOtp.value = ''
+ restoreNewPassword.value = ''
+ restoreError.value = ''
forgotError.value = ''
forgotSuccess.value = false
isUserInactive.value = false
@@ -779,11 +1007,19 @@ watch(authMode, () => {
authStore.clearError()
})
-function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend') {
+function switchMode(mode: 'login' | 'register' | 'forgot' | 'resend' | 'restore') {
if (mode !== 'login') {
activeBackFace.value = mode
}
authMode.value = mode
+ // Reset restore state when switching away from restore
+ if (mode !== 'restore') {
+ restoreStep.value = 1
+ restoreEmail.value = ''
+ restoreOtp.value = ''
+ restoreNewPassword.value = ''
+ restoreError.value = ''
+ }
}
/**
@@ -903,6 +1139,52 @@ async function handleResend() {
resendError.value = errMsg || 'auth.resendFailed'
}
}
+
+// ── Account Restore Logic ──
+async function handleRestoreStep() {
+ restoreError.value = ''
+
+ if (restoreStep.value === 1) {
+ // Step 1: Send restoration code to email
+ try {
+ await authStore.requestRestore(restoreEmail.value)
+ // Move to step 2
+ restoreStep.value = 2
+ } catch (err: any) {
+ // Capture error from store — display user-friendly message
+ restoreError.value = authStore.error || err?.response?.data?.detail || 'auth.restoreError'
+ // Log the actual error for debugging
+ console.warn('Account restore step 1 failed:', err)
+ } finally {
+ // GUARANTEE: loading state is always reset, even if catch throws
+ authStore.isLoading = false
+ }
+ } else if (restoreStep.value === 2) {
+ // Step 2: Verify OTP + set new password
+ try {
+ await authStore.verifyRestore(restoreEmail.value, restoreOtp.value, restoreNewPassword.value)
+ // Success — show success message
+ restoreStep.value = 3
+ // Auto-close modal and redirect after short delay
+ setTimeout(() => {
+ emit('close')
+ if (authStore.isKycComplete) {
+ router.push('/dashboard')
+ } else {
+ router.push('/complete-kyc')
+ }
+ }, 2000)
+ } catch (err: any) {
+ // Capture error from store — display user-friendly message
+ restoreError.value = authStore.error || err?.response?.data?.detail || 'auth.restoreError'
+ // Log the actual error for debugging
+ console.warn('Account restore step 2 failed:', err)
+ } finally {
+ // GUARANTEE: loading state is always reset, even if catch throws
+ authStore.isLoading = false
+ }
+ }
+}
diff --git a/frontend/src/components/admin/PackageEditModal.vue b/frontend/src/components/admin/PackageEditModal.vue
new file mode 100644
index 0000000..c09a364
--- /dev/null
+++ b/frontend/src/components/admin/PackageEditModal.vue
@@ -0,0 +1,482 @@
+
+
+
+
+
+
+
+ {{ isEditing ? $t('admin.packages.modal_edit_title') : $t('admin.packages.modal_create_title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.packages.field_display_name') || 'Megjelenített név' }}
+
+
+
Emberi olvasásra szánt megjelenítési név
+
+
+
+
+
+ {{ $t('admin.packages.field_name') }}
+
+
+
Technikai azonosító (pl. private_free_v1)
+
+
+
+
+
+ {{ $t('admin.packages.field_type') }}
+
+
+ {{ $t('admin.packages.type_private') }}
+ {{ $t('admin.packages.type_corporate') }}
+
+
+
+
+
+
+
+ {{ $t('admin.packages.field_monthly_price') }}
+
+
+
+
+
+ {{ $t('admin.packages.field_yearly_price') }}
+
+
+
+
+
+ {{ $t('admin.packages.field_currency') }}
+
+
+ EUR
+ HUF
+ USD
+ GBP
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.packages.field_credit_price') }}
+
+
+
Havi kredit költség a csomaghoz
+
+
+
+
+
+
+ {{ $t('admin.packages.field_is_public') }}
+
+
+
+
+ {{ form.is_public ? $t('admin.packages.active') : $t('admin.packages.inactive') }}
+
+
+
+
+ {{ $t('admin.packages.field_available_until') }}
+
+
+
Ha üres, nincs lejárat
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.packages.field_entitlements') }}
+
+
+
+ {{ ent }}
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.packages.entitlement_placeholder') }}
+
+ {{ ent }}
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+ {{ $t('admin.packages.cancel') }}
+
+
+
+
+
+
+
+ {{ $t('admin.packages.saving') }}
+
+
+ {{ isEditing ? $t('admin.packages.save_changes') : $t('admin.packages.create') }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/dashboard/ComplexExpenseModal.vue b/frontend/src/components/dashboard/ComplexExpenseModal.vue
index b5cecab..57fb62a 100644
--- a/frontend/src/components/dashboard/ComplexExpenseModal.vue
+++ b/frontend/src/components/dashboard/ComplexExpenseModal.vue
@@ -180,6 +180,7 @@ const errorMessage = ref(null)
interface CategoryOption {
id: number
name: string
+ code?: string
parent_id: number | null
}
@@ -208,14 +209,16 @@ async function fetchCategories() {
window.__allCostCategories = allCategories
} catch (err) {
console.error('[ComplexExpenseModal] Failed to load categories:', err)
- // Fallback: provide basic categories
+ // Fallback: provide basic categories — IDs must match vehicle.cost_categories in DB
mainCategories.value = [
- { id: 1, name: 'Üzemanyag', parent_id: null },
- { id: 2, name: 'Szerviz / Karbantartás', parent_id: null },
- { id: 3, name: 'Parkolás / Útdíj', parent_id: null },
+ { id: 1, name: 'Üzemanyag / Töltés', parent_id: null },
+ { id: 2, name: 'Szerviz & Karbantartás', parent_id: null },
+ { id: 3, name: 'Gumiabroncsok', parent_id: null },
{ id: 4, name: 'Biztosítás', parent_id: null },
- { id: 5, name: 'Adók / Illetékek', parent_id: null },
- { id: 6, name: 'Egyéb', parent_id: null },
+ { id: 5, name: 'Adók', parent_id: null },
+ { id: 6, name: 'Útdíj & Parkolás', parent_id: null },
+ { id: 9, name: 'Ápolás & Kozmetika', parent_id: null },
+ { id: 10, name: 'Egyéb', parent_id: null },
]
}
}
@@ -246,22 +249,23 @@ async function handleSubmit() {
let categoryId: number
if (props.isFeeMode) {
- // Fee mode: pre-filled as FEES
+ // Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
costType = 'fee'
- categoryId = 3 // Parkolás / Útdíj
+ const allCats: CategoryOption[] = (window as any).__allCostCategories || []
+ const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
+ categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
} else {
// Complex mode: use selected sub-category or main category
costType = 'other'
- categoryId = Number(form.subCategory || form.mainCategory) || 6
+ categoryId = Number(form.subCategory || form.mainCategory) || 10
}
const payload = {
asset_id: props.vehicle.id,
// organization_id is omitted — backend auto-resolves from asset
category_id: categoryId,
- cost_type: costType,
- amount_local: form.amount,
- currency_local: 'HUF' as const,
+ amount_net: form.amount,
+ currency: 'HUF' as const,
date: new Date(form.date).toISOString(),
mileage_at_cost: null,
description: form.description || (props.isFeeMode ? 'Parkolás / Útdíj' : 'Egyéb költség'),
diff --git a/frontend/src/components/dashboard/PrivateVehicleManager.vue b/frontend/src/components/dashboard/PrivateVehicleManager.vue
index 766a477..403a627 100644
--- a/frontend/src/components/dashboard/PrivateVehicleManager.vue
+++ b/frontend/src/components/dashboard/PrivateVehicleManager.vue
@@ -13,23 +13,13 @@
-
-
-
-
+
- {{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
+ Új jármű hozzáadása
@@ -70,7 +60,7 @@
:key="vehicle.id"
:vehicle="vehicle"
@click="openDetailView(vehicle)"
- @edit="openEditDirect(vehicle)"
+ @edit="$emit('edit-vehicle', vehicle)"
@set-primary="setPrimaryVehicle"
/>
@@ -86,41 +76,17 @@
Még nincsenek járművek
Kattints az "Új jármű hozzáadása" gombra az első jármű felvételéhez.
-
-
-
-
+
- {{ isCheckingQuota ? 'Ellenőrzés...' : 'Új jármű hozzáadása' }}
+ Új jármű hozzáadása
-
-
-
-
-
-
- {{ triggerEditFromParent }}
@@ -138,18 +102,16 @@
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useVehicleStore } from '../../stores/vehicle'
-import api from '../../api/axios'
-import VehicleFormModal from './VehicleFormModal.vue'
import VehicleDetailModal from '../vehicle/VehicleDetailModal.vue'
import VehicleCardStandard from '../vehicle/VehicleCardStandard.vue'
const props = defineProps<{
targetVehicleId?: string | null
- editTargetVehicle?: VehicleData | null
}>()
const emit = defineEmits<{
- 'clear-edit-target': []
+ 'add-new': []
+ 'edit-vehicle': [vehicle: VehicleData]
}>()
const vehicleStore = useVehicleStore()
@@ -188,71 +150,12 @@ function openDetailView(vehicle: VehicleData) {
isDetailOpen.value = true
}
-// ── Direct Edit (from card edit button) ──
-function openEditDirect(vehicle: VehicleData) {
- editingVehicle.value = vehicle ? { ...vehicle } : null
- isEditFormOpen.value = true
-}
-
-// ── Bridge: Detail → Edit ──
+// ── Bridge: Detail → Edit (emits to parent DashboardView) ──
async function openEditFromDetail(vehicle: VehicleData) {
isDetailOpen.value = false
// Use nextTick so detail modal closes before edit opens
await nextTick()
- editingVehicle.value = vehicle ? { ...vehicle } : null
- isEditFormOpen.value = true
-}
-
-// ── Modal state (Add) ──
-const isAddFormOpen = ref(false)
-const isCheckingQuota = ref(false)
-
-/**
- * Preemptive quota check before opening the add-vehicle modal.
- * Calls GET /api/v1/assets/vehicles/quota-status.
- * If the user has reached their limit, shows a blocking toast/alert instead of opening the form.
- */
-async function handleAddNewClick() {
- if (isCheckingQuota.value) return
- isCheckingQuota.value = true
-
- try {
- const res = await api.get('/assets/vehicles/quota-status')
- const { can_add, current_count, limit } = res.data
-
- if (!can_add) {
- // Block — show a prominent warning instead of opening the modal
- alert(
- `Elérted a csomagodhoz tartozó járműlimitet (${current_count}/${limit})! ` +
- 'Kérjük, válts magasabb csomagra az új jármű rögzítéséhez.'
- )
- return
- }
-
- // Quota OK — open the add form
- isAddFormOpen.value = true
- } catch (err: any) {
- console.error('[PrivateVehicleManager] Quota check failed:', err)
- // Fail open — if the API is unreachable, let the user proceed
- isAddFormOpen.value = true
- } finally {
- isCheckingQuota.value = false
- }
-}
-
-function onVehicleSaved() {
- isAddFormOpen.value = false
- // In real implementation, refresh the vehicle list from the store
-}
-
-// ── Modal state (Edit) ──
-const isEditFormOpen = ref(false)
-const editingVehicle = ref(null)
-
-function onVehicleEdited() {
- isEditFormOpen.value = false
- editingVehicle.value = null
- // In real implementation, refresh the vehicle list from the store
+ emit('edit-vehicle', vehicle)
}
// ── Targeted scroll to vehicle card ──
@@ -276,24 +179,9 @@ watch(() => props.targetVehicleId, (newVal) => {
}
})
-// ── Task 3: Watch for editTargetVehicle from parent (DashboardView) ──
-const triggerEditFromParent = computed(() => {
- if (props.editTargetVehicle) {
- // Open the edit form directly with the vehicle data
- editingVehicle.value = { ...props.editTargetVehicle } as VehicleData
- isEditFormOpen.value = true
- // Clear the prop so it doesn't re-trigger
- emit('clear-edit-target')
- }
- return props.editTargetVehicle?.id || null
-})
-
// ── Set Primary Vehicle ──
async function setPrimaryVehicle(vehicle: VehicleData) {
console.log('[PrivateVehicleManager] setPrimaryVehicle called for:', vehicle.id, vehicle.license_plate)
- // TODO: Real API call — PUT /api/v1/assets/vehicles/{id} with { is_primary: true }
- // This will also need to unset any other primary vehicle for this user.
- // For now, just log and update local state optimistically.
vehicleStore.setPrimaryVehicle(vehicle.id)
}
diff --git a/frontend/src/components/dashboard/SimpleFuelModal.vue b/frontend/src/components/dashboard/SimpleFuelModal.vue
index 51ee062..549a86e 100644
--- a/frontend/src/components/dashboard/SimpleFuelModal.vue
+++ b/frontend/src/components/dashboard/SimpleFuelModal.vue
@@ -110,8 +110,9 @@
+.overflow-y-auto::-webkit-scrollbar-thumb {
+ background: #cbd5e1;
+ border-radius: 3px;
+}
+.overflow-y-auto::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
+
+/* ── Autocomplete dropdown animation ── */
+.absolute.z-50 {
+ animation: dropdown-fade-in 0.15s ease-out;
+}
+@keyframes dropdown-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
\ No newline at end of file
diff --git a/frontend/src/components/header/HeaderCompanySwitcher.vue b/frontend/src/components/header/HeaderCompanySwitcher.vue
index 58debe5..9b1cc16 100644
--- a/frontend/src/components/header/HeaderCompanySwitcher.vue
+++ b/frontend/src/components/header/HeaderCompanySwitcher.vue
@@ -4,29 +4,15 @@
v-if="authStore.isAuthenticated"
@click.stop="isOpen = !isOpen"
:disabled="authStore.isLoading"
- class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
- :class="orgButtonClass"
+ class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer text-white/70 hover:bg-white/5 hover:text-white"
>
-
-
-
-
+
+
+
-
-
-
-
-
- {{ orgButtonLabel }}
-
+ {{ t('header.garageSelector') }}
+
-
+
-
-
-
+
+
+
+
+ 👤 {{ t('header.privateGarage') }}
+
-
-
-
- 👤 {{ t('header.privateGarage') }}
-
-
+
+
+
-
-
-
-
-
-
-
- {{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}
-
-
+
+
+
+ {{ t('header.myCompanies') }}
+
+
+
+
+
+ {{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}
+
+
+
+
+
+
-
-
- {{ t('header.noCompanies') }}
-
-
-
-
-
-
-
-
-
-
-
- {{ t('header.newCompany') }}
-
-
+
+
+
+
+
+
+ {{ t('header.createCompany') }}
+
+
+
+
+
+ {{ t('header.joinCompany') }}
+
@@ -136,7 +134,15 @@ onMounted(async () => {
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
await authStore.fetchMyOrganizations()
}
- console.log('🔍 [HeaderCompanySwitcher] Available companies:', authStore.myOrganizations)
+ console.log('🔍 [HeaderCompanySwitcher] All orgs:', authStore.myOrganizations)
+ console.log('🔍 [HeaderCompanySwitcher] Company orgs (filtered):', companyOrganizations.value)
+})
+
+// ── Filter: only real companies (exclude individual/private orgs) ──
+const companyOrganizations = computed(() => {
+ return authStore.myOrganizations.filter(
+ (org) => org.org_type && org.org_type !== 'individual'
+ )
})
// ── Route-based detection ─────────────────────────────────────────
@@ -144,55 +150,6 @@ const isOnCompanyGarage = computed(() => {
return route.path.startsWith('/organization/')
})
-// ── Current org ID from route (when on company garage) ────────────
-const currentOrgId = computed(() => {
- if (!isOnCompanyGarage.value) return null
- return Number(route.params.id) || null
-})
-
-// ── Filtered organizations: exclude the current one when on garage ─
-const filteredOrganizations = computed(() => {
- const orgs = authStore.myOrganizations
- if (isOnCompanyGarage.value && currentOrgId.value) {
- return orgs.filter((o) => o.organization_id !== currentOrgId.value)
- }
- return orgs
-})
-
-// ── Organization Button State ──────────────────────────────────────
-const orgButtonState = computed(() => {
- if (!authStore.isAuthenticated) return 'create'
- if (isOnCompanyGarage.value) return 'on-garage'
- if (authStore.isCorporateMode) return 'corporate'
- if (authStore.hasBusinessOrganization) return 'switch'
- return 'create'
-})
-
-const orgButtonLabel = computed(() => {
- switch (orgButtonState.value) {
- case 'create':
- return t('header.newCompany')
- case 'switch': {
- const count = authStore.myOrganizations.length
- if (count === 1) return t('header.myCompany')
- return t('header.myCompanies')
- }
- case 'corporate':
- return t('header.personalGarage')
- case 'on-garage':
- return t('header.privateGarage')
- default:
- return t('header.company')
- }
-})
-
-const orgButtonClass = computed(() => {
- if (orgButtonState.value === 'corporate') {
- return 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 hover:bg-yellow-500/25 hover:text-yellow-300'
- }
- return 'text-white/70 hover:bg-white/5 hover:text-white'
-})
-
// ── Check if org is the active one ─────────────────────────────────
function isActiveOrg(org: OrganizationItem): boolean {
return authStore.user?.active_organization_id === org.organization_id
@@ -205,12 +162,20 @@ async function goToPersonalDashboard() {
router.push('/dashboard')
}
-// ── Navigate to onboard ───────────────────────────────────────────
-function goToOnboard() {
+// ── PLG: Create Company ───────────────────────────────────────────
+function handleCreateCompany() {
isOpen.value = false
+ console.log('🔧 [PLG] Create Company clicked — modal/onboarding coming soon')
router.push('/company/onboard')
}
+// ── PLG: Join Company ─────────────────────────────────────────────
+function handleJoinCompany() {
+ isOpen.value = false
+ console.log('🔧 [PLG] Join Company clicked — join flow coming soon')
+ alert('🔗 Join Company — This feature is coming soon!')
+}
+
// ── Close dropdown on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
diff --git a/frontend/src/components/header/HeaderProfile.vue b/frontend/src/components/header/HeaderProfile.vue
index 4f8ab08..a54fc33 100644
--- a/frontend/src/components/header/HeaderProfile.vue
+++ b/frontend/src/components/header/HeaderProfile.vue
@@ -50,6 +50,28 @@
{{ t('header.profile') }}
+
+
+
+
+
+
+ {{ t('header.adminCenter') }}
+
@@ -120,12 +142,26 @@ const initials = computed(() => {
return '?'
})
+// ── Admin check: only admin/superadmin/region_admin/country_admin/moderator ──
+const isAdmin = computed(() => {
+ const role = authStore.user?.role
+ if (!role) return false
+ const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
+ return adminRoles.includes(role)
+})
+
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isOpen.value = false
router.push('/profile')
}
+// ── Navigate to Admin Center ───────────────────────────────────────
+function goToAdmin() {
+ isOpen.value = false
+ router.push('/admin')
+}
+
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isOpen.value = false
diff --git a/frontend/src/components/organization/CompanyDataModal.vue b/frontend/src/components/organization/CompanyDataModal.vue
new file mode 100644
index 0000000..8c0930f
--- /dev/null
+++ b/frontend/src/components/organization/CompanyDataModal.vue
@@ -0,0 +1,318 @@
+
+
+
+
+
+
+
+ {{ isEditing ? t('company.settingsModalTitle') : t('company.companyDataTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('company.settingsName') }}
+
+
{{ org?.name || '—' }}
+
+
+
+
+
{{ t('company.settingsFullName') }}
+
+
{{ org?.full_name || '—' }}
+
+
+
+
+
{{ t('company.settingsDisplayName') }}
+
+
{{ org?.display_name || '—' }}
+
+
+
+
+
{{ t('company.settingsTaxNumber') }}
+
+
{{ org?.tax_number || '—' }}
+
+
+
+
+ {{ t('company.addressTitle') || 'Address' }}
+
+
+
+
{{ t('company.settingsAddressZip') }}
+
+
{{ org?.address_zip || '—' }}
+
+
+
+
+
{{ t('company.settingsAddressCity') }}
+
+
{{ org?.address_city || '—' }}
+
+
+
+
+
{{ t('company.settingsAddressStreet') }}
+
+
{{ org?.address_street_name || '—' }}
+
+
+
+
+
{{ t('company.settingsAddressHouseNumber') }}
+
+
{{ org?.address_house_number || '—' }}
+
+
+
+
+
+
+
+ {{ isEditing ? t('profile.cancel') : t('company.close') || t('header.close') }}
+
+
+
+
+ {{ t('profile.edit') }}
+
+
+
+
+
+
+
+
+
+ {{ t('company.submitting') }}
+
+ {{ t('profile.save') }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/organization/OrganizationSettingsModal.vue b/frontend/src/components/organization/OrganizationSettingsModal.vue
new file mode 100644
index 0000000..17868d4
--- /dev/null
+++ b/frontend/src/components/organization/OrganizationSettingsModal.vue
@@ -0,0 +1,251 @@
+
+
+
+
+
+
+
{{ t('company.settingsModalTitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('company.cancel') }}
+
+
+
+
+
+
+
+ {{ t('company.submitting') }}
+
+ {{ t('profile.save') }}
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/vehicle/VehicleDetailModal.vue b/frontend/src/components/vehicle/VehicleDetailModal.vue
index cf78bfe..b73df38 100644
--- a/frontend/src/components/vehicle/VehicleDetailModal.vue
+++ b/frontend/src/components/vehicle/VehicleDetailModal.vue
@@ -170,168 +170,144 @@
-
-
-
Éves Összköltség (TCO)
-
-
~ 3,411 Ft / nap
+
+
-
-
-
-
-
-
-
- Költségtípus
- Összeg
-
-
-
-
- Biztosítás (KGFB + Casco)
- 320,000 Ft
-
-
- Gépjárműadó
- 45,000 Ft
-
-
- Műszaki vizsga
- 18,000 Ft
-
-
- Tankolás (üzemanyag)
- 520,000 Ft
-
-
- Szerviz & Karbantartás
- 210,000 Ft
-
-
- Parkolás & Útdíj
- 132,000 Ft
-
-
-
-
- Összesen
- 1,245,000 Ft
-
-
-
+
+
+
+
Éves Összköltség (TCO)
+
+
~ {{ dailyCostDisplay }} / nap
+
+
+
+
+
+
Nincs rögzített költség
+
+
+
+
+
+ Dátum
+ Kategória
+ Összeg
+
+
+
+
+ {{ formatDate(cost.occurrence_date) }}
+ {{ cost.category_name || cost.category }}
+ {{ formatAmount(cost.amount) }}
+
+
+
+
+ Összesen
+ {{ totalCostDisplay }}
+
+
+
+
+
+
+
+
+
+
Nincs rögzített költség
+
+
+
+
+
+
🔒
+
Állandó költségek
+
+
+
+ {{ cost.category_name || cost.category }}
+ {{ formatAmount(cost.amount) }}
+
+
Nincs állandó költség
+
+ Állandó összesen
+ {{ formatAmount(fixedTotal) }}
+
+
+
+
+
+
+
+
+
+ {{ cost.category_name || cost.category }}
+ {{ formatAmount(cost.amount) }}
+
+
Nincs folyó költség
+
+ Folyó összesen
+ {{ formatAmount(runningTotal) }}
+
+
+
+
+
+
+
+
Költségeloszlás (éves)
+
+
+
+ {{ cat.label }}
+ {{ cat.percent }}%
+
+
+
+
Nincs adat
+
+
+
+
+
+
+
+ TCO / km: ~ {{ tcoPerKmDisplay }}
+ |
+ Havi átlag: ~ {{ monthlyAverageDisplay }}
+
-
-
-
-
-
-
-
-
🔒
-
Állandó költségek
-
-
-
- Biztosítás (KGFB + Casco)
- 320,000 Ft
-
-
- Gépjárműadó
- 45,000 Ft
-
-
- Műszaki vizsga
- 18,000 Ft
-
-
- Állandó összesen
- 383,000 Ft
-
-
-
-
-
-
-
-
-
- Tankolás (üzemanyag)
- 520,000 Ft
-
-
- Szerviz & Karbantartás
- 210,000 Ft
-
-
- Parkolás & Útdíj
- 132,000 Ft
-
-
- Folyó összesen
- 862,000 Ft
-
-
-
-
-
-
-
-
Költségeloszlás (éves)
-
-
-
- Biztosítás
- 26%
-
-
-
-
-
- Üzemanyag
- 42%
-
-
-
-
-
-
-
-
-
-
-
-
- TCO / km: ~ 62 Ft/km
- |
- Havi átlag: ~ 103,750 Ft
-
-
@@ -448,6 +424,7 @@
import { ref, computed, watch } from 'vue'
import type { VehicleData } from '../../types/vehicle'
import { useAuthStore } from '../../stores/auth'
+import api from '../../api/axios'
import VehiclePlateBadge from './VehiclePlateBadge.vue'
const props = defineProps<{
@@ -476,9 +453,114 @@ const tabs = [
watch(() => props.isOpen, (newVal) => {
if (newVal) {
activeTab.value = 'basics'
+ vehicleCosts.value = []
+ costsLoading.value = false
}
})
+// ── Financials: Vehicle Costs from API ──
+interface CostItem {
+ id: string | number
+ /** Backend returns `date` (ISO datetime), mapped to `occurrence_date` for display */
+ occurrence_date: string
+ /** Backend returns `category_name` from CostCategory relationship */
+ category: string
+ category_name?: string
+ /** Backend returns `category_code` from CostCategory relationship */
+ category_code?: string
+ /** Backend returns `amount_net` (Decimal) — the main monetary value */
+ amount: number
+ /** Backend returns `currency` (e.g. 'HUF') */
+ currency?: string
+ /** Backend returns `description` extracted from data JSONB */
+ description?: string
+ /** Backend returns `mileage_at_cost` extracted from data JSONB */
+ mileage_at_cost?: number
+ /** Raw backend fields kept for reference */
+ amount_net?: number
+ currency_raw?: string
+ date?: string
+ data?: Record
+}
+
+const vehicleCosts = ref([])
+const costsLoading = ref(false)
+
+/**
+ * Map a raw backend AssetCostResponse item to the frontend CostItem shape.
+ * Backend schema (AssetCostResponse) now uses:
+ * amount_net, currency, date, category_name, category_code, description, mileage_at_cost
+ */
+function mapBackendCost(raw: any): CostItem {
+ return {
+ id: raw.id,
+ occurrence_date: raw.date || raw.occurrence_date || '',
+ category: raw.category_name || raw.category_code || raw.category || '',
+ category_name: raw.category_name,
+ category_code: raw.category_code,
+ amount: Number(raw.amount_net ?? raw.amount ?? 0),
+ currency: raw.currency || 'HUF',
+ description: raw.description || raw.data?.description || '',
+ mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
+ // Keep raw fields for reference
+ amount_net: raw.amount_net,
+ currency_raw: raw.currency,
+ date: raw.date,
+ data: raw.data,
+ }
+}
+
+/**
+ * Fetch costs for the current vehicle when the 'finance' tab is activated.
+ * GET /api/v1/assets/{vehicle.id}/costs
+ */
+watch(() => activeTab.value, async (tab) => {
+ if (tab === 'finance' && props.vehicle?.id) {
+ costsLoading.value = true
+ try {
+ const res = await api.get(`/assets/${props.vehicle.id}/costs`)
+ const rawData = (res.data as any[]) || []
+ vehicleCosts.value = rawData.map(mapBackendCost)
+ } catch (err: any) {
+ console.error('[VehicleDetailModal] Failed to fetch costs:', err)
+ vehicleCosts.value = []
+ } finally {
+ costsLoading.value = false
+ }
+ }
+})
+
+/**
+ * Also fetch costs when the vehicle prop changes while on the finance tab.
+ */
+watch(() => props.vehicle, (newVehicle) => {
+ if (activeTab.value === 'finance' && newVehicle?.id) {
+ costsLoading.value = true
+ api.get(`/assets/${newVehicle.id}/costs`)
+ .then(res => {
+ const rawData = (res.data as any[]) || []
+ vehicleCosts.value = rawData.map(mapBackendCost)
+ })
+ .catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
+ .finally(() => { costsLoading.value = false })
+ }
+})
+
+// ── Format date helper ──
+function formatDate(dateStr: string): string {
+ if (!dateStr) return '—'
+ const d = new Date(dateStr)
+ if (isNaN(d.getTime())) return '—'
+ return d.toLocaleDateString('hu-HU', { year: 'numeric', month: '2-digit', day: '2-digit' })
+}
+
+// ── Format currency helper ──
+function formatAmount(amount: number): string {
+ if (amount === undefined || amount === null) return '—'
+ if (isNaN(amount)) return '—'
+ return new Intl.NumberFormat('hu-HU', { style: 'currency', currency: 'HUF', maximumFractionDigits: 0 }).format(amount)
+}
+
// ── Task 4: Trust Score computed helpers ──
const trustScorePercent = computed(() => {
const v = props.vehicle
@@ -543,6 +625,100 @@ const hpDisplay = computed(() => {
if (!kw) return '—'
return `${Math.round(kw * 1.341)} LE`
})
+
+// ── Financial computed helpers ──
+const totalCost = computed(() => {
+ return vehicleCosts.value.reduce((sum, c) => {
+ const amt = Number(c.amount)
+ return sum + (isNaN(amt) ? 0 : amt)
+ }, 0)
+})
+
+const totalCostDisplay = computed(() => {
+ return formatAmount(totalCost.value)
+})
+
+const dailyCostDisplay = computed(() => {
+ if (!totalCost.value || totalCost.value === 0) return '0 Ft'
+ const daily = totalCost.value / 365
+ return formatAmount(daily)
+})
+
+const monthlyAverageDisplay = computed(() => {
+ if (!totalCost.value || totalCost.value === 0) return '0 Ft'
+ const monthly = totalCost.value / 12
+ return formatAmount(monthly)
+})
+
+const tcoPerKmDisplay = computed(() => {
+ const mileage = props.vehicle?.current_mileage || props.vehicle?.mileage
+ if (!mileage || mileage === 0 || !totalCost.value || totalCost.value === 0) return '—'
+ const perKm = totalCost.value / mileage
+ return formatAmount(perKm) + '/km'
+})
+
+// ── Premium breakdown: fixed vs running costs ──
+const fixedCategories = ['insurance', 'tax', 'inspection', 'Biztosítás', 'Gépjárműadó', 'Műszaki vizsga', 'Adó', 'Kötelező']
+const fixedCosts = computed(() => {
+ return vehicleCosts.value.filter(c => {
+ const cat = (c.category || '').toLowerCase()
+ const name = (c.category_name || '').toLowerCase()
+ return fixedCategories.some(fc => cat.includes(fc.toLowerCase()) || name.includes(fc.toLowerCase()))
+ })
+})
+
+const runningCosts = computed(() => {
+ return vehicleCosts.value.filter(c => {
+ const cat = (c.category || '').toLowerCase()
+ const name = (c.category_name || '').toLowerCase()
+ return !fixedCategories.some(fc => cat.includes(fc.toLowerCase()) || name.includes(fc.toLowerCase()))
+ })
+})
+
+const fixedTotal = computed(() => fixedCosts.value.reduce((sum, c) => {
+ const amt = Number(c.amount)
+ return sum + (isNaN(amt) ? 0 : amt)
+}, 0))
+const runningTotal = computed(() => runningCosts.value.reduce((sum, c) => {
+ const amt = Number(c.amount)
+ return sum + (isNaN(amt) ? 0 : amt)
+}, 0))
+
+// ── Cost distribution for premium chart ──
+const chartColors = [
+ 'bg-blue-500',
+ 'bg-emerald-500',
+ 'bg-amber-500',
+ 'bg-purple-500',
+ 'bg-rose-500',
+ 'bg-cyan-500',
+ 'bg-orange-500',
+ 'bg-teal-500',
+]
+
+interface DistributionItem {
+ label: string
+ percent: number
+ color: string
+}
+
+const costDistribution = computed(() => {
+ if (!totalCost.value || totalCost.value === 0) return []
+ // Group by category_name or category
+ const grouped: Record = {}
+ for (const c of vehicleCosts.value) {
+ const key = c.category_name || c.category || 'Egyéb'
+ const amt = Number(c.amount)
+ grouped[key] = (grouped[key] || 0) + (isNaN(amt) ? 0 : amt)
+ }
+ return Object.entries(grouped)
+ .map(([label, amount], idx) => ({
+ label,
+ percent: Math.round((amount / totalCost.value) * 100),
+ color: chartColors[idx % chartColors.length],
+ }))
+ .sort((a, b) => b.percent - a.percent)
+})
diff --git a/frontend/src/views/admin/AdminServicesView.vue b/frontend/src/views/admin/AdminServicesView.vue
new file mode 100644
index 0000000..2d7b0b2
--- /dev/null
+++ b/frontend/src/views/admin/AdminServicesView.vue
@@ -0,0 +1,349 @@
+
+
+
+
+
+
{{ $t('admin.services.title') }}
+
{{ $t('admin.services.subtitle') }}
+
+
+
+
+
+ {{ $t('admin.services.create_button') }}
+
+
+
+
+
+
+ {{ $t('admin.services.refresh') }}
+
+
+
+
+
+ {{ $t('admin.services.loading') }}
+
+
+
+
+ {{ store.error }}
+
+
+
+
+
🛠️
+
{{ $t('admin.services.empty') }}
+
{{ $t('admin.services.empty_hint') }}
+
+
+
+
+
+
+
+ {{ $t('admin.services.badge_inactive') }}
+
+
+
+
+
{{ svc.name }}
+
+
+ {{ svc.service_code }}
+
+
+ {{ svc.description }}
+
+
+
+
+
+
+ {{ $t('admin.services.credit_cost_label') }}
+ {{ svc.credit_cost }} {{ $t('admin.services.credit_unit') }}
+
+
+
+
+
+
+ {{ $t('admin.services.edit_button') }}
+
+
+ {{ svc.is_active ? $t('admin.services.deactivate_button') : $t('admin.services.activate_button') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingService ? $t('admin.services.modal_edit_title') : $t('admin.services.modal_create_title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.services.field_code') }}
+
+
+
{{ formErrors.service_code }}
+
+
+
+
+
+ {{ $t('admin.services.field_name') }}
+
+
+
{{ formErrors.name }}
+
+
+
+
+
+ {{ $t('admin.services.field_description') }}
+
+
+
+
+
+
+
+ {{ $t('admin.services.field_credit_cost') }}
+
+
+
+
+
+
+
+
+ {{ $t('admin.services.cancel') }}
+
+
+ {{ $t('admin.services.saving') }}
+ {{ editingService ? $t('admin.services.save_changes') : $t('admin.services.create') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/admin/AdminUsersView.vue b/frontend/src/views/admin/AdminUsersView.vue
new file mode 100644
index 0000000..c87b59b
--- /dev/null
+++ b/frontend/src/views/admin/AdminUsersView.vue
@@ -0,0 +1,594 @@
+
+
+
+
+
+
+ {{ $t('admin.users.search_category_all') }}
+ {{ $t('admin.users.search_category_email') }}
+ {{ $t('admin.users.search_category_name') }}
+ {{ $t('admin.users.search_category_phone') }}
+ {{ $t('admin.users.search_category_id') }}
+ {{ $t('admin.users.search_category_address') }}
+
+
+
+
+
+
+
+ {{ $t('admin.users.filter_all_roles') }}
+ Superadmin
+ Admin
+ Region Admin
+ Country Admin
+ Moderator
+ User
+ Service Owner
+ Fleet Manager
+ Driver
+
+
+
+
+ {{ $t('admin.users.filter_all_status') }}
+ {{ $t('admin.users.status_active') }}
+ {{ $t('admin.users.status_inactive') }}
+ {{ $t('admin.users.status_deleted') }}
+
+
+
+
+ {{ $t('admin.users.refresh') }}
+
+
+
+
+
+
+ {{ $t('admin.users.n_selected', { count: selectedUserIds.length }) }}
+
+
+
+ {{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_ban') }}
+
+
+ {{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_unban') }}
+
+
+ {{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_soft_delete') }}
+
+
+ {{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_restore') }}
+
+
+
+
+ {{ bulkLoading ? $t('admin.users.loading') : $t('admin.users.bulk_hard_delete') }}
+
+
+
+ {{ $t('admin.users.clear_selection') }}
+
+
+
+
+
+ {{ $t('admin.users.loading') }}
+
+
+
+
+ {{ store.error }}
+
+
+
+
+
+
+
+
+
+
+ ID
+ {{ $t('admin.users.col_email') }}
+ {{ $t('admin.users.col_name') }}
+ {{ $t('admin.users.col_phone') }}
+ {{ $t('admin.users.col_address') }}
+ {{ $t('admin.users.col_role') }}
+ {{ $t('admin.users.col_status') }}
+ {{ $t('admin.users.col_subscription') }}
+ {{ $t('admin.users.col_region') }}
+ {{ $t('admin.users.col_created') }}
+ {{ $t('admin.users.col_actions') }}
+
+
+
+
+
+
+
+ {{ user.id }}
+ {{ user.email }}
+ {{ user.first_name || '' }} {{ user.last_name || '' }}
+ {{ user.phone || '-' }}
+ {{ user.address || '-' }}
+
+
+ {{ user.role }}
+
+
+
+
+ {{ statusLabel(user) }}
+
+
+ {{ user.subscription_plan }}
+ {{ user.region_code }}
+ {{ formatDate(user.created_at) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.users.action_edit') }}
+
+
+
+
+
+ {{ $t('admin.users.action_password_reset') }}
+
+
+
+
+
+ {{ $t('admin.users.action_ban') }}
+
+
+
+
+
+ {{ $t('admin.users.action_unban') }}
+
+
+
+
+
+ {{ $t('admin.users.action_soft_delete') }}
+
+
+
+
+
+ {{ $t('admin.users.action_restore') }}
+
+
+
+
+
+
+ {{ $t('admin.users.action_hard_delete') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ searchQuery ? $t('admin.users.empty_search') : $t('admin.users.empty_no_users') }}
+
+
+ {{ $t('admin.users.empty_search_hint') }}
+
+
+ {{ $t('admin.users.clear_search') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('admin.users.showing_of', { showing: store.users.length, total: store.total }) }}
+
+
+
+ {{ $t('admin.users.pagination_prev') }}
+
+
+ {{ $t('admin.users.pagination_page', { current: currentPage, total: totalPages }) }}
+
+
+ {{ $t('admin.users.pagination_next') }}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/organization/CompanyGarageView.vue b/frontend/src/views/organization/CompanyGarageView.vue
index 787893e..da0a638 100644
--- a/frontend/src/views/organization/CompanyGarageView.vue
+++ b/frontend/src/views/organization/CompanyGarageView.vue
@@ -205,6 +205,17 @@
{{ t('company.quickSchedule') || 'Schedule Service' }}
+
+
+
+
+
+
+ {{ t('company.settingsTitle') }}
+
@@ -224,15 +235,26 @@
+
+
+