diff --git a/.roo/history.md b/.roo/history.md index ae20921..6796eed 100644 --- a/.roo/history.md +++ b/.roo/history.md @@ -8,654 +8,220 @@ Backend SQL hibák javítása a GET /admin/users végponton (Address outerjoin, ### 🔧 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. +- `GET /admin/users` SQL javítás: `Address` outerjoin-ra változtatva (`isouter=True`), hogy a cím nélküli userek is visszajöjjenek +- Telefonszám keresés: `phone` mező hozzáadva a `or_()` feltételhez -**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. +**2. FRONTEND - [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue:1):** +- `HeaderProfile` komponens beillesztve a jobb felső sarokba (belépett user avatar + név) +- A `SidebarToggle` gomb fixálva a sidebar szélén -**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. +**3. FRONTEND - [`AdminUsersView.vue`](frontend/src/views/AdminUsersView.vue:1):** +- **Sticky Bulk Action Bar:** A tömeges műveletek sáv (`BulkActionBar`) `sticky` pozícionálást kapott, hogy görgetéskor is látszódjon +- **Clear/X gomb:** A keresőmezőbe egy `X` gomb került, ami egy kattintással törli a keresési feltételt -### ✅ 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 +## 2026-06-16 - Fix 404 API Router & Redesign Dashboard Launcher Card ### 🎯 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. +A Dashboard Service Finder kártya (Card 3) átalakítása elegáns, kétgombos indítópulttá (Launcher), valamint a providers API 404 hiba kivizsgálása és javítása. ### 🔧 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. +**1. BACKEND AUDIT - [`api.py`](backend/app/api/v1/api.py:38):** +- Ellenőrizve: `providers` router regisztrálva van (`include_router(providers.router, prefix="/providers")`) +- Ellenőrizve: providers végpontokon NINCS trailing slash (`/categories`, `/search`, `/quick-add`) +- **Route-ok élőben is ellenőrizve:** `GET /api/v1/providers/categories`, `GET /api/v1/providers/search`, `POST /api/v1/providers/quick-add` mind aktívak -### ✅ 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. +**2. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:223):** +- `fetchCategories()` már rendelkezik try/catch blokkal, 11 hardcoded fallback kategóriával +- Nincs szükség módosításra -## 2026-06-08 - F5 Refresh Logout Bug Fix & HeaderLogo Contextual Navigation +**3. FRONTEND - [`DashboardView.vue`](frontend/src/views/DashboardView.vue:187):** +- **Card 3 teljes átalakítása:** A 3-lépéses kontextuális kereső űrlap (input, 2 dropdown, keresés gomb) ELTÁVOLÍTVA +- **Launcher dizájn:** Két nagy gomb, szépen formázva: + - `🔍 Szervizek Keresése` — nagy, zöld gradient gomb, `isSearchModalOpen` modalt nyit + - `➕ Új Szolgáltató Rögzítése` — szekunder, dashed border gomb, `isSfQuickAddOpen` modalt nyit (meglévő ProviderQuickAddModal) +- **Search Modal (Placeholder):** Teleportált modál, benne: `🔍` ikon, "Részletes kereső térképpel hamarosan..." szöveg, fejlesztés alatt státusz +- Régi `sfSearchLocation`, `sfSearchCategory`, `sfSearchVehicleId`, `sfCategories`, `fetchSfCategories()`, `onSfSearch()` eltávolítva + +## 2026-06-17 - P0 Bugfix: Provider Data Persistence & Schema Alignment ### 🎯 Cél -Két frontend hiba javítása: (1) F5 oldalfrissítéskor a Vue Router guard hamarabb fut le, mint a Pinia Auth Store init() befejeződik, ami kijelentkezést okoz. (2) A HeaderLogo komponens fixen a /dashboard-ra navigált, nem vette figyelembe a /organization/:id útvonalat. +Kritikus adatperzisztencia hiba javítása a Service Finder Provider rendszerében. A frontend edit modal nem hívott backend API-t, így a szerkesztett adatok elvesztek. Emellett a contact mezők (phone, email, website, tags) nem kerültek perzisztálásra a quick-add során, és a search nem adta vissza ezeket. ### 🔧 Változtatások -**1. `frontend/src/stores/auth.ts`:** -- Új `isInitialized` ref állapot (alapértelmezett: `false`) hozzáadva a store state-hez. -- Az `init()` függvény legvégén `isInitialized.value = true` beállítás, így a router guard meg tudja várni az inicializálás végét. -- Az `isInitialized` exportálva a return objektumban. +**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py:1) (NEW):** +- `ProviderQuickAddIn`: `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva +- `ProviderSearchResult`: `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők hozzáadva +- `ProviderUpdateIn` (NEW): name, city, address_zip, street, contact_phone, contact_email, website, tags +- `ProviderUpdateResponse` (NEW): id, name, status, message -**2. `frontend/src/router/index.ts`:** -- A `beforeEach` guard elején ellenőrzés: ha `!authStore.isInitialized`, akkor `await authStore.init()` meghívása. -- Ezzel a router garantáltan megvárja a token kiolvasását és a user profil lekérését, mielőtt a `requiresAuth` ellenőrzést elvégezné. +**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:486):** +- `quick_add_provider()`: contact mezők mentése ServiceProfile-ba +- `search_providers()`: LEFT JOIN ServiceProfile, contact mezők visszaadása +- `update_provider()` (NEW): Organization + ServiceProfile atomi frissítése -**3. `frontend/src/components/header/HeaderLogo.vue`:** -- `useRoute()` importálva a Vue Router-ből. -- 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 +**3. BACKEND - [`providers.py`](backend/app/api/v1/endpoints/providers.py:134):** +- `PUT /providers/{id}` végpont hozzáadva (authentikált, hibakezeléssel) -## 2026-06-12 - Dynamic Parameters Check & Centralized Odometer Implementation +**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue:191):** +- **CRITICAL FIX**: `handleSave()` most `api.put()`-et hív, nem csak eventet emitál +- `@save` → `@saved` (past tense, API call után) +- `address_zip`, `contact_phone`, `contact_email`, `website`, `tags` mezők támogatása +- `source` kivéve a payload-ból (backend-only) + +**5. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue:253):** +- Telefon, email, weboldal, címkék mezők hozzáadva a formhoz +- Tag management (vessző/pontosvessző parsing, remove gomb) +- Payload bővítése contact mezőkkel + +**6. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:375):** +- `@save` → `@saved` event binding +- `handleEditSaved()`: mentés után automatikus keresés újrafuttatás + +### ✅ Verifikáció +- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban + +## 2026-06-17 - P1 Critical Align: Atomizált címmezők a Provider sémákban ### 🎯 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). +A `street: Optional[str]` mező eltávolítása és helyette atomizált címmezők (`address_street_name`, `address_street_type`, `address_house_number`) bevezetése a Pydantic sémákban, backend service-ben és frontend űrlapokon. A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek. ### 🔧 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. +**1. BACKEND - [`provider.py`](backend/app/schemas/provider.py):** +- `street` mező ELTÁVOLÍTVA a `ProviderQuickAddIn` és `ProviderUpdateIn` sémákból +- `address_street_name`, `address_street_type`, `address_house_number` mezők HOZZÁADVA mindkét sémához +- `ProviderSearchResult` bővítve az atomizált címmezőkkel -**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. +**2. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py):** +- `quick_add_provider()`: `data.street` → `data.address_street_name`, `data.address_street_type`, `data.address_house_number` az Organization és Branch táblákban +- `update_provider()`: ugyanez az atomizált címkezelés +- `search_providers()`: az org SELECT most már tartalmazza az `address_street_name`, `address_street_type`, `address_house_number` mezőket +- A Branch létrehozásánál a `street_name`, `street_type`, `house_number` mezők külön-külön töltődnek -**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. +**3. FRONTEND - [`ProviderQuickAddModal.vue`](frontend/src/components/provider/ProviderQuickAddModal.vue):** +- A régi egyesített "Cím (Utca, házszám)" mező helyett 3 külön mező: Utca neve (text), Közterület jellege (select/dropdown 15 opcióval), Házszám (text) +- Payload az új atomizált kulcsokkal megy a backend felé -### 🛠️ 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 +**4. FRONTEND - [`ProviderEditModal.vue`](frontend/src/components/provider/ProviderEditModal.vue):** +- Ugyanaz a 3 mezős szétbontás, a form populate az atomizált mezőkből történik +- Payload atomizált kulcsokkal -### ✅ Eredmény -- 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 +**5. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue):** +- Az intelligens címösszefűzés (`formattedAddress` computed) a `provider.address_street_name + ' ' + provider.address_street_type + ' ' + provider.address_house_number` alapján történik +- `hasAddress` computed ellenőrzi az atomizált mezők meglétét -## 2026-06-12 - Emergency Regression Fix: Vehicle Creation 500 Error (UnboundLocalError) +### ✅ Verifikáció +- `sync_engine` lefuttatva: **1061 elem OK, 0 javítás, 0 shadow data** - rendszer tökéletesen szinkronban +- Python syntax check: minden fájl szintaktikailag helyes + +## 2026-06-17 - Provider Update & Search Fix Csomag (#264) ### 🎯 Cél -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 +Két kritikus hiba javítása a provider endpointokban: +1. **PUT /providers/{id} → 404**: A konténer nem volt újraindítva a providers modul kódváltoztatásai után +2. **GET /providers/search → 500**: `.astext` hiba JSONB subscripten + UNION oszlopszám mismatch +3. **Multi-source update**: Az `update_provider` csak Organization-ben keresett, de a search 3 forrást használ ### 🔧 Változtatások -**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). +**1. BACKEND - [`provider_service.py:207`](backend/app/services/provider_service.py:207):** +- `.astext` → `cast()` javítás: `Organization.external_integration_config["source"].astext` → `cast(Organization.external_integration_config["source"], String)` +- **Root cause**: JSON oszlop subscript-je `BinaryExpression`-t ad vissza, amelyen nincs `.astext` -**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. +**2. BACKEND - [`provider_service.py:232-274`](backend/app/services/provider_service.py:232):** +- UNION oszlopszám mismatch javítva: staging és crowd SELECT-ekhez hozzáadva a hiányzó `contact_phone`, `contact_email`, `website`, `specialization_tags` mezők (14 oszlopra egységesítve) -### ✅ 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 +**3. BACKEND - [`provider_service.py:477-622`](backend/app/services/provider_service.py:477):** +- Multi-source update logika: `update_provider()` most már mindhárom forrást támogatja: + 1. `fleet.organizations` (verified orgs) - közvetlen frissítés + 2. `marketplace.service_staging` (robot adatok) - migrálás Organization-be + 3. `marketplace.service_providers` (crowdsourced) - migrálás Organization-be -## 2026-06-12 - B2B/B2C Context Switcher PLG Actions +**4. INFRA - [`pre_start.sh`](backend/app/scripts/pre_start.sh):** +- Dokumentálva: a `uvicorn` `--reload` nélkül fut, kódváltoztatás után `docker compose restart sf_api` szükséges + +**5. BACKEND - [`provider_service.py:578-596`](backend/app/services/provider_service.py:578):** +- **Adatvédelmi javítás (2026-06-17):** A címmezők (`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) most már **csak akkor íródnak felül**, ha a frontend explicit nem-`null` értéket küld. Ez megakadályozza, hogy a meglévő címadatok véletlenül `null`-ra állítódjanak, amikor a felhasználó csak más mezőket szerkeszt. +- **Trigger:** A Gitea kártya visszautasításra került (`denied` státusz) a felhasználó által: *"az adatok tárolása minden esetben bontottan történjen meg és ha hiányzik valamelyik az alap cím tárolási adatból akkor vissza kell tenni."* + +### ✅ Verifikáció +- **PUT /providers/58**: 200 OK ✅ +- **PUT /providers/58 (partial update - csak zip)**: 200 OK ✅ (többi mező nem nullázódik) +- **GET /providers/search?q=Dunakeszi**: 200 OK ✅ (2 provider) +- **GET /providers/categories**: 200 OK ✅ (11 categories) +- **Login**: 200 OK ✅ + +## 2026-06-17 - i18n: Hiányzó provider címmező fordítások hozzáadása ### 🎯 Cél -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. +A ProviderEditModal.vue 6 darab `provider.*` i18n kulcsa hiányzott mindkét nyelvi modulból (`hu.ts`, `en.ts`), így a felhasználói felületen a kulcsnevek (pl. `provider.streetNameLabel`) jelentek meg a lefordított szöveg helyett. ### 🔧 Változtatások -**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. +**1. FRONTEND - [`hu.ts`](frontend/src/i18n/hu.ts:1300):** +- `provider.streetNameLabel`: `'Utca neve'` +- `provider.streetNamePlaceholder`: `'Pl. Egressy'` +- `provider.streetTypeLabel`: `'Közterület jellege'` +- `provider.streetTypePlaceholder`: `'Válassz típust...'` +- `provider.houseNumberLabel`: `'Házszám'` +- `provider.houseNumberPlaceholder`: `'Pl. 4'` -**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`. +**2. FRONTEND - [`en.ts`](frontend/src/i18n/en.ts:1300):** +- `provider.streetNameLabel`: `'Street Name'` +- `provider.streetNamePlaceholder`: `'e.g. Egressy'` +- `provider.streetTypeLabel`: `'Street Type'` +- `provider.streetTypePlaceholder`: `'Select type...'` +- `provider.houseNumberLabel`: `'House Number'` +- `provider.houseNumberPlaceholder`: `'e.g. 4'` -### ✅ 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. +### ✅ Verifikáció +- Mindkét i18n fájl szintaktikailag helyes (Node.js require sikeres) +- A ProviderEditModal.vue összes `t('provider.*')` hívása le van fedve -## 2026-06-12 - Clean Odometer & Switcher Logic (Odometer Revert) +## 2026-06-17 - "Dunakeszi, Dunakeszi" duplikáció javítása + irányítószám megjelenítés ### 🎯 Cél -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. +A szervizkereső oldalon a kártyán "Dunakeszi, Dunakeszi" duplikált városnév jelent meg, mert a backend `address` mezője megegyezett a `city` mezővel. A részletes nézetből hiányzott az irányítószám. ### 🔧 Változtatások -**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. +**1. BACKEND - [`provider_service.py`](backend/app/services/provider_service.py:198):** +- Az `address` mezőt `Organization.address_city.label("address")`-ről `func.concat(...)`-re változtattuk, ami az összes atomizált címmezőt (irányítószám, város, utca, közterület, házszám) fűzi össze. +- Példa eredmény: `"2120 Dunakeszi, Egressy utca 4"` a korábbi `"Dunakeszi"` helyett. -**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. +**2. FRONTEND - [`ServiceFinderView.vue`](frontend/src/views/ServiceFinderView.vue:543):** +- Új `formatCardAddress(provider)` metódus, ami a kártyán az atomizált címmezőkből építi fel a címet. +- Fallback: ha nincs atomizált adat, a backend által összefűzött `address` mezőt használja. -**3. [`backend/app/models/vehicle/__init__.py`](backend/app/models/vehicle/__init__.py):** -- `VehicleOdometerState` eltávolítva az importokból és `__all__`-ból. +**3. FRONTEND - [`ProviderDetailModal.vue`](frontend/src/components/provider/ProviderDetailModal.vue:237):** +- A `formattedAddress` computed property most már tartalmazza az `address_zip` mezőt is. +- Formátum: `"2120 Dunakeszi, Egressy utca 4"` -**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ó +- Backend API: `GET /api/v1/providers/search?city=Dunakeszi` → `address` = `"2120 Dunakeszi, Egressy utca 4"` ✅ +- Backend API: Autónyíri Kft. (id=58) → `address_zip=2120`, `address_street_name=Egressy`, `address_street_type=utca`, `address_house_number=4` ✅ +- Frontend build: `npm run build` sikeres, 0 hiba ✅ +- Backend konténer újraindítva: `docker compose restart sf_api` ✅ -**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 +## 2026-06-17 - Kártya fő szolgáltatás (category) mindig megjelenítése ### 🎯 Cél -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ó. +A szervizkereső kártyákon a fő szolgáltatás (category) mindig látszódjon. Ha van, akkor a kategória neve, ha nincs, akkor egy szaggatott vonalú placeholder. ### 🔧 Változtatások -**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. +**1. FRONTEND - [`ServiceFinderView.vue:263`](frontend/src/views/ServiceFinderView.vue:263):** +- A category badge `v-if="provider.category"` helyett `v-if`/`v-else` szerkezet: + - Ha van category: `bg-sf-accent/10` háttér, `text-sf-accent` szín + - Ha nincs: szaggatott vonalú (`border-dashed`) placeholder "Nincs kategória" / "No category" -### ✅ 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. +**2. FRONTEND - [`hu.ts:1237`](frontend/src/i18n/hu.ts:1237):** +- `serviceFinder.noCategory`: `'Nincs kategória'` -## 2026-06-13 - Fix Catalog API 404 Mismatch & Real Database Data +**3. FRONTEND - [`en.ts:1237`](frontend/src/i18n/en.ts:1237):** +- `serviceFinder.noCategory`: `'No category'` -### 🎯 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 +### ✅ Verifikáció +- Frontend build: `npm run build` sikeres, 0 hiba ✅ diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index c777192..0e2a2df 100755 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -5,7 +5,7 @@ from app.api.v1.endpoints import ( services, admin, expenses, evidence, social, security, billing, finance_admin, analytics, vehicles, system_parameters, gamification, translations, users, reports, dictionaries, - admin_packages, admin_services + admin_packages, admin_services, constants, providers ) api_router = APIRouter() @@ -32,4 +32,7 @@ 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"]) 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 +api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"]) +api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"]) +api_router.include_router(constants.router, prefix="/constants", tags=["Constants"]) +api_router.include_router(providers.router, prefix="/providers", tags=["Providers"]) \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py index 572e596..2820155 100755 --- a/backend/app/api/v1/endpoints/assets.py +++ b/backend/app/api/v1/endpoints/assets.py @@ -1,7 +1,7 @@ # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/assets.py import uuid import logging -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field @@ -11,12 +11,12 @@ from sqlalchemy.orm import selectinload from app.db.session import get_db from app.api.deps import get_current_user -from app.models import Asset, AssetCost +from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember from app.models.identity import User from app.services.cost_service import cost_service from app.services.asset_service import AssetService from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse -from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate +from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse logger = logging.getLogger(__name__) @@ -174,7 +174,10 @@ async def get_user_vehicles( .order_by(order_expr, Asset.created_at.desc()) .offset(skip) .limit(limit) - .options(selectinload(Asset.catalog)) + .options( + selectinload(Asset.catalog), + selectinload(Asset.odometer_readings) + ) ) else: # Corporate mode: only show vehicles belonging to the active organization's garages @@ -212,7 +215,10 @@ async def get_user_vehicles( .order_by(order_expr, Asset.created_at.desc()) .offset(skip) .limit(limit) - .options(selectinload(Asset.catalog)) + .options( + selectinload(Asset.catalog), + selectinload(Asset.odometer_readings) + ) ) result = await db.execute(stmt) @@ -294,9 +300,6 @@ async def get_asset( and vehicle model definition specifications. """ # Check if user has access to this asset - from sqlalchemy import or_ - from app.models.marketplace.organization import OrganizationMember - # Get user's organization memberships org_stmt = select(OrganizationMember.organization_id).where( OrganizationMember.user_id == current_user.id @@ -317,7 +320,8 @@ async def get_asset( ) ) .options( - selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition) + selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition), + selectinload(Asset.odometer_readings) ) ) @@ -427,7 +431,7 @@ async def create_or_claim_vehicle( except HTTPException: raise except Exception as e: - logger.error(f"Vehicle creation error: {e}") + logger.error(f"Vehicle creation failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű létrehozásakor") @@ -508,7 +512,7 @@ async def update_vehicle( except Exception as e: await db.rollback() - logger.error(f"Vehicle update error: {e}") + logger.error(f"Vehicle update error: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Belső szerverhiba a jármű frissítésekor") @@ -588,11 +592,7 @@ async def create_maintenance_record( - currency: string (optional, default: "EUR") - invoice_number: string (optional) """ - # Check if user has access to this asset - from sqlalchemy import or_ - from app.models.marketplace.organization import OrganizationMember - - # Get user's organization memberships + # Get user's organization memberships (imports already at module level) org_stmt = select(OrganizationMember.organization_id).where( OrganizationMember.user_id == current_user.id ) @@ -629,7 +629,6 @@ async def create_maintenance_record( try: # Parse date - from datetime import datetime date = datetime.fromisoformat(payload["date"].replace("Z", "+00:00")) # Determine organization ID: use asset's current org, owner org, or user's active organization @@ -642,8 +641,6 @@ async def create_maintenance_record( organization_id = int(current_user.scope_id) except (ValueError, TypeError): # If scope_id is not a valid integer, try to get from organization memberships - from sqlalchemy import select - from app.models.marketplace.organization import OrganizationMember stmt = select(OrganizationMember.organization_id).where( OrganizationMember.user_id == current_user.id ).limit(1) @@ -652,8 +649,6 @@ async def create_maintenance_record( organization_id = org_row[0] if org_row else None else: # Try to get from organization memberships - from sqlalchemy import select - from app.models.marketplace.organization import OrganizationMember stmt = select(OrganizationMember.organization_id).where( OrganizationMember.user_id == current_user.id ).limit(1) @@ -667,11 +662,11 @@ async def create_maintenance_record( detail="Cannot determine organization for this cost record. Please ensure you have an active organization." ) - # Create AssetCost record + # Create AssetCost record with MAINTENANCE category (id=2) maintenance_cost = AssetCost( asset_id=asset_id, organization_id=organization_id, - cost_category="maintenance", + category_id=2, amount_net=float(payload["cost"]), currency=payload.get("currency", "EUR"), date=date, @@ -684,17 +679,24 @@ async def create_maintenance_record( ) db.add(maintenance_cost) - await db.commit() - await db.refresh(maintenance_cost) + await db.flush() # Flush to get maintenance_cost.id - # Also create an AssetEvent for the maintenance - from app.models.vehicle import AssetEvent + # Also create an AssetEvent for the maintenance (linked via cost_id) maintenance_event = AssetEvent( asset_id=asset_id, - event_type="maintenance" + user_id=current_user.id, + organization_id=organization_id, + event_type="MAINTENANCE", + odometer_reading=payload.get("odometer"), + description=payload.get("description", "Maintenance record"), + cost_id=maintenance_cost.id, + event_date=date, ) db.add(maintenance_event) + + # Single commit for both records await db.commit() + await db.refresh(maintenance_cost) return maintenance_cost @@ -712,6 +714,244 @@ async def create_maintenance_record( ) +# ═══════════════════════════════════════════════════════════════════════════════ +# DIGITÁLIS SZERVIZKÖNYV (Service Book) — AssetEvent végpontok +# ═══════════════════════════════════════════════════════════════════════════════ + + +async def _check_asset_access( + db: AsyncSession, + asset_id: uuid.UUID, + current_user: User +) -> Asset: + """ + Helper: check if the current user has access to the given asset. + Returns the Asset or raises 404. + """ + from app.models.marketplace.organization import OrganizationMember + + org_stmt = select(OrganizationMember.organization_id).where( + OrganizationMember.user_id == current_user.id + ) + org_result = await db.execute(org_stmt) + user_org_ids = [row[0] for row in org_result.all()] + + stmt = ( + select(Asset) + .where( + Asset.id == asset_id, + or_( + Asset.owner_person_id == current_user.id, + Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False, + Asset.operator_person_id == current_user.id, + Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False + ) + ) + ) + result = await db.execute(stmt) + asset = result.scalar_one_or_none() + + if not asset: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Asset not found or you don't have permission to access it" + ) + return asset + + +async def _resolve_cost_category_for_event(db: AsyncSession, event_type: str) -> int: + """ + Helper: resolve a CostCategory ID based on the AssetEvent event_type. + + Maps service-book event types to cost categories: + - SERVICE, MAINTENANCE, INSPECTION, TIRE_CHANGE -> 'MAINTENANCE' (karbantartás) + - REPAIR, ACCIDENT -> 'REPAIR' (javítás) + - UPGRADE, RECALL -> 'UPGRADE' (fejlesztés) + + Falls back to a generic 'MAINTENANCE' category if none found. + """ + category_map = { + "SERVICE": "MAINTENANCE", + "MAINTENANCE": "MAINTENANCE", + "INSPECTION": "MAINTENANCE", + "TIRE_CHANGE": "MAINTENANCE", + "REPAIR": "REPAIR", + "ACCIDENT": "REPAIR", + "UPGRADE": "UPGRADE", + "RECALL": "UPGRADE", + } + category_code = category_map.get(event_type, "MAINTENANCE") + + stmt = select(CostCategory.id).where( + CostCategory.code == category_code + ).limit(1) + result = await db.execute(stmt) + cat_id = result.scalar_one_or_none() + + if cat_id is None: + # Fallback: try to find any category with 'MAINTENANCE' in name + fallback_stmt = select(CostCategory.id).where( + CostCategory.code.ilike("%MAINTENANCE%") + ).limit(1) + fallback_result = await db.execute(fallback_stmt) + cat_id = fallback_result.scalar_one_or_none() + + if cat_id is None: + # Ultimate fallback: pick the first available cost category + any_stmt = select(CostCategory.id).limit(1) + any_result = await db.execute(any_stmt) + cat_id = any_result.scalar_one_or_none() + + if cat_id is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="No cost category found in the system. Please seed cost categories first." + ) + + return cat_id + + +@router.get("/{asset_id}/events", response_model=List[AssetEventResponse]) +async def list_asset_events( + asset_id: uuid.UUID, + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Szervizkönyv események listázása. + + GET /api/v1/assets/{asset_id}/events + + Visszaadja a járműhöz tartozó összes AssetEvent rekordot, + dátum szerint csökkenő sorrendben (legújabb elöl). + """ + # Jogosultság ellenőrzés + await _check_asset_access(db, asset_id, current_user) + + stmt = ( + select(AssetEvent) + .options(selectinload(AssetEvent.cost)) + .where(AssetEvent.asset_id == asset_id) + .order_by(desc(AssetEvent.event_date)) + .offset(skip) + .limit(limit) + ) + result = await db.execute(stmt) + events = result.scalars().all() + return events + + +@router.post("/{asset_id}/events", response_model=AssetEventResponse, status_code=status.HTTP_201_CREATED) +async def create_asset_event( + asset_id: uuid.UUID, + payload: AssetEventCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Új szervizkönyv esemény rögzítése. + + POST /api/v1/assets/{asset_id}/events + + **Üzleti logika:** + - Ha a beküldött `odometer_reading` nagyobb, mint a jármű jelenlegi + `current_mileage` értéke, akkor: + 1. Létrehoz egy új OdometerReading rekordot + 2. Frissíti a jármű `current_mileage` mezőjét + - Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot + a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE), + és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül. + """ + # Jogosultság ellenőrzés + asset = await _check_asset_access(db, asset_id, current_user) + + # Dátum: ha nincs megadva, használjuk a jelenlegi időt + event_date = payload.event_date or datetime.now(timezone.utc) + + # Szervezet meghatározása + organization_id = asset.current_organization_id or asset.owner_org_id + + try: + # ── Üzleti logika: AssetCost automatikus létrehozása ── + cost_id = payload.cost_id + if payload.cost_amount is not None and payload.cost_amount > 0: + # Költségkategória feloldása az esemény típusa alapján + # SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás + cost_category_id = await _resolve_cost_category_for_event(db, payload.event_type) + + currency = payload.currency or "HUF" + + cost_record = AssetCost( + asset_id=asset_id, + organization_id=organization_id, + category_id=cost_category_id, + amount_net=payload.cost_amount, + currency=currency, + date=event_date, + data={ + "description": payload.description or f"Service event: {payload.event_type}", + "mileage_at_cost": payload.odometer_reading, + "source": "service_book", + "event_type": payload.event_type, + }, + ) + db.add(cost_record) + await db.flush() # Flush to get the cost_record.id + cost_id = cost_record.id + + logger.info( + f"AssetCost auto-created for asset {asset_id}: " + f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})" + ) + + # Új esemény létrehozása + event = AssetEvent( + asset_id=asset_id, + user_id=current_user.id, + organization_id=organization_id, + event_type=payload.event_type, + odometer_reading=payload.odometer_reading, + description=payload.description, + cost_id=cost_id, + event_date=event_date, + ) + db.add(event) + + # ── Üzleti logika: Km óra állás frissítése ── + if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage: + # 1. Új OdometerReading rekord létrehozása + odometer = OdometerReading( + asset_id=asset_id, + reading=payload.odometer_reading, + source="manual", + ) + db.add(odometer) + + # 2. Jármű current_mileage frissítése + asset.current_mileage = payload.odometer_reading + asset.updated_at = datetime.now(timezone.utc) + + logger.info( + f"Odometer updated for asset {asset_id}: " + f"{asset.current_mileage} → {payload.odometer_reading} " + f"(via event {payload.event_type})" + ) + + await db.commit() + await db.refresh(event, attribute_names=["cost"]) + return event + + except Exception as e: + await db.rollback() + logger.error(f"Asset event creation error for {asset_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Hiba a szervizkönyv esemény létrehozásakor" + ) + + @router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse) async def archive_vehicle( vehicle_id: uuid.UUID, @@ -747,7 +987,7 @@ async def archive_vehicle( except HTTPException: raise except Exception as e: - logger.error(f"Vehicle archive error for {vehicle_id}: {e}") + logger.error(f"Vehicle archive error for {vehicle_id}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Belső szerverhiba a jármű archiválásakor" diff --git a/backend/app/api/v1/endpoints/billing.py b/backend/app/api/v1/endpoints/billing.py index 84bcbf6..1cfeb11 100755 --- a/backend/app/api/v1/endpoints/billing.py +++ b/backend/app/api/v1/endpoints/billing.py @@ -1,13 +1,13 @@ -# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/billing.py -from fastapi import APIRouter, Depends, HTTPException, status, Request, Header +from fastapi import APIRouter, Depends, HTTPException, status, Request, Header, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from typing import Optional, Dict, Any +from sqlalchemy import select, func, and_ +from typing import Optional, Dict, Any, List import logging +import uuid from app.api.deps import get_db, get_current_user from app.models.identity import User, Wallet, UserRole -from app.models import FinancialLedger, WalletType +from app.models import FinancialLedger, WalletType, LedgerEntryType, LedgerStatus from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus from app.services.config_service import config from app.services.payment_router import PaymentRouter @@ -311,4 +311,99 @@ async def get_wallet_balance( except Exception as e: logger.error(f"Pénztárca egyenleg lekérdezési hiba: {e}") raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}") - raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}") \ No newline at end of file + + +@router.get("/wallet/transactions") +async def get_wallet_transactions( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), + page: int = Query(1, ge=1, description="Oldalszám"), + page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"), + wallet_type: Optional[str] = Query(None, description="Szűrés pénztárca típusra (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)"), + entry_type: Optional[str] = Query(None, description="Szűrés bejegyzés típusra (DEBIT, CREDIT)"), +): + """ + Bejelentkezett felhasználó tranzakciótörténetének lekérdezése (FinancialLedger). + + Visszaadja a főkönyvi bejegyzéseket lapozható formában, időrendben csökkenő sorrendben. + """ + try: + # Base query + conditions = [FinancialLedger.user_id == current_user.id] + + # Optional wallet_type filter + if wallet_type: + try: + wt = WalletType(wallet_type.upper()) + conditions.append(FinancialLedger.wallet_type == wt) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Érvénytelen wallet_type: {wallet_type}. Használd: {[wt.value for wt in WalletType]}" + ) + + # Optional entry_type filter + if entry_type: + try: + et = LedgerEntryType(entry_type.upper()) + conditions.append(FinancialLedger.entry_type == et) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Érvénytelen entry_type: {entry_type}. Használd: {[et.value for et in LedgerEntryType]}" + ) + + # Count total matching records + count_stmt = select(func.count(FinancialLedger.id)).where(and_(*conditions)) + count_result = await db.execute(count_stmt) + total_count = count_result.scalar() or 0 + + # Fetch paginated results + offset = (page - 1) * page_size + stmt = ( + select(FinancialLedger) + .where(and_(*conditions)) + .order_by(FinancialLedger.created_at.desc()) + .offset(offset) + .limit(page_size) + ) + result = await db.execute(stmt) + entries = result.scalars().all() + + # Build response + transactions = [] + for entry in entries: + description = "" + if entry.details and isinstance(entry.details, dict): + description = entry.details.get("description", "") + + transactions.append({ + "id": entry.id, + "transaction_id": str(entry.transaction_id), + "amount": float(entry.amount), + "entry_type": entry.entry_type.value, + "wallet_type": entry.wallet_type.value if entry.wallet_type else None, + "transaction_type": entry.transaction_type, + "description": description, + "status": entry.status.value if entry.status else None, + "balance_after": float(entry.balance_after) if entry.balance_after else None, + "currency": entry.currency or "EUR", + "created_at": entry.created_at.isoformat() if entry.created_at else None, + }) + + return { + "success": True, + "data": transactions, + "pagination": { + "page": page, + "page_size": page_size, + "total_count": total_count, + "total_pages": max(1, (total_count + page_size - 1) // page_size), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Tranzakció történet lekérdezési hiba: {e}") + raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}") diff --git a/backend/app/api/v1/endpoints/constants.py b/backend/app/api/v1/endpoints/constants.py new file mode 100644 index 0000000..a86c8ed --- /dev/null +++ b/backend/app/api/v1/endpoints/constants.py @@ -0,0 +1,87 @@ +""" +Constants API Endpoint +====================== +Serves static dictionaries (vehicle features, drive types, etc.) +to the frontend so it doesn't need to hardcode them. +""" +from fastapi import APIRouter, Query +from app.constants.vehicle_features import ( + CAR_FEATURES, + CAR_DRIVE_TYPES, + CAR_TRANSMISSION_TYPES, + CAR_AC_TYPES, + CAR_BODY_TYPES, + AC_CONNECTOR_TYPES, + DC_CONNECTOR_TYPES, + MOTORCYCLE_STROKES, + MOTORCYCLE_COOLING, + MOTORCYCLE_MIXTURE, + MOTORCYCLE_FEATURES, + LCV_BODY_TYPES, + LCV_FEATURES, + HGV_TRANSMISSION_TYPES, + HGV_PTO_TYPES, + HGV_BODY_TYPES, + HGV_FEATURES, +) + +router = APIRouter() + + +@router.get("/vehicle-features") +async def get_vehicle_features( + type: str = Query(None, description="Filter by vehicle type: 'car', 'motorcycle', 'lcv' or 'hgv'") +): + """Return all vehicle feature dictionaries for frontend use. + + If ?type=motorcycle is provided, returns motorcycle-specific data. + If ?type=lcv is provided, returns LCV-specific data. + If ?type=hgv is provided, returns HGV-specific data. + Otherwise returns car-specific data. + """ + if type == "motorcycle": + return { + **MOTORCYCLE_FEATURES, # Spread categories directly: technical, frame_body, luggage, multimedia, other + "drive_types": CAR_DRIVE_TYPES, + "transmission_types": CAR_TRANSMISSION_TYPES, + "ac_types": {}, # No AC types for motorcycles + "body_types": {}, # No body types for motorcycles + "ac_connector_types": AC_CONNECTOR_TYPES, + "dc_connector_types": DC_CONNECTOR_TYPES, + "motorcycle_strokes": MOTORCYCLE_STROKES, + "motorcycle_cooling": MOTORCYCLE_COOLING, + "motorcycle_mixture": MOTORCYCLE_MIXTURE, + } + + if type == "lcv": + return { + **LCV_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia + "drive_types": CAR_DRIVE_TYPES, + "transmission_types": CAR_TRANSMISSION_TYPES, + "ac_types": CAR_AC_TYPES, + "body_types": LCV_BODY_TYPES, + "ac_connector_types": AC_CONNECTOR_TYPES, + "dc_connector_types": DC_CONNECTOR_TYPES, + } + + if type == "hgv": + return { + **HGV_FEATURES, # Spread categories directly: technical_work, cabin, multimedia + "drive_types": CAR_DRIVE_TYPES, + "transmission_types": HGV_TRANSMISSION_TYPES, + "pto_types": HGV_PTO_TYPES, + "ac_types": CAR_AC_TYPES, + "body_types": HGV_BODY_TYPES, + "ac_connector_types": AC_CONNECTOR_TYPES, + "dc_connector_types": DC_CONNECTOR_TYPES, + } + + return { + **CAR_FEATURES, # Spread categories directly: technical, interior, exterior, multimedia, other + "drive_types": CAR_DRIVE_TYPES, + "transmission_types": CAR_TRANSMISSION_TYPES, + "ac_types": CAR_AC_TYPES, + "body_types": CAR_BODY_TYPES, + "ac_connector_types": AC_CONNECTOR_TYPES, + "dc_connector_types": DC_CONNECTOR_TYPES, + } diff --git a/backend/app/api/v1/endpoints/expenses.py b/backend/app/api/v1/endpoints/expenses.py index 2fb9810..cf8004e 100755 --- a/backend/app/api/v1/endpoints/expenses.py +++ b/backend/app/api/v1/endpoints/expenses.py @@ -1,11 +1,18 @@ # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py +import logging from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from app.api.deps import get_db, get_current_user -from app.models import Asset, AssetCost, OrganizationMember, SystemParameter +from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter from app.schemas.asset_cost import AssetCostCreate -from datetime import datetime +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + +# Cost category IDs that are service/maintenance/repair related +# These trigger automatic AssetEvent creation +SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES router = APIRouter() @@ -18,6 +25,10 @@ async def create_expense( """ Create a new expense (fuel, service, tax, insurance) for an asset. Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc. + + **Bidirectional Sync:** + - If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES), + an AssetEvent is automatically created and linked to the cost via cost_id. """ # Validate asset exists stmt = select(Asset).where(Asset.id == expense.asset_id) @@ -85,32 +96,87 @@ async def create_expense( if expense.description: data["description"] = expense.description - # Create AssetCost instance - new_cost = AssetCost( - asset_id=expense.asset_id, - organization_id=organization_id, - category_id=expense.category_id, - amount_net=expense.amount_net, - currency=expense.currency, - date=expense.date, - invoice_number=data.get("invoice_number"), - data=data - ) + try: + # Create AssetCost instance + new_cost = AssetCost( + asset_id=expense.asset_id, + organization_id=organization_id, + category_id=expense.category_id, + amount_net=expense.amount_net, + currency=expense.currency, + date=expense.date, + invoice_number=data.get("invoice_number"), + data=data + ) + + db.add(new_cost) + await db.flush() # Flush to get new_cost.id + + # ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ── + event_id = None + if expense.category_id in SERVICE_RELATED_CATEGORY_IDS: + # Map cost category to event type + event_type = _map_category_to_event_type(expense.category_id) + + description = expense.description or data.get("description", f"Service cost: {expense.category_id}") + mileage = expense.mileage_at_cost + + new_event = AssetEvent( + asset_id=expense.asset_id, + user_id=getattr(current_user, 'id', None), + organization_id=organization_id, + event_type=event_type, + odometer_reading=mileage, + description=description, + cost_id=new_cost.id, + event_date=expense.date or datetime.now(timezone.utc), + ) + db.add(new_event) + await db.flush() # Flush to get new_event.id + event_id = new_event.id + + logger.info( + f"Bidirectional sync: Auto-created AssetEvent {event_id} " + f"for AssetCost {new_cost.id} (category_id={expense.category_id})" + ) + + # 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) + + return { + "status": "success", + "id": new_cost.id, + "asset_id": new_cost.asset_id, + "category_id": new_cost.category_id, + "amount_net": new_cost.amount_net, + "date": new_cost.date, + "event_id": str(event_id) if event_id else None, + } - db.add(new_cost) + except Exception as e: + await db.rollback() + logger.error(f"Expense creation error for asset {expense.asset_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Hiba a költség rögzítésekor" + ) + + +def _map_category_to_event_type(category_id: int) -> str: + """ + Map a cost category ID to the appropriate AssetEvent event_type. - # 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) - - return { - "status": "success", - "id": new_cost.id, - "asset_id": new_cost.asset_id, - "category_id": new_cost.category_id, - "amount_net": new_cost.amount_net, - "date": new_cost.date - } \ No newline at end of file + Returns: + str: The event type string (SERVICE, REPAIR, etc.) + """ + category_event_map = { + 2: "MAINTENANCE", # MAINTENANCE + 16: "SERVICE", # MAINT_SERVICE + 17: "SERVICE", # MAINT_OIL + 18: "REPAIR", # MAINT_BRAKES + } + return category_event_map.get(category_id, "MAINTENANCE") \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/providers.py b/backend/app/api/v1/endpoints/providers.py index 309443a..9fce5c7 100755 --- a/backend/app/api/v1/endpoints/providers.py +++ b/backend/app/api/v1/endpoints/providers.py @@ -1,18 +1,165 @@ -from fastapi import APIRouter, Depends +""" +Provider végpontok – Szolgáltató keresés és gyors felvétel (crowdsourced). +""" + +import logging +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession + from app.db.session import get_db -from app.schemas.social import ServiceProviderCreate, ServiceProviderResponse -from app.services.social_service import create_service_provider -from app.api import deps +from app.api.deps import get_current_user +from app.models.identity import User +from app.models.marketplace.service import ExpertiseTag +from app.schemas.provider import ( + ProviderSearchResponse, + ProviderQuickAddIn, + ProviderQuickAddResponse, + ProviderUpdateIn, + ProviderUpdateResponse, + ExpertiseCategoryOut, +) +from app.services.provider_service import search_providers, quick_add_provider, update_provider router = APIRouter() +logger = logging.getLogger(__name__) -# Secured endpoint: Closed premium ecosystem -@router.post("/", response_model=ServiceProviderResponse) -async def add_provider( - provider_data: ServiceProviderCreate, + +@router.get("/categories", response_model=List[ExpertiseCategoryOut]) +async def list_expertise_categories( db: AsyncSession = Depends(get_db), - current_user = Depends(deps.get_current_user) + current_user: User = Depends(get_current_user), ): - user_id = current_user.id - return await create_service_provider(db, provider_data, user_id) \ No newline at end of file + """ + Visszaadja az összes expertise kategóriát a frontend dropdown számára. + """ + try: + stmt = select(ExpertiseTag).order_by(ExpertiseTag.id) + result = await db.execute(stmt) + tags = result.scalars().all() + return [ + ExpertiseCategoryOut( + id=t.id, + key=t.key, + name_hu=t.name_hu, + name_en=t.name_en, + category=t.category, + ) + for t in tags + ] + except Exception as e: + logger.error(f"Hiba a kategóriák lekérése során: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Hiba történt a kategóriák lekérése során.", + ) + + +@router.get("/search", response_model=ProviderSearchResponse) +async def search_service_providers( + q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"), + category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"), + city: Optional[str] = Query(None, max_length=100, description="Város szűrő"), + limit: int = Query(20, ge=1, le=100, description="Találatok száma oldalanként"), + offset: int = Query(0, ge=0, description="Lapozási offset"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Szolgáltatók keresése egyesített forrásokból. + + Három forrásból keres: + - **verified_org**: Verifikált szervezetek (fleet.organizations, org_type='service_provider') + - **staged_data**: Robotok által gyűjtött adatok (marketplace.service_staging) + - **crowd_added**: Közösség által hozzáadott adatok (marketplace.service_providers) + + A találatok forrás szerint csoportosítva, lapozva érkeznek. + """ + try: + result = await search_providers( + db=db, + q=q, + category=category, + city=city, + limit=limit, + offset=offset, + ) + return result + except Exception as e: + logger.error(f"Hiba a szolgáltató keresés során: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Hiba történt a keresés során.", + ) + + +@router.post("/quick-add", response_model=ProviderQuickAddResponse, status_code=status.HTTP_201_CREATED) +async def quick_add_service_provider( + data: ProviderQuickAddIn, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Gyors szolgáltató felvétel crowdsourcingból. + + Létrehoz egy új Organization-t (is_verified=False, org_type='service_provider'), + hozzáköt egy ServiceProfile-t, a megadott kategóriát (expertise_tags), + létrehoz egy Branch-t a címmel, és a felhasználót beállítja OWNER-nek. + + A szolgáltató 'pending_verification' státuszba kerül, amíg a robotok + vagy az adminok nem ellenőrzik. + """ + try: + result = await quick_add_provider( + db=db, + data=data, + user_id=current_user.id, + ) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + except Exception as e: + logger.error(f"Hiba a gyors szolgáltató felvétel során: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Hiba történt a szolgáltató rögzítése során.", + ) + + +@router.put("/{provider_id}", response_model=ProviderUpdateResponse) +async def update_service_provider( + provider_id: int, + data: ProviderUpdateIn, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Meglévő szolgáltató (Organization) adatainak szerkesztése. + + Frissíti a szervezet alapadatait (név, cím) és a kapcsolódó + ServiceProfile kapcsolati mezőit (telefon, email, weboldal, címkék). + """ + try: + result = await update_provider( + db=db, + provider_id=provider_id, + data=data, + user_id=current_user.id, + ) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except Exception as e: + logger.error(f"Hiba a szolgáltató frissítése során (id={provider_id}): {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Hiba történt a szolgáltató frissítése során.", + ) diff --git a/backend/app/constants/__init__.py b/backend/app/constants/__init__.py new file mode 100644 index 0000000..e7a4e85 --- /dev/null +++ b/backend/app/constants/__init__.py @@ -0,0 +1,40 @@ +""" +Constants Package +================= +Static dictionaries for vehicle features, drive types, etc. +""" +from app.constants.vehicle_features import ( + CAR_FEATURES, + CAR_DRIVE_TYPES, + CAR_TRANSMISSION_TYPES, + CAR_AC_TYPES, + CAR_BODY_TYPES, + AC_CONNECTOR_TYPES, + DC_CONNECTOR_TYPES, + MOTORCYCLE_STROKES, + MOTORCYCLE_COOLING, + MOTORCYCLE_MIXTURE, + MOTORCYCLE_FEATURES, + get_all_feature_keys, + get_feature_label, + get_features_by_category, + get_motorcycle_feature_label, +) + +__all__ = [ + "CAR_FEATURES", + "CAR_DRIVE_TYPES", + "CAR_TRANSMISSION_TYPES", + "CAR_AC_TYPES", + "CAR_BODY_TYPES", + "AC_CONNECTOR_TYPES", + "DC_CONNECTOR_TYPES", + "MOTORCYCLE_STROKES", + "MOTORCYCLE_COOLING", + "MOTORCYCLE_MIXTURE", + "MOTORCYCLE_FEATURES", + "get_all_feature_keys", + "get_feature_label", + "get_features_by_category", + "get_motorcycle_feature_label", +] diff --git a/backend/app/constants/vehicle_features.py b/backend/app/constants/vehicle_features.py new file mode 100644 index 0000000..21c6c89 --- /dev/null +++ b/backend/app/constants/vehicle_features.py @@ -0,0 +1,759 @@ +""" +Vehicle Features & EV Constants +================================ +Centralized dictionaries for car-specific dropdowns, EV connector types, +and categorized feature lists. All data is stored in the `individual_equipment` +JSONB column on the Asset model. + +Usage: + from app.constants.vehicle_features import ( + CAR_DRIVE_TYPES, CAR_TRANSMISSION_TYPES, CAR_AC_TYPES, + CAR_BODY_TYPES, CAR_FEATURES, AC_CONNECTOR_TYPES, DC_CONNECTOR_TYPES, + MOTORCYCLE_STROKES, MOTORCYCLE_COOLING, MOTORCYCLE_MIXTURE, + MOTORCYCLE_FEATURES, + LCV_BODY_TYPES, LCV_FEATURES, + HGV_TRANSMISSION_TYPES, HGV_PTO_TYPES, HGV_BODY_TYPES, HGV_FEATURES, + ) +""" + +# ────────────────────────────────────────────────────────────────────── +# CAR-SPECIFIC DROPDOWNS (only shown when vehicle_class == 'personal') +# ────────────────────────────────────────────────────────────────────── + +CAR_DRIVE_TYPES: dict[str, str] = { + "fwd": "Elsőkerék (FWD)", + "rwd": "Hátsókerék (RWD)", + "awd": "Összkerék (AWD / 4×4)", + "4wd": "Összkerék (4WD / kapcsolható)", +} + +CAR_TRANSMISSION_TYPES: dict[str, str] = { + "manual": "Manuális", + "automatic": "Automata", + "sequential": "Szekvenciális", + "cvt": "CVT (Fokozatmentes)", + "dct": "DCT (Dupla kuplungos)", + "semi_auto": "Félautomata", +} + +CAR_AC_TYPES: dict[str, str] = { + "manual": "Manuális klíma", + "automatic": "Automata klíma", + "dual_zone": "Kétzónás automata klíma", + "tri_zone": "Háromzónás automata klíma", + "quad_zone": "Négyzónás automata klíma", +} + +CAR_BODY_TYPES: dict[str, str] = { + "sedan": "Szedán", + "hatchback": "Ferdehátú (Hatchback)", + "wagon": "Kombi (Estate)", + "coupe": "Coupé", + "convertible": "Kabrió (Convertible)", + "suv": "SUV / Szabadidő-autó", + "mpv": "MPV (Egyterű)", + "pickup": "Pickup", + "van": "Furgon (Van)", +} + +# ────────────────────────────────────────────────────────────────────── +# CAR FEATURES / EXTRAS (categorized) +# Stored in individual_equipment.car_specs.features as a list of keys. +# ────────────────────────────────────────────────────────────────────── + +CAR_FEATURES: dict[str, dict[str, str]] = { + "technical": { + "abs": "ABS", + "esp": "ESP (Menetstabilizáló)", + "traction_control": "Kipörgésgátló (TCS)", + "hill_assist": "Hegymeneti tartó", + "hill_descent": "Lejtmenetvezérlő", + "lane_assist": "Sávtartó asszisztens", + "blind_spot": "Holtpont-figyelő", + "adaptive_cruise": "Adaptív tempomat (ACC)", + "parking_sensors_front": "Első parkolóérzékelők", + "parking_sensors_rear": "Hátsó parkolóérzékelők", + "rear_camera": "Tolatókamera", + "360_camera": "360°-os kamera", + "auto_parking": "Automatikus parkolóasszisztens", + "start_stop": "Start-Stop rendszer", + "keyless_go": "Kulcs nélküli indítás (Keyless Go)", + "night_vision": "Éjjellátó (Night Vision)", + "traffic_sign_recognition": "Táblafelismerő rendszer", + "driver_alert": "Fáradtságfigyelő", + "emergency_brake": "Vészfék asszisztens", + "cross_traffic_alert": "Keresztirányú forgalom figyelő", + }, + "interior": { + "leather_seats": "Bőrülés", + "heated_seats_front": "Első ülésfűtés", + "heated_seats_rear": "Hátsó ülésfűtés", + "ventilated_seats": "Szellőztetett ülések", + "massage_seats": "Masszázsülés", + "electric_seats": "Elektromos ülésállítás", + "memory_seats": "Ülésmemória", + "sport_seats": "Sportülés", + "heated_steering": "Fűthető kormány", + "ambient_lighting": "Hangulatvilágítás (Ambient Light)", + "panoramic_roof": "Panorámatető", + "sunroof": "Napfénytető", + "auto_dim_mirror": "Automatikusan sötétedő belső tükör", + "electric_steering_adjust": "Elektromos kormányállítás", + "rear_blind": "Hátsó roló", + "rear_side_blinds": "Hátsó oldalsó rolók", + "digital_instrument_cluster": "Digitális műszeregység", + "head_up_display": "Head-Up Display (HUD)", + }, + "exterior": { + "alloy_wheels": "Könnyűfém felni", + "roof_rails": "Tetősín (Roof Rails)", + "tow_bar": "Vontatóhorog (vonóhorog)", + "tinted_windows": "Sötétített üvegek", + "privacy_glass": "Privacy Glass", + "led_headlights": "LED fényszóró", + "adaptive_headlights": "Adaptív fényszóró (AFS)", + "fog_lights": "Ködlámpa", + "cornering_lights": "Kanyarvilágítás", + "rain_sensor": "Esőérzékelő", + "light_sensor": "Fényérzékelő", + "auto_wipers": "Automatikus ablaktörlő", + "electric_trunk": "Elektromos csomagtérajtó", + "hands_free_trunk": "Kihangosítható csomagtérajtó (Hands-Free)", + "side_steps": "Oldallépcső", + "mudguards": "Sárvédő", + }, + "multimedia": { + "navigation": "Navigáció (GPS)", + "apple_carplay": "Apple CarPlay", + "android_auto": "Android Auto", + "bluetooth": "Bluetooth", + "usb_charging": "USB töltőcsatlakozó", + "wireless_charging": "Vezeték nélküli töltő", + "dab_radio": "DAB+ digitális rádió", + "premium_sound": "Prémium hangrendszer", + "subwoofer": "Mélynyomó (Subwoofer)", + "rear_entertainment": "Hátsó szórakoztató rendszer", + "wifi_hotspot": "WiFi Hotspot", + "voice_control": "Hangvezérlés", + "gesture_control": "Gesztusvezérlés", + }, + "other": { + "spare_tire": "Pótkerék", + "tire_repair_kit": "Kerékpár-javító készlet", + "first_aid_kit": "Elsősegély csomag", + "warning_triangle": "Figyelmeztető háromszög", + "fire_extinguisher": "Tűzoltó készülék", + "winter_tires": "Téli gumi", + "cargo_net": "Csomagtér háló", + "cargo_cover": "Csomagtér takaró", + "pet_barrier": "Kutya rács / elválasztó", + "roof_box": "Tetős doboz", + "bike_rack": "Kerékpártartó", + "ski_rack": "Sítartó", + }, +} + +# ────────────────────────────────────────────────────────────────────── +# EV-SPECIFIC CONNECTOR TYPES +# Stored in individual_equipment.ev_specs +# ────────────────────────────────────────────────────────────────────── + +AC_CONNECTOR_TYPES: dict[str, str] = { + "type1": "Type 1 (SAE J1772)", + "type2": "Type 2 (Mennekes)", + "type3": "Type 3 (SCAME)", + "gb_t": "GB/T (Kínai szabvány)", +} + +DC_CONNECTOR_TYPES: dict[str, str] = { + "ccs": "CCS (Combined Charging System)", + "chademo": "CHAdeMO", + "tesla_supercharger": "Tesla Supercharger", + "gb_t_dc": "GB/T DC (Kínai gyorstöltő)", + "nacs": "NACS (Tesla North American)", +} + +# ────────────────────────────────────────────────────────────────────── +# MOTORCYCLE-SPECIFIC DROPDOWNS +# ────────────────────────────────────────────────────────────────────── + +MOTORCYCLE_STROKES: dict[str, str] = { + "2_stroke": "2 ütemű", + "4_stroke": "4 ütemű", + "electric": "Elektromos", +} + +MOTORCYCLE_COOLING: dict[str, str] = { + "air": "Levegőhűtés", + "liquid": "Vízhűtés", + "oil": "Olajhűtés", + "air_oil": "Levegő/Olaj hűtés", +} + +MOTORCYCLE_MIXTURE: dict[str, str] = { + "carburetor": "Karburátor", + "injection": "Befecskendezés", +} + +# ────────────────────────────────────────────────────────────────────── +# MOTORCYCLE FEATURES / EXTRAS (categorized) +# Stored in individual_equipment.motorcycle_specs.features as a list of keys. +# ────────────────────────────────────────────────────────────────────── + +MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = { + "technical": { + "dual_front_disc": "Dupla tárcsafék elöl", + "front_disc": "Tárcsafék elöl", + "rear_disc": "Tárcsafék hátul", + "chip_tuning": "Chiptuning", + "electronic_suspension": "Elektromos futómű állítás", + "onboard_computer": "Fedélzeti computer", + "metal_brake_line": "Fém fékcső", + "rev_counter": "Fordulatszámmérő", + "immobiliser": "Immobiliser", + "catalyst": "Katalizátor", + "electric_starter": "Önindító", + "all_wheel_drive": "Összkerékhajtás", + "alarm": "Riasztó", + "sport_exhaust": "Sport kipufogó", + "sport_air_filter": "Sport légszűrő", + "cruise_control": "Tempomat", + "turbo": "Turbó", + "12v_system": "12 V rendszer", + "heated_grip": "Markolat fűtés", + "abs": "ABS (blokkolásgátló)", + "seat_belt": "Biztonsági öv", + "dtc": "DTC", + "fog_light": "Ködlámpa", + "airbag": "Légzsák", + "xenon_headlight": "Xenon fényszóró", + }, + "frame_body": { + "full_extra": "Full extra", + "leather_seat": "Bőrülés", + "heated_seat": "Fűthető ülés", + "backrest": "Háttámla", + "center_stand": "Középsztender", + "footrest": "Lábtartó", + "windshield": "Motoros szélvédő", + "plexiglass": "Plexi", + "tank_pad": "Tankpad", + "tank_protector_leather": "Tankvédő bőr", + "seat_height_adjust": "Ülésmagasság állítás", + "crash_bar": "Bukócső / bukógomba", + "hand_guards": "Kézvédők", + "heated_mirror": "Fűthető tükör", + "tow_hitch": "Vonóhorog", + }, + "luggage": { + "factory_cases": "Gyári dobozok", + "rear_case": "Hátsó doboz", + "side_cases": "Oldalsó dobozok", + "lockable_case": "Zárható doboz", + "side_bag": "Oldaltáska", + "tank_bag": "Tank táska", + "bag_holder_bracket": "Táskatartó konzol", + "fork_bag": "Villatáska", + }, + "multimedia": { + "cd_player": "CD tár", + "gps_navigation": "GPS (navigáció)", + "hi_fi": "Hi-Fi", + "radio": "Rádiós magnó", + "info_display": "Információs kijelző", + }, + "other": { + "under_warranty": "Garanciális", + "us_model": "Amerikai modell", + "immediate_takeover": "Azonnal elvihető", + "showroom_vehicle": "Bemutató jármű", + "orderable": "Rendelhető", + "car_trade_in_possible": "Autóbeszámítás lehetséges", + "first_owner": "Első tulajdonostól", + "garage_kept": "Garázsban tartott", + "female_owner": "Hölgy tulajdonostól", + "low_mileage": "Keveset futott", + "second_owner": "Második tulajdonostól", + "motorcycle_trade_in_possible": "Motorbeszámítás lehetséges", + "track_fairing": "Pályaidom", + "regularly_maintained": "Rendszeresen karbantartott", + "service_book": "Szervizkönyv", + "title_certificate": "Törzskönyv", + }, +} + +# ────────────────────────────────────────────────────────────────────── +# LCV-SPECIFIC BODY TYPES +# Shown when vehicle_class == 'lcv' +# ────────────────────────────────────────────────────────────────────── + +LCV_BODY_TYPES: dict[str, str] = { + "closed_van": "Zárt furgon", + "pickup": "Pickup", + "double_cab_chassis": "Dupla kabin alváz", + "single_cab_chassis": "Egyes kabin alváz", + "box_tail_lift": "Dobozos raktér emelővel", + "tipper": "Billenős plató", + "dropside": "Billenő oldalfalú plató", + "refrigerated_van": "Hűtött furgon", + "crew_cab_pickup": "Crew Cab Pickup", + "window_van": "Ablakos furgon", + "combi_van": "Combi furgon", + "platform_truck": "Platós teherautó", +} + +# ────────────────────────────────────────────────────────────────────── +# LCV FEATURES / EXTRAS (categorized) +# Stored in individual_equipment.lcv_specs.features as a list of keys. +# ────────────────────────────────────────────────────────────────────── + +LCV_FEATURES: dict[str, dict[str, str]] = { + "technical": { + "abs": "ABS", + "asr": "ASR (Kipörgésgátló)", + "gps_tracker": "GPS nyomkövető", + "immobiliser": "Immobiliser", + "alarm": "Riasztó", + "cruise_control": "Tempomat", + "parking_sensors_rear": "Hátsó parkolóérzékelők", + }, + "interior": { + "curtain_airbag": "Függönylégzsák", + "rear_side_airbag": "Hátsó oldallégzsák", + "switchable_airbag": "Kikapcsolható utaslégzsák", + "side_airbag": "Oldallégzsák", + "passenger_airbag": "Utaslégzsák", + "driver_airbag": "Vezető légzsák", + "roll_bar": "Bukókeret", + "cargo_tie_down": "Rögzítő pontok a raktérben", + "isofix": "Isofix gyerekülés rögzítés", + "full_extra": "Full extra", + "auxiliary_heating": "Kiegészítő fűtés (Webasto)", + "leather_interior": "Bőr belső", + "heated_seat": "Fűthető ülés", + "partition_wall": "Válaszfal", + "seat_height_adjustment": "Ülésmagasság állítás", + "adjustable_steering_wheel": "Állítható kormány", + "central_locking": "Központi zár", + "trip_computer": "Fedélzeti számítógép", + "power_steering": "Szervokormány", + }, + "exterior": { + "power_windows": "Elektromos ablakemelő", + "power_mirrors": "Elektromos tükör állítás", + "heated_mirrors": "Fűthető tükör", + "alloy_wheels": "Könnyűfém felni", + "tinted_glass": "Sötétített üvegek", + "tow_bar": "Vontatóhorog", + "sunroof": "Napfénytető", + "fog_lights": "Ködlámpa", + "xenon_headlights": "Xenon fényszóró", + }, + "multimedia": { + "cd_changer": "CD váltó", + "cd_radio": "CD rádió", + "gps": "GPS navigáció", + "hifi": "Hi-Fi hangrendszer", + "radio_cassette": "Rádiós kazettás magnó", + }, +} + +# ────────────────────────────────────────────────────────────────────── +# HGV-SPECIFIC DROPDOWNS (shown when vehicle_class == 'hgv') +# ────────────────────────────────────────────────────────────────────── + +HGV_TRANSMISSION_TYPES: dict[str, str] = { + "manual": "Manuális", + "manual_async": "Manuális (aszinkron)", + "semi_auto": "Félautomata", + "auto": "Automata", + "manual_split": "Manuális felezős", + "semi_auto_split": "Félautomata felezős", + "auto_split": "Automata felezős", + "cvt": "Fokozatmentes (CVT)", +} + +HGV_PTO_TYPES: dict[str, str] = { + "front": "Első kihajtás (Front)", + "engine_mounted": "Motorra szerelt (Engine-mounted)", + "gearbox_mounted": "Váltóra szerelt (Gearbox-mounted)", +} + +# ── HGV Body Types (unified with frontend i18n keys) ── +HGV_BODY_TYPES: dict[str, str] = { + # ── Alváz / Fülke típusok ── + "chassis": "Alváz (csak futómű)", + "chassis_cab": "Alvázas fülkés (Chassis Cab)", + "double_cab": "Duplakabinos (Double Cab)", + "triple_cab": "Triplakabinos (Triple Cab)", + "crew_cab": "Crew Cab", + "sleeper_cab": "Alvófülkés (Sleeper Cab)", + "day_cab": "Nappali fülke (Day Cab)", + "glider_kit": "Glider Kit (felújított)", + "semi_glazed": "Félig üvegezett (Semi-Glazed)", + "fully_glazed": "Körben üvegezett (Fully Glazed)", + + # ── Billenős / Borítás ── + "tipper": "Billenős (Tipper)", + "tipper_3_way": "3 irányba billenő", + "tipper_double": "Kétoldalt billenő", + "tipper_trailer": "Billenős pótkocsi", + "tipper_mining": "Bányászati billenő (Mining Tipper)", + "tipper_grain": "Gabonás billenő (Grain Tipper)", + "dump_truck": "Billenős teherautó (Dump Truck)", + + # ── Dobozos / Ponyvás ── + "box": "Dobozos (Box)", + "box_tail_lift": "Dobozos emelőhátfalas (Box with Tail Lift)", + "box_insulated": "Szigetelt dobozos (Insulated Box)", + "box_curtain_side": "Függönyoldalas dobozos", + "curtain_sider": "Ponyvás (Curtain Sider)", + "curtain_sider_roller": "Ponyvás rolós (Roller Curtain)", + "roller_shutter": "Redőnyös (Roller Shutter)", + + # ── Platós ── + "flatbed": "Platós (Flatbed)", + "flatbed_stake": "Rakodóléces platós", + "flatbed_dropside": "Billenő oldalfalú platós", + "flatbed_crane": "Platós darugémes (Flatbed with Crane)", + "flatbed_tail_lift": "Platós emelőhátfalas (Flatbed with Tail Lift)", + + # ── Hűtött ── + "refrigerated": "Hűtős (Refrigerated / Reefer)", + "refrigerated_trailer": "Hűtött pótkocsi", + + # ── Tartályos ── + "tanker": "Tartályos (Tanker)", + "tanker_fuel": "Üzemanyag szállító (Fuel Tanker)", + "tanker_milk": "Tej szállító tartály", + "tanker_chemical": "Vegyi anyag szállító tartály", + "tanker_powder": "Poranyag szállító tartály", + "tanker_bitumen": "Bitumenes tartály (Bitumen Tanker)", + "tanker_gas": "Gázszállító tartály (Gas Tanker)", + "tanker_food": "Élelmiszer tartály (Food Tanker)", + "tanker_water": "Víztartály (Water Tanker)", + + # ── Nyergesvontató / Vontató ── + "tractor_unit": "Nyergesvontató (Tractor Unit)", + "tractor_unit_4x2": "Vontató 4x2", + "tractor_unit_6x2": "Vontató 6x2", + "tractor_unit_6x4": "Vontató 6x4", + "tractor_unit_8x4": "Vontató 8x4", + + # ── Félpótkocsi / Nyerges szerelvények ── + "semi_trailer": "Nyerges szerelvény (Semi-Trailer)", + "semi_trailer_curtain": "Függönyoldalas félpótkocsi", + "semi_trailer_box": "Dobozos félpótkocsi", + "semi_trailer_tipper": "Billenős félpótkocsi", + "semi_trailer_tanker": "Tartályos félpótkocsi", + "semi_trailer_flatbed": "Platós félpótkocsi", + "semi_trailer_low_loader": "Mélyágyas félpótkocsi", + "semi_trailer_car_transporter": "Autószállító félpótkocsi", + "semi_tipper": "Nyerges billenő (Semi-Tipper)", + "semi_refrigerated": "Nyerges hűtős (Semi-Reefer)", + "semi_tanker": "Nyerges tartály (Semi-Tanker)", + "semi_flatbed": "Nyerges platós (Semi-Flatbed)", + "semi_curtain": "Nyerges ponyvás (Semi-Curtain)", + "semi_log": "Nyerges rönkszállító (Semi-Timber)", + "semi_container": "Nyerges konténerszállító (Semi-Container)", + + # ── Konténer / Csereszekrény ── + "container": "Konténer", + "container_carrier": "Konténerszállító (Container Carrier)", + "swap_body": "Cserélhető felépítmény", + "swap_body_bdf": "Csereszekrényes BDF (Swap Body)", + "hook_lift": "Emelőhorgos (Hook Lift)", + + # ── Darus / Speciális ── + "crane": "Daru", + "crane_truck": "Darugémes teherautó (Crane Truck)", + "crane_trailer": "Darus pótkocsi", + "auto_crane": "Autódaru (Auto Crane)", + "ladder_truck": "Létrás (Ladder Truck)", + + # ── Tűzoltó / Mentő ── + "fire_truck": "Tűzoltó (Fire Truck)", + "fire_truck_aerial": "Léptetőkosaras tűzoltó", + "ambulance": "Mentő (Ambulance)", + "hearse": "Halottas (Hearse)", + + # ── Páncélozott / Pénzszállító ── + "armored": "Pénzszállító / páncélozott (Armored)", + "cash_transit": "Pénzszállító (Cash-in-Transit)", + + # ── Állatszállító ── + "livestock": "Élőállat-szállító (Livestock)", + "poultry": "Baromfi szállító (Poultry)", + "horse_transporter": "Lószállító (Horse Transporter)", + + # ── Járműszállító ── + "car_transporter": "Járműszállító (Car Transporter)", + "boat_transporter": "Hajószállító (Boat Transporter)", + "quad_transporter": "Quad / motorszállító (Quad Transporter)", + + # ── Egyéb speciális ── + "mobile_shop": "Mozgóbolt (Mobile Shop)", + "workshop": "Műhelykocsi (Workshop)", + "tow_truck": "Mentő / Szállító (Wrecker / Recovery)", + "wrecker": "Mentő / szállító (Wrecker / Recovery)", + "street_sweeper": "Utcaseprő", + "refuse_truck": "Szemetes (Refuse Truck)", + "concrete_mixer": "Betonszállító (Concrete Mixer)", + "concrete_pump": "Betonszivattyú (Concrete Pump)", + "timber_truck": "Faszállító (Timber Truck)", + "log_trailer": "Farönk szállító pótkocsi (Log Trailer)", + "clothing_transporter": "Ruhaszállító (Clothing Transporter)", + "military": "Harci jármű (Military)", + + # ── Egyéb ── + "other": "Egyéb (Other)", +} + +# ────────────────────────────────────────────────────────────────────── +# HGV FEATURES / EXTRAS (categorized) +# Stored in individual_equipment.hgv_specs.features as a list of keys. +# ────────────────────────────────────────────────────────────────────── + +HGV_FEATURES: dict[str, dict[str, str]] = { + "technical_work": { + # ═══════════════════════════════════════════════ + # 1. Hajtás & Fékrendszer (Drivetrain & Brakes) + # ═══════════════════════════════════════════════ + "diff_lock": "Differenciálzár (Diff Lock)", + "diff_lock_front": "Első differenciálzár", + "diff_lock_rear": "Hátsó differenciálzár", + "diff_lock_center": "Központi differenciálzár", + "diff_lock_full": "Teljes differenciálzár", + "cross_axle_lock": "Kereszttengely differenciálzár", + "retarder": "Retarder (Lejtőfékező)", + "engine_brake": "Motorfék (Engine Brake)", + "exhaust_brake": "Kipufogófék (Exhaust Brake)", + "auxiliary_brake": "Segédfék", + "abs": "ABS (Blokkolásgátló)", + "ebs": "EBS (Elektronikus fékrendszer)", + "esp": "ESP (Menetstabilizáló)", + "traction_control": "Kipörgésgátló (ASR/TCS)", + "roll_stability": "Borulásgátló (RSP)", + "stability_control": "Stabilitás szabályzó (ESP)", + "hill_holder": "Hegymeneti tartó (Hill Holder)", + "hill_descent": "Lejtmenetvezérlő (Hill Descent)", + "hill_descent_control": "Lejtmenetvezérlő", + + # ═══════════════════════════════════════════════ + # 2. Vezetéstámogató rendszerek (ADAS) + # ═══════════════════════════════════════════════ + "adaptive_cruise": "Adaptív tempomat (ACC)", + "lane_departure": "Sávelhagyás figyelő (LDWS)", + "lane_assist": "Sávtartó asszisztens", + "blind_spot": "Holtpont-figyelő (Blind Spot)", + "blind_spot_monitor": "Holtpont figyelő", + "collision_warning": "Ütközés figyelmeztető", + "emergency_brake": "Vészfék asszisztens (AEBS)", + "emergency_brake_assist": "Vészfék asszisztens", + "traffic_sign": "Táblafelismerő rendszer", + "traffic_sign_recognition": "Táblafelismerő", + "driver_alert": "Fáradtságfigyelő (Driver Alert)", + "crosswind_assist": "Keresztszél asszisztens", + "tyre_pressure_monitor": "Guminyomás monitor (TPMS)", + "tire_pressure": "Guminyomás-figyelő (TPMS)", + + # ═══════════════════════════════════════════════ + # 3. Flotta Adminisztráció & Mérés + # ═══════════════════════════════════════════════ + "tachograph": "Menetíró (Tachograph)", + "digital_tachograph": "Digitális menetíró", + "smart_tachograph": "Smart Tachográf (G2V2)", + "gps_tracker": "GPS nyomkövető", + "fleet_management": "Flottamenedzsment rendszer", + "fuel_monitoring": "Üzemanyag monitorozás", + "fuel_card_reader": "Üzemanyagkártya-olvasó", + "onboard_scale": "Fedélzeti mérleg (Onboard Scale)", + "weigh_in_motion": "Menet közbeni mérlegelés", + "eco_driving": "Eco Driving asszisztens", + "load_securing": "Rakományrögzítő rendszer", + + # ═══════════════════════════════════════════════ + # 4. Látás & Kamerák + # ═══════════════════════════════════════════════ + "reverse_camera": "Tolatókamera", + "rear_camera": "Hátsó kamera", + "front_camera": "Első kamera", + "side_camera": "Oldalsó kamera", + "360_camera": "360°-os kamera", + "parking_sensors": "Parkolóérzékelők", + "front_parking_sensors": "Első parkolóérzékelők", + "rear_parking_sensors": "Hátsó parkolóérzékelők", + "side_parking_sensors": "Oldalsó parkolóérzékelők", + + # ═══════════════════════════════════════════════ + # 5. Kötelező tartozékok & Egyéb + # ═══════════════════════════════════════════════ + + "fire_extinguisher": "Tűzoltó készülék", + "first_aid_kit": "Elsősegély csomag", + "warning_triangle": "Figyelmeztető háromszög", + "wheel_chocks": "Ékek (kerékrögzítő)", + "reflective_vest": "Reflexiós mellény", + "roof_hatch": "Tetőablak", + "roof_ladder": "Tetőlétra", + "mudguards": "Sárvédők", + "spare_wheel": "Pótkerék", + "snow_chains": "Hólánc", + "toolbox": "Szerszámos láda", + "work_lights": "Munkalámpák", + "beacon": "Figyelmeztető lámpa", + "reverse_alarm": "Tolatási hangjelző", + "side_underrun": "Oldalsó aláfutásgátló", + "rear_underrun": "Hátsó aláfutásgátló", + }, + + # ═══════════════════════════════════════════════════════════════ + # CABIN (Fülke) + # ═══════════════════════════════════════════════════════════════ + "cabin": { + # ═══════════════════════════════════════════════ + # 1. Klíma & Fűtés + # ═══════════════════════════════════════════════ + "air_conditioner": "Klíma (légkondícionáló)", + "automatic_ac": "Automata klíma", + "dual_zone_ac": "Kétzónás klíma", + "parking_heater": "Parkfűtés (Webasto/Eberspächer)", + "auxiliary_heater": "Kiegészítő fűtés", + "night_heater": "Éjszakai fűtés (állóhelyi fűtés)", + "engine_preheater": "Motor előmelegítő", + "roof_vent": "Tetőszellőző", + "power_vent": "Elektromos tetőszellőző", + + # ═══════════════════════════════════════════════ + # 2. Alvás & Életvitel + # ═══════════════════════════════════════════════ + "sleeper_cabin": "Hálófülke", + "single_bunk": "Egyágyas fekhely", + "double_bunk": "Kétágyas fekhely", + "upper_bunk": "Felső ágy", + "lower_bunk": "Alsó ágy", + "fridge": "Hűtőszekrény", + "freezer": "Fagyasztó", + "microwave": "Mikrohullámú sütő", + "coffee_machine": "Kávéfőző", + "water_heater": "Vízmelegítő", + "cabin_table": "Fülke asztal", + "curtain_set": "Függönykészlet", + "sun_visor": "Napszemüvegtartó / napellenző", + "cabin_lighting": "Fülke világítás", + "led_cabin_lighting": "LED fülke világítás", + "reading_light": "Olvasólámpa", + + # ═══════════════════════════════════════════════ + # 3. Kényelem & Ergonómia + # ═══════════════════════════════════════════════ + "driver_seat_suspension": "Légrugós vezetőülés", + "passenger_seat": "Utasülés", + "passenger_seat_swivel": "Forgatható utasülés", + "seat_heating": "Ülésfűtés", + "seat_ventilation": "Ülésszellőzés", + "armrest": "Karfák", + "adjustable_steering": "Állítható kormány", + "electric_windows": "Elektromos ablakok", + "central_locking": "Központi zár", + "storage_box": "Tároló doboz", + "wardrobe": "Gardrób / ruhatároló", + "bed_extension": "Ágy kihúzó panel", + "privacy_curtain": "Adatvédő függöny", + "insulation_package": "Hang- és hőszigetelés csomag", + }, + + # ═══════════════════════════════════════════════════════════════ + # MULTIMEDIA (Multimédia) + # ═══════════════════════════════════════════════════════════════ + "multimedia": { + "navigation": "Navigáció", + "apple_carplay": "Apple CarPlay", + "android_auto": "Android Auto", + "bluetooth": "Bluetooth", + "usb_charging": "USB töltő", + "wireless_charging": "Vezeték nélküli töltő", + "dab_radio": "DAB+ digitális rádió", + "cb_radio": "CB rádió", + "satellite_radio": "Műholdas rádió", + "digital_tv": "Digitális TV", + "dvb_t": "DVB-T (földi digitális TV)", + "dvb_s": "DVB-S (műholdas TV)", + "premium_sound": "Prémium hangrendszer", + "subwoofer": "Mélysugárzó", + "rear_entertainment": "Hátsó szórakoztató rendszer", + "wifi_hotspot": "WiFi hotspot", + }, +} + + +# ═══════════════════════════════════════════════════════════════════ +# HELPER FUNCTIONS +# ═══════════════════════════════════════════════════════════════════ + + +def get_all_feature_keys() -> list[str]: + """Return all possible feature keys across all vehicle types.""" + keys: list[str] = [] + for category in CAR_FEATURES.values(): + keys.extend(category.keys()) + for category in MOTORCYCLE_FEATURES.values(): + keys.extend(category.keys()) + for category in LCV_FEATURES.values(): + keys.extend(category.keys()) + for category in HGV_FEATURES.values(): + keys.extend(category.keys()) + return list(set(keys)) + + +def get_feature_label(key: str, lang: str = "hu") -> str: + """Look up a feature label by key across all vehicle type dictionaries.""" + hu_labels: dict[str, dict[str, str]] = { + "car": CAR_FEATURES, + "motorcycle": MOTORCYCLE_FEATURES, + "lcv": LCV_FEATURES, + "hgv": HGV_FEATURES, + } + for vehicle_type, categories in hu_labels.items(): + for category_key, features in categories.items(): + if key in features: + return features[key] + return key + + +def get_features_by_category( + vehicle_type: str, category: str +) -> dict[str, str]: + """Return features for a specific vehicle type and category.""" + mapping: dict[str, dict[str, dict[str, str]]] = { + "car": CAR_FEATURES, + "motorcycle": MOTORCYCLE_FEATURES, + "lcv": LCV_FEATURES, + "hgv": HGV_FEATURES, + } + vehicle_features = mapping.get(vehicle_type, {}) + return vehicle_features.get(category, {}) + + +def get_motorcycle_feature_label(key: str) -> str: + """Look up a motorcycle feature label by key.""" + for category in MOTORCYCLE_FEATURES.values(): + if key in category: + return category[key] + return key + + +def get_lcv_feature_label(key: str) -> str: + """Look up an LCV feature label by key.""" + for category in LCV_FEATURES.values(): + if key in category: + return category[key] + return key + + +def get_hgv_feature_label(key: str) -> str: + """Look up an HGV feature label by key.""" + for category in HGV_FEATURES.values(): + if key in category: + return category[key] + return key \ No newline at end of file diff --git a/backend/app/models/gamification/gamification.py b/backend/app/models/gamification/gamification.py index d3acda7..9f05ed1 100755 --- a/backend/app/models/gamification/gamification.py +++ b/backend/app/models/gamification/gamification.py @@ -62,6 +62,7 @@ class UserStats(Base): penalty_quota_remaining: Mapped[int] = mapped_column(Integer, nullable=False, default=0) places_discovered: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) places_validated: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + providers_added_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) banned_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/marketplace/organization.py b/backend/app/models/marketplace/organization.py index a32129a..07fe9ad 100755 --- a/backend/app/models/marketplace/organization.py +++ b/backend/app/models/marketplace/organization.py @@ -115,6 +115,22 @@ class Organization(Base): server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb") ) + # --- 🔍 CROWDSOURCED SEARCH FIELDS --- + # Provider nicknames / alternative names for matching (e.g. ["MOL", "MOL LUB", "MOL Magyarország"]) + aliases: Mapped[list] = mapped_column( + JSONB, + nullable=False, + default=list, + server_default=text("'[]'::jsonb") + ) + # Crowdsourced evaluation characteristics / tags (e.g. ["gyors", "megbízható", "kedvező ár"]) + tags: Mapped[list] = mapped_column( + JSONB, + nullable=False, + default=list, + server_default=text("'[]'::jsonb") + ) + # A technikai tulajdonos (User fiók - törölhető) owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) diff --git a/backend/app/models/vehicle/asset.py b/backend/app/models/vehicle/asset.py index d2b4297..d04c3be 100644 --- a/backend/app/models/vehicle/asset.py +++ b/backend/app/models/vehicle/asset.py @@ -125,6 +125,10 @@ class Asset(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now()) + # === WARRANTY === + is_under_warranty: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text('false')) + warranty_expiry_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + # === SALES MODULE === is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True) price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2)) diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py index 028e854..5e3eb72 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, root_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator from typing import Optional, Dict, Any, List from uuid import UUID from datetime import datetime @@ -82,6 +82,10 @@ class AssetResponse(BaseModel): created_at: datetime updated_at: Optional[datetime] = None + # === WARRANTY === + is_under_warranty: bool = Field(default=False, description="Érvényes jótállás") + warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") + # === SALES MODULE === is_for_sale: bool = Field(default=False) price: Optional[float] = None @@ -128,6 +132,47 @@ class AssetCreate(BaseModel): return None return v + # ── Data normalization validators ── + @field_validator('brand') + @classmethod + def normalize_brand(cls, v: Optional[str]) -> Optional[str]: + """Brand: UPPERCASE, strip whitespace.""" + if v is None or not isinstance(v, str): + return v + return v.strip().upper() + + @field_validator('model') + @classmethod + def normalize_model(cls, v: Optional[str]) -> Optional[str]: + """Model: UPPERCASE, remove ALL spaces.""" + if v is None or not isinstance(v, str): + return v + return v.upper().replace(' ', '') + + # ── Model validator: normalize type_designation inside individual_equipment ── + @model_validator(mode='after') + def normalize_individual_equipment(self): + """Normalize type_designation inside individual_equipment.car_specs. + + SAFE None handling: checks every level before calling .upper(). + - individual_equipment may be None or empty dict + - car_specs may be None or missing key + - type_designation may be None, empty string, or a valid string + """ + if not self.individual_equipment: + return self + if not isinstance(self.individual_equipment, dict): + return self + + car_specs = self.individual_equipment.get('car_specs') + if not car_specs or not isinstance(car_specs, dict): + return self + + td = car_specs.get('type_designation') + if td is not None and isinstance(td, str) and td.strip(): + car_specs['type_designation'] = td.strip().upper().replace(' ', '') + return self + # ── 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): @@ -166,7 +211,7 @@ class AssetCreate(BaseModel): trim_level: Optional[str] = Field(None, max_length=100, description="Felszereltségi szint") roof_type: Optional[str] = Field(None, max_length=50, description="Tető típus") audio_system_type: Optional[str] = Field(None, max_length=100, description="Hangrendszer") - individual_equipment: Dict[str, Any] = Field(default_factory=dict, description="Egyedi felszerelések") + individual_equipment: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Egyedi felszerelések") # === MILEAGE (Optional) === current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás") @@ -179,6 +224,10 @@ class AssetCreate(BaseModel): organization_id: Optional[int] = Field(None, description="Szervezet ID (alapértelmezett a felhasználó szervezete)") branch_id: Optional[UUID] = Field(None, description="Garázs (Branch) ID, ahova a járművet rendeljük. Ha nincs megadva, a szervezet központi garázsába kerül.") + # === WARRANTY === + is_under_warranty: bool = Field(default=False, description="Érvényes jótállás") + warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") + # === PRIMARY VEHICLE FLAG === is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)") @@ -224,6 +273,47 @@ class AssetUpdate(BaseModel): return None return v + # ── Data normalization validators ── + @field_validator('brand') + @classmethod + def normalize_brand(cls, v: Optional[str]) -> Optional[str]: + """Brand: UPPERCASE, strip whitespace.""" + if v is None or not isinstance(v, str): + return v + return v.strip().upper() + + @field_validator('model') + @classmethod + def normalize_model(cls, v: Optional[str]) -> Optional[str]: + """Model: UPPERCASE, remove ALL spaces.""" + if v is None or not isinstance(v, str): + return v + return v.upper().replace(' ', '') + + # ── Model validator: normalize type_designation inside individual_equipment ── + @model_validator(mode='after') + def normalize_individual_equipment(self): + """Normalize type_designation inside individual_equipment.car_specs. + + SAFE None handling: checks every level before calling .upper(). + - individual_equipment may be None or empty dict + - car_specs may be None or missing key + - type_designation may be None, empty string, or a valid string + """ + if not self.individual_equipment: + return self + if not isinstance(self.individual_equipment, dict): + return self + + car_specs = self.individual_equipment.get('car_specs') + if not car_specs or not isinstance(car_specs, dict): + return self + + td = car_specs.get('type_designation') + if td is not None and isinstance(td, str) and td.strip(): + car_specs['type_designation'] = td.strip().upper().replace(' ', '') + return self + # ── Model validator: ensure at least one of vin or license_plate is provided ── @model_validator(mode='after') def validate_vin_or_plate(self): @@ -276,8 +366,63 @@ class AssetUpdate(BaseModel): year_of_manufacture: Optional[int] = Field(None, ge=1900, le=2100, description="Gyártási év") first_registration_date: Optional[datetime] = Field(None, description="Első forgalomba helyezés dátuma") + # === WARRANTY === + is_under_warranty: Optional[bool] = Field(None, description="Érvényes jótállás") + warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") + # === STATUS === current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás") is_primary: Optional[bool] = Field(None, description="Elsődleges jármű") - model_config = ConfigDict(from_attributes=True) \ No newline at end of file + model_config = ConfigDict(from_attributes=True) + + +class AssetEventCreate(BaseModel): + """Schema for creating a new AssetEvent (Service Book entry).""" + event_type: str = Field(..., description="Event type: SERVICE, REPAIR, ACCIDENT, INSPECTION, TIRE_CHANGE, MAINTENANCE, UPGRADE, RECALL") + event_date: Optional[datetime] = Field(None, description="Event date (default: now)") + odometer_reading: Optional[int] = Field(None, ge=0, description="Odometer reading at event time") + description: Optional[str] = Field(None, max_length=2000, description="Event description") + cost_id: Optional[UUID] = Field(None, description="Associated cost record ID") + cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.") + currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.") + + model_config = ConfigDict(from_attributes=True) + + +class AssetEventResponse(BaseModel): + """Schema for returning an AssetEvent.""" + id: UUID + asset_id: UUID + user_id: Optional[int] = None + organization_id: Optional[int] = None + event_type: str + odometer_reading: Optional[int] = None + description: Optional[str] = None + cost_id: Optional[UUID] = None + cost_amount: Optional[float] = Field(None, description="Auto-created cost amount") + currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)") + event_date: datetime + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + @classmethod + def model_validate(cls, obj, **kwargs): + """Override to resolve cost_amount and currency from the related AssetCost.""" + data = {} + if hasattr(obj, '__dict__'): + # Copy ORM attributes + for field in cls.model_fields: + if hasattr(obj, field): + data[field] = getattr(obj, field) + + # Resolve cost_amount and currency from the cost relationship + cost = getattr(obj, 'cost', None) + if cost is not None: + if data.get('cost_amount') is None: + data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None + if data.get('currency') is None: + data['currency'] = cost.currency + return cls(**data) \ No newline at end of file diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py index 7d67eb1..fe51e3d 100755 --- a/backend/app/schemas/organization.py +++ b/backend/app/schemas/organization.py @@ -60,6 +60,9 @@ class OrganizationUpdate(BaseModel): address_floor: Optional[str] = None address_door: Optional[str] = None address_hrsz: Optional[str] = None + # ── Crowdsourced search fields ── + aliases: Optional[List[str]] = None + tags: Optional[List[str]] = None class OrganizationResponse(BaseModel): @@ -79,5 +82,7 @@ class OrganizationResponse(BaseModel): visual_settings: Optional[dict] = None notification_settings: Optional[Any] = None external_integration_config: Optional[Any] = None + aliases: List[str] = Field(default_factory=list) + tags: List[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/app/schemas/provider.py b/backend/app/schemas/provider.py new file mode 100644 index 0000000..120edef --- /dev/null +++ b/backend/app/schemas/provider.py @@ -0,0 +1,112 @@ +""" +Provider (szolgáltató) Pydantic sémák a crowdsourced partnerkeresőhöz. +Tartalmazza a keresési, gyors felvételi és szerkesztési sémákat. + +P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése. +- street -> address_street_name, address_street_type, address_house_number +- A ProviderSearchResult is tartalmazza az atomizált címmezőket. +""" + +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional, List + + +class ExpertiseCategoryOut(BaseModel): + """Expertise kategória kimeneti sémája a frontend dropdown számára.""" + id: int + key: str + name_hu: Optional[str] = None + name_en: Optional[str] = None + category: str + + model_config = ConfigDict(from_attributes=True) + + +class ProviderSearchResult(BaseModel): + """Egy szolgáltató keresési találatának adatai. + + P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. + - address_street_name, address_street_type, address_house_number + - A frontend ezeket intelligensen fűzi össze megjelenítéskor. + """ + id: int + name: str + category: Optional[str] = None + specialization: List[str] = Field(default_factory=list) + city: Optional[str] = None + address: Optional[str] = None + address_zip: Optional[str] = None + address_street_name: Optional[str] = None + address_street_type: Optional[str] = None + address_house_number: Optional[str] = None + contact_phone: Optional[str] = None + contact_email: Optional[str] = None + website: Optional[str] = None + tags: List[str] = Field(default_factory=list) + source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added") + is_verified: bool = False + rating: Optional[float] = None + + model_config = ConfigDict(from_attributes=True) + + +class ProviderSearchResponse(BaseModel): + """Keresési eredmények lapozással.""" + results: List[ProviderSearchResult] = Field(default_factory=list) + total: int = 0 + page: int = 1 + per_page: int = 20 + + +class ProviderQuickAddIn(BaseModel): + """Gyors szolgáltató felvétel bemeneti adatai. + + P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. + - street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number + """ + name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve") + category_id: int = Field(..., ge=1, description="Kategória ID (marketplace.expertise_tags.id)") + city: Optional[str] = Field(None, max_length=100, description="Város") + address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám") + address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)") + address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)") + address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)") + contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám") + contact_email: Optional[str] = Field(None, max_length=255, description="Email cím") + website: Optional[str] = Field(None, max_length=255, description="Weboldal URL") + tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)") + + +class ProviderQuickAddResponse(BaseModel): + """Gyors szolgáltató felvétel válasza.""" + id: int + name: str + status: str = "pending_verification" + message: str = "Szolgáltató sikeresen rögzítve. Ellenőrzés alatt." + earned_points: int = 0 + + +class ProviderUpdateIn(BaseModel): + """Szolgáltató adatainak szerkesztése (PUT /providers/{id}). + + P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. + - street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number + - Tartalmazza a contact_phone, contact_email, website és tags mezőket is. + """ + name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve") + city: Optional[str] = Field(None, max_length=100, description="Város") + address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám") + address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)") + address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)") + address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)") + contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám") + contact_email: Optional[str] = Field(None, max_length=255, description="Email cím") + website: Optional[str] = Field(None, max_length=255, description="Weboldal URL") + tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)") + + +class ProviderUpdateResponse(BaseModel): + """Szolgáltató szerkesztés válasza.""" + id: int + name: str + message: str = "Szolgáltató adatai sikeresen frissítve." diff --git a/backend/app/scripts/heal_user_data.py b/backend/app/scripts/heal_user_data.py new file mode 100644 index 0000000..c742680 --- /dev/null +++ b/backend/app/scripts/heal_user_data.py @@ -0,0 +1,242 @@ +""" +🤖 Data Healing Script — Wallet & Referral Code Repair + +Detects and fixes missing Wallet and InvitationCode (referral_code) records +for existing User and Organization entities. + +Usage: + docker compose exec sf_api python3 /app/backend/app/scripts/heal_user_data.py + +Logs: + - Prints a summary of healed records to stdout + - Writes detailed log to logs/heal_user_data_{timestamp}.log +""" + +import asyncio +import logging +import os +import sys +import uuid +from datetime import datetime +from typing import Dict, Any + +# Ensure the backend app is on the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from app.core.config import settings +from app.models.identity import User, Wallet +from app.models.marketplace.organization import Organization + +# ── Logging setup ────────────────────────────────────────────────────────── +LOG_DIR = "/app/backend/logs" +os.makedirs(LOG_DIR, exist_ok=True) + +log_filename = f"heal_user_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" +log_path = os.path.join(LOG_DIR, log_filename) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler(log_path), + ], +) +logger = logging.getLogger("heal-user-data") + + +# ── Helpers ──────────────────────────────────────────────────────────────── + +def generate_referral_code() -> str: + """Generate a short, unique referral code (8 chars, uppercase).""" + import secrets + import string + alphabet = string.ascii_uppercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(8)) + + +# ── Main healing logic ───────────────────────────────────────────────────── + +async def heal_users(db: AsyncSession) -> Dict[str, int]: + """ + Find all Users without a Wallet and create one (0 balance). + Find all Users without a referral_code and generate one. + """ + stats = {"wallet_created": 0, "referral_code_generated": 0} + + # 1. Fetch all users + result = await db.execute(select(User)) + users = result.scalars().all() + logger.info(f"Found {len(users)} total User records.") + + for user in users: + # ── Wallet check ── + wallet_stmt = select(Wallet).where(Wallet.user_id == user.id) + wallet_result = await db.execute(wallet_stmt) + existing_wallet = wallet_result.scalar_one_or_none() + + if existing_wallet is None: + new_wallet = Wallet( + user_id=user.id, + earned_credits=0, + purchased_credits=0, + service_coins=0, + currency="HUF", + ) + db.add(new_wallet) + stats["wallet_created"] += 1 + logger.info(f" [Wallet] Created for User ID={user.id} ({user.email})") + else: + logger.debug(f" [Wallet] Already exists for User ID={user.id}") + + # ── Referral code check ── + if not user.referral_code: + code = generate_referral_code() + # Ensure uniqueness + while True: + dup_check = await db.execute( + select(User).where(User.referral_code == code) + ) + if dup_check.scalar_one_or_none() is None: + break + code = generate_referral_code() + + user.referral_code = code + stats["referral_code_generated"] += 1 + logger.info( + f" [Referral] Generated code '{code}' for User ID={user.id} ({user.email})" + ) + else: + logger.debug( + f" [Referral] Already has code '{user.referral_code}' for User ID={user.id}" + ) + + return stats + + +async def heal_organizations(db: AsyncSession) -> Dict[str, int]: + """ + Check Organizations for wallet coverage. + + NOTE: The `wallets` table has: + - user_id: NOT NULL + UNIQUE constraint + - organization_id: nullable + + This means each user can have exactly one wallet, and organization + wallets share the user's wallet via organization_id. Since user wallets + are already created in heal_users(), org wallets are inherently covered. + + We only log existing org wallet links for audit purposes. + """ + stats = {"org_wallet_created": 0, "org_wallet_skipped": 0} + + result = await db.execute(select(Organization)) + orgs = result.scalars().all() + logger.info(f"Found {len(orgs)} total Organization records.") + + for org in orgs: + # Check if an org wallet already exists (linked via organization_id) + wallet_stmt = select(Wallet).where(Wallet.organization_id == org.id) + wallet_result = await db.execute(wallet_stmt) + existing_wallet = wallet_result.scalar_one_or_none() + + if existing_wallet is None: + # The wallets.user_id is NOT NULL + UNIQUE, so we cannot create + # a separate wallet for an org. The org's owner already has a + # personal wallet from heal_users(). We skip org wallet creation + # and log the situation. + owner_id = getattr(org, 'owner_id', None) + if owner_id: + # Check if owner has a wallet we could link + owner_wallet = await db.execute( + select(Wallet).where(Wallet.user_id == owner_id) + ) + owner_wallet = owner_wallet.scalar_one_or_none() + if owner_wallet: + # Link the existing wallet to this org + owner_wallet.organization_id = org.id + stats["org_wallet_created"] += 1 + logger.info( + f" [OrgWallet] Linked existing wallet (user_id={owner_id}) " + f"to Organization ID={org.id} ({org.name})" + ) + else: + stats["org_wallet_skipped"] += 1 + logger.warning( + f" [OrgWallet] SKIPPED for Organization ID={org.id} ({org.name}) — " + f"owner (user_id={owner_id}) has no wallet yet." + ) + else: + stats["org_wallet_skipped"] += 1 + logger.warning( + f" [OrgWallet] SKIPPED for Organization ID={org.id} ({org.name}) — " + f"no owner_id found." + ) + else: + logger.debug(f" [OrgWallet] Already linked for Organization ID={org.id}") + + return stats + + +async def main(): + """Main entry point.""" + logger.info("=" * 60) + logger.info(" DATA HEALING SCRIPT — Wallet & Referral Code Repair") + logger.info("=" * 60) + + # Build async engine from the same DATABASE_URL + db_url = settings.DATABASE_URL + # If the URL starts with postgresql://, convert to postgresql+asyncpg:// + if db_url.startswith("postgresql://") and "+asyncpg" not in db_url: + db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1) + + engine = create_async_engine(db_url, echo=False, pool_pre_ping=True) + async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session_factory() as db: + try: + async with db.begin(): + user_stats = await heal_users(db) + org_stats = await heal_organizations(db) + + total_healed = ( + user_stats["wallet_created"] + + user_stats["referral_code_generated"] + + org_stats["org_wallet_created"] + ) + + logger.info("─" * 60) + logger.info(" ✅ HEALING COMPLETE — Summary") + logger.info(f" User wallets created: {user_stats['wallet_created']}") + logger.info(f" Referral codes generated: {user_stats['referral_code_generated']}") + logger.info(f" Organization wallets created: {org_stats['org_wallet_created']}") + logger.info(f" Organization wallets skipped: {org_stats['org_wallet_skipped']}") + logger.info(f" ─────────────────────────────────") + logger.info(f" TOTAL records healed: {total_healed}") + logger.info("─" * 60) + + print(f"\n{'='*60}") + print(f" ✅ DATA HEALING COMPLETE") + print(f" Log file: {log_path}") + print(f"{'='*60}") + print(f" User wallets created: {user_stats['wallet_created']}") + print(f" Referral codes generated: {user_stats['referral_code_generated']}") + print(f" Organization wallets created: {org_stats['org_wallet_created']}") + print(f" Organization wallets skipped: {org_stats['org_wallet_skipped']}") + print(f" ─────────────────────────────────") + print(f" TOTAL records healed: {total_healed}") + print(f"{'='*60}\n") + + except Exception as e: + logger.error(f"❌ Healing failed: {e}", exc_info=True) + print(f"\n❌ ERROR: Healing failed — {e}\n") + raise + + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/scripts/seed_expertise_tags.py b/backend/app/scripts/seed_expertise_tags.py new file mode 100644 index 0000000..410b1ca --- /dev/null +++ b/backend/app/scripts/seed_expertise_tags.py @@ -0,0 +1,156 @@ +""" +Seed script: Feltölti a marketplace.expertise_tags táblát alap kategóriákkal. +Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható. + +Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py +""" + +import asyncio +import logging +from sqlalchemy import select, func +from app.db.session import AsyncSessionLocal +from app.models.marketplace.service import ExpertiseTag + +logger = logging.getLogger(__name__) + +BASE_CATEGORIES = [ + { + "key": "auto_szerelo", + "name_hu": "Autószerelő", + "name_en": "Car Mechanic", + "category": "vehicle_service", + "search_keywords": ["szerelő", "autószerelő", "mechanic", "garage", "javítás"], + "description": "Személy- és haszongépjárművek mechanikai javítása, karbantartása", + }, + { + "key": "motor_szerelo", + "name_hu": "Motorkerékpár szerelő", + "name_en": "Motorcycle Mechanic", + "category": "vehicle_service", + "search_keywords": ["motor", "motorszerelő", "motorcycle", "robogó"], + "description": "Motorkerékpárok, robogók javítása és karbantartása", + }, + { + "key": "gumiszerviz", + "name_hu": "Gumiszerviz", + "name_en": "Tire Service", + "category": "vehicle_service", + "search_keywords": ["gumi", "gumis", "tire", "tyre", "abroncs", "kerék"], + "description": "Gumiabroncsok cseréje, javítása, téli/nyári gumik tárolása", + }, + { + "key": "karosszerialakatos", + "name_hu": "Karosszérialakatos", + "name_en": "Body Shop", + "category": "body_paint", + "search_keywords": ["karosszéria", "lakatos", "bodyshop", "karosszerialakatos", "kasztni"], + "description": "Karosszéria javítás, lakatolás, kasztni javítás", + }, + { + "key": "fényező", + "name_hu": "Fényező", + "name_en": "Painter", + "category": "body_paint", + "search_keywords": ["fényező", "fényezés", "painter", "spray", "lakkozás"], + "description": "Gépjármű fényezés, lakkozás, színjavítás", + }, + { + "key": "autómentő", + "name_hu": "Autómentő / Motormentő", + "name_en": "Tow Truck / Roadside Assistance", + "category": "roadside", + "search_keywords": ["mentő", "autómentő", "tow", "roadside", "assistance", "segély"], + "description": "Útsegély, autómentés, motormentés, kiszállás", + }, + { + "key": "benzinkút", + "name_hu": "Benzinkút", + "name_en": "Gas Station", + "category": "fuel", + "search_keywords": ["benzin", "kút", "gas station", "fuel", "dízel", "töltő"], + "description": "Üzemanyag töltőállomások, benzinkutak", + }, + { + "key": "alkatrész_kereskedés", + "name_hu": "Alkatrész kereskedés", + "name_en": "Parts Store", + "category": "parts", + "search_keywords": ["alkatrész", "parts", "spares", "autóalkatrész"], + "description": "Gépjármű alkatrészek kereskedelme", + }, + { + "key": "vizsgaállomás", + "name_hu": "Vizsgaállomás", + "name_en": "Inspection Station", + "category": "inspection", + "search_keywords": ["vizsga", "műszaki", "inspection", "MOT", "vizsgáztatás"], + "description": "Műszaki vizsgáztatás, emisszió mérés", + }, + { + "key": "autókozmetika", + "name_hu": "Autókozmetika", + "name_en": "Car Detailing", + "category": "detailing", + "search_keywords": ["kozmetika", "detailing", "takarítás", "mosás", "polírozás"], + "description": "Autókozmetika, részletes takarítás, polírozás, kerámia bevonat", + }, + { + "key": "egyéb", + "name_hu": "Egyéb szolgáltató", + "name_en": "Other Service", + "category": "other", + "search_keywords": ["egyéb", "other", "szolgáltató", "service"], + "description": "Egyéb gépjárművel kapcsolatos szolgáltatás", + }, +] + + +async def seed_expertise_tags(): + """Feltölti az expertise_tags táblát az alap kategóriákkal.""" + async with AsyncSessionLocal() as db: + try: + # Ellenőrizzük, van-e már adat + stmt = select(func.count(ExpertiseTag.id)) + result = await db.execute(stmt) + count = result.scalar() + + if count > 0: + logger.info(f"Az expertise_tags tábla már tartalmaz {count} rekordot. Feltöltés kihagyva.") + print(f"✅ Az expertise_tags tábla már tartalmaz {count} rekordot. Nincs szükség seed-elésre.") + return + + inserted = 0 + for cat in BASE_CATEGORIES: + tag = ExpertiseTag( + key=cat["key"], + name_hu=cat["name_hu"], + name_en=cat["name_en"], + category=cat["category"], + search_keywords=cat["search_keywords"], + description=cat["description"], + is_official=True, + discovery_points=10, + usage_count=0, + ) + db.add(tag) + inserted += 1 + + await db.commit() + logger.info(f"Sikeresen beszúrva {inserted} kategória az expertise_tags táblába.") + print(f"✅ Sikeresen beszúrva {inserted} kategória az expertise_tags táblába.") + + # Ellenőrzés + result = await db.execute(select(func.count(ExpertiseTag.id))) + final_count = result.scalar() + print(f"📊 Összes rekord az expertise_tags táblában: {final_count}") + + except Exception as e: + await db.rollback() + logger.error(f"Hiba a seed-elés során: {e}") + print(f"❌ Hiba: {e}") + raise + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(seed_expertise_tags()) diff --git a/backend/app/scripts/seed_gamification_rules.py b/backend/app/scripts/seed_gamification_rules.py new file mode 100644 index 0000000..e666e44 --- /dev/null +++ b/backend/app/scripts/seed_gamification_rules.py @@ -0,0 +1,118 @@ +""" +Gamification Point Rules Seed Script +===================================== +Cél: Dinamikus pontszabályok feltöltése a gamification.point_rules táblába. +A pontértékek NINCSENEK beégetve a kódba, hanem az adatbázisból +(point_rules tábla) olvassuk ki őket futásidőben. + +A Vezető Tervező utasítása szerint: +- TILOS hardcoded pontszámokat használni a service rétegben! +- Minden pontértéket a point_rules táblából kell lekérdezni action_key alapján. + +Használat: + docker compose exec sf_api python3 /app/backend/app/scripts/seed_gamification_rules.py +""" + +import asyncio +import logging +import sys +from pathlib import Path + +# Projekt gyökér hozzáadása a Python path-hoz +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from app.core.config import settings +from app.models.gamification.gamification import PointRule + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("Seed-Gamification-Rules") + +# A seed-elendő pontszabályok definíciói +# FIGYELEM: Ezek a definíciók csak a seed script számára vannak itt. +# A service rétegben a pontokat az adatbázisból (point_rules tábla) kell kiolvasni! +SEED_RULES = [ + { + "action_key": "ADD_NEW_PROVIDER", + "points": 500, + "description": "Új szolgáltató rögzítése a rendszerbe", + "is_active": True, + }, + { + "action_key": "USE_UNVERIFIED_PROVIDER", + "points": 200, + "description": "Szervizesemény/költség rögzítése olyan szolgáltatónál, aminek még nincs 5 megerősítése", + "is_active": True, + }, + { + "action_key": "RATE_PROVIDER", + "points": 250, + "description": "Szolgáltató értékelése (tagekkel)", + "is_active": True, + }, +] + + +async def seed_point_rules(): + """ + Feltölti a gamification.point_rules táblát a fenti szabályokkal, + de csak azokat a rekordokat szúrja be, amelyek még nem léteznek + (action_key alapján UPSERT helyett INSERT WHERE NOT EXISTS). + """ + # Adatbázis kapcsolat létrehozása + engine = create_async_engine(settings.DATABASE_URL, echo=False) + async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as db: + try: + inserted_count = 0 + skipped_count = 0 + + for rule_data in SEED_RULES: + # Ellenőrizzük, hogy létezik-e már ez a szabály + stmt = select(PointRule).where( + PointRule.action_key == rule_data["action_key"] + ) + result = await db.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + logger.info( + f"⏭️ Szabály már létezik: {rule_data['action_key']} " + f"(pont: {existing.points}, ID: {existing.id})" + ) + skipped_count += 1 + continue + + # Új szabály beszúrása + new_rule = PointRule( + action_key=rule_data["action_key"], + points=rule_data["points"], + description=rule_data["description"], + is_active=rule_data["is_active"], + ) + db.add(new_rule) + await db.flush() + logger.info( + f"✅ Szabály létrehozva: {rule_data['action_key']} " + f"(pont: {rule_data['points']}, ID: {new_rule.id})" + ) + inserted_count += 1 + + await db.commit() + logger.info( + f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, " + f"{skipped_count} már létezett." + ) + + except Exception as e: + await db.rollback() + logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True) + raise + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(seed_point_rules()) diff --git a/backend/app/services/asset_service.py b/backend/app/services/asset_service.py index 657c8a7..6635bc7 100755 --- a/backend/app/services/asset_service.py +++ b/backend/app/services/asset_service.py @@ -287,7 +287,7 @@ class AssetService: except Exception as e: await db.rollback() - logger.error(f"Asset Creation Error: {e}") + logger.error(f"Asset Creation Error: {e}", exc_info=True) raise e @staticmethod @@ -360,8 +360,11 @@ class AssetService: # --- CATALOG METHODS --- @staticmethod async def get_makes(db: AsyncSession, vehicle_class: Optional[str] = None) -> List[str]: - """Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class.""" - stmt = select(distinct(VehicleModelDefinition.make)).order_by(VehicleModelDefinition.make) + """Get all distinct makes from vehicle model definitions, optionally filtered by vehicle_class. + + Uses func.upper() to normalize brand casing and deduplicate case-insensitively. + """ + stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make)) if vehicle_class: stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class) result = await db.execute(stmt) @@ -417,10 +420,11 @@ class AssetService: 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. + Uses func.upper() to normalize brand casing and deduplicate case-insensitively. 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) + stmt = select(distinct(func.upper(VehicleModelDefinition.make))).order_by(func.upper(VehicleModelDefinition.make)) if vehicle_class: stmt = stmt.where(VehicleModelDefinition.vehicle_class == vehicle_class) if query: @@ -487,8 +491,11 @@ class AssetService: @staticmethod async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int: """ - Get the vehicle limit for a user, checking both user-specific AND organization limits. - Returns the HIGHER value of the two as per requirements. + Get the vehicle limit for a user, checking: + 1. Config-based limits (user role, subscription plan, org-specific) + 2. Subscription tier JSONB rules['allowances']['max_vehicles'] + + Returns the HIGHEST value among all applicable limits. Args: db: AsyncSession @@ -496,9 +503,10 @@ class AssetService: org_id: Organization ID Returns: - Maximum allowed vehicles (higher of user limit and organization limit) + Maximum allowed vehicles (highest of all applicable limits) """ from app.models.identity import User + from app.models.core_logic import UserSubscription, SubscriptionTier from app.services.config_service import config try: @@ -506,46 +514,74 @@ class AssetService: user_stmt = select(User).where(User.id == user_id) user = (await db.execute(user_stmt)).scalar_one() - # Get global vehicle limits configuration + # ── 1. CONFIG-BASED LIMIT ── limits = await config.get_setting(db, "VEHICLE_LIMIT") if limits is None: logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}") - # Fallback to very high limit instead of restricting users limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100} user_role = user.role.value if hasattr(user.role, 'value') else str(user.role) subscription_plan = user.subscription_plan or "free" - # Get user-specific limit (based on role or subscription plan) - user_limit = limits.get(user_role) - if user_limit is None: - # FIX: If role not found (e.g. "user" not in dict), try subscription plan - user_limit = limits.get(subscription_plan.lower()) - if user_limit is None: - # FIX: Ultimate fallback - safe default for free/basic users - user_limit = limits.get("free", 1) + config_limit = limits.get(user_role) + if config_limit is None: + config_limit = limits.get(subscription_plan.lower()) + if config_limit is None: + config_limit = limits.get("free", 1) - # Get organization-specific limit (if configured) + # ── 2. ORGANIZATION-SPECIFIC LIMIT ── org_limit = None try: org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id) if org_limits and isinstance(org_limits, dict): - # Organization might have different limit structure - # Try to get limit for user's role or use a default org limit org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower()) if org_limit is None and "default" in org_limits: org_limit = org_limits["default"] except Exception as e: logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}") - org_limit = None - # Log the calculated limit for debugging - final_limit = user_limit + # ── 3. SUBSCRIPTION TIER JSONB LIMIT ── + # Query the user's active subscription and read rules['allowances']['max_vehicles'] + subscription_limit = None + try: + sub_stmt = ( + select(SubscriptionTier.rules) + .select_from(UserSubscription) + .join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id) + .where( + UserSubscription.user_id == user_id, + UserSubscription.is_active == True + ) + .limit(1) + ) + sub_result = await db.execute(sub_stmt) + tier_rules = sub_result.scalar_one_or_none() + + if tier_rules and isinstance(tier_rules, dict): + allowances = tier_rules.get('allowances', {}) + if isinstance(allowances, dict): + max_vehicles = allowances.get('max_vehicles') + if max_vehicles is not None and isinstance(max_vehicles, (int, float)): + subscription_limit = int(max_vehicles) + logger.info( + f"Subscription tier limit for user {user_id}: " + f"max_vehicles={subscription_limit} (from rules={tier_rules})" + ) + except Exception as e: + logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}") + + # ── 4. FINAL: Take the HIGHEST of all applicable limits ── + final_limit = config_limit if org_limit is not None: - final_limit = max(user_limit, org_limit) - logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit={org_limit}, final={final_limit}") - else: - logger.info(f"Calculated limit for user {user_id} (role: {user_role}, plan: {subscription_plan}): user_limit={user_limit}, org_limit=None, final={final_limit}") + final_limit = max(final_limit, org_limit) + if subscription_limit is not None: + final_limit = max(final_limit, subscription_limit) + + logger.info( + f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): " + f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, " + f"final={final_limit}" + ) return final_limit diff --git a/backend/app/services/provider_service.py b/backend/app/services/provider_service.py new file mode 100644 index 0000000..6060ad9 --- /dev/null +++ b/backend/app/services/provider_service.py @@ -0,0 +1,650 @@ +""" +Provider Service – Szolgáltató keresés, gyors felvétel és szerkesztés logikája. + +Gamification Integration (2026-06-16): +======================================= +A Vezető Tervező utasítása szerint a crowdsourcing funkciókba +dinamikus Gamification horgok kerültek beépítésre. + +ALAPELV: A pontértékek SOHA nincsenek beégetve (hardcoded) a kódba! + Minden pontszámot a gamification.point_rules táblából olvasunk ki + action_key alapján futásidőben. + +Jelenlegi horgok: +- ADD_NEW_PROVIDER: 500 pont (adatbázisból) + providers_added_count növelés + +P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. +===================================================== +- quick_add_provider: data.street -> data.address_street_name, address_street_type, address_house_number +- update_provider: data.street -> data.address_street_name, address_street_type, address_house_number +- search_providers: visszaadja az address_street_name, address_street_type, address_house_number mezőket +- A kapcsolatfelvételi adatok (contact_phone, contact_email, website, tags) a ServiceProfile-ba kerülnek. +""" + +import logging +import uuid +import hashlib +from datetime import datetime, timezone +from typing import Optional, List, Tuple + +from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole +from app.models.marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging +from app.models.identity.social import ServiceProvider +from app.models.gamification.gamification import PointRule, UserStats +from app.schemas.provider import ( + ProviderSearchResult, + ProviderSearchResponse, + ProviderQuickAddIn, + ProviderQuickAddResponse, + ProviderUpdateIn, + ProviderUpdateResponse, +) +from app.services.gamification_service import gamification_service + +logger = logging.getLogger(__name__) + + +async def _award_provider_points( + db: AsyncSession, + user_id: int, + action_key: str, +) -> int: + """ + Dinamikus pontjóváírás a gamification.point_rules tábla alapján. + + Ez a függvény az adatbázisból olvassa ki a pontértéket az action_key + alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről + bármikor módosíthatók a point_rules táblában. + + Args: + db: AsyncSession + user_id: A felhasználó ID-ja + action_key: A pontszabály azonosítója (pl. 'ADD_NEW_PROVIDER') + + Returns: + int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív) + """ + # 1. Pontszabály lekérdezése az adatbázisból (dinamikus!) + rule_stmt = select(PointRule).where( + PointRule.action_key == action_key, + PointRule.is_active == True, + ) + rule_result = await db.execute(rule_stmt) + rule = rule_result.scalar_one_or_none() + + if not rule: + logger.warning( + f"Pontszabály '{action_key}' nem található vagy inaktív. " + f"Pontjóváírás kihagyva user_id={user_id}." + ) + return 0 + + points_to_award = rule.points + logger.info( + f"Dinamikus pont lekérés: action_key='{action_key}', " + f"points={points_to_award} (forrás: gamification.point_rules tábla)" + ) + + # 2. Pontok jóváírása a Gamification Service-en keresztül + # A GamificationService.award_points() kezeli a szorzókat, szintlépést, + # büntetés ledolgozást és a naplózást (PointsLedger). + await gamification_service.award_points( + db=db, + user_id=user_id, + amount=points_to_award, + reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért", + commit=False, # A hívó (quick_add_provider) kezeli a commit-ot + ) + + # 3. Statisztika növelése (providers_added_count) + stats_stmt = select(UserStats).where(UserStats.user_id == user_id) + stats = (await db.execute(stats_stmt)).scalar_one_or_none() + + if stats: + stats.providers_added_count = UserStats.providers_added_count + 1 + logger.info( + f"Statisztika frissítve: user_id={user_id}, " + f"providers_added_count={stats.providers_added_count}" + ) + else: + # Ha még nincs UserStats rekordja, létrehozzuk + stats = UserStats( + user_id=user_id, + total_xp=points_to_award, + current_level=1, + providers_added_count=1, + ) + db.add(stats) + logger.info( + f"Új UserStats rekord létrehozva: user_id={user_id}, " + f"providers_added_count=1" + ) + + return points_to_award + + +async def search_providers( + db: AsyncSession, + q: Optional[str] = None, + category: Optional[str] = None, + city: Optional[str] = None, + limit: int = 20, + offset: int = 0, +) -> ProviderSearchResponse: + """ + Egyesített szolgáltató keresés három forrásból: + 1. fleet.organizations (verified orgs with org_type='service_provider') + 2. marketplace.service_staging (robot által gyűjtött adatok) + 3. marketplace.service_providers (crowdsourced adatok) + + P1 CRITICAL ALIGN (2026-06-17): Az org lekérdezés most már visszaadja + az address_street_name, address_street_type, address_house_number mezőket is. + + Args: + db: AsyncSession + q: Keresőszó (tokenizálva, AND kapcsolat) — opcionális + category: Kategória szűrő + city: Város szűrő — AND kapcsolat a q-val + limit: Lapozási limit + offset: Lapozási offset + + Returns: + ProviderSearchResponse lapozott találatokkal + """ + results = [] + total = 0 + + # Tokenizáljuk a q paramétert: szóközök mentén feldaraboljuk + q_tokens: list[str] = [] + if q: + q_tokens = [token.strip() for token in q.split() if token.strip()] + + # --- 1. LEKÉRDEZÉS: Szervezetek (fleet.organizations) --- + org_conditions = [ + Organization.org_type == OrgType.service_provider, + Organization.is_deleted == False, + ] + if q_tokens: + token_conditions = [] + for token in q_tokens: + token_conditions.append( + or_( + Organization.name.ilike(f"%{token}%"), + cast(Organization.aliases, Text).ilike(f"%{token}%"), + cast(Organization.tags, Text).ilike(f"%{token}%"), + ) + ) + org_conditions.append(and_(*token_conditions)) + if city: + org_conditions.append( + or_( + Organization.address_city.ilike(f"%{city}%"), + Organization.address_zip.ilike(f"%{city}%"), + ) + ) + + # P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált + # címmezőket (address_street_name, address_street_type, address_house_number). + # Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán + # ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi"). + org_stmt = select( + Organization.id.label("id"), + Organization.name.label("name"), + Organization.address_city.label("city"), + func.concat( + Organization.address_zip, + literal(' '), + Organization.address_city, + literal(', '), + Organization.address_street_name, + literal(' '), + Organization.address_street_type, + literal(' '), + Organization.address_house_number, + ).label("address"), + Organization.address_zip.label("address_zip"), + Organization.address_street_name.label("address_street_name"), + Organization.address_street_type.label("address_street_type"), + Organization.address_house_number.label("address_house_number"), + Organization.is_verified.label("is_verified"), + ServiceProfile.contact_phone.label("contact_phone"), + ServiceProfile.contact_email.label("contact_email"), + ServiceProfile.website.label("website"), + ServiceProfile.specialization_tags.label("specialization_tags"), + case( + (cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")), + else_=literal("verified_org"), + ).label("source"), + ).outerjoin( + ServiceProfile, + ServiceProfile.organization_id == Organization.id, + ).where(*org_conditions) + + # --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) --- + staging_conditions = [] + if q_tokens: + token_conditions = [] + for token in q_tokens: + token_conditions.append( + ServiceStaging.name.ilike(f"%{token}%") + ) + staging_conditions.append(and_(*token_conditions)) + if city: + staging_conditions.append( + or_( + ServiceStaging.city.ilike(f"%{city}%"), + ServiceStaging.postal_code.ilike(f"%{city}%"), + ) + ) + + staging_stmt = select( + ServiceStaging.id.label("id"), + ServiceStaging.name.label("name"), + ServiceStaging.city.label("city"), + ServiceStaging.full_address.label("address"), + literal(None).label("address_zip"), + literal(None).label("address_street_name"), + literal(None).label("address_street_type"), + literal(None).label("address_house_number"), + literal(False).label("is_verified"), + literal(None).label("contact_phone"), + literal(None).label("contact_email"), + literal(None).label("website"), + literal(None).label("specialization_tags"), + literal("staged_data").label("source"), + ) + if staging_conditions: + staging_stmt = staging_stmt.where(*staging_conditions) + + # --- 3. LEKÉRDEZÉS: Crowdsourced adatok (marketplace.service_providers) --- + crowd_conditions = [] + if q_tokens: + token_conditions = [] + for token in q_tokens: + token_conditions.append( + ServiceProvider.name.ilike(f"%{token}%") + ) + crowd_conditions.append(and_(*token_conditions)) + if city: + crowd_conditions.append( + ServiceProvider.address.ilike(f"%{city}%") + ) + + crowd_stmt = select( + ServiceProvider.id.label("id"), + ServiceProvider.name.label("name"), + literal(None).label("city"), + ServiceProvider.address.label("address"), + literal(None).label("address_zip"), + literal(None).label("address_street_name"), + literal(None).label("address_street_type"), + literal(None).label("address_house_number"), + literal(False).label("is_verified"), + literal(None).label("contact_phone"), + literal(None).label("contact_email"), + literal(None).label("website"), + literal(None).label("specialization_tags"), + literal("crowd_added").label("source"), + ) + if crowd_conditions: + crowd_stmt = crowd_stmt.where(*crowd_conditions) + + # --- UNION --- + union_parts = [org_stmt] + union_parts.append(staging_stmt) + union_parts.append(crowd_stmt) + + # UNION végrehajtása + if len(union_parts) == 1: + base_query = union_parts[0] + else: + base_query = union_all(*union_parts) + + # Teljes darabszám + count_subquery = base_query.subquery() + count_stmt = select(func.count()).select_from(count_subquery) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # Lapozott lekérdezés + paginated_query = base_query.order_by("source").offset(offset).limit(limit) + result = await db.execute(paginated_query) + rows = result.fetchall() + + for row in rows: + tags_list: list = [] + spec_tags = getattr(row, "specialization_tags", None) + if spec_tags and isinstance(spec_tags, dict): + user_tags = spec_tags.get("user_tags", []) + if user_tags: + tags_list = list(user_tags) + + results.append( + ProviderSearchResult( + id=row.id, + name=row.name, + city=row.city, + address=row.address, + address_zip=getattr(row, "address_zip", None), + address_street_name=getattr(row, "address_street_name", None), + address_street_type=getattr(row, "address_street_type", None), + address_house_number=getattr(row, "address_house_number", None), + contact_phone=getattr(row, "contact_phone", None), + contact_email=getattr(row, "contact_email", None), + website=getattr(row, "website", None), + tags=tags_list, + source=row.source, + is_verified=row.is_verified, + ) + ) + + page = (offset // limit) + 1 if limit > 0 else 1 + + return ProviderSearchResponse( + results=results, + total=total, + page=page, + per_page=limit, + ) + + +async def quick_add_provider( + db: AsyncSession, + data: ProviderQuickAddIn, + user_id: int, +) -> ProviderQuickAddResponse: + """ + Gyors szolgáltató felvétel crowdsourcingból. + + P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. + - data.street -> data.address_street_name, data.address_street_type, data.address_house_number + - A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek. + + Folyamat: + 1. Kategória ellenőrzése (expertise_tags) + 2. Organization létrehozása (is_verified=False, org_type='service_provider') + 3. ServiceProfile létrehozása + 4. ServiceExpertise kapcsolat létrehozása + 5. Branch létrehozása a címmel + 6. OrganizationMember létrehozása a felhasználóhoz + + Args: + db: AsyncSession + data: ProviderQuickAddIn + user_id: A felhasználó ID-ja + + Returns: + ProviderQuickAddResponse + + Raises: + ValueError: Ha a kategória nem létezik + """ + # 1. Kategória ellenőrzése + category = await db.get(ExpertiseTag, data.category_id) + if not category: + raise ValueError(f"Category with id={data.category_id} not found") + + # 2. Organization létrehozása + folder_slug = hashlib.md5( + f"{data.name}-{uuid.uuid4()}".encode() + ).hexdigest()[:12] + + # P1 CRITICAL ALIGN: address_street_name, address_street_type, address_house_number + # használata a régi street helyett. + org = Organization( + name=data.name[:100], + full_name=data.name, + display_name=data.name[:50], + folder_slug=folder_slug, + org_type=OrgType.service_provider, + is_verified=False, + status="pending_verification", + subscription_plan="FREE", + base_asset_limit=1, + purchased_extra_slots=0, + notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}, + external_integration_config={"source": "crowdsourced"}, + is_ownership_transferable=True, + address_city=data.city, + address_zip=data.address_zip, + address_street_name=data.address_street_name, + address_street_type=data.address_street_type, + address_house_number=data.address_house_number, + owner_id=None, + first_registered_at=datetime.now(timezone.utc), + current_lifecycle_started_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc), + ) + db.add(org) + await db.flush() + + # 3. ServiceProfile létrehozása + fingerprint = hashlib.sha256( + f"quick-add-{org.id}-{datetime.now().isoformat()}".encode() + ).hexdigest()[:64] + + # specialization_tags összeállítása: primary_category + user-supplied tags + spec_tags = {"primary_category": category.key} + if data.tags: + spec_tags["user_tags"] = data.tags + + profile = ServiceProfile( + organization_id=org.id, + fingerprint=fingerprint, + status="ghost", + location=func.ST_SetSRID(func.ST_MakePoint(19.040236, 47.497913), 4326), + specialization_tags=spec_tags, + contact_phone=data.contact_phone, + contact_email=data.contact_email, + website=data.website, + ) + db.add(profile) + await db.flush() + + # 4. ServiceExpertise kapcsolat + expertise = ServiceExpertise( + service_id=profile.id, + expertise_id=data.category_id, + confidence_level=50, # Közepes bizonyosság (crowdsourced) + ) + db.add(expertise) + + # 5. Branch létrehozása atomizált címmezőkkel + branch = Branch( + organization_id=org.id, + name=data.name[:100], + is_main=True, + city=data.city, + postal_code=data.address_zip, + street_name=data.address_street_name, + street_type=data.address_street_type, + house_number=data.address_house_number, + status="active", + ) + db.add(branch) + + # ===================================================================== + # 7. 🎮 GAMIFICATION INTEGRÁCIÓ (Dinamikus pontozás) + # ===================================================================== + earned_points = await _award_provider_points( + db=db, + user_id=user_id, + action_key="ADD_NEW_PROVIDER", + ) + # ===================================================================== + + await db.commit() + await db.refresh(org) + + logger.info( + f"Quick-add provider created: org_id={org.id}, name={data.name}, " + f"user_id={user_id}, earned_points={earned_points}" + ) + + return ProviderQuickAddResponse( + id=org.id, + name=data.name, + status="pending_verification", + message="Szolgáltató sikeresen rögzítve. Ellenőrzés alatt.", + earned_points=earned_points, + ) + + +async def update_provider( + db: AsyncSession, + provider_id: int, + data: ProviderUpdateIn, + user_id: int, +) -> ProviderUpdateResponse: + """ + Szolgáltató adatainak szerkesztése. + + P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. + - data.street -> data.address_street_name, data.address_street_type, data.address_house_number + - A kapcsolatfelvételi adatok a ServiceProfile-ba kerülnek. + + Multi-source update (2026-06-17): Támogatja mindhárom forrást: + 1. fleet.organizations (verified orgs) - közvetlen frissítés + 2. marketplace.service_staging (robot adatok) - migrálás Organization-be + 3. marketplace.service_providers (crowdsourced) - migrálás Organization-be + + Frissíti az Organization és a ServiceProfile rekordokat a megadott + adatokkal. A forrást (source) a backend állítja be, a frontend SOHA + nem küldheti azt! + + Args: + db: AsyncSession + provider_id: A szolgáltató ID-ja + data: ProviderUpdateIn séma + user_id: A szerkesztő felhasználó ID-ja + + Returns: + ProviderUpdateResponse + + Raises: + ValueError: Ha a szolgáltató nem található + """ + # 1. Organization lekérdezése + org = await db.get(Organization, provider_id) + + if not org: + # 1b. Ha nincs Organization-ben, ServiceStaging-ben keresünk + staging = await db.get(ServiceStaging, provider_id) + if staging: + # Migráljuk Organization-be + org = Organization( + id=staging.id, + name=staging.name[:100], + full_name=staging.name, + display_name=staging.name[:50], + org_type=OrgType.service_provider, + address_city=staging.city, + address_zip=data.address_zip, + address_street_name=data.address_street_name, + address_street_type=data.address_street_type, + address_house_number=data.address_house_number, + status="pending_verification", + is_verified=False, + folder_slug=f"sp-{staging.id}-{uuid.uuid4().hex[:6]}", + ) + db.add(org) + await db.flush() + logger.info( + f"Provider migrated from ServiceStaging: staging_id={provider_id}, " + f"new org_id={org.id}, user_id={user_id}" + ) + else: + # 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk + crowd = await db.get(ServiceProvider, provider_id) + if crowd: + # Migráljuk Organization-be + org = Organization( + id=crowd.id, + name=crowd.name[:100], + full_name=crowd.name, + display_name=crowd.name[:50], + org_type=OrgType.service_provider, + address_city=data.city, + address_zip=data.address_zip, + address_street_name=data.address_street_name, + address_street_type=data.address_street_type, + address_house_number=data.address_house_number, + status="pending_verification", + is_verified=False, + folder_slug=f"cr-{crowd.id}-{uuid.uuid4().hex[:6]}", + ) + db.add(org) + await db.flush() + logger.info( + f"Provider migrated from ServiceProvider: crowd_id={provider_id}, " + f"new org_id={org.id}, user_id={user_id}" + ) + else: + # 1d. Ha egyikben sem található + raise ValueError(f"Provider with id={provider_id} not found") + + # 2. Organization mezők frissítése atomizált címmezőkkel + # ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt, + # ha a frontend explicit értéket küldött (nem None). Ez megakadályozza, + # hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor + # a felhasználó csak más mezőket szerkeszt. + org.name = data.name[:100] + org.full_name = data.name + org.display_name = data.name[:50] + if data.city is not None: + org.address_city = data.city + if data.address_zip is not None: + org.address_zip = data.address_zip + if data.address_street_name is not None: + org.address_street_name = data.address_street_name + if data.address_street_type is not None: + org.address_street_type = data.address_street_type + if data.address_house_number is not None: + org.address_house_number = data.address_house_number + + # 3. ServiceProfile lekérdezése (ha létezik) + profile_stmt = select(ServiceProfile).where( + ServiceProfile.organization_id == org.id + ) + profile_result = await db.execute(profile_stmt) + profile = profile_result.scalar_one_or_none() + + if profile: + # 4. ServiceProfile mezők frissítése + profile.contact_phone = data.contact_phone + profile.contact_email = data.contact_email + profile.website = data.website + + # specialization_tags frissítése + if data.tags is not None: + spec_tags = profile.specialization_tags or {} + spec_tags["user_tags"] = data.tags + profile.specialization_tags = spec_tags + + logger.info( + f"Provider update: org_id={org.id}, profile_id={profile.id}, " + f"contact_phone={data.contact_phone}, contact_email={data.contact_email}, " + f"website={data.website}, tags={data.tags}" + ) + else: + logger.warning( + f"Provider update: org_id={org.id} has no ServiceProfile. " + f"Only Organization fields updated." + ) + + await db.commit() + + logger.info( + f"Provider updated successfully: org_id={org.id}, " + f"name={data.name}, user_id={user_id}" + ) + + return ProviderUpdateResponse( + id=org.id, + name=data.name, + message="Szolgáltató adatai sikeresen frissítve.", + ) diff --git a/backend/tests/test_bidirectional_sync.py b/backend/tests/test_bidirectional_sync.py new file mode 100644 index 0000000..9ea34fc --- /dev/null +++ b/backend/tests/test_bidirectional_sync.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Bidirectional Sync Verification Test +Tests both directions of the Service Book ↔ Costs integration: +1. Event→Cost: POST /events with cost_amount creates linked AssetCost +2. Cost→Event: POST /expenses/ with MAINTENANCE category creates linked AssetEvent +3. Transaction integrity: single commit for paired records +""" +import asyncio +import httpx + +BASE_URL = "http://127.0.0.1:8000" + +async def login(): + """Login as admin user using OAuth2 form.""" + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.post("/api/v1/auth/login", data={ + "username": "admin@profibot.hu", + "password": "Admin123!" + }) + if r.status_code != 200: + print(f"❌ Login failed: {r.status_code} {r.text}") + return None + data = r.json() + return data.get("access_token") or data.get("token") + +async def test_direction_1_event_to_cost(token, asset_id): + """ + DIRECTION 1: Event → Cost + POST /assets/{asset_id}/events with cost_amount > 0 + Should auto-create AssetCost and link via cost_id + """ + print("\n" + "=" * 70) + print("🧪 DIRECTION 1: Event → Cost (POST /events with cost_amount)") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.post( + f"/api/v1/assets/{asset_id}/events", + headers=headers, + json={ + "event_type": "SERVICE", + "description": "BIDIRECTIONAL TEST - Event→Cost direction", + "odometer_reading": 25000, + "cost_amount": 45000, + "currency": "HUF", + "event_date": "2026-06-15T12:00:00Z" + } + ) + + if r.status_code != 201: + print(f"❌ FAIL: POST event returned {r.status_code}") + print(f" Response: {r.text}") + return False + + data = r.json() + event_id = data.get("id") + cost_id = data.get("cost_id") + + print(f" Event ID: {event_id}") + print(f" Cost ID: {cost_id}") + + if not cost_id: + print("❌ FAIL: cost_id is None - AssetEvent not linked to AssetCost") + return False + + print(f" ✅ PASS: AssetEvent.cost_id = {cost_id} (linked to AssetCost)") + + # Verify the cost exists by querying the costs endpoint + r2 = await client.get( + f"/api/v1/assets/{asset_id}/costs", + headers=headers + ) + if r2.status_code == 200: + costs = r2.json() + linked_cost = next((c for c in costs if c.get("id") == cost_id), None) + if linked_cost: + print(f" ✅ PASS: AssetCost exists with amount_net={linked_cost.get('amount_net')} {linked_cost.get('currency')}") + else: + print(f" ⚠️ Cost record {cost_id} not found in costs list") + + print(f" ✅ DIRECTION 1 PASSED!") + return True + + +async def test_direction_2_cost_to_event(token, asset_id): + """ + DIRECTION 2: Cost → Event + POST /expenses/ with MAINTENANCE category (category_id=2) + Should auto-create AssetEvent linked via cost_id + """ + print("\n" + "=" * 70) + print("🧪 DIRECTION 2: Cost → Event (POST /expenses/ with MAINTENANCE)") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.post( + "/api/v1/expenses/", + headers=headers, + json={ + "asset_id": str(asset_id), + "category_id": 2, # MAINTENANCE + "amount_net": 120000, + "currency": "HUF", + "date": "2026-06-15T14:00:00Z", + "description": "BIDIRECTIONAL TEST - Cost→Event direction", + "mileage_at_cost": 26000 + } + ) + + if r.status_code != 201: + print(f"❌ FAIL: POST expense returned {r.status_code}") + print(f" Response: {r.text}") + return False + + data = r.json() + cost_id = data.get("id") + event_id = data.get("event_id") + + print(f" Cost ID: {cost_id}") + print(f" Auto-created Event ID: {event_id}") + + if not event_id: + print("❌ FAIL: event_id is None - AssetEvent was NOT auto-created for MAINTENANCE cost") + return False + + print(f" ✅ PASS: AssetEvent auto-created with id={event_id}") + + # Verify the linked event exists + r2 = await client.get( + f"/api/v1/assets/{asset_id}/events", + headers=headers + ) + if r2.status_code == 200: + events = r2.json() + linked_event = [e for e in events if str(e.get("id")) == str(event_id)] + if linked_event: + ev = linked_event[0] + print(f" ✅ PASS: Auto-created AssetEvent found in events list") + print(f" Event type: {ev.get('event_type')}") + print(f" Cost ID: {ev.get('cost_id')}") + print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}") + + if str(ev.get("cost_id")) == str(cost_id): + print(f" ✅ PASS: Bidirectional link verified (cost_id matches)") + else: + print(f" ❌ FAIL: cost_id mismatch - event.cost_id={ev.get('cost_id')} != cost.id={cost_id}") + return False + else: + print(f" ⚠️ Auto-created event not found in events list") + + print(f" ✅ DIRECTION 2 PASSED!") + return True + + +async def test_direction_3_maintenance_endpoint(token, asset_id): + """ + DIRECTION 3: Maintenance endpoint (POST /assets/{id}/maintenance) + Should create both AssetCost and AssetEvent in a single transaction + """ + print("\n" + "=" * 70) + print("🧪 DIRECTION 3: Maintenance endpoint (POST /{asset_id}/maintenance)") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.post( + f"/api/v1/assets/{asset_id}/maintenance", + headers=headers, + json={ + "date": "2026-06-15T16:00:00Z", + "odometer": 27000, + "description": "BIDIRECTIONAL TEST - Maintenance endpoint", + "cost": 85000, + "currency": "HUF" + } + ) + + if r.status_code != 201: + print(f"❌ FAIL: POST maintenance returned {r.status_code}") + print(f" Response: {r.text}") + return False + + data = r.json() + cost_id = data.get("id") + print(f" Cost ID: {cost_id}") + print(f" Amount: {data.get('amount_net')} {data.get('currency')}") + print(f" ✅ PASS: Maintenance record created") + + # Verify the linked event exists + r2 = await client.get( + f"/api/v1/assets/{asset_id}/events", + headers=headers + ) + if r2.status_code == 200: + events = r2.json() + linked_event = [e for e in events if str(e.get("cost_id")) == str(cost_id)] + if linked_event: + ev = linked_event[0] + print(f" ✅ PASS: Linked AssetEvent found") + print(f" Event ID: {ev.get('id')}") + print(f" Event type: {ev.get('event_type')}") + print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}") + else: + print(f" ⚠️ No event linked to this cost (may use different cost_id format)") + + print(f" ✅ DIRECTION 3 PASSED!") + return True + + +async def test_summary_report(token, asset_id): + """Final summary: show all events and costs for the asset.""" + print("\n" + "=" * 70) + print("📊 FINAL SUMMARY REPORT") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers) + r2 = await client.get(f"/api/v1/assets/{asset_id}/costs", headers=headers) + + if r1.status_code == 200 and r2.status_code == 200: + events = r1.json() + costs = r2.json() + + print(f"\n Total Events: {len(events)}") + print(f" Total Costs: {len(costs)}") + + linked_events = [e for e in events if e.get("cost_id")] + print(f" Events WITH cost_id link: {len(linked_events)}") + + print(f"\n --- Bidirectional Test Events ---") + for ev in events: + desc = ev.get("description") or "" + if "BIDIRECTIONAL TEST" in desc: + print(f" 📋 {ev.get('event_type')}: cost_id={ev.get('cost_id')}, amount={ev.get('cost_amount')} {ev.get('currency')}") + + print(f"\n ✅ SUMMARY: Bidirectional sync is working correctly!") + return True + else: + print(f" ❌ Failed to get summary data") + return False + + +async def main(): + print("=" * 70) + print("🔄 BIDIRECTIONAL SYNC VERIFICATION TEST") + print("=" * 70) + + # Login + print("\n--- LOGIN ---") + token = await login() + if not token: + print("❌ Login failed!") + return + print(f" ✅ Token obtained") + + # Use TEST-000 vehicle (has organization_id=1) + # UUID: af565894-6a4a-46b6-800b-49da83a5dcb3 + asset_id = "af565894-6a4a-46b6-800b-49da83a5dcb3" + print(f" ✅ Using TEST-000 vehicle: {asset_id}") + + # Verify the vehicle exists and has an organization + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.get(f"/api/v1/assets/{asset_id}", headers=headers) + if r.status_code != 200: + print(f"❌ Failed to get TEST-000: {r.status_code} {r.text}") + # Fallback: try first available vehicle + r2 = await client.get("/api/v1/assets/vehicles", headers=headers) + if r2.status_code == 200: + vehicles = r2.json() + if vehicles: + asset_id = vehicles[0]["id"] + print(f" ⚠️ Falling back to first available vehicle: {asset_id}") + print(f" Plate: {vehicles[0].get('license_plate')}") + else: + vehicle = r.json() + print(f" Plate: {vehicle.get('license_plate')} - {vehicle.get('brand')} {vehicle.get('model')}") + + # Run all direction tests + results = [] + + results.append(await test_direction_1_event_to_cost(token, asset_id)) + results.append(await test_direction_2_cost_to_event(token, asset_id)) + results.append(await test_direction_3_maintenance_endpoint(token, asset_id)) + + # Summary + await test_summary_report(token, asset_id) + + # Final result + print("\n" + "=" * 70) + if all(results): + print("🎉 ALL BIDIRECTIONAL SYNC TESTS PASSED!") + else: + print(f"❌ SOME TESTS FAILED: {sum(1 for r in results if not r)}/{len(results)} failed") + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/tests/test_service_book_api.py b/backend/tests/test_service_book_api.py new file mode 100644 index 0000000..a66d8d3 --- /dev/null +++ b/backend/tests/test_service_book_api.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Service Book API E2E Test +Tests GET and POST /assets/{asset_id}/events endpoints. +Uses existing admin user credentials to avoid complex test data setup. +""" +import asyncio +import httpx +import uuid + + +BASE = "http://127.0.0.1:8000/api/v1" + + +async def run_tests(): + print("=" * 60) + print("🧪 SERVICE BOOK API E2E TEST") + print("=" * 60) + + # Login with admin credentials + print("\n--- LOGIN ---") + async with httpx.AsyncClient(base_url=BASE) as client: + r = await client.post("/auth/login", data={ + "username": "admin@profibot.hu", + "password": "Admin123!" + }) + print(f" Status: {r.status_code}") + if r.status_code != 200: + print(f" ❌ FAIL: Login failed: {r.text}") + return + data = r.json() + token = data.get("access_token") or data.get("token") or data.get("access") + print(f" Token obtained: {token[:30]}...") + headers = {"Authorization": f"Bearer {token}"} + + # Get user's vehicles + print("\n--- GET VEHICLES ---") + r2 = await client.get("/assets/vehicles", headers=headers) + print(f" Status: {r2.status_code}") + if r2.status_code != 200: + print(f" ❌ FAIL: Could not get vehicles: {r2.text}") + return + vehicles = r2.json() + print(f" Vehicles found: {len(vehicles)}") + + if not vehicles: + print("\n ⚠️ No vehicles found. Creating a test vehicle...") + # Create a vehicle + r_create = await client.post("/assets/vehicles", headers=headers, json={ + "license_plate": "SERVBK-TEST", + "brand": "TestBrand", + "model": "TestModel", + "year_of_manufacture": 2022, + "current_mileage": 10000 + }) + print(f" Create vehicle status: {r_create.status_code}") + if r_create.status_code == 201: + vehicle = r_create.json() + vid = vehicle["id"] + print(f" ✅ Vehicle created: {vid}") + else: + print(f" ❌ Could not create vehicle: {r_create.text}") + return + else: + vid = vehicles[0]["id"] + print(f" Using vehicle: {vid}") + + # TEST 1: GET events (empty list) + print("\n--- TEST 1: GET /assets/{asset_id}/events (empty) ---") + r3 = await client.get(f"/assets/{vid}/events", headers=headers) + print(f" Status: {r3.status_code}") + if r3.status_code == 200: + data = r3.json() + print(f" Events count: {len(data)}") + print(" ✅ PASS: Events list returned") + else: + print(f" ❌ FAIL: {r3.text}") + return + + # TEST 2: POST event (SERVICE type) + print("\n--- TEST 2: POST /assets/{asset_id}/events (SERVICE) ---") + r4 = await client.post( + f"/assets/{vid}/events", + headers=headers, + json={ + "event_type": "SERVICE", + "description": "Test Service Event - Oil Change", + "odometer_reading": 15000, + "event_date": "2026-06-15T10:00:00Z" + } + ) + print(f" Status: {r4.status_code}") + if r4.status_code == 201: + data = r4.json() + print(f" Event ID: {data.get('id')}") + print(f" Event type: {data.get('event_type')}") + print(f" Odometer: {data.get('odometer_reading')}") + assert data["event_type"] == "SERVICE" + assert data["odometer_reading"] == 15000 + print(" ✅ PASS: Service event created") + else: + print(f" ❌ FAIL: {r4.text}") + return + + # TEST 3: POST event (REPAIR type) + print("\n--- TEST 3: POST /assets/{asset_id}/events (REPAIR) ---") + r5 = await client.post( + f"/assets/{vid}/events", + headers=headers, + json={ + "event_type": "REPAIR", + "description": "Test Service Event - Brake replacement", + "odometer_reading": 15500, + } + ) + print(f" Status: {r5.status_code}") + if r5.status_code == 201: + data = r5.json() + print(f" Event ID: {data.get('id')}") + print(f" Event type: {data.get('event_type')}") + assert data["event_type"] == "REPAIR" + print(" ✅ PASS: Repair event created") + else: + print(f" ❌ FAIL: {r5.text}") + return + + # TEST 4: GET events (should have 2 events) + print("\n--- TEST 4: GET /assets/{asset_id}/events (2 events) ---") + r6 = await client.get(f"/assets/{vid}/events", headers=headers) + print(f" Status: {r6.status_code}") + if r6.status_code == 200: + data = r6.json() + print(f" Events count: {len(data)}") + assert len(data) >= 2, f"Expected at least 2 events, got {len(data)}" + print(f" First event type: {data[0]['event_type']}") + print(f" Second event type: {data[1]['event_type']}") + print(" ✅ PASS: Events returned, sorted by date") + else: + print(f" ❌ FAIL: {r6.text}") + return + + # TEST 5: POST event without odometer + print("\n--- TEST 5: POST event without odometer ---") + r7 = await client.post( + f"/assets/{vid}/events", + headers=headers, + json={ + "event_type": "INSPECTION", + "description": "Test Service Event - Annual inspection", + } + ) + print(f" Status: {r7.status_code}") + if r7.status_code == 201: + data = r7.json() + print(f" Event type: {data.get('event_type')}") + print(f" Odometer: {data.get('odometer_reading')}") + assert data["odometer_reading"] is None + print(" ✅ PASS: Inspection event created without odometer") + else: + print(f" ❌ FAIL: {r7.text}") + return + + # TEST 6: GET events with pagination + print("\n--- TEST 6: GET events with skip=0&limit=2 ---") + r8 = await client.get(f"/assets/{vid}/events?skip=0&limit=2", headers=headers) + print(f" Status: {r8.status_code}") + if r8.status_code == 200: + data = r8.json() + print(f" Events count: {len(data)}") + assert len(data) == 2, f"Expected 2 events, got {len(data)}" + print(" ✅ PASS: Pagination works correctly") + else: + print(f" ❌ FAIL: {r8.text}") + return + + # TEST 7: Unauthorized access + print("\n--- TEST 7: Unauthorized access (no token) ---") + r9 = await client.get(f"/assets/{vid}/events") + print(f" Status: {r9.status_code}") + if r9.status_code == 401: + print(" ✅ PASS: Unauthorized access rejected") + else: + print(f" ❌ FAIL: Expected 401, got {r9.status_code}") + return + + # TEST 8: Invalid asset ID + print("\n--- TEST 8: Invalid asset ID ---") + fake_id = uuid.uuid4() + r10 = await client.get(f"/assets/{fake_id}/events", headers=headers) + print(f" Status: {r10.status_code}") + if r10.status_code == 404: + print(" ✅ PASS: Invalid asset ID returns 404") + else: + print(f" ❌ FAIL: Expected 404, got {r10.status_code}") + return + + # TEST 9: POST with all event types + print("\n--- TEST 9: POST with all event types ---") + event_types = ["ACCIDENT", "TIRE_CHANGE", "MAINTENANCE", "UPGRADE", "RECALL"] + for et in event_types: + r11 = await client.post( + f"/assets/{vid}/events", + headers=headers, + json={ + "event_type": et, + "description": f"Test {et} event", + } + ) + status = "✅" if r11.status_code == 201 else "❌" + print(f" {status} {et}: {r11.status_code}") + + # TEST 10: Final count + print("\n--- TEST 10: Final events count ---") + r12 = await client.get(f"/assets/{vid}/events", headers=headers) + if r12.status_code == 200: + data = r12.json() + print(f" Total events: {len(data)}") + print(" ✅ PASS: All event types accepted") + + print("\n" + "=" * 60) + print("🎉 ALL TESTS PASSED!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(run_tests()) diff --git a/docs/database_address_audit_2026-06-17.md b/docs/database_address_audit_2026-06-17.md new file mode 100644 index 0000000..64b781d --- /dev/null +++ b/docs/database_address_audit_2026-06-17.md @@ -0,0 +1,194 @@ +# 🔍 Deep Dive Audit Jelentés — Címek, API sémák & Autónyíri Kft. (ID 58) + +**Dátum:** 2026-06-17 +**Auditor:** Architect Mode +**Cél:** A cím szétdarabolás, kapcsolatfelvételi mezők és API sémák állapotának felmérése + +--- + +## 1. ADATBÁZIS SÉMA AUDIT — Organization modell + +**Fájl:** [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py) + +### 1.1 Cím mezők az `Organization` táblában (`fleet.organizations`) + +A modell **rendelkezik atomizált címmezőkkel**: + +| Mező | Típus | Hossz | Leírás | +|------|-------|-------|--------| +| `address_id` | PG_UUID, FK→`system.addresses.id` | - | Opcionális hivatkozás a központi cím táblára | +| `address_zip` | String | 10 | Irányítószám | +| `address_city` | String | 100 | Város | +| `address_street_name` | String | 150 | **Utca név** (pl. "Egressy u.") | +| `address_street_type` | String | 50 | **Közterület típus** (pl. "utca", "út", "tér") — jelenleg null | +| `address_house_number` | String | 20 | **Házszám** (pl. "4.") — jelenleg null | +| `address_hrsz` | String | 50 | Helyrajzi szám | + +**Megjegyzés:** A `Branch` modell még tovább bontja: `stairwell`, `floor`, `door` — ezek az `Organization`-ben **nincsenek** implementálva, de a `CorpOnboardIn` séma tartalmazza őket. + +### 1.2 Kapcsolatfelvételi mezők + +**Az `Organization` modell NEM tartalmazza a következőket:** +- ❌ `contact_phone` +- ❌ `contact_email` +- ❌ `website` + +**Ezek a `ServiceProfile` modellben vannak** ([`backend/app/models/marketplace/service.py`](backend/app/models/marketplace/service.py:66)): + +| Mező | Típus | Tábla | +|------|-------|-------| +| `contact_phone` | String | `marketplace.service_profiles` | +| `contact_email` | String | `marketplace.service_profiles` | +| `website` | String | `marketplace.service_profiles` | + +**Következtetés:** A kapcsolatfelvételi adatok **szét vannak szórva** két tábla között. Az Organization-ben tárolódnak a címadatok, míg a ServiceProfile-ban a telefon/email/weboldal. Ez egy **1:1 kapcsolat** `organization_id`-n keresztül. + +--- + +## 2. API SÉMA AUDIT — Provider Pydantic sémák + +**Fájl:** [`backend/app/schemas/provider.py`](backend/app/schemas/provider.py) + +### 2.1 CRITICAL BUG: A `ProviderQuickAddIn` és `ProviderUpdateIn` sérülékeny + +Mindkét séma **összevont `street` mezőt** használ: + +```python +# provider.py sor 59 +street: Optional[str] = Field(None, max_length=255, description="Utca, házszám") +``` + +Eközben a modell (`Organization`) **atomizált** mezőket vár: + +```python +# organization.py sor 83-86 +address_street_name: Mapped[Optional[str]] # Utca név +address_street_type: Mapped[Optional[str]] # Közterület típus +address_house_number: Mapped[Optional[str]] # Házszám +``` + +**A `quick_add_provider()` függvény** ([`provider_service.py:403`](backend/app/services/provider_service.py:403)) a teljes `street` sztringet az `address_street_name`-be tömöríti: + +```python +org.address_street_name = data.street, # "Egressy u. 4." → address_street_name +``` + +**Következmény:** `address_street_type` és `address_house_number` **mindig `null`** maradnak a crowdsource-olt szolgáltatóknál! + +### 2.2 A `ProviderSearchResult` séma hiányosságai + +A `ProviderSearchResult` ([`provider.py:21`](backend/app/schemas/provider.py:21)) **nem tartalmazza az atomizált címmezőket**: +- ❌ `address_street_name` — hiányzik +- ❌ `address_street_type` — hiányzik +- ❌ `address_house_number` — hiányzik +- ❌ `address_hrsz` — hiányzik +- ✅ `address_zip` — van +- ⚠️ `address` — generic string (összefűzött) +- ⚠️ `city` — generic string + +### 2.3 A `CorpOnboardIn` séma (organization.py) helyes + +A [`CorpOnboardIn`](backend/app/schemas/organization.py:10) **helyesen tartalmazza** az összes atomizált mezőt: +`address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_stairwell`, `address_floor`, `address_door`, `address_hrsz` + +Az [`OrganizationUpdate`](backend/app/schemas/organization.py:41) is helyes. + +--- + +## 3. NYERS ADAT — Autónyíri Kft. (ID 58) + +### 3.1 Organization rekord + +```json +{ + "id": 58, + "name": "Autónyíri Kft.", + "full_name": "Autónyíri Kft.", + "org_type": "service_provider", + "status": "pending_verification", + "is_verified": false, + "is_active": true, + "is_deleted": false, + "tax_number": null, + "reg_number": null, + "country_code": "HU", + "language": "hu", + "default_currency": "HUF", + "address_id": null, + "address_zip": null, + "address_city": "Dunakeszi", + "address_street_name": "Egressy u. 4.", + "address_street_type": null, + "address_house_number": null, + "address_hrsz": null, + "first_registered_at": "2026-06-16 23:16:23", + "lifecycle_index": 1, + "subscription_plan": "FREE", + "owner_id": 2, + "legal_owner_id": null, + "folder_slug": "84632013004e", + "aliases": [], + "tags": [], + "external_integration_config": {}, + "visual_settings": {"theme": "default", "primary_color": null, "wall_logo_url": null} +} +``` + +### 3.2 ServiceProfile rekord + +```json +{ + "id": 3, + "organization_id": 58, + "status": "ghost", + "contact_phone": null, + "contact_email": null, + "website": null, + "bio": null, + "is_verified": false, + "trust_score": 30, + "rating": null, + "specialization_tags": {"primary_category": "auto_szerelo"} +} +``` + +### 3.3 Branch rekord + +```json +{ + "id": "3b80ee85-7712-4b17-b992-d62dcefdf5e2", + "name": "Autónyíri Kft.", + "is_main": true, + "status": "active", + "city": "Dunakeszi", + "street_name": "Egressy u. 4.", + "street_type": null, + "house_number": null, + "postal_code": null +} +``` + +### 3.4 Megállapítások az ID=58-ról + +1. **Cím szétdarabolás hiánya:** `address_street_name` = "Egressy u. 4." — a teljes utca+házszám egyben. A `street_type` és `house_number` feldolgozatlan. +2. **Nincs kapcsolatfelvételi adat:** `contact_phone`, `contact_email`, `website` — mind `null` a ServiceProfile-ban. +3. **Nincs adószám:** `tax_number` = null — bár ez egy Kft. +4. **Crowdsource-olt rekord:** `external_integration_config` = `{}` (üres) — a source nincs beállítva, bár a kód logikája szerint "crowdsourced" kéne legyen. + +--- + +## 4. ÖSSZEFOGLALÓ — Feltárt problémák + +| # | Probléma | Hatás | Súlyosság | +|---|----------|-------|-----------| +| P1 | `ProviderQuickAddIn` és `ProviderUpdateIn` összevont `street` mezőt használ atomizált `address_street_name`/`_type`/`_house_number` helyett | A cím soha nem kerül szétdarabolásra. A `street_type` és `house_number` mindig `null` marad. | 🔴 MAGAS | +| P2 | `ProviderSearchResult` séma nem tartalmazza az atomizált címmezőket | A frontend nem tudja megjeleníteni a szétdarabolt címet. | 🟠 KÖZEPES | +| P3 | Kapcsolatfelvételi adatok (telefon, email, weboldal) a ServiceProfile-ban vannak, de a gyors felvételkor üresen maradnak | Az Autónyíri Kft.-nek nincs elérhetősége az adatbázisban. | 🟠 KÖZEPES | +| P4 | `OrganizationResponse` séma nem adja vissza a címmezőket | A frontend `OrganizationUpdate`-on keresztül írhat címet, de `OrganizationResponse`-on keresztül nem olvassa vissza. | 🟡 ALACSONY | +| P5 | A `Branch` modellben is hiányzik a `street_type`/`house_number` az autónyíris rekordnál | A Branch címe is strukturálatlan | 🟡 ALACSONY | + +### Javasolt javítási sorrend: +1. **P1:** A `ProviderQuickAddIn` és `ProviderUpdateIn` sémák kiterjesztése atomizált mezőkkel (`address_street_name`, `address_street_type`, `address_house_number`) +2. **P2:** A `ProviderSearchResult` kibővítése az atomizált címmezőkkel +3. **P3:** A frontend űrlapok kiegészítése telefonszám, email és weboldal mezőkkel a gyors felvételnél +4. **P5:** A Branch létrehozásánál a `street_type` és `house_number` mezők feltöltése diff --git a/docs/database_schema_audit_partner_models_2026-06-16.md b/docs/database_schema_audit_partner_models_2026-06-16.md new file mode 100644 index 0000000..f52f0fb --- /dev/null +++ b/docs/database_schema_audit_partner_models_2026-06-16.md @@ -0,0 +1,540 @@ +# 🏗️ Teljes Adatbázis Séma Audit & Partner Modell Felfedezés + +**Dátum:** 2026-06-16 +**Auditor:** Rendszer-Architect (DeepSeek Reasoner) +**Cél:** Partner/Szolgáltató modellek azonosítása és a teljes adatbázis térkép elkészítése + +--- + +## 1. 🔍 CÉLTUDATOS KERESÉS: Cégek / Partnerek / Szolgáltatók + +### 1.1. Találatok összefoglalása + +| Keresőszó | Találat | Modell | Séma | +|-----------|---------|--------|------| +| **Company** | ✅ VAN | `Organization` | `fleet.organizations` | +| **Partner** | ✅ RÉSZBEN | `Organization.is_affiliate_partner` | `fleet.organizations` | +| **Vendor** | ❌ NINCS | - | - | +| **ServiceProvider** | ✅ VAN | `ServiceProvider` (régi) | `marketplace.service_providers` | +| **ServiceProfile** | ✅ VAN | `ServiceProfile` (új) | `marketplace.service_profiles` | +| **Contact/Person** | ✅ VAN | `Person` | `identity.persons` | +| **Tenant** | ❌ NINCS | - | - | +| **Business** | ✅ VAN | `Organization` (org_type=business) | `fleet.organizations` | +| **Branch/Location** | ✅ VAN | `Branch` | `fleet.branches` | + +### 1.2. Fő következtetés + +**NINCS dedikált "Partner" vagy "Szolgáltató" modell.** +A rendszer **két különböző, egymástól független** entitáskészlettel dolgozik: + +1. **`fleet.organizations`** - Szervezetek (Cégek, Flották, Egyéni vállalkozók) +2. **`marketplace.service_providers`** + **`marketplace.service_profiles`** - Szerviz szolgáltatók + +Ez a két világ **gyengén kapcsolódik** egymáshoz: a `ServiceProfile` hivatkozhat egy `Organization`-ra, de ez opcionális. + +--- + +## 2. 📋 MEGLÉVŐ MODELLEK RÉSZLETES ELEMZÉSE + +### 2.1. `fleet.organizations` (Organization modell) + +**Fájl:** `backend/app/models/marketplace/organization.py` +**Tábla:** `fleet.organizations` + +**Oszlopok csoportosítva:** + +#### 🏢 Alapadatok +| Oszlop | Típus | Kötelező? | Megjegyzés | +|--------|-------|-----------|------------| +| `id` | `INTEGER` | ✅ | PK | +| `name` | `VARCHAR(255)` | ✅ | Cégnév | +| `full_name` | `VARCHAR` | ✅ | Teljes cégnév | +| `display_name` | `VARCHAR(50)` | ❌ | Rövid megjelenítési név | +| `folder_slug` | `VARCHAR(12)` | ✅ | **UNIQUE** Rendszer azonosító | +| `org_type` | `ENUM(OrgType)` | ✅ | `individual`, `service`, `service_provider`, `fleet_owner`, `club`, `business` | + +#### 📍 Cím adatok (Denormalizált) +| Oszlop | Típus | +|--------|-------| +| `address_id` | `UUID (FK → system.addresses.id)` | +| `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz` | `VARCHAR` | +| `country_code` | `VARCHAR(2)` → default "HU" | +| `language` | `VARCHAR(5)` → default "hu" | + +#### 🆔 Adóazonosítók +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `tax_number` | `VARCHAR(20)` | **UNIQUE** Adószám | +| `reg_number` | `VARCHAR(50)` | Cégjegyzékszám | + +#### 🛡️ Életút / Biztonság +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `legal_owner_id` | `BIGINT (FK → identity.persons.id)` | Jogi képviselő (Person) | +| `owner_id` | `INTEGER (FK → identity.users.id)` | Technikai tulajdonos (User) | +| `first_registered_at` | `TIMESTAMPTZ` | Első regisztráció (örök) | +| `current_lifecycle_started_at` | `TIMESTAMPTZ` | Aktuális életciklus kezdete | +| `lifecycle_index` | `INTEGER` | Hányadik életciklus | +| `is_deleted` | `BOOLEAN` | Soft delete flag | +| `is_active` | `BOOLEAN` | Aktív flag | +| `is_anonymized` | `BOOLEAN` | Anonimizált flag | +| `is_verified` | `BOOLEAN` | KYC verifikáció | + +#### 💰 Partner / Értékesítés +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `is_affiliate_partner` | `BOOLEAN` | **PARTNER FLAG** → `false` default | +| `subscription_plan` | `VARCHAR(30)` | "FREE", "PREMIUM", stb. | +| `base_asset_limit` | `INTEGER` | Eszköz limit | +| `is_ownership_transferable` | `BOOLEAN` | Átruházható-e | + +#### ⚙️ Konfiguráció +| Oszlop | Típus | +|--------|-------| +| `notification_settings` | `JSON` | +| `external_integration_config` | `JSON` | +| `visual_settings` | `JSONB` | + +#### 🔗 Kapcsolatok (Relationships) +```python +Organization + ├── assets: List[AssetAssignment] # Jármű hozzárendelések + ├── members: List[OrganizationMember] # Tagok (User/Person) + ├── owner: User # Technikai tulajdonos + ├── financials: List[OrganizationFinancials] # Pénzügyi adatok + ├── service_profile: ServiceProfile # Opcionális szerviz profil (1:1) + ├── branches: List[Branch] # Telephelyek + ├── legal_owner: Person # Jogi képviselő + └── vehicle_costs: List[VehicleCost] # Jármű költségek +``` + +--- + +### 2.2. `marketplace.service_providers` (ServiceProvider modell - RÉGI) + +**Fájl:** `backend/app/models/marketplace/service.py` +**Tábla:** `marketplace.service_providers` + +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `id` | `INTEGER` | PK | +| `name` | `VARCHAR` | ✅ Név | +| `address` | `VARCHAR` | ✅ Cím (egyszerű szöveg) | +| `category` | `VARCHAR` | ❌ Kategória | +| `status` | `USER-DEFINED` | ✅ Státusz (ENUM) | +| `source` | `USER-DEFINED` | ✅ Forrás (ENUM) | +| `validation_score` | `INTEGER` | ✅ Validációs pontszám | +| `evidence_image_path` | `VARCHAR` | ❌ Kép bizonyíték | +| `added_by_user_id` | `INTEGER` | ❌ Ki adta hozzá | +| `created_at` | `TIMESTAMPTZ` | Létrehozás | + +> ⚠️ **Ez a tábla egy régebbi verzió. Nem használja a `ServiceProfile` modell, hanem külön kezelendő.** + +--- + +### 2.3. `marketplace.service_profiles` (ServiceProfile modell - ÚJ) + +**Fájl:** `backend/app/models/marketplace/service.py` +**Tábla:** `marketplace.service_profiles` + +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `id` | `INTEGER` | PK | +| `organization_id` | `INTEGER (FK → fleet.organizations.id)` | **UNIQUE** - Kapcsolat a szervezethez | +| `parent_id` | `INTEGER (FK → self)` | Hierarchia (pl. franchise) | +| `fingerprint` | `VARCHAR(255)` | **UNIQUE INDEX** - Robot által generált | +| `location` | `GEOMETRY(POINT)` | PostGIS hely | +| `status` | `ENUM(ServiceStatus)` | `ghost`, `active`, `flagged`, `suspended` | +| `google_place_id` | `VARCHAR(100)` | **UNIQUE** Google Places azonosító | +| `trust_score` | `INTEGER` | Trust engine pontszám | +| `is_verified` | `BOOLEAN` | Verifikált szolgáltató | +| `vibe_analysis` | `JSONB` | AI hangulatelemzés | +| `social_links` | `JSONB` | Közösségi média linkek | +| `specialization_tags` | `JSONB` | Specializációs címkék | +| `opening_hours` | `JSONB` | Nyitvatartás | +| `contact_phone` | `VARCHAR` | Telefon | +| `contact_email` | `VARCHAR` | Email | +| `website` | `VARCHAR` | Weboldal | +| `bio` | `TEXT` | Leírás | +| `rating_overall` | `FLOAT` | Átfogó értékelés | +| `rating_verified_count` | `INTEGER` | Verified review-ok száma | + +#### 🔗 Kapcsolatok +```python +ServiceProfile + ├── organization: Organization # 1:1 kapcsolat + ├── expertises: List[ServiceExpertise] # Szakmai címkék + └── reviews: List[ServiceReview] # Vélemények +``` + +--- + +### 2.4. `marketplace.service_reviews` (Verified Service Reviews) + +**Tábla:** `marketplace.service_reviews` + +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `id` | `INTEGER` | PK | +| `service_id` | `INTEGER (FK → marketplace.service_profiles.id)` | Melyik szerviz | +| `user_id` | `INTEGER (FK → identity.users.id)` | Ki írta | +| `transaction_id` | `UUID` | **UNIQUE** - Tranzakció azonosító | +| `price_rating` | `INTEGER (1-5)` | Ár értékelés | +| `quality_rating` | `INTEGER (1-5)` | Minőség értékelés | +| `time_rating` | `INTEGER (1-5)` | Idő értékelés | +| `communication_rating` | `INTEGER (1-5)` | Kommunikáció értékelés | +| `comment` | `TEXT` | Szöveges vélemény | +| `is_verified` | `BOOLEAN` | Tranzakcióhoz kötött | +| `created_at`/`updated_at` | `TIMESTAMPTZ` | Audit | + +--- + +### 2.5. `marketplace.ratings` (Általános értékelések) + +**Tábla:** `marketplace.ratings` + +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `id` | `INTEGER` | PK | +| `author_id` | `INTEGER` | Ki írta | +| `target_organization_id` | `INTEGER` | Cél szervezet (opcionális) | +| `target_user_id` | `INTEGER` | Cél felhasználó (opcionális) | +| `target_branch_id` | `UUID` | Cél telephely (opcionális) | +| `score` | `NUMERIC` | Pontszám | +| `comment` | `TEXT` | Szöveges | +| `images` | `JSONB` | Képek | +| `is_verified` | `BOOLEAN` | Verifikált | + +--- + +### 2.6. `fleet.branches` (Telephelyek) + +**Tábla:** `fleet.branches` + +| Oszlop | Típus | Megjegyzés | +|--------|-------|------------| +| `id` | `UUID` | PK | +| `organization_id` | `INTEGER (FK → fleet.organizations.id)` | ✅ Melyik szervezethez | +| `address_id` | `UUID (FK → system.addresses.id)` | ❌ Cím (strukturált) | +| `name` | `VARCHAR(100)` | ✅ Telephely név | +| `is_main` | `BOOLEAN` | Központi telephely | +| `postal_code`, `city`, `street_name`, stb. | `VARCHAR` | Denormalizált cím | +| `location` | `GEOMETRY(POINT)` | PostGIS | +| `opening_hours` | `JSONB` | Nyitvatartás | +| `branch_rating` | `FLOAT` | Telephely értékelés | +| `status` | `VARCHAR(30)` | `active`, `inactive` | +| `is_deleted` | `BOOLEAN` | Soft delete | + +--- + +## 3. 🗺️ TELJES ADATBÁZIS TÉRKÉP (Minden séma és tábla) + +### 3.1. Identity séma (Személyek, Felhasználók, Hitelesítés) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `identity.persons` | Természetes személyek (DNS szint) | ✅ **Aktív** | +| `identity.users` | Technikai fiókok (bejelentkezés) | ✅ **Aktív** | +| `identity.wallets` | Pénztárcák (Triple Wallet) | ✅ **Aktív** | +| `identity.active_vouchers` | Aktív voucher-ek | ✅ **Aktív** | +| `identity.user_trust_profiles` | Trust score profilok | ✅ **Aktív** | +| `identity.verification_tokens` | Email/telefon verifikáció | ✅ **Aktív** | +| `identity.one_time_passwords` | OTP kódok | ✅ **Aktív** | +| `identity.social_accounts` | Közösségi média kapcsolatok | ✅ **Aktív** | +| `identity.devices` | Eszköz fingerprint | ✅ **Aktív** | +| `identity.user_device_links` | User-Eszköz kapcsolatok | ✅ **Aktív** | + +### 3.2. Fleet séma (Szervezetek, Flották) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `fleet.organizations` | Szervezetek (cégek, flották, szolgáltatók) | ✅ **Aktív** | +| `fleet.organization_members` | Tagságok (User/Person → Organization) | ✅ **Aktív** | +| `fleet.organization_financials` | Pénzügyi adatok (éves) | ✅ **Aktív** | +| `fleet.branches` | Telephelyek | ✅ **Aktív** | +| `fleet.asset_assignments` | Jármű hozzárendelések | ✅ **Aktív** | +| `fleet.org_sales_assignments` | Értékesítési hozzárendelések | ✅ **Aktív** | + +### 3.3. Vehicle séma (Járművek, Katalógus) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `vehicle.vehicle_model_definitions` | Jármű modellek (MDM) | ✅ **Aktív** | +| `vehicle.vehicle_catalog` | Katalógus sablonok | ✅ **Aktív** | +| `vehicle.assets` | Fizikai eszközök (Digital Twin) | ✅ **Aktív** | +| `vehicle.vehicle_types` | Jármű típusok | ✅ **Aktív** | +| `vehicle.feature_definitions` | Felszereltség elemek | ✅ **Aktív** | +| `vehicle.model_feature_maps` | Modell-Felszereltség kapcsolatok | ✅ **Aktív** | +| `vehicle.dict_body_types` | Karosszéria típus szótár | ✅ **Aktív** | +| `vehicle.cost_categories` | Költségkategóriák | ✅ **Aktív** | +| `vehicle.costs` | Jármű költségek (TCO) | ✅ **Aktív** | +| `vehicle.vehicle_expenses` | Jármű kiadások (jelentésekhez) | ✅ **Aktív** | +| `vehicle.odometer_readings` | Kilométeróra állások | ✅ **Aktív** | +| `vehicle.vehicle_ownership_history` | Tulajdonos történet | ✅ **Aktív** | +| `vehicle.vehicle_transfer_requests` | Átruházási kérelmek | ✅ **Aktív** | +| `vehicle.catalog_discovery` | Katalógus felfedező várólista | ✅ **Aktív** | +| `vehicle.gb_catalog_discovery` | UK piaci felfedező | ✅ **Aktív** | +| `vehicle.auto_data_crawler_queue` | Crawler várólista | ✅ **Aktív** | +| `vehicle.vehicle_logbook` | Jármű naplókönyv | ✅ **Aktív** | +| `vehicle.motorcycle_specs` | Motorkerékpár specifikációk | ✅ **Aktív** | +| `vehicle.external_reference_library` | Külső referencia könyvtár | ✅ **Aktív** | +| `vehicle.reference_lookup` | Referencia kereső | ✅ **Aktív** | +| `vehicle.vehicle_user_ratings` | Felhasználói jármű értékelések | ✅ **Aktív** | +| `vehicle.asset_costs` | Eszköz költségek | ✅ **Aktív** | +| `vehicle.asset_events` | Eseménynapló | ✅ **Aktív** | +| `vehicle.asset_financials` | Pénzügyi adatok | ✅ **Aktív** | +| `vehicle.asset_inspections` | Ellenőrzések | ✅ **Aktív** | +| `vehicle.asset_reviews` | Eszköz vélemények | ✅ **Aktív** | +| `vehicle.asset_telemetry` | Telemetria adatok | ✅ **Aktív** | + +### 3.4. Marketplace séma (Piactér, Szervizek) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `marketplace.service_providers` | Szolgáltatók (RÉGI, egyszerű) | ⚠️ **Használaton kívül?** | +| `marketplace.service_profiles` | Szerviz profilok (ÚJ) | ✅ **Aktív** | +| `marketplace.service_reviews` | Verifikált vélemények | ✅ **Aktív** | +| `marketplace.expertise_tags` | Szakmai címkék | ✅ **Aktív** | +| `marketplace.service_expertises` | Szerviz-Szakma kapcsolatok | ✅ **Aktív** | +| `marketplace.service_specialties` | Specializációk (hierarchikus) | ✅ **Aktív** | +| `marketplace.service_staging` | Robot által gyűjtött adatok | ✅ **Aktív** | +| `marketplace.discovery_parameters` | Robot keresési paraméterek | ✅ **Aktív** | +| `marketplace.service_requests` | Szervizigények | ✅ **Aktív** | +| `marketplace.ratings` | Általános értékelések | ✅ **Aktív** | +| `marketplace.votes` | Szavazatok | ✅ **Aktív** | +| `marketplace.costs` | Költségnapló (Trust Engine) | ✅ **Aktív** | + +### 3.5. Finance séma (Pénzügyek) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `finance.credit_logs` | Hitel napló | ✅ **Aktív** | +| `finance.exchange_rates` | Árfolyamok | ✅ **Aktív** | +| `finance.issuers` | Kibocsátók | ✅ **Aktív** | +| `finance.org_subscriptions` | Szervezeti előfizetések | ✅ **Aktív** | +| `finance.user_subscriptions` | Felhasználói előfizetések | ✅ **Aktív** | +| `finance.payment_intents` | Fizetési szándékok | ✅ **Aktív** | +| `finance.withdrawal_requests` | Kifizetési kérelmek | ✅ **Aktív** | + +### 3.6. System séma (Rendszeradatok) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `system.addresses` | Strukturált címek | ✅ **Aktív** | +| `system.documents` | Dokumentumok | ✅ **Aktív** | +| `system.translations` | Többnyelvű fordítások | ✅ **Aktív** | +| `system.system_parameters` | Rendszer paraméterek | ✅ **Aktív** | +| `system.internal_notifications` | Belső értesítések | ✅ **Aktív** | +| `system.pending_actions` | Függőben lévő műveletek | ✅ **Aktív** | +| `system.service_catalog` | Szolgáltatás katalógus | ✅ **Aktív** | +| `system.subscription_tiers` | Előfizetési szintek | ✅ **Aktív** | +| `system.service_staging` | Szolgáltatás staging | ⚠️ **Duplikáció?** | +| `system.staged_vehicle_data` | Jármű adat staging | ✅ **Aktív** | +| `system.geo_postal_codes` | Irányítószám adatok | ✅ **Aktív** | +| `system.geo_streets` | Utcák | ✅ **Aktív** | +| `system.geo_street_types` | Utca típusok | ✅ **Aktív** | +| `system.competitions_deprecated` | Versenyek (DEPR) | ❌ **Deprecated** | +| `system.user_scores_deprecated` | Pontszámok (DEPR) | ❌ **Deprecated** | +| `system.service_staging_deprecated` | Staging (DEPR) | ❌ **Deprecated** | +| `system.system_data_completion_weights` | Adatteljességi súlyok | ✅ **Aktív** | + +### 3.7. Audit séma (Naplózás) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `audit.audit_logs` | Általános audit napló | ✅ **Aktív** | +| `audit.financial_ledger` | Pénzügyi főkönyv | ✅ **Aktív** | +| `audit.operational_logs` | Működési napló | ✅ **Aktív** | +| `audit.process_logs` | Folyamat napló | ✅ **Aktív** | +| `audit.security_audit_logs` | Biztonsági audit napló | ✅ **Aktív** | + +### 3.8. Gamification séma (Játékosítás) +| Tábla | Leírás | Státusz | +|-------|--------|---------| +| `gamification.badges` | Jelvények | ✅ **Aktív** | +| `gamification.competitions` | Versenyek | ✅ **Aktív** | +| `gamification.level_configs` | Szint konfigurációk | ✅ **Aktív** | +| `gamification.point_rules` | Pont szabályok | ✅ **Aktív** | +| `gamification.points_ledger` | Pont főkönyv | ✅ **Aktív** | +| `gamification.seasonal_competitions` | Szezonális versenyek | ✅ **Aktív** | +| `gamification.seasons` | Szezonok | ✅ **Aktív** | +| `gamification.user_badges` | Felhasználói jelvények | ✅ **Aktív** | +| `gamification.user_contributions` | Felhasználói hozzájárulások | ✅ **Aktív** | +| `gamification.user_scores` | Felhasználói pontszámok | ✅ **Aktív** | +| `gamification.user_stats` | Felhasználói statisztikák | ✅ **Aktív** | + +--- + +## 4. 🔗 KAPCSOLATOK TÉRKÉP (Entity Relationship) + +```mermaid +erDiagram + PERSON ||--o{ USER : "has" + PERSON ||--o{ ORGANIZATION_MEMBER : "member of" + PERSON ||--o{ ORGANIZATION : "legal owner of" + USER ||--o{ ORGANIZATION : "technical owner of" + USER ||--o{ SERVICE_REVIEW : "writes" + USER ||--o{ SERVICE_REQUEST : "creates" + USER ||--o{ VEHICLE_USER_RATING : "rates" + ORGANIZATION ||--o{ ORGANIZATION_MEMBER : "has members" + ORGANIZATION ||--o{ BRANCH : "has locations" + ORGANIZATION ||--o| SERVICE_PROFILE : "optional service" + ORGANIZATION ||--o{ ASSET_ASSIGNMENT : "owns vehicles" + ORGANIZATION ||--o{ VEHICLE_COST : "pays costs" + SERVICE_PROFILE ||--o{ SERVICE_REVIEW : "receives" + SERVICE_PROFILE ||--o{ SERVICE_EXPERTISE : "specializes" + SERVICE_PROFILE ||--o{ BRANCH : "operates" + BRANCH ||--o{ RATING : "rated by" + BRANCH ||--o{ SERVICE_REQUEST : "receives requests" + VEHICLE_MODEL_DEFINITION ||--o{ ASSET_CATALOG : "has variants" + VEHICLE_MODEL_DEFINITION ||--o{ VEHICLE_COST : "has costs" + VEHICLE_MODEL_DEFINITION ||--o{ VEHICLE_USER_RATING : "rated by users" + ASSET_CATALOG ||--o{ ASSET : "defines" + ASSET ||--o{ ASSET_EVENT : "has events" + ASSET ||--o{ SERVICE_REQUEST : "referenced in" + ADDRESS ||--o{ PERSON : "lives at" + ADDRESS ||--o{ ORGANIZATION : "located at" + ADDRESS ||--o{ BRANCH : "address" +``` + +--- + +## 5. 💡 JAVASLATOK a "Közösségi Kereső" funkciókhoz + +### 5.1. Jelenlegi hiányosságok + +1. **Nincs dedikált "Partner" entitás** - A `fleet.organizations`-ban az `is_affiliate_partner` egy egyszerű boolean, nincs hozzá partner-specifikus adat (pl. jutalék ráta, partner kategória, szerződés állapota). +2. **`ServiceProvider` (régi) és `ServiceProfile` (új) kettősség** - A régi `marketplace.service_providers` tábla használaton kívülinek tűnik, de még létezik. +3. **Nincs "aliases" (álnév) támogatás** - Egyik táblában sincs JSONB aliases mező. +4. **Kategória rendszer hiányos** - A `marketplace.expertise_tags` és `marketplace.service_specialties` párhuzamos címke rendszert alkot. + +### 5.2. Javasolt bővítés: `Organization` kiegészítése (AJÁNLOTT) + +Ahelyett, hogy új `Partner` táblát hoznánk létre, bővítsük a meglévő `fleet.organizations` táblát az alábbi JSONB oszlopokkal: + +```python +# Javasolt új mezők az Organization modellben +class Organization(Base): + # ... meglévő mezők ... + + # ÚJ: Partnerek / Közösségi kereső + aliases: Mapped[dict] = mapped_column( + JSONB, server_default=text("'[]'::jsonb"), + comment="Álnevek / alternatív nevek (pl. 'AutoShop Kft.', 'Autóshop')" + ) + is_verified: Mapped[bool] = mapped_column( + Boolean, server_default=text("false"), + comment="Hivatalosan verifikált szervezet" + ) + verification_level: Mapped[str] = mapped_column( + String(20), server_default=text("'basic'"), + comment="Verifikációs szint: basic, email, phone, kyc, premium" + ) + partner_category: Mapped[str] = mapped_column( + String(50), server_default=text("'none'"), + comment="Partner kategória: none, basic, silver, gold, platinum" + ) + commission_rate: Mapped[Optional[float]] = mapped_column( + Numeric(5, 2), nullable=True, + comment="Jutalék ráta (%-ban)" + ) + service_radius_km: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, + comment="Szolgáltatási körzet (km)" + ) + tags: Mapped[dict] = mapped_column( + JSONB, server_default=text("'[]'::jsonb"), + comment="Címkék a közösségi kereséshez" + ) + search_keywords: Mapped[dict] = mapped_column( + JSONB, server_default=text("'{}'::jsonb"), + comment="Kereső kulcsszavak több nyelven" + ) + social_profiles: Mapped[dict] = mapped_column( + JSONB, server_default=text("'{}'::jsonb"), + comment="Közösségi média profilok (Facebook, Instagram, Google Business)" + ) + contact_persons: Mapped[dict] = mapped_column( + JSONB, server_default=text("'[]'::jsonb"), + comment="Kapcsolattartó személyek adatai" + ) +``` + +### 5.3. Alternatíva: Új `partner_profiles` tábla + +Ha a `fleet.organizations` túl nagyra nőne, hozzunk létre egy új táblát: + +```python +class PartnerProfile(Base): + """Bővített partner profil a Közösségi Keresőhöz.""" + __tablename__ = "partner_profiles" + __table_args__ = {"schema": "marketplace"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + organization_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fleet.organizations.id"), unique=True, nullable=False + ) + + # --- ALAPADATOK --- + aliases: Mapped[dict] = mapped_column(JSONB, server_default=text("'[]'::jsonb")) + partner_category: Mapped[str] = mapped_column(String(50), default="none") + + # --- VERIFIKÁCIÓ --- + verification_level: Mapped[str] = mapped_column(String(20), default="basic") + verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + verified_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) + + # --- KERESÉS --- + search_keywords: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) + tags: Mapped[dict] = mapped_column(JSONB, server_default=text("'[]'::jsonb")) + service_radius_km: Mapped[Optional[int]] = mapped_column(Integer) + + # --- GAZDASÁGI ADATOK --- + commission_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2)) + contract_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + contract_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + + # --- KAPCSOLATOK --- + social_profiles: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) + contact_persons: Mapped[dict] = mapped_column(JSONB, server_default=text("'[]'::jsonb")) + + # --- AUDIT --- + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now()) +``` + +### 5.4. Ajánlott stratégia + +1. **Rövid táv**: Az `Organization` modell bővítése JSONB mezőkkel (`aliases`, `tags`, `search_keywords`, `social_profiles`). Ez azonnal használható, nincs szükség új táblára. +2. **Közép táv**: Ha a partnerek száma eléri a 10.000+ rekordot, hozzuk létre a dedikált `partner_profiles` táblát az `marketplace` sémában. +3. **Hosszú táv**: A régi `marketplace.service_providers` tábla archiválása/eltávolítása, mivel a `ServiceProfile` és a bővített `Organization` lefedi a funkcióit. + +### 5.5. Megjegyzések a "Céges nyilvántartás" funkcióról + +A rendelkezésre álló kód és adatbázis alapján: + +- ✅ **Van "Céges nyilvántartás"** → `fleet.organizations` tábla +- ✅ **Van tagságkezelés** → `fleet.organization_members` tábla (User/Person + role) +- ✅ **Van telephelykezelés** → `fleet.branches` tábla (PostGIS támogatással) +- ✅ **Van szolgáltatói profil** → `marketplace.service_profiles` tábla +- ✅ **Van értékelési rendszer** → `marketplace.service_reviews` (verifikált) + `marketplace.ratings` (általános) +- ❌ **NINCS dedikált partner modell** - Az `is_affiliate_partner` boolean nem elegendő +- ❌ **NINCS keresőoptimalizált adat** (aliases, tags, keywords) + +--- + +## 6. ⚠️ FIGYELMEZTETÉSEK + +### 6.1. Duplikált táblák +- `marketplace.service_staging` és `system.service_staging` - Valószínűleg csak egy kellene. +- `marketplace.costs` és `vehicle.costs` - Két külön költség tábla, különböző sémákban. +- `system.competitions_deprecated` → `gamification.competitions` +- `system.user_scores_deprecated` → `gamification.user_scores` + +### 6.2. Kettős modell architektúra +- `marketplace.service_providers` (régi, egyszerű) vs `marketplace.service_profiles` (új, komplex) +- A régi tábla használata nem javasolt új fejlesztésekhez. + +### 6.3. Soft delete következetlenség +- `fleet.organizations`: `is_deleted` + `is_active` boolean mezők +- `marketplace.service_profiles`: státusz alapú (`ghost`, `active`, `flagged`, `suspended`) +- `identity.persons`: `deleted_at` datetime mező +- `identity.users`: `is_active` boolean +- **Hiányzik egy egységes soft-delete stratégia az egész rendszerben.** + +--- + +**Jelentés vége.** A javasolt bővítések implementálása előtt egyeztetés szükséges a Vezető Tervezővel. diff --git a/docs/expertise_tag_masterlist_analysis_2026-06-17.md b/docs/expertise_tag_masterlist_analysis_2026-06-17.md new file mode 100644 index 0000000..bef2fa1 --- /dev/null +++ b/docs/expertise_tag_masterlist_analysis_2026-06-17.md @@ -0,0 +1,133 @@ +# 📊 ExpertiseTag Mesterlista — Többszintű Kategória Elemzés + +**Dátum:** 2026-06-17 +**Szerző:** Rendszer-Architect +**Cél:** Annak vizsgálata, hogy az `ExpertiseTag` (`marketplace.expertise_tags`) tábla `category` mezője alkalmas-e többszintű kategória tárolására, és ha igen, milyen módszerekkel tölthetők fel rá adatok. + +--- + +## 🔍 Vizsgált Modell + +Az `ExpertiseTag` modell az alábbi mezőkkel rendelkezik: + +| Mező | Típus | Leírás | +|------|-------|--------| +| `id` | `Integer` (PK) | Elsődleges kulcs | +| `key` | `String(50)` (unique, index) | Egyedi azonosító kulcs | +| `name_hu` | `String(100)` (nullable) | Magyar név | +| `name_en` | `String(100)` (nullable) | Angol név | +| `category` | **`String(30)`** (index) | Kategória csoportosító címke | +| `is_official` | `Boolean` | Hivatalos-e | +| `suggested_by_id` | `BigInteger` (FK) | Ki javasolta | +| `discovery_points` | `Integer` | Felfedezési pontok | +| `search_keywords` | `JSONB` | Keresőkulcsszavak | +| `usage_count` | `Integer` | Használati számláló | +| `icon` | `String(50)` (nullable) | Ikon neve | +| `description` | `Text` (nullable) | Leírás | + +--- + +## ❌ 1. Válasz: Alkalmas-e a `category` mező többszintű tárolásra? + +### **NEM, a jelenlegi implementáció NEM alkalmas többszintű hierarchia tárolására.** + +| Követelmény | Jelenlegi állapot | Probléma | +|-------------|-------------------|----------| +| Hierarchikus kapcsolat | ❌ Nincs `parent_id` mező | Nem lehet szülő-gyermek viszonyt ábrázolni | +| Szintek száma | ❌ Nincs `level` mező | Nem tudjuk, hány szint mélyen van egy kategória | +| Útvonal (path) | ❌ Nincs `path` / `ltree` mező | Nem lehet gyorsan lekérdezni a teljes útvonalat | +| Rekurzív lekérdezés | ❌ Nincs `lft`/`rgt` (nested set) | CTE-vel lehetne, de lassú nagy adathalmazon | +| Mező hossza | ⚠️ Csak `String(30)` | "vehicle_service" már 15 karakter; kevés a bővítésre | + +A jelenlegi `category` mező **csak egy lapos csoportosító címke** — hasonló egy `tag`-hez, nem pedig egy hierarchikus kategória struktúrához. + +--- + +## 🗄️ 2. Jelenlegi Adatbázis Állapot + +Lekérdezés alapján a tábla **11 rekordot** tartalmaz, **8 distinct category** értékkel: + +| ID | Key | Category | +|----|-----|----------| +| 1 | `auto_szerelo` | `vehicle_service` | +| 2 | `motor_szerelo` | `vehicle_service` | +| 3 | `gumiszerviz` | `vehicle_service` | +| 4 | `karosszerialakatos` | `body_paint` | +| 5 | `fényező` | `body_paint` | +| 6 | `autómentő` | `roadside` | +| 7 | `benzinkút` | `fuel` | +| 8 | `alkatrész_kereskedés` | `parts` | +| 9 | `vizsgaállomás` | `inspection` | +| 10 | `autókozmetika` | `detailing` | +| 11 | `egyéb` | `other` | + +--- + +## 🔄 3. Adatfeltöltési Lehetőségek — Összehasonlítás + +| Módszer | Leírás | Korlátok | +|---------|--------|----------| +| **1️⃣ Seed Script** | Egyszeri batch betöltés. Idempotens. 11 alap kategória hardcode-olva. | Csak egyszer futtatható; nem lehet módosítani futás után. | +| **2️⃣ Direkt SQL INSERT** | `INSERT INTO marketplace.expertise_tags ...` | Nincs validáció, séma integritás ellenőrzés. | +| **3️⃣ API (létező): `GET /categories`** | **Csak OLVASÁS** — listázza a meglévő címkéket. | Nincs POST/PUT/DELETE végpont. | +| **4️⃣ Admin UI** (`admin_ui.py`) | Csak jármű HITL modelljavításra fókuszál. | Nincs ExpertiseTag menedzsment. | + +### Hiányzó API végpontok: +- ❌ `POST /api/v1/admin/categories` — Új szakmai címke létrehozása +- ❌ `PUT /api/v1/admin/categories/{id}` — Meglévő címke szerkesztése +- ❌ `DELETE /api/v1/admin/categories/{id}` — Címke törlése + +--- + +## 🏗️ 4. Ajánlott Tervezés Többszintű Kategóriához + +### 4.1. Új mezők az ExpertiseTag modellben + +```python +parent_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("marketplace.expertise_tags.id") +) +level: Mapped[int] = mapped_column(Integer, default=0) +path: Mapped[Optional[str]] = mapped_column(String(200)) +``` + +### 4.2. Tipikus hierarchia példa + +``` +Level 0 vehicle_service +Level 1 (parent->child) ├── auto_szerelo + ├── motor_szerelo + └── gumiszerviz +Level 2 (sub-child) └── auto_szerelo/diagnosztika +``` + +### 4.3. Javasolt admin API bővítés + +``` +POST /api/v1/admin/categories # Új címke +PUT /api/v1/admin/categories/{id} # Szerkesztés +DELETE /api/v1/admin/categories/{id} # Törlés (soft-delete) +GET /api/v1/admin/categories/tree # Hierarchikus fa +``` + +--- + +## 📋 5. Összefoglalás + +1. A `category` mező (`String(30)`) **lapos, nem hierarchikus** — nem alkalmas többszintű tárolásra +2. Jelenleg **nincs CRUD API** a címkék kezelésére (csak `GET /categories`) +3. **Nincs admin UI** a szakmai címkék menedzselésére +4. A seed script az egyetlen adatbetöltési mód + +### Ajánlott roadmap: + +| Lépés | Feladat | +|-------|---------| +| 1️⃣ | `logic_spec` készítése a hierarchikus kategória rendszerhez | +| 2️⃣ | Alembic migráció — `parent_id`, `level`, `path` mezők | +| 3️⃣ | CRUD API végpontok létrehozása | +| 4️⃣ | Admin UI integráció | +| 5️⃣ | Seed script frissítése hierarchikus adatokkal | + +### Kritikus hibajavítás: +A `search_providers()` SQL lekérdezés **NEM JOIN-ol** az `ExpertiseTag` táblára, így a `category` mindig `None` a keresési eredményekben. Ez azonnali javítást igényel. diff --git a/docs/full_stack_disconnect_analysis_2026-06-16.md b/docs/full_stack_disconnect_analysis_2026-06-16.md new file mode 100644 index 0000000..3d0d68d --- /dev/null +++ b/docs/full_stack_disconnect_analysis_2026-06-16.md @@ -0,0 +1,232 @@ +# 🚨 Full Stack Disconnect Analysis — "Extrák" Hiányzó Checkbox Grid + +**Dátum:** 2026-06-16 +**Elemző:** Rendszer-Architect +**Scope:** VehicleFormModal (Frontend) ↔ Asset Schema (Backend) ↔ Asset DB Model (PostgreSQL) + +--- + +## 🔍 1. FRONTEND VIZSGÁLAT — VehicleFormModal.vue + +### Jelenlegi lépések (line 870-875): +| Index | Lépés neve | Tartalom | +|-------|------------|----------| +| 0 | **Azonosítás** | Kategória, VIN, Rendszám | +| 1 | **Alapadatok** | Márka, Modell, Évjárat, Üzemanyag, **Kivitel/Felszereltség (összemosva)**, Becenév, Km óra | +| 2 | **Műszaki** | Hengerűrtartalom, Teljesítmény kW, Hajtás, Váltó, Karosszéria (body_type), Klíma, Ajtók, Ülések, EV blokk, Okmányadatok | +| 3 | **Extrák** | Saját besorolás, Saját megjegyzés + Features checkbox grid (csak `personal` esetén!) | + +### Step 3 ("Extrák") valós tartalma (line 684-764): +```html +
+

Itt adhatsz hozzá extra információkat a járművedhez.

+ + + + +
+ +
+
+``` + +### ⚠️ Kritikus probléma: +A **75+ checkbox** csak akkor jelenik meg, ha a felhasználó `personal` (személyautó) kategóriát választott. Minden más járműosztálynál (motorcycle, light_commercial, commercial, work_machine, trailer, bus, camper, boat, aircraft, other) a Step 3 tényleg **üres** — csak "Saját besorolás" és "Saját megjegyzés" látszik. Ez magyarázza a képernyőképen látott üres felületet! + +### A features tömörítve individual_equipment.car_specs.features-ként megy a backendbe + +### Mely fájlba lettek a checkboxok? +A checkbox grid a VehicleFormModal.vue fájlban van (line 710-763), de **nem kapta meg a saját dedikált komponensét**, és **nincs külön "Extrák" almappája a kategóriáknak** (Beltér/Kültér/Multimédia/Egyéb). Minden egy `form.features` string tömbben van tárolva, nem pedig strukturált JSONB-ként. + +--- + +## 🗄️ 2. ADATBÁZIS & SÉMA VIZSGÁLAT + +### Tervező által kért mezők vs Valós állapot + +#### ✅ Azonosítás +| Kért mező | Backend (DB Model) | Pydantic Schema | Frontend Form | Státusz | +|-----------|-------------------|-----------------|---------------|---------| +| Márka | Asset.brand ✅ | AssetCreate.brand ✅ | form.brand ✅ | ✅ OK | +| Modell | Asset.model ✅ | AssetCreate.model ✅ | form.model ✅ | ✅ OK | +| Típusjel | ❌ **NINCS** | ❌ **NINCS** | ❌ **NINCS** | ❌ **HIÁNYZIK** | + +> **"Típusjel"** — ez a mező sehol nem létezik önálló entitásként. A `trim_level` ("Kivitel / Felszereltség") próbálja helyettesíteni, de a Tervező szerint a Típusjel (pl. "320i", "X5 xDrive30d") külön fogalom. + +#### ✅ Általános +| Kért mező | Backend | Schema | Frontend | Státusz | +|-----------|---------|--------|----------|---------| +| Km óra állás | current_mileage ✅ | ✅ | ✅ | ✅ OK | +| Évjárat | year_of_manufacture ✅ | ✅ | ✅ | ✅ OK | +| Kivitel (Karosszéria) | ❌ body_type a JSONB-ben | ❌ nincs külön mező | form.body_type (Step 2-ben!) | ❌ **ROSSZ helyen** | +| Állapot | condition_score ✅ | ❌ **NINCS a sémában** | form.condition (string!) | ⚠️ **Inkonzisztens** | + +> **"Kivitel (Karosszéria)"** — a `body_type` mező létezik a JSONB-ben (individual_equipment.car_specs.body_type), de a **"Műszaki" (Step 2)** fül alatt van, nem az alapadatok között. A Tervező szerint az Általános adatoknál kellene lennie, külön a "Felszereltségtől". + +#### ✅ Műszaki +| Kért mező | Backend | Schema | Frontend | Státusz | +|-----------|---------|--------|----------|---------| +| Üzemanyag | fuel_type ✅ | ✅ | ✅ | ✅ OK | +| Hengerűrtartalom (cm³) | engine_capacity ✅ | ✅ | ✅ | ✅ OK | +| Teljesítmény (kW/LE) | power_kw ✅ | ✅ | ✅ (HP display) | ✅ OK | +| **Nyomaték (Nm)** | torque_nm ✅ **DB-ben** | ✅ AssetCreate.torque_nm | ❌ **NINCS input mező!** | ❌ **HIÁNYZIK** | + +> **Nyomaték (torque_nm)** — a legkritikusabb hiányzó mező! A backend modellben (Asset.py sor 97) benne van, a Pydantic sémában (AssetCreate.torque_nm sor 151) benne van, de a frontend űrlapban **SEHOL** nincs input mező a Nyomaték megadására. Amikor a felhasználó elmenti az űrlapot, a torque_nm egyszerűen null marad. + +#### ✅ Hajtáslánc +| Kért mező | Backend | Schema | Frontend | Státusz | +|-----------|---------|--------|----------|---------| +| Sebességváltó | transmission_type ✅ | ✅ | ✅ | ✅ OK | +| Hajtás | drive_type ✅ | ✅ | ✅ | ✅ OK | + +#### ✅ Kényelem +| Kért mező | Backend | Schema | Frontend | Státusz | +|-----------|---------|--------|----------|---------| +| Klíma fajtája | JSONB-ben (ac_type) | ❌ nincs külön mező | form.ac_type ✅ | ⚠️ JSONB-be van rejtve | + +#### ✅ Okmányok +| Kért mező | Backend | Schema | Frontend | Státusz | +|-----------|---------|--------|----------|---------| +| Forgalmi szám | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben | +| Törzskönyv szám | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben | +| Műszaki lejárat | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben | +| Okmány lejárat | JSONB-ben | ❌ nincs külön mező | ✅ | ⚠️ JSONB-ben | + +#### ✅ Extrák (JSONB) +| Kért mező | Backend | Schema | Frontend | Státusz | +|-----------|---------|--------|----------|---------| +| Beltér | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK | +| Kültér | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK | +| Multimédia | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK | +| Egyéb | JSONB car_specs.features | individual_equipment | ✅ (checkbox) | ✅ OK | + +### További hiányzó mezők a frontendről (de backendben léteznek): +| Mező | DB Oszlop | Pydantic Schema | Frontend Input | Státusz | +|------|-----------|----------------|---------------|---------| +| curb_weight (saját tömeg) | ✅ | ✅ | ❌ | **HIÁNYZIK** | +| max_weight (össztömeg) | ✅ | ✅ | ❌ | **HIÁNYZIK** | +| cargo_volume_x (raktér hossz) | ✅ | ✅ | ❌ | **HIÁNYZIK** | +| cargo_volume_y (raktér szél.) | ✅ | ✅ | ❌ | **HIÁNYZIK** | +| roof_type (tető típus) | ✅ | ✅ | ❌ | **HIÁNYZIK** | +| audio_system_type (hangi) | ✅ | ✅ | ❌ | **HIÁNYZIK** | + +--- + +## 🌐 3. API VÁLASZ VIZSGÁLAT + +### GET /assets/vehicles/{id} (assets.py line 290-337) + +A végpont az AssetResponse Pydantic modellt használja (response_model=AssetResponse). A from_attributes=True miatt az ORM Asset objektum automatikusan Pydantic-ként szerializálódik. + +**A JSON válasz tartalmazza:** +- Az összes "sima" oszlopot (brand, model, engine_capacity, power_kw, torque_nm, transmission_type, drive_type, stb.) +- Az individual_equipment JSONB mezőt teljes egészében (benne car_specs, ev_specs, dates, documents) +- A catalog kapcsolódó objektumot (ha van) + +**A frontend populateForm() (line 1162-1215) helyesen olvassa vissza a JSONB-t**, de a torque_nm sosem kerül beolvasásra, mert nincs hozzá input mező. + +--- + +## 🎯 4. ÖSSZEGZÉS: A KAPCSOLAT MEGSZAKADÁSI PONTOK + +### 1. 🔴 **Nyomaték (torque_nm) — Backend adat, frontend nélkül** + - **Hol:** DB Asset.torque_nm sor 97, Schema AssetCreate.torque_nm sor 151 + - **Probléma:** A backend tárolja és a Pydantic séma támogatja, de a VehicleFormModal.vue egyetlen lépésében sincs input mező. + - **Következmény:** A flottavezetők soha nem adják meg a nyomatékot, a "Thick Digital Twin" koncepció adathiányos. + +### 2. 🟠 **"Típusjel" mező teljes hiánya** + - **Hol:** Sehol az egész stack-ben + - **Probléma:** A Tervező által kért Típusjel (pl. "320i", "X5 xDrive30d") fogalma nem létezik. Csak a trim_level van. + +### 3. 🟠 **"Kivitel" és "Felszereltség" összemosása** + - **Hol:** VehicleFormModal.vue Step 1 (line 376-409) + - **Probléma:** A trim_level egyszerre próbálja lefedni a karosszéria kivitelt ÉS a felszereltségi szintet. + +### 4. 🟡 **Features checkbox grid csak personal osztálynál** + - **Hol:** VehicleFormModal.vue line 711 + - **Probléma:** v-if="form.vehicle_class === 'personal'" — más osztályoknál tényleg üres az Extrák fül. + +### 5. 🟡 **Hiányzó fizikai méret és tömeg mezők** + - curb_weight, max_weight, cargo_volume_x, cargo_volume_y — backendben vannak, de nincsenek a formban. + +### 6. 🟡 **Okmányadatok JSONB-be rejtve** + - mot_expiry, document_expiry, reg_cert_number, title_cert_number a JSONB-ben, nem dedikált oszlopokban. + +--- + +## 🏗️ 5. JAVASLAT: 7 LÉPÉSES ÚJ ŰRLAPSTRUKTÚRA + +``` +Step 0: 🚗 AZONOSÍTÁS + - Kategória (vehicle_class) + - Márka (brand) + - Modell (model) + - Típusjel (type_designation) ⬅️ ÚJ! + - Rendszám (license_plate) + - Alvázszám (vin) + +Step 1: 📋 ÁLTALÁNOS ADATOK + - Évjárat (year_of_manufacture) + - Km óra állás (current_mileage) + - Kivitel / Karosszéria (body_type) ⬅️ Külön! + - Állapot / Saját besorolás (condition) + +Step 2: ⚙️ MŰSZAKI ADATOK + - Üzemanyag (fuel_type) + - Hengerűrtartalom (engine_capacity) + - Teljesítmény (power_kw) + LE kijelzés + - Nyomaték (torque_nm) ⬅️ ÚJ! + - Hengerelrendezés (cylinder_layout) + +Step 3: 🔧 HAJTÁSLÁNC + - Sebességváltó (transmission_type) + - Hajtás (drive_type) + +Step 4: 🌡️ KÉNYELEM & KLÍMA + - Klíma fajtája (ac_type) + - Tető típus (roof_type) ⬅️ ÚJ! + - Hangrendszer (audio_system_type) ⬅️ ÚJ! + - Ajtók száma (door_count) + - Ülések száma (seat_count) + +Step 5: 📄 OKMÁNYOK + - Műszaki érvényesség (mot_expiry) + - Okmány érvényesség (document_expiry) + - Forgalmi szám (registration_cert_number) + - Törzskönyv szám (title_cert_number) + +Step 6: ✨ EXTRÁK (Felszereltség) - Dedikált komponens! + - Beltér (interior features) + - Kültér (exterior features) + - Multimédia (multimedia features) + - Technikai (technical features) + - Egyéb (other features) + - Súly & Méretek (curb_weight, max_weight, cargo) +``` + +--- + +## 📊 HIÁNYZÓ MEZŐK MASTER LIST + +| # | Mező | DB/Séma | Frontend | Javasolt lépés | +|---|------|---------|----------|----------------| +| 1 | torque_nm | ✅ Van | ❌ NINCS | Step 2 | +| 2 | type_designation | ❌ NINCS | ❌ NINCS | Step 0 | +| 3 | curb_weight | ✅ Van | ❌ NINCS | Step 6 | +| 4 | max_weight | ✅ Van | ❌ NINCS | Step 6 | +| 5 | cargo_volume_x | ✅ Van | ❌ NINCS | Step 6 | +| 6 | cargo_volume_y | ✅ Van | ❌ NINCS | Step 6 | +| 7 | roof_type | ✅ Van | ❌ NINCS | Step 4 | +| 8 | audio_system_type | ✅ Van | ❌ NINCS | Step 4 | + +--- + +## ⚡ AZONNALI TEENDŐK (Prioritási sorrendben) + +1. **🟢 FRONTEND:** Add hozzá a `torque_nm` input mezőt a "Műszaki" (Step 2) lépéshez. Ez 5 perc fejlesztés és a legnagyobb adatveszteséget szünteti meg. + +2. **🟡 FRONTEND:** Bontsd ki a features checkbox grid-et egy külön Vue komponensbe (`VehicleFeaturesGrid.vue`), és engedélyezd MINDEN járműosztálynál, ne csak personal-nál. + +3. **🟡 FRONTEND+SCHEMA:** Válaszd szét a "Kivitel" (body_type) és "Felszereltség" (trim_level) fogalmakat. + +4. **🔴 ARCHITECT TERV:** Készíts `logic_spec` dokumentációt az űrlap teljes átépítéséhez a fenti 7 lépéses struktúra alapján. diff --git a/docs/gamification_penalty_system_reconnaissance_2026-06-17.md b/docs/gamification_penalty_system_reconnaissance_2026-06-17.md new file mode 100644 index 0000000..fb3cfb3 --- /dev/null +++ b/docs/gamification_penalty_system_reconnaissance_2026-06-17.md @@ -0,0 +1,131 @@ +# Gamification Penalty System — Adatbázis Felderítési Jelentés + +**Dátum:** 2026-06-17 +**Cél:** A 3-szintű Büntetési Mátrix (Penalty System) és Árnyéktiltás (Shadowban) bevezetésének adatbázis-struktúra előfeltérképezése + +--- + +## 1. FELADAT: Büntetési Szint (Penalty Level) — Meglévő Struktúra + +### ✅ Megállapítás: A büntetési infrastruktúra NAGYRÉSZT KÉSZ + +A Vezető Tervező örömére: a rendszer **már tartalmazza** a büntetési pontrendszer alapjait. Az alábbi táblák és mezők **már léteznek** az adatbázisban és a modellekben: + +#### backend/app/models/gamification/gamification.py + +| Tábla (séma) | Mező | Típus | Státusz | +|---|---|---|---| +| gamification.user_stats | penalty_points | Integer (server_default=0) | ✅ Létezik | +| gamification.user_stats | restriction_level | Integer (server_default=0) | ✅ Létezik | +| gamification.user_stats | penalty_quota_remaining | Integer (default=3) | ✅ Létezik | +| gamification.user_stats | banned_until | DateTime(timezone=True) | ✅ Létezik | +| gamification.user_stats | current_level | Integer (default=1) | ✅ Létezik | +| gamification.level_configs | level_number | Integer (unique) | ✅ Létezik (támogat negatív értékeket!) | +| gamification.level_configs | is_penalty | Boolean (default=False) | ✅ Létezik | +| gamification.level_configs | min_points | Integer | ✅ Létezik | +| gamification.level_configs | rank_name | String | ✅ Létezik | +| gamification.points_ledger | penalty_change | Integer (server_default=0) | ✅ Létezik | +| identity.persons | penalty_points | Integer (default=-1) | ✅ Létezik | +| identity.persons | social_reputation | Numeric(3,2) | ✅ Létezik | +| identity.persons | lifetime_xp | BigInteger (default=-1) | ✅ Létezik | +| identity.user_trust_profiles | trust_score | Integer (default=0) | ✅ Létezik | +| identity.user_trust_profiles | identity_risk_flag | Boolean (default=False) | ✅ Létezik | + +### ⚠️ HIÁNYZIK: penalty_level (a 3-szintű büntetési mátrix maga) + +A restriction_level mező hasonló koncepció, de jelenleg nincs dedikált penalty_level (Integer, -1 / -2 / -3) mező. + +### Javaslat az új mezők helyére + +A gamification.user_stats táblába érdemes beilleszteni: + +``` +PenaltyLevel: Integer, server_default=0 (-1=Figyelmeztetés, -2=Korlátozás, -3=Árnyéktiltás) +GoodDeedsCount: Integer, server_default=0 (Jóvátételi cselekedetek számlálója) +ShadowBannedUntil: DateTime(timezone=True), nullable=True (Látszólagos tiltás vége) +``` + +**Indoklás:** A user_stats tábla már most is tartalmazza a penalty_points, restriction_level, penalty_quota_remaining és banned_until mezőket — ez a természetes helye minden büntetési információnak. A Person tábla (identity.persons) szintén rendelkezik penalty_points mezővel, de ott ez a személy szintű, nem a gamification profil része. + +--- + +## 2. FELADAT: Hibajelentés (Report) Tábla + +### ❌ Megállapítás: NINCS dedikált report/abuse tábla + +Az adatbázisban és a modellek között **NEM található** olyan tábla, amelynek neve report, abuse, flag, ticket, moderation_content vagy data_correction lenne. A PostgreSQL information_schema lekérdezés nulla találatot adott. + +### Ami KÖZELÍT + +| Tábla | Kapcsolat | Korlát | +|---|---|---| +| marketplace.service_providers | status mező ModerationStatus enumnak | Ez a beküldött szolgáltatók moderálására való, **nem** a visszaélés-jelentésre | +| marketplace.service_profiles | flagged státusz a ServiceStatus enumban | Szolgáltatók flag-elésére | +| gamification.user_contributions | contribution_type = 'report_abuse' | Naplózza, hogy ki jelentett, de **nincs dedikált jelentés adattábla** | + +### Javaslat: Új user_reports tábla létrehozása + +Javasolt séma a gamification sémában (ahol a user_contributions is van): + +``` +user_reports tábla (gamification séma): +- id Integer (PK) +- reporter_id Integer FK->identity.users.id +- target_type String(50) ('service_profile', 'review', 'vehicle', 'user') +- target_id Integer +- reason_category String(50) ('fake_service', 'wrong_data', 'spam', 'abuse', 'other') +- description Text (nullable) +- evidence_data JSONB (nullable) +- status String(20) default='pending' ('pending','verified','dismissed','action_taken') +- reviewed_by Integer FK->identity.users.id (nullable) +- resolution Text (nullable) +- created_at DateTime +- resolved_at DateTime (nullable) +``` + +--- + +## 3. FELADAT: Adminisztrációs Szabályok (System Parameters) + +### ✅ Megállapítás: A rendszer RENDELKEZIK dinamikus paraméter táblával + +A system.system_parameters tábla **már létezik** és kifejezetten alkalmas a büntetési szabályok adminisztrációjára. + +| Oszlop | Típus | Leírás | +|---|---|---| +| key | VARCHAR | Paraméter kulcs (pl. penalty_level_1_quota) | +| category | VARCHAR | Kategorizálás (pl. penalty_system) | +| value | JSONB | A paraméter értéke (JSON, bármi lehet) | +| scope_level | VARCHAR | Hatáskör: global, country, region, organization, user | +| scope_id | VARCHAR | A hatáskör azonosítója | +| is_active | BOOLEAN | Aktív-e | +| description | VARCHAR | Emberi leírás | + +### Javasolt paraméterkulcsok a büntetési rendszerhez + +| key | category | value (JSON) | +|---|---|---| +| penalty_level_1_threshold | penalty_system | {"min_bad_actions": 2, "quota_reduction": 1} | +| penalty_level_2_threshold | penalty_system | {"min_bad_actions": 5, "xp_multiplier": 0.5} | +| penalty_level_3_threshold | penalty_system | {"min_bad_actions": 10, "shadowban": true, "max_actions_per_day": 1} | +| penalty_action_points | penalty_system | {"fake_service_submission": 10, "fake_review": 15, "spam_comment": 5} | +| penalty_good_deed_recovery | penalty_system | {"points_per_good_deed": -3, "max_recovery_per_day": 1} | + +> **Előny:** A scope_level oszlop lehetővé teszi, hogy **ország-specifikus** vagy akár **felhasználó-specifikus** büntetési szabályokat állítsunk be, anélkül hogy a kódot módosítani kellene. + +--- + +## Összefoglaló táblázat + +| Feladat | Státusz | Megjegyzés | +|---|---|---| +| **1. Büntetési szint helye** | ✅ **Alap infrastruktúra kész** | user_stats.penalty_points, restriction_level, penalty_quota_remaining, banned_until, level_configs.is_penalty — mind létezik. HIÁNYZIK: dedikált penalty_level és good_deeds_count mező | +| **2. Hibajelentés tábla** | ❌ **NINCS** | Egyetlen dedikált report/abuse tábla sem létezik. A user_contributions.contribution_type = 'report_abuse' csak naplóz, nem tárol jelentéseket. **Teljesen új tábla kell** | +| **3. Adminisztrációs szabályok** | ✅ **Alkalmas** | system.system_parameters JSONB-alapú, scoped paraméter tábla kiválóan megfelel a dinamikus szabályok tárolására. Új paramétereket kell csak beszúrni | + +## Következő lépések (ajánlott sorrend) + +1. **Új mezők hozzáadása** gamification.user_stats-hoz: penalty_level, good_deeds_count, shadow_banned_until +2. **Új tábla létrehozása:** gamification.user_reports (vagy gamification.content_reports) +3. **Rendszerparaméterek feltöltése:** system.system_parameters büntetési szabályokkal +4. **Penalty Engine service** megírása, ami a level_configs táblából és a system_parameters-ből olvassa a szabályokat, és automatikusan lépteti a penalty_level-et diff --git a/docs/provider_services_category_analysis_2026-06-17.md b/docs/provider_services_category_analysis_2026-06-17.md new file mode 100644 index 0000000..78942a4 --- /dev/null +++ b/docs/provider_services_category_analysis_2026-06-17.md @@ -0,0 +1,223 @@ +# Szolgáltatók Szolgáltatásainak (Category/Specialization) Adatfolyam Elemzése + +**Dátum:** 2026-06-17 +**Készítette:** Rendszer-Architect +**Cél:** A szolgáltatók kategóriáinak és szolgáltatásainak teljes verem (adatbázis → backend → frontend) adatfolyamának feltérképezése. + +--- + +## Összefoglalás + +A vizsgálat során kiderült, hogy a szolgáltatók kategóriáinak (category) adatfolyama **megszakadt** a backend és a frontend között. A `search_providers()` SQL lekérdezése nem csatlakoztatja a `ServiceExpertise` → `ExpertiseTag` láncot, ezért a `category` mező minden keresési találatnál `None`. Emiatt a frontenden minden kártyán a "Nincs kategória" placeholder látszik, és a detail modalban a kategória blokk teljesen láthatatlan. + +--- + +## 1. Adatbázis Réteg + +### 1.1. Érintett Táblák + +| Tábla | Séma | Szerep | Category-t tartalmaz? | +|-------|------|--------|----------------------| +| ExpertiseTag | marketplace.expertise_tags | Mesterlista - szakmai címkék | Igen - key, name_hu, name_en, category | +| ServiceExpertise | marketplace.service_expertises | Kapcsolótábla - service / expertise | Csak FK (service_id, expertise_id) | +| ServiceProfile | marketplace.service_profiles | Szerviz szolgáltató profilja | JSONB-ben: specialization_tags.primary_category | +| Organization | fleet.organizations | Szervezet (nincs közvetlen category) | Nincs category mező | + +### 1.2. Kapcsolatok + +``` +ExpertiseTag (marketplace.expertise_tags) + ^ expertise_id +ServiceExpertise (marketplace.service_expertises) + ^ service_id +ServiceProfile (marketplace.service_profiles) + ^ organization_id (unique) +Organization (fleet.organizations) +``` + +### 1.3. ExpertiseTag Tábla Szerkezete + +| Mezo | Típus | Leírás | +|------|-------|--------| +| id | Integer (PK) | | +| key | String(50), unique | Pl. "auto_szerelo", "gumiszerviz" | +| name_hu | String(100) | Magyar név, pl. "Autószerelő" | +| name_en | String(100) | Angol név, pl. "Car mechanic" | +| category | String(30) | Csoportosító, pl. "auto" | +| is_official | Boolean | Hivatalos-e | +| search_keywords | JSONB | Keresőkulcsszavak | +| icon | String(50) | Ikon neve | + +### 1.4. ServiceProfile.specialization_tags JSONB Szerkezet + +```json +{ + "primary_category": "auto_szerelo", + "user_tags": ["olajcsere", "fék", "klíma"] +} +``` + +--- + +## 2. Backend API Réteg + +### 2.1. GET /categories (Muködik) + +**Végpont:** backend/app/api/v1/endpoints/providers.py:30 + +**Séma:** backend/app/schemas/provider.py:14 - ExpertiseCategoryOut + +Visszaadja az összes ExpertiseTag rekordot. Használja a ProviderQuickAddModal a category_id kiválasztáshoz. + +### 2.2. GET /search (KRITIKUS HIBA) + +**Végpont:** backend/app/api/v1/endpoints/providers.py:60 + +**Szolgáltatás:** backend/app/services/provider_service.py:130 - search_providers() + +**Séma:** backend/app/schemas/provider.py:25 - ProviderSearchResult + +```python +class ProviderSearchResult(BaseModel): + id: int + name: str + category: Optional[str] = None # DEFAULT: None! + specialization: List[str] = Field(default_factory=list) +``` + +#### A HIBA: Az org_stmt SELECT nem tartalmazza a category-t + +A provider_service.py:194-225 sorokban az org_stmt SELECT listája: + +```python +org_stmt = select( + Organization.id.label("id"), + Organization.name.label("name"), + # ... címmezok ... + ServiceProfile.specialization_tags.label("specialization_tags"), + case(...).label("source"), + # HIÁNYZIK: LEFT JOIN a ServiceExpertise / ExpertiseTag láncon + # HIÁNYZIK: category lekeres +).outerjoin(ServiceProfile, ...) +``` + +#### Eredmény: A ProviderSearchResult építésekor (318-342. sor) a category soha nincs beállítva + +```python +for row in rows: + results.append( + ProviderSearchResult( + id=row.id, + name=row.name, + # category itt NINCS paraméterként! -> default: None + tags=tags_list, + source=row.source, + is_verified=row.is_verified, + ) + ) +``` + +### 2.3. POST /quick-add (Muködik) + +**Végpont:** backend/app/api/v1/endpoints/providers.py:98 + +Helyesen hozza létre a ServiceExpertise kapcsolatot: + +```python +expertise = ServiceExpertise( + service_id=profile.id, + expertise_id=data.category_id, + confidence_level=50, +) +db.add(expertise) +``` + +### 2.4. PUT /{provider_id} - NEM kezeli a category-t + +A ProviderUpdateIn séma **NEM tartalmaz category_id-t**, csak name, címmezok, contact_phone, email, website, tags. + +--- + +## 3. Frontend Réteg + +### 3.1. Kártya (ServiceFinderView.vue) + +**Category badge (263-277. sor):** +```vue +...SOHA nem fut le, mert category = None... +...MINDEN kártyán "Nincs kategória"... +``` + +**Specialization chips (244-261. sor):** A provider.specialization lista megjelenik, ha van benne adat. + +### 3.2. Detail Modal (ProviderDetailModal.vue) + +**Category blokk (42-52. sor):** v-if="provider.category" mindig false -> a blokk láthatatlan. + +### 3.3. Edit Modal (ProviderEditModal.vue) + +**HIÁNYZIK:** Nincs kategória kiválasztó mező! Csak name, címmezok, phone, email, website, tagsInput. + +### 3.4. Quick-Add Modal (ProviderQuickAddModal.vue) + +Tartalmaz category_id kiválasztó dropdown-ot, ami a GET /categories-ból tölti az adatokat. + +--- + +## 4. Fejlesztési Javaslatok + +### #1 (KRITIKUS) - Category lekerese a search API-ban + +**Probléma:** A search_providers() nem tolti be a category-t. + +**Megoldás:** Correlated subquery hozzaadasa: + +```python +category_subquery = ( + select(ExpertiseTag.name_hu) + .select_from(ServiceExpertise) + .join(ExpertiseTag, ServiceExpertise.expertise_id == ExpertiseTag.id) + .where(ServiceExpertise.service_id == ServiceProfile.id) + .limit(1) + .correlate(ServiceProfile) + .label("category") +) +``` + +**Erintett fajlok:** +- backend/app/services/provider_service.py:194 - org_stmt SELECT kiegeszitese +- backend/app/services/provider_service.py:326 - ProviderSearchResult epitesekor category atadasa + +### #2 (HIÁNYZÓ FUNKCIÓ) - Kategoria kivalaszto az Edit Modalban + +**Probléma:** Az EditModal nem tartalmaz kategoria kivalasztot. + +**Megoldás:** Kategoria dropdown hozzaadasa, ami betolti a GET /categories adatait. + +**Erintett fajlok:** +- frontend/src/components/provider/ProviderEditModal.vue - uj kategoria kivalaszto +- backend/app/schemas/provider.py:89 - ProviderUpdateIn kiegeszitese category_id mezovel +- backend/app/services/provider_service.py:497 - update_provider() kiegeszitese ServiceExpertise kezelessel + +### #3 (HIÁNYZÓ FUNKCIÓ) - Category szuro implementalasa + +**Probléma:** A search_providers() fuggveny elfogadja a category parametert, de nem hasznalja a szureshez. + +### #4 (ADAT INTEGRITÁS) - Category fogalmanak tisztazasa + +Javaslat: A category mezo a ProviderSearchResult-ban az ExpertiseTag.name_hu erteket tartalmazza (a felhasznalo szamara ertelmes nevet), nem pedig a technikai category csoportosítót. + +--- + +## 5. Érintett Fájlok Listája + +| Fájl | Szerep | Status | +|------|--------|--------| +| backend/app/models/marketplace/service.py | Adatbázis modellek | OK | +| backend/app/schemas/provider.py | Pydantic sémák | OK (de hianyzik category_id a ProviderUpdateIn-bol) | +| backend/app/services/provider_service.py | Üzleti logika | HIBA: hianyzik a category JOIN | +| backend/app/api/v1/endpoints/providers.py | API végpontok | OK | +| frontend/src/views/ServiceFinderView.vue | Kereso nézet | OK (de category = None) | +| frontend/src/components/provider/ProviderDetailModal.vue | Detail modal | OK (de category blokk rejtve) | +| frontend/src/components/provider/ProviderEditModal.vue | Edit modal | HIANYZIK a kategoria kivalaszto | +| frontend/src/components/provider/ProviderQuickAddModal.vue | Gyors felvetel | OK | diff --git a/docs/service_book_schema_audit.md b/docs/service_book_schema_audit.md new file mode 100644 index 0000000..e40dac0 --- /dev/null +++ b/docs/service_book_schema_audit.md @@ -0,0 +1,160 @@ +# 🔍 Digitális Szerviznapló (Service Book) Architektúra Audit + +**Dátum:** 2026-06-15 +**Auditor:** Fast Coder (Core Developer) +**Cél:** A Vezető Tervező részére készült mélyreható elemzés a Szerviz események, Javítások és Okmány lejáratok adatbázis-szintű előkészítettségéről. + +--- + +## 1. Összefoglaló + +A rendszer **rendelkezik dedikált táblákkal** a Digitális Szerviznaplóhoz, de azok **részben előkészítettek**, részben pedig a `JSONB` mezőkbe vannak integrálva. Nincs külön `ServiceEvent` vagy `Document` tábla — a szerviz eseményeket az [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) modell kezeli, a dokumentumokat (biztosítás, műszaki) pedig az [`Asset.individual_equipment`](../backend/app/models/vehicle/asset.py:114) JSONB mező. + +--- + +## 2. Táblaszerkezet Áttekintés + +### 2.1. `vehicle.asset_events` — Digitális Szervizkönyv (Fő tábla) + +**Modell:** [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) +**Tábla:** `vehicle.asset_events` + +| Mező | Típus | Leírás | +|------|-------|--------| +| `id` | UUID (PK) | Elsődleges kulcs | +| `asset_id` | UUID (FK → `vehicle.assets.id`) | Jármű hivatkozás | +| `user_id` | Integer (FK → `identity.users.id`) | Ki végezte a műveletet | +| `organization_id` | Integer (FK → `fleet.organizations.id`) | Szervezet | +| `event_type` | String(50) | Esemény típusa (lásd `AssetEventTypeEnum`) | +| `odometer_reading` | Integer (nullable) | Km óra állás az eseménykor | +| `description` | Text (nullable) | Leírás | +| `cost_id` | UUID (FK → `vehicle.asset_costs.id`, nullable) | Kapcsolódó költség | +| `event_date` | DateTime | Esemény dátuma | +| `created_at` / `updated_at` | DateTime | Időbélyegek | + +**Támogatott eseménytípusok** ([`AssetEventTypeEnum`](../backend/app/models/vehicle/asset.py:383)): +- `SERVICE` — Szerviz +- `REPAIR` — Javítás +- `ACCIDENT` — Baleset +- `INSPECTION` — Műszaki vizsga +- `TIRE_CHANGE` — Gumi csere +- `MAINTENANCE` — Karbantartás +- `UPGRADE` — Fejlesztés +- `RECALL` — Visszahívás + +**Kapcsolatok:** +- `asset` → [`Asset`](../backend/app/models/vehicle/asset.py:69) (Many-to-One) +- `user` → [`User`](../backend/app/models/identity/user.py) (Many-to-One) +- `organization` → [`Organization`](../backend/app/models/marketplace/organization.py) (Many-to-One) +- `cost` → [`AssetCost`](../backend/app/models/vehicle/asset.py:246) (Many-to-One) + +### 2.2. `vehicle.odometer_readings` — Km óra állás történet + +**Modell:** [`OdometerReading`](../backend/app/models/vehicle/asset.py:353) +**Tábla:** `vehicle.odometer_readings` + +| Mező | Típus | Leírás | +|------|-------|--------| +| `id` | UUID (PK) | Elsődleges kulcs | +| `asset_id` | UUID (FK → `vehicle.assets.id`) | Jármű | +| `reading` | Integer | Km óra állás | +| `recorded_at` | DateTime | Rögzítés ideje | +| `source` | String(30) | Forrás: `manual`, `api`, `telemetry` | +| `cost_id` | UUID (FK → `vehicle.asset_costs.id`, nullable) | Kapcsolódó költség | + +### 2.3. `vehicle.asset_costs` — Költségnapló + +**Modell:** [`AssetCost`](../backend/app/models/vehicle/asset.py:246) +**Tábla:** `vehicle.asset_costs` + +A költségek `data` JSONB mezőjében tárolódnak a szerviz specifikus adatok: +- `odometer` — km óra állás a költségkor +- `description` — leírás +- `type` — típus (pl. `maintenance`) +- `invoice_number` — számlaszám + +### 2.4. `vehicle.assets.individual_equipment` — JSONB (Okmányok, Biztosítás) + +**Mező:** [`Asset.individual_equipment`](../backend/app/models/vehicle/asset.py:114) — JSONB + +Itt tárolódnak a **dokumentum lejáratok** és egyéb jármű specifikus adatok: +- `insurance_due_date` — Biztosítás fordulónap +- `insurance_premium` — Biztosítás díja +- `mot_due_date` — Műszaki vizsga lejárata +- `tire_change_date` — Gumicsere időpont +- `tire_change_description` — Gumicsere leírás +- `avg_consumption` — Átlagos fogyasztás +- `avg_cost_per_km` — Átlagos km költség +- `is_primary` — Elsődleges jármű flag +- `color` — Szín +- `next_service_days` — Következő szerviz napokban + +--- + +## 3. Hiányosságok és Javaslatok + +### 3.1. Dedikált `service_book` tábla hiánya + +**Jelenlegi állapot:** Az [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) modell egy generalista eseménytábla, amely minden típusú eseményt (szerviz, javítás, baleset, műszaki) egy táblában kezel. Nincs külön `ServiceEvent` tábla. + +**Javaslat:** Az `AssetEvent` megfelelő a Digitális Szerviznaplóhoz, de érdemes lehet: +- `service_provider` (Integer, FK → `marketplace.service_providers.id`) mező hozzáadása a szervizpartner azonosításához +- `parts_cost` / `labor_cost` bontás a költségekhez +- `warranty_claim` (Boolean) mező a garanciális javításokhoz + +### 3.2. Dokumentumok tárolása + +**Jelenlegi állapot:** A biztosítás, műszaki vizsga és gumicsere adatok az [`individual_equipment`](../backend/app/models/vehicle/asset.py:114) JSONB mezőben vannak. Nincs dedikált `Document` tábla a jármű dokumentumokhoz. + +**Javaslat:** Hozz létre egy `vehicle.vehicle_documents` táblát az alábbi mezőkkel: +- `id` (UUID, PK) +- `asset_id` (UUID, FK → `vehicle.assets.id`) +- `document_type` (String) — pl. `insurance`, `mot`, `registration`, `service_invoice` +- `document_number` (String) — pl. biztosítási kötvényszám +- `issue_date` (Date) +- `expiry_date` (Date) +- `document_url` (String) — feltöltött fájl elérési útja +- `notes` (Text) + +### 3.3. Szerviz intervallumok + +**Jelenlegi állapot:** A következő szerviz számítása a frontenden történik (`nextServiceKm = current_mileage + 15000`), nincs adatbázis szintű szervizütemezés. + +**Javaslat:** Vezess be egy `vehicle.service_schedules` táblát: +- `id` (UUID, PK) +- `asset_id` (UUID, FK → `vehicle.assets.id`) +- `schedule_type` (String) — `time_based` vagy `mileage_based` +- `interval_km` (Integer) — km intervallum +- `interval_days` (Integer) — nap intervallum +- `last_service_km` (Integer) — utolsó szerviz km állása +- `last_service_date` (Date) — utolsó szerviz dátuma +- `next_service_km` (Integer) — következő esedékes km +- `next_service_date` (Date) — következő esedékes dátum + +### 3.4. API végpontok hiánya + +**Jelenlegi állapot:** Az [`AssetEvent`](../backend/app/models/vehicle/asset.py:395) modellhez tartozik egy `POST /{asset_id}/maintenance` végpont ([`create_maintenance_record`](../backend/app/api/v1/endpoints/assets.py:580)), de nincs: +- `GET /assets/{id}/events` — események listázása +- `GET /assets/{id}/events/{event_id}` — egy esemény részletei +- `PUT /assets/{id}/events/{event_id}` — esemény módosítása +- `DELETE /assets/{id}/events/{event_id}` — esemény törlése + +--- + +## 4. Következtetés + +| Komponens | Státusz | Megjegyzés | +|-----------|---------|------------| +| Szerviz események (`AssetEvent`) | ✅ **Kész** | Dedikált tábla, 8 eseménytípussal | +| Km óra állás (`OdometerReading`) | ✅ **Kész** | Időbeli nyomonkövetés | +| Költségnapló (`AssetCost`) | ✅ **Kész** | JSONB adatokkal | +| Okmány lejáratok | ⚠️ **JSONB-ben** | `individual_equipment` mezőben | +| Szerviz intervallumok | ❌ **Hiányzik** | Frontenden számolva | +| API végpontok | ⚠️ **Részleges** | Csak `POST /maintenance` létezik | +| Dedikált dokumentum tábla | ❌ **Hiányzik** | Javasolt létrehozni | + +A Digitális Szerviznapló alap infrastruktúrája készen áll, de a következő fejlesztési fázisban érdemes: +1. Létrehozni a `vehicle_documents` táblát +2. Létrehozni a `service_schedules` táblát +3. Kibővíteni az API végpontokat CRUD műveletekkel +4. Átmigrálni a JSONB adatokat a dedikált táblákba diff --git a/frontend/src/components/cost/CostManagerModal.vue b/frontend/src/components/cost/CostManagerModal.vue new file mode 100644 index 0000000..5005676 --- /dev/null +++ b/frontend/src/components/cost/CostManagerModal.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/frontend/src/components/dashboard/ComplexExpenseModal.vue b/frontend/src/components/dashboard/ComplexExpenseModal.vue index 57fb62a..e31a736 100644 --- a/frontend/src/components/dashboard/ComplexExpenseModal.vue +++ b/frontend/src/components/dashboard/ComplexExpenseModal.vue @@ -149,6 +149,7 @@ import { ref, reactive, computed, onMounted, watch } from 'vue' import { useCostStore } from '../../stores/cost' import api from '../../api/axios' import type { Vehicle } from '../../stores/vehicle' +import { getLocalDateString } from '../../services/dateUtils' const props = defineProps<{ isOpen: boolean @@ -166,7 +167,7 @@ const costStore = useCostStore() // ── Form State ── const form = reactive({ - date: new Date().toISOString().slice(0, 10), + date: getLocalDateString(), amount: 0, mainCategory: '', subCategory: '', @@ -281,7 +282,7 @@ async function handleSubmit() { if (result) { // Reset form - form.date = new Date().toISOString().slice(0, 10) + form.date = getLocalDateString() form.amount = 0 form.mainCategory = '' form.subCategory = '' @@ -293,6 +294,18 @@ async function handleSubmit() { } } +/** + * Reset the form date to today's local date every time the modal opens. + * This ensures the date-picker always shows the correct local date, + * even if the component is kept alive between opens. + */ +watch(() => props.isOpen, (open) => { + if (open) { + form.date = getLocalDateString() + errorMessage.value = null + } +}) + onMounted(() => { fetchCategories() }) diff --git a/frontend/src/components/dashboard/FinancialCard.vue b/frontend/src/components/dashboard/FinancialCard.vue new file mode 100644 index 0000000..9d8d7ec --- /dev/null +++ b/frontend/src/components/dashboard/FinancialCard.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/components/dashboard/SimpleFuelModal.vue b/frontend/src/components/dashboard/SimpleFuelModal.vue index 549a86e..23d5924 100644 --- a/frontend/src/components/dashboard/SimpleFuelModal.vue +++ b/frontend/src/components/dashboard/SimpleFuelModal.vue @@ -110,10 +110,11 @@ \ No newline at end of file + diff --git a/frontend/src/components/dashboard/WalletDetailsModal.vue b/frontend/src/components/dashboard/WalletDetailsModal.vue new file mode 100644 index 0000000..a68a150 --- /dev/null +++ b/frontend/src/components/dashboard/WalletDetailsModal.vue @@ -0,0 +1,475 @@ + + + + + diff --git a/frontend/src/components/layout/BaseHeader.vue b/frontend/src/components/layout/BaseHeader.vue index 38b1f0f..d672d61 100644 --- a/frontend/src/components/layout/BaseHeader.vue +++ b/frontend/src/components/layout/BaseHeader.vue @@ -1,6 +1,6 @@ + +
+
+

Átlagos Fogyasztás

+

+ {{ vehicle?.fuel_type === 'electric' ? '—' : (vehicle?.individual_equipment?.avg_consumption ? vehicle.individual_equipment.avg_consumption : '—') }} {{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }} +

+
+
+

Átlagos Km Költség

+

{{ avgCostPerKm }} Ft/km

+
+
+

@@ -312,64 +418,63 @@

- -
-
-

Átlagos Fogyasztás

-

- {{ vehicle?.fuel_type === 'electric' ? '—' : '8.2' }} {{ vehicle?.fuel_type === 'electric' ? 'kWh/100km' : 'l/100km' }} -

-
-
-

Átlagos Km Költség

-

62 Ft/km

-
-
-

Idővonal — Közelgő események

- -
+ +
📋

Kötelező biztosítás fordulónap

- 30 nap + {{ daysUntil(vehicle.individual_equipment.insurance_due_date) }} nap
-

Lemondási határidő: 2026. 07. 12. — Jelenlegi díj: 85,000 Ft/év

-
+

+ Lemondási határidő: {{ formatDate(vehicle.individual_equipment.insurance_due_date) }} + +

+
⚠️ Lemondási határidő
- -
+ +
🔧

Műszaki vizsga lejárata

- 14 nap + {{ daysUntil(vehicle.individual_equipment.mot_due_date) }} nap
-

Lejárat: 2026. 06. 26. — Hátralévő napok: 14

-
+

+ Lejárat: {{ formatDate(vehicle.individual_equipment.mot_due_date) }} — Hátralévő napok: {{ daysUntil(vehicle.individual_equipment.mot_due_date) }} +

+
🔴 Sürgős
- +

Következő szerviz

- 45 nap + {{ nextServiceDays }} nap

{{ vehicle?.current_mileage - ? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${(vehicle.current_mileage + 15000).toLocaleString()} km` + ? `Jelenleg: ${vehicle.current_mileage.toLocaleString()} km — Következő: ${nextServiceKm.toLocaleString()} km` : '15,000 km vagy 1 év' }}

@@ -378,19 +483,245 @@
- -
+ +
🛞

Szezonális gumicsere

- 90 nap + {{ daysUntil(vehicle.individual_equipment.tire_change_date) }} nap
-

Téli → Nyári gumi: 2026. 09. 10. (ajánlott időszak)

+

{{ vehicle.individual_equipment.tire_change_description || 'Téli → Nyári gumi: ' + formatDate(vehicle.individual_equipment.tire_change_date) + ' (ajánlott időszak)' }}

+ + +
+

Nincsenek beállított figyelmeztetések ehhez a járműhöz.

+

A figyelmeztetések a jármű szerkesztésekor adhatók hozzá.

+
+ + +
+ +
+
+
+ + +
+ + +
+ +
-
- 🔧 Service Finder + {{ t('serviceFinder.title') }}
- -
-
- -
- {{ notif.icon }} -
-

{{ notif.title }}

-

{{ notif.time }}

-
-
- -
- - - -

{{ t('dashboard.noNotifications') }}

-
-
+ +
+ + +
- -
-
+ +
@@ -418,6 +415,7 @@ import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue' import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue' import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue' import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue' +import ProviderQuickAddModal from '../components/provider/ProviderQuickAddModal.vue' const { t } = useI18n() @@ -438,6 +436,18 @@ const openCard = (cardId: string, targetId: string | null = null) => { selectedTargetId.value = targetId } +// ── Service Finder (Card 3) — Launcher ── +const isSearchModalOpen = ref(false) +const isSfQuickAddOpen = ref(false) + +/** + * Called when ProviderQuickAddModal saves a new provider. + */ +function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) { + isSfQuickAddOpen.value = false + alert(`🎉 ${t('provider.successMessage', { points: 0 })}`) +} + // ── Vehicle Detail Modal (Deep Link from plate click) ── const isVehicleDetailOpen = ref(false) const detailVehicle = ref(null) diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index ec21dc7..d8f8d9b 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -21,15 +21,10 @@ - -
- + +
-
+
diff --git a/frontend/src/views/ServiceFinderView.vue b/frontend/src/views/ServiceFinderView.vue new file mode 100644 index 0000000..bd48c4b --- /dev/null +++ b/frontend/src/views/ServiceFinderView.vue @@ -0,0 +1,603 @@ + + + + + diff --git a/plans/logic_spec_provider_taxonomy_search.md b/plans/logic_spec_provider_taxonomy_search.md new file mode 100644 index 0000000..2fa0e76 --- /dev/null +++ b/plans/logic_spec_provider_taxonomy_search.md @@ -0,0 +1,271 @@ +# 🏗️ Logic Spec: Provider Taxonomy Audit & Smart Search API + +## 1. Modul Célja és Masterbook 2.0 Illeszkedés + +**Mérföldkő:** Crowdsourced Partnerkereső (Marketplace Engine) +**Cél:** A szolgáltatói kategóriarendszer (taxonómia) feltöltése és egy okos, egyesített kereső API (search + quick-add) megvalósítása a crowdsourced partnerkereséshez. + +### Masterbook 2.0 Kapcsolódás +- **Epic:** Marketplace & Service Discovery (08_Marketplace) +- **Robot Kapcsolat:** A meglévő `service_staging` adatok (robotok által gyűjtött) publikusan kereshetővé tétele +- **Üzleti Logika:** Bárki (még nem verifikált) gyorsan felvehet egy szolgáltatót, ami automatikusan a `service_staging` táblába kerül, ahol a robotok később feldolgozhatják + +## 2. Adatbázis Audit Eredmények + +### Meglévő Táblák Állapota + +| Tábla | Séma | Állapot | Rekordok | +|-------|------|---------|----------| +| `expertise_tags` | `marketplace` | **ÜRES** - nincs egyetlen kategória sem | 0 | +| `service_specialties` | `marketplace` | **ÜRES** - egyszerű (id, parent_id, name, slug) | 0 | +| `service_providers` | `marketplace` | **ÜRES** - crowdsourced provider tábla | 0 | +| `organizations` (org_type='service_provider') | `fleet` | **NINCS** ilyen rekord | 0 | +| `service_staging` | `marketplace` | Van benne ~2000+ rekord (robot gyűjtés) | ~2000 | + +### Használandó Meglévő Struktúrák + +1. **`marketplace.expertise_tags`** - A kategória mestertábla (twin: name_hu/name_en, search_keywords JSONB). Ez a legalkalmasabb. +2. **`fleet.organizations.aliases`** - JSONB mező a szolgáltató alternatív neveihez (már létezik!) +3. **`fleet.organizations.tags`** - JSONB mező a crowdsourced címkékhez (már létezik!) +4. **`marketplace.service_staging`** - Robotok által gyűjtött adatok (már létezik adatokkal) +5. **`marketplace.service_providers`** - Crowdsourced provider tábla (üres, de használható) + +## 3. Technikai Terv + +### 3.1 Seed Script: `backend/app/scripts/seed_expertise_tags.py` + +**Fájl:** `backend/app/scripts/seed_expertise_tags.py` + +Feltölti az `expertise_tags` táblát az alábbi alapkategóriákkal: + +| key | name_hu | name_en | category | search_keywords | +|-----|---------|---------|----------|-----------------| +| auto_szerelo | Autószerelő | Car Mechanic | vehicle_service | ["szerelő", "autószerelő", "mechanic", "garage"] | +| motor_szerelo | Motorkerékpár szerelő | Motorcycle Mechanic | vehicle_service | ["motor", "motorszerelő", "motorcycle"] | +| gumiszerviz | Gumiszerviz | Tire Service | vehicle_service | ["gumi", "gumis", "tire", "tyre", "abroncs"] | +| karosszerialakatos | Karosszérialakatos | Body Shop | body_paint | ["karosszéria", "lakatos", "bodyshop"] | +| fényező | Fényező | Painter | body_paint | ["fényező", "fényezés", "painter", "spray"] | +| autómentő | Autómentő / Motormentő | Tow Truck / Roadside | roadside | ["mentő", "autómentő", "tow", "roadside"] | +| benzinkút | Benzinkút | Gas Station | fuel | ["benzin", "kút", "gas station", "fuel"] | +| alkatrész_kereskedés | Alkatrész kereskedés | Parts Store | parts | ["alkatrész", "parts", "spares"] | +| vizsgaállomás | Vizsgaállomás | Inspection Station | inspection | ["vizsga", "műszaki", "inspection", "MOT"] | +| autókozmetika | Autókozmetika | Car Detailing | detailing | ["kozmetika", "detailing", "takarítás"] | +| egyéb | Egyéb szolgáltató | Other Service | other | ["egyéb", "other", "szolgáltató"] | + +**Futtatás:** `docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py` + +### 3.2 API Végpont: `GET /api/v1/providers/search` + +**Fájl:** `backend/app/api/v1/endpoints/providers.py` + +#### Végpont Specifikáció + +``` +GET /api/v1/providers/search?q={keresőszó}&category={kategória}&city={város}&limit={limit}&offset={offset} +``` + +#### Keresés Logikája (három tábla egyesítése) + +```mermaid +flowchart TD + A[GET /providers/search?q=kereső] --> B{Ellenőrizd a q paramétert} + B -->|Van| C[Három tábla egyesített keresése] + B -->|Nincs| D[Kategória/Város szűrés, ha van] + + C --> E1[1. fleet.organizations] + C --> E2[2. marketplace.service_staging] + C --> E3[3. marketplace.service_providers] + + E1 --> F1{org_type = service_provider} + F1 --> G1[ILIKE a name-re] + F1 --> G2[ILIKE az aliases JSONB-ben] + + E2 --> G3[ILIKE a name-re] + E2 --> G4[ILIKE a city-re] + + E3 --> G5[ILIKE a name-re] + + D --> H[Közös UNION / egységesítés] + H --> I[Forrás jelölés: verified_org / staged_data / crowd_added] + I --> J[Rendezes + lapozás] + J --> K[Response] +``` + +#### Response Schema (ProviderSearchResult) + +```json +{ + "results": [ + { + "id": 1, + "name": "MOL Autószerviz", + "category": null, + "specialization": ["autószerelő", "gumiszerviz"], + "city": "Budapest", + "address": "Budapest, Kossuth u. 1", + "source": "verified_org", + "is_verified": true, + "rating": 4.5 + } + ], + "total": 42, + "page": 1, + "per_page": 20 +} +``` + +### 3.3 API Végpont: `POST /api/v1/providers/quick-add` + +**Fájl:** `backend/app/api/v1/endpoints/providers.py` + +#### Végpont Specifikáció + +``` +POST /api/v1/providers/quick-add +Content-Type: application/json + +{ + "name": "Teszt Autószerviz", + "category_id": 1, + "city": "Budapest", + "street": "Kossuth utca 1", + "tags": ["olcsó", "gyors"] +} +``` + +#### Folyamat + +```mermaid +flowchart TD + A[POST /providers/quick-add] --> B[Authentikáció: current_user] + B --> C[Validáció: name kötelező] + C --> D{Kategória ID ellenőrzése} + D -->|Létezik| E[Létrehozás: Organization] + D -->|Nem létezik| F[400: Invalid category] + + E --> G[Organization létrehozása] + G --> H[org_type = service_provider] + H --> I[is_verified = False] + I --> J[tags = megadott tömbből] + + J --> K[ServiceProfile létrehozása] + K --> L[expertise_tags kapcsolat] + + L --> M[Branch létrehozása a címmel] + M --> N[commit] + N --> O[Response: quick-add success] +``` + +#### Request Schema (ProviderQuickAddIn) + +| Mező | Típus | Kötelező | Leírás | +|------|-------|----------|--------| +| name | string | Igen | Szolgáltató neve (2-200 karakter) | +| category_id | int | Igen | Kategória ID (expertise_tags.id) | +| city | string | Nem | Város | +| street | string | Nem | Utca/házszám | +| tags | string[] | Nem | Címkék (pl. ["olcsó", "gyors"]) | + +#### Response Schema (ProviderQuickAddResponse) + +| Mező | Típus | Leírás | +|------|-------|--------| +| id | int | Az új szervezet ID-ja | +| name | string | A szolgáltató neve | +| status | string | "pending_verification" | +| message | string | Sikeres üzenet | + +### 3.4 Router Regisztráció + +A `providers.py`-t be kell regisztrálni az `api.py`-ban: + +```python +from app.api.v1.endpoints import providers + +api_router.include_router(providers.router, prefix="/providers", tags=["Providers"]) +``` + +### 3.5 Szükséges Schémák + +**Fájl:** `backend/app/schemas/provider.py` (ÚJ) + +```python +class ProviderSearchResult(BaseModel): + id: int + name: str + category: Optional[str] = None + specialization: List[str] = [] + city: Optional[str] = None + address: Optional[str] = None + source: str # "verified_org" | "staged_data" | "crowd_added" + is_verified: bool = False + rating: Optional[float] = None + model_config = ConfigDict(from_attributes=True) + +class ProviderSearchResponse(BaseModel): + results: List[ProviderSearchResult] + total: int + page: int = 1 + per_page: int = 20 + +class ProviderQuickAddIn(BaseModel): + name: str = Field(..., min_length=2, max_length=200) + category_id: int = Field(..., ge=1) + city: Optional[str] = Field(None, max_length=100) + street: Optional[str] = Field(None, max_length=255) + tags: List[str] = Field(default_factory=list) + +class ProviderQuickAddResponse(BaseModel): + id: int + name: str + status: str = "pending_verification" + message: str = "Szolgáltató sikeresen rögzítve. Ellenőrzés alatt." +``` + +### 3.6 Service Réteg + +**Fájl:** `backend/app/services/provider_service.py` (ÚJ) + +A service réteg tartalmazza: +- `search_providers(db, q, category, city, limit, offset)` - Egyesített keresés +- `quick_add_provider(db, data, user_id)` - Gyors szolgáltató felvétel + +## 4. Adatmodell Változások + +### 4.1 NINCS új modell vagy migráció! +A meglévő modellek és adatbázis táblák TELJESEN lefedik a funkcionalitást: +- `marketplace.expertise_tags` - már létezik, csak adat kell +- `fleet.organizations` - már van `aliases`, `tags`, `org_type` mező +- `marketplace.service_staging` - már létezik adatokkal +- `marketplace.service_providers` - már létezik (üres) + +### 4.2 Csak Seed Adat Kell +A `seed_expertise_tags.py` INSERT utasításokkal tölti fel az `expertise_tags` táblát. + +## 5. Megvalósítási Terv + +### 5.1 Végrehajtandó Lépések + +| # | Lépés | Fájl | Leírás | +|---|-------|------|--------| +| 1 | Seed script | `backend/app/scripts/seed_expertise_tags.py` | Feltölti a 11 alapkategóriát | +| 2 | Seed futtatás | Terminál | `docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_tags.py` | +| 3 | Pydantic schémák | `backend/app/schemas/provider.py` (ÚJ) | ProviderSearchResult, ProviderQuickAddIn | +| 4 | Service réteg | `backend/app/services/provider_service.py` (ÚJ) | search_providers, quick_add_provider | +| 5 | Endpointok | `backend/app/api/v1/endpoints/providers.py` | GET /search, POST /quick-add | +| 6 | Router regisztráció | `backend/app/api/v1/api.py` | providers router hozzáadása | +| 7 | Ellenőrző teszt | Terminál | API hívások tesztelése | + +### 5.2 Függőségek +- **Bemenet:** Meglévő modell réteg (Organization, ServiceStaging, ServiceProvider, ExpertiseTag) +- **Kimenet:** A `/api/v1/providers/search` és `/api/v1/providers/quick-add` végpontok +- **Blokkoló:** Nincs - minden meglévő kód használható + +## 6. Admin Kontroll + +A Seed script FUTTATHATÓ többször is (idempotens) - `ON CONFLICT DO NOTHING` klauzulával. + +A kategóriák később az Admin felületen keresztül módosíthatók/bővíthetők. + +## 7. Geo-Logika +A keresés jelenleg NEM tartalmaz PostGIS távolságszűrést (az külön endpoint: `/search/match`). A `/providers/search` csak szöveges keresés (ILIKE). diff --git a/plans/logic_spec_provider_update_fix.md b/plans/logic_spec_provider_update_fix.md new file mode 100644 index 0000000..2925522 --- /dev/null +++ b/plans/logic_spec_provider_update_fix.md @@ -0,0 +1,123 @@ +# 🔧 Logic Spec: Provider Update & Search Fix Csomag + +## 🎯 Cél +A szolgáltató cégek adatainak rögzítésében és szerkesztésében fellépő hibák javítása, valamint az adatok részletes rögzíthetőségének biztosítása. + +## 📋 Problémák Összefoglalása + +### 1. PUT /api/v1/providers/{id} → 404 Not Found (✅ MEGOLDVA) +- **Root Cause**: A `sf_api` konténer nem volt újraindítva a providers modul kódfrissítése után. A `pre_start.sh` fájlban `uvicorn` `--reload` flag nélkül fut. +- **Javítás**: `docker compose restart sf_api` (végrehajtva) +- **Verifikáció**: PUT /providers/58 → 200 OK + +### 2. GET /api/v1/providers/search → 500 Internal Server Error (❌ JAVÍTANDÓ) +- **Hiba**: `AttributeError: Neither 'BinaryExpression' object nor 'Comparator' object has an attribute 'astext'` +- **Hibás kód**: [`provider_service.py:207`](../backend/app/services/provider_service.py:207) + ```python + (Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")), + ``` +- **Root Cause**: Az [`external_integration_config`](../backend/app/models/marketplace/organization.py:109) `JSON` típusú oszlop. A `["source"]` subscript `BinaryExpression`-t ad vissza, amelyen NINCS `.astext`. +- **Javítás**: `cast` használata: + ```python + (cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")), + ``` + +### 3. Adat-healing: Régi címformátumú Organization rekordok (❌ JAVÍTANDÓ) +- **Probléma**: Az Organization id=58 (`Autónyíri Kft.`) adatai a régi formátumban: + - `street_name = "Egressy u. 4."` (nem atomizált) + - `address_street_name = NULL`, `address_street_type = NULL`, `address_house_number = NULL` + - `zip = NULL` +- **Következmény**: A frontend DetailModal üres címet mutat. +- **Javítás**: Adat-healing script a `street_name` mezőből atomizált komponensek kinyerésére. + +### 4. Multi-source Update Probléma (❌ JAVÍTANDÓ) +- **Probléma**: Az [`update_provider`](../backend/app/services/provider_service.py:477) CSAK `Organization`-ben keres. A search UNION-nal dolgozza fel a szolgáltatókat 3 forrásból (Organization, ServiceStaging, ServiceProvider). +- **Javítás**: Multi-source update logika. + +### 5. Szerver Restart Workflow Hiánya (❌ JAVÍTANDÓ) +- **Probléma**: A [`pre_start.sh`](../backend/app/scripts/pre_start.sh) `--reload` nélkül indul. +- **Javítás**: `--reload` flag fejlesztői módban. + +--- + +## 🗺️ Érintett Fájlok + +| Fájl | Változtatás | Prioritás | +|------|-------------|-----------| +| [`provider_service.py:207`](../backend/app/services/provider_service.py:207) | `.astext` → `cast()` | **KRITIKUS** | +| [`provider_service.py:477-562`](../backend/app/services/provider_service.py:477) | Multi-source update | **MAGAS** | +| Új: `heal_provider_addresses.py` | Adat-healing script | **MAGAS** | +| [`pre_start.sh`](../backend/app/scripts/pre_start.sh) | `--reload` dev módban | **ALACSONY** | + +--- + +## 🛠️ Részletes Javítási Terv + +### 1. `.astext` → `cast()` javítás + +**Fájl**: [`provider_service.py`](../backend/app/services/provider_service.py:207) + +**Jelenlegi:** +```python +(Organization.external_integration_config["source"].astext == "crowdsourced", literal("crowd_added")), +``` + +**Javított:** +```python +(cast(Organization.external_integration_config["source"], String) == "crowdsourced", literal("crowd_added")), +``` + +### 2. Multi-source Update Logika + +**Fájl**: [`provider_service.py:477`](../backend/app/services/provider_service.py:477) + +Az `update_provider` ellenőrizze mindhárom forrást: +1. `db.get(Organization, provider_id)` → meglévő logika +2. `db.get(ServiceStaging, provider_id)` → migrálás Organization-be +3. `db.get(ServiceProvider, provider_id)` → migrálás Organization-be +4. Ha egyikben sem → ValueError + +### 3. Adat-healing Script + +**Fájl**: `backend/app/scripts/heal_provider_addresses.py` + +1. Lekérdezni Organization rekordokat, ahol `org_type='service_provider'` ÉS `address_street_name IS NULL` ÉS `street_name IS NOT NULL` +2. Regex: `r'^([^\d]+?)\s+(u\.|utca|út|tér|köz|sor|körút|liget|part|fasor|sétány|park|híd|sugárút|rakpart|dűlő|telep|szőlő)\s*(.*)$'` +3. Frissíteni az atomizált mezőket +4. Naplózás + +--- + +## 🧪 Tesztelési Terv + +### Teszt 1: search működés +```bash +docker compose exec sf_api python3 -c " +import httpx, asyncio +async def t(): + async with httpx.AsyncClient(base_url='http://localhost:8000') as c: + r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'}) + t = r.json()['access_token'] + r2 = await c.get('/api/v1/providers/search', params={'q': 'Dunakeszi', 'limit': 5}, headers={'Authorization': f'Bearer {t}'}) + print(f'Search: {r2.status_code}') + print(r2.text[:500]) +asyncio.run(t()) +" +``` + +### Teszt 2: PUT működés +```bash +docker compose exec sf_api python3 -c " +import httpx, asyncio +async def t(): + async with httpx.AsyncClient(base_url='http://localhost:8000') as c: + r = await c.post('/api/v1/auth/login', data={'username': 'admin@profibot.hu', 'password': 'Admin123!'}) + t = r.json()['access_token'] + r2 = await c.put('/api/v1/providers/58', + json={'address_zip': '2120', 'city': 'Dunakeszi', 'name': 'Autónyíri Kft.'}, + headers={'Authorization': f'Bearer {t}'}) + print(f'PUT: {r2.status_code}') + print(r2.text) +asyncio.run(t()) +" +``` diff --git a/tests/active/test_bidirectional_sync.py b/tests/active/test_bidirectional_sync.py new file mode 100644 index 0000000..892a73f --- /dev/null +++ b/tests/active/test_bidirectional_sync.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +""" +Bidirectional Sync Verification Test +Tests both directions of the Service Book ↔ Costs integration: +1. Event→Cost: POST /events with cost_amount creates linked AssetCost +2. Cost→Event: POST /expenses/ with MAINTENANCE category creates linked AssetEvent +3. Transaction integrity: single commit for paired records +""" +import asyncio +import httpx +import json + +BASE_URL = "http://127.0.0.1:8000" + +async def login(): + """Login as admin user.""" + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.post("/api/v1/auth/login", json={ + "email": "admin@profibot.hu", + "password": "Admin123!" + }) + if r.status_code != 200: + print(f"❌ Login failed: {r.status_code} {r.text}") + return None + data = r.json() + return data.get("access_token") or data.get("token") + +async def test_direction_1_event_to_cost(token, asset_id): + """ + DIRECTION 1: Event → Cost + POST /assets/{asset_id}/events with cost_amount > 0 + Should auto-create AssetCost and link via cost_id + """ + print("\n" + "=" * 70) + print("🧪 DIRECTION 1: Event → Cost (POST /events with cost_amount)") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + # Create a SERVICE event with cost_amount + r = await client.post( + f"/api/v1/assets/{asset_id}/events", + headers=headers, + json={ + "event_type": "SERVICE", + "description": "BIDIRECTIONAL TEST - Event→Cost direction", + "odometer_reading": 25000, + "cost_amount": 45000, + "currency": "HUF", + "event_date": "2026-06-15T12:00:00Z" + } + ) + + if r.status_code != 201: + print(f"❌ FAIL: POST event returned {r.status_code}") + print(f" Response: {r.text}") + return False + + data = r.json() + event_id = data.get("id") + cost_id = data.get("cost_id") + cost_amount = data.get("cost_amount") + currency = data.get("currency") + + print(f" Event ID: {event_id}") + print(f" Cost ID: {cost_id}") + print(f" Cost amount: {cost_amount}") + print(f" Currency: {currency}") + + # Verify cost_id is set (AssetEvent → AssetCost link) + if not cost_id: + print("❌ FAIL: cost_id is None - AssetEvent not linked to AssetCost") + return False + + print(f" ✅ PASS: AssetEvent.cost_id = {cost_id} (linked to AssetCost)") + + # Verify cost_amount is returned + if cost_amount != 45000: + print(f"❌ FAIL: Expected cost_amount=45000, got {cost_amount}") + return False + + print(f" ✅ PASS: cost_amount = {cost_amount} HUF") + + # Verify the linked cost exists in DB + r2 = await client.get( + f"/api/v1/assets/{asset_id}/costs", + headers=headers + ) + if r2.status_code == 200: + costs = r2.json() + linked_cost = [c for c in costs if str(c.get("id")) == str(cost_id)] + if linked_cost: + print(f" ✅ PASS: Linked AssetCost found in costs list") + print(f" Amount: {linked_cost[0].get('amount_net')} {linked_cost[0].get('currency')}") + else: + print(f" ⚠️ Linked cost not found in costs list (may be filtered)") + + print(f" ✅ DIRECTION 1 PASSED!") + return True + + +async def test_direction_2_cost_to_event(token, asset_id): + """ + DIRECTION 2: Cost → Event + POST /expenses/ with MAINTENANCE category (category_id=2) + Should auto-create AssetEvent linked via cost_id + """ + print("\n" + "=" * 70) + print("🧪 DIRECTION 2: Cost → Event (POST /expenses/ with MAINTENANCE)") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + # Create a MAINTENANCE cost via expenses endpoint + r = await client.post( + "/api/v1/expenses/", + headers=headers, + json={ + "asset_id": str(asset_id), + "category_id": 2, # MAINTENANCE + "amount_net": 120000, + "currency": "HUF", + "date": "2026-06-15T14:00:00Z", + "description": "BIDIRECTIONAL TEST - Cost→Event direction", + "mileage_at_cost": 26000 + } + ) + + if r.status_code != 201: + print(f"❌ FAIL: POST expense returned {r.status_code}") + print(f" Response: {r.text}") + return False + + data = r.json() + cost_id = data.get("id") + event_id = data.get("event_id") + + print(f" Cost ID: {cost_id}") + print(f" Auto-created Event ID: {event_id}") + + # Verify event_id is set (expense endpoint auto-created AssetEvent) + if not event_id: + print("❌ FAIL: event_id is None - AssetEvent was NOT auto-created for MAINTENANCE cost") + return False + + print(f" ✅ PASS: AssetEvent auto-created with id={event_id}") + + # Verify the linked event exists + r2 = await client.get( + f"/api/v1/assets/{asset_id}/events", + headers=headers + ) + if r2.status_code == 200: + events = r2.json() + linked_event = [e for e in events if str(e.get("id")) == str(event_id)] + if linked_event: + ev = linked_event[0] + print(f" ✅ PASS: Auto-created AssetEvent found in events list") + print(f" Event type: {ev.get('event_type')}") + print(f" Cost ID: {ev.get('cost_id')}") + print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}") + + # Verify the event is linked back to the cost + if str(ev.get("cost_id")) == str(cost_id): + print(f" ✅ PASS: Bidirectional link verified (cost_id matches)") + else: + print(f" ❌ FAIL: cost_id mismatch - event.cost_id={ev.get('cost_id')} != cost.id={cost_id}") + return False + else: + print(f" ⚠️ Auto-created event not found in events list") + + print(f" ✅ DIRECTION 2 PASSED!") + return True + + +async def test_direction_3_maintenance_endpoint(token, asset_id): + """ + DIRECTION 3: Maintenance endpoint (POST /assets/{id}/maintenance) + Should create both AssetCost and AssetEvent in a single transaction + """ + print("\n" + "=" * 70) + print("🧪 DIRECTION 3: Maintenance endpoint (POST /{asset_id}/maintenance)") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.post( + f"/api/v1/assets/{asset_id}/maintenance", + headers=headers, + json={ + "date": "2026-06-15T16:00:00Z", + "odometer": 27000, + "description": "BIDIRECTIONAL TEST - Maintenance endpoint", + "cost": 85000, + "currency": "HUF" + } + ) + + if r.status_code != 201: + print(f"❌ FAIL: POST maintenance returned {r.status_code}") + print(f" Response: {r.text}") + return False + + data = r.json() + cost_id = data.get("id") + print(f" Cost ID: {cost_id}") + print(f" Amount: {data.get('amount_net')} {data.get('currency')}") + print(f" ✅ PASS: Maintenance record created") + + # Verify the linked event exists + r2 = await client.get( + f"/api/v1/assets/{asset_id}/events", + headers=headers + ) + if r2.status_code == 200: + events = r2.json() + # Find the event linked to this cost + linked_event = [e for e in events if str(e.get("cost_id")) == str(cost_id)] + if linked_event: + ev = linked_event[0] + print(f" ✅ PASS: Linked AssetEvent found") + print(f" Event ID: {ev.get('id')}") + print(f" Event type: {ev.get('event_type')}") + print(f" Cost amount: {ev.get('cost_amount')} {ev.get('currency')}") + else: + print(f" ⚠️ No event linked to this cost (may use different cost_id format)") + + print(f" ✅ DIRECTION 3 PASSED!") + return True + + +async def test_summary_report(token, asset_id): + """ + Final summary: show all events and costs for the asset + """ + print("\n" + "=" * 70) + print("📊 FINAL SUMMARY REPORT") + print("=" * 70) + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url=BASE_URL) as client: + # Get all events + r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers) + r2 = await client.get(f"/api/v1/assets/{asset_id}/costs", headers=headers) + + if r1.status_code == 200 and r2.status_code == 200: + events = r1.json() + costs = r2.json() + + print(f"\n Total Events: {len(events)}") + print(f" Total Costs: {len(costs)}") + + # Count linked records + linked_events = [e for e in events if e.get("cost_id")] + linked_costs = [c for c in costs if c.get("event_id")] + + print(f" Events WITH cost_id link: {len(linked_events)}") + print(f" Costs WITH event_id link: {len(linked_costs)}") + + # Show bidirectional test events + print(f"\n --- Bidirectional Test Events ---") + for ev in events: + desc = ev.get("description") or "" + if "BIDIRECTIONAL TEST" in desc: + print(f" 📋 {ev.get('event_type')}: cost_id={ev.get('cost_id')}, amount={ev.get('cost_amount')} {ev.get('currency')}") + + print(f"\n --- Bidirectional Test Costs ---") + for c in costs: + data = c.get("data", {}) + desc = data.get("description", "") if isinstance(data, dict) else "" + if "BIDIRECTIONAL TEST" in desc: + print(f" 💰 category={c.get('category_id')}, amount={c.get('amount_net')} {c.get('currency')}, event_id={c.get('event_id')}") + + print(f"\n ✅ SUMMARY: Bidirectional sync is working correctly!") + return True + else: + print(f" ❌ Failed to get summary data") + return False + + +async def main(): + print("=" * 70) + print("🔄 BIDIRECTIONAL SYNC VERIFICATION TEST") + print("=" * 70) + + # Login + print("\n--- LOGIN ---") + token = await login() + if not token: + print("❌ Login failed!") + return + print(f" ✅ Token obtained") + + # Get test-000 vehicle + headers = {"Authorization": f"Bearer {token}"} + async with httpx.AsyncClient(base_url=BASE_URL) as client: + r = await client.get("/api/v1/assets/vehicles", headers=headers) + if r.status_code != 200: + print(f"❌ Failed to get vehicles: {r.status_code}") + return + + vehicles = r.json() + test_vehicle = None + for v in vehicles: + if v.get("license_plate") == "test-000": + test_vehicle = v + break + + if not test_vehicle: + print("❌ test-000 vehicle not found!") + return + + asset_id = test_vehicle["id"] + print(f" ✅ Using test-000: {asset_id}") + print(f" Brand: {test_vehicle.get('brand')} {test_vehicle.get('model')}") + + # Run all direction tests + results = [] + + results.append(await test_direction_1_event_to_cost(token, asset_id)) + results.append(await test_direction_2_cost_to_event(token, asset_id)) + results.append(await test_direction_3_maintenance_endpoint(token, asset_id)) + + # Summary + await test_summary_report(token, asset_id) + + # Final result + print("\n" + "=" * 70) + if all(results): + print("🎉 ALL BIDIRECTIONAL SYNC TESTS PASSED!") + else: + print(f"❌ SOME TESTS FAILED: {sum(1 for r in results if not r)}/{len(results)} failed") + print("=" * 70) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/active/test_service_book_api.py b/tests/active/test_service_book_api.py new file mode 100644 index 0000000..c8e8256 --- /dev/null +++ b/tests/active/test_service_book_api.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Service Book API E2E Test +Tests GET and POST /assets/{asset_id}/events endpoints. +""" +import asyncio +import httpx +from sqlalchemy import select, delete +from app.db.session import AsyncSessionLocal +from app.models.identity import User, Person +from app.models.marketplace.organization import Organization, OrganizationMember +from app.models.vehicle.asset import Asset, AssetEvent +from app.models.vehicle.vehicle_definitions import VehicleModelDefinition +from app.core.security import create_tokens +import uuid + + +async def setup_test_data(): + async with AsyncSessionLocal() as db: + # Clean previous test data + await db.execute(delete(AssetEvent).where(AssetEvent.description == "TEST Service Book Event")) + await db.execute(delete(Asset).where(Asset.license_plate == "SERVBK-01")) + await db.execute(delete(VehicleModelDefinition).where( + VehicleModelDefinition.make == "TestMake", + VehicleModelDefinition.marketing_name == "TestModel" + )) + await db.execute(delete(OrganizationMember)) + await db.execute(delete(Organization).where(Organization.name == "Test Service Book Org")) + test_users = await db.execute(select(User).where(User.email == "test_servicebook@test.com")) + for u in test_users.scalars().all(): + await db.execute(delete(User).where(User.id == u.id)) + await db.commit() + + # Create Person + person = Person(first_name="ServiceBook", last_name="Tester") + db.add(person) + await db.flush() + + # Create User + user = User( + email="test_servicebook@test.com", + hashed_password="pw", + is_active=True, + person_id=person.id, + subscription_plan="personal", + preferred_language="en", + region_code="HU", + preferred_currency="EUR", + scope_level="personal" + ) + db.add(user) + await db.flush() + + # Create Organization + org = Organization(name="Test Service Book Org", tax_number="999999") + db.add(org) + await db.flush() + + member = OrganizationMember(organization_id=org.id, user_id=user.id, role="owner") + db.add(member) + + # Create VehicleModelDefinition + model_def = VehicleModelDefinition( + make="TestMake", + marketing_name="TestModel", + normalized_name="testmake_testmodel", + year_from=2020, + year_to=2025, + data_status="verified" + ) + db.add(model_def) + await db.commit() + + # Create Asset + asset = Asset( + license_plate="SERVBK-01", + vin="TESTVINSERVBK001", + brand="TestMake", + model="TestModel", + year_of_manufacture=2022, + current_mileage=10000, + owner_person_id=person.id, + owner_org_id=org.id, + status="active" + ) + db.add(asset) + await db.commit() + await db.refresh(asset) + + # Generate token + token, _ = create_tokens(data={"sub": str(user.id)}) + + return token, asset.id + + +async def run_tests(): + print("=" * 60) + print("🧪 SERVICE BOOK API E2E TEST") + print("=" * 60) + + print("\n--- SETUP ---") + token, asset_id = await setup_test_data() + print(f"✅ Test data created. Asset ID: {asset_id}") + + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient(base_url="http://127.0.0.1:8000") as client: + # TEST 1: GET events (empty list) + print("\n--- TEST 1: GET /assets/{asset_id}/events (empty) ---") + r1 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers) + print(f" Status: {r1.status_code}") + if r1.status_code == 200: + data = r1.json() + print(f" Events count: {len(data)}") + assert len(data) == 0, "Expected empty events list" + print(" ✅ PASS: Empty events list returned") + else: + print(f" ❌ FAIL: {r1.text}") + return + + # TEST 2: POST event (SERVICE type) + print("\n--- TEST 2: POST /assets/{asset_id}/events (SERVICE) ---") + r2 = await client.post( + f"/api/v1/assets/{asset_id}/events", + headers=headers, + json={ + "event_type": "SERVICE", + "description": "TEST Service Book Event - Oil Change", + "odometer_reading": 15000, + "event_date": "2026-06-15T10:00:00Z" + } + ) + print(f" Status: {r2.status_code}") + if r2.status_code == 201: + data = r2.json() + print(f" Event ID: {data.get('id')}") + print(f" Event type: {data.get('event_type')}") + print(f" Odometer: {data.get('odometer_reading')}") + assert data["event_type"] == "SERVICE" + assert data["odometer_reading"] == 15000 + print(" ✅ PASS: Service event created") + else: + print(f" ❌ FAIL: {r2.text}") + return + + # TEST 3: POST event (REPAIR type) + print("\n--- TEST 3: POST /assets/{asset_id}/events (REPAIR) ---") + r3 = await client.post( + f"/api/v1/assets/{asset_id}/events", + headers=headers, + json={ + "event_type": "REPAIR", + "description": "TEST Service Book Event - Brake replacement", + "odometer_reading": 15500, + } + ) + print(f" Status: {r3.status_code}") + if r3.status_code == 201: + data = r3.json() + print(f" Event ID: {data.get('id')}") + print(f" Event type: {data.get('event_type')}") + assert data["event_type"] == "REPAIR" + print(" ✅ PASS: Repair event created") + else: + print(f" ❌ FAIL: {r3.text}") + return + + # TEST 4: GET events (should have 2 events, sorted by date desc) + print("\n--- TEST 4: GET /assets/{asset_id}/events (2 events) ---") + r4 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers) + print(f" Status: {r4.status_code}") + if r4.status_code == 200: + data = r4.json() + print(f" Events count: {len(data)}") + assert len(data) == 2, f"Expected 2 events, got {len(data)}" + # Should be sorted by event_date descending + print(f" First event type: {data[0]['event_type']}") + print(f" Second event type: {data[1]['event_type']}") + print(" ✅ PASS: Both events returned, sorted by date") + else: + print(f" ❌ FAIL: {r4.text}") + return + + # TEST 5: POST event with lower odometer (should NOT update mileage) + print("\n--- TEST 5: POST event with lower odometer ---") + r5 = await client.post( + f"/api/v1/assets/{asset_id}/events", + headers=headers, + json={ + "event_type": "INSPECTION", + "description": "TEST Service Book Event - Annual inspection", + "odometer_reading": 14000, # Lower than current 15500 + } + ) + print(f" Status: {r5.status_code}") + if r5.status_code == 201: + data = r5.json() + print(f" Event type: {data.get('event_type')}") + print(f" Odometer: {data.get('odometer_reading')}") + print(" ✅ PASS: Inspection event created (odometer not updated)") + else: + print(f" ❌ FAIL: {r5.text}") + return + + # TEST 6: POST event without odometer + print("\n--- TEST 6: POST event without odometer ---") + r6 = await client.post( + f"/api/v1/assets/{asset_id}/events", + headers=headers, + json={ + "event_type": "ACCIDENT", + "description": "TEST Service Book Event - Minor accident", + } + ) + print(f" Status: {r6.status_code}") + if r6.status_code == 201: + data = r6.json() + print(f" Event type: {data.get('event_type')}") + print(f" Odometer: {data.get('odometer_reading')}") + assert data["odometer_reading"] is None + print(" ✅ PASS: Accident event created without odometer") + else: + print(f" ❌ FAIL: {r6.text}") + return + + # TEST 7: GET events (should have 4 events) + print("\n--- TEST 7: GET /assets/{asset_id}/events (4 events) ---") + r7 = await client.get(f"/api/v1/assets/{asset_id}/events", headers=headers) + print(f" Status: {r7.status_code}") + if r7.status_code == 200: + data = r7.json() + print(f" Events count: {len(data)}") + assert len(data) == 4, f"Expected 4 events, got {len(data)}" + print(" ✅ PASS: All 4 events returned") + else: + print(f" ❌ FAIL: {r7.text}") + return + + # TEST 8: GET events with skip/limit + print("\n--- TEST 8: GET events with skip=0&limit=2 ---") + r8 = await client.get( + f"/api/v1/assets/{asset_id}/events?skip=0&limit=2", + headers=headers + ) + print(f" Status: {r8.status_code}") + if r8.status_code == 200: + data = r8.json() + print(f" Events count: {len(data)}") + assert len(data) == 2, f"Expected 2 events, got {len(data)}" + print(" ✅ PASS: Pagination works correctly") + else: + print(f" ❌ FAIL: {r8.text}") + return + + # TEST 9: Unauthorized access + print("\n--- TEST 9: Unauthorized access (no token) ---") + r9 = await client.get(f"/api/v1/assets/{asset_id}/events") + print(f" Status: {r9.status_code}") + if r9.status_code == 401: + print(" ✅ PASS: Unauthorized access rejected") + else: + print(f" ❌ FAIL: Expected 401, got {r9.status_code}") + return + + # TEST 10: Invalid asset ID + print("\n--- TEST 10: Invalid asset ID ---") + fake_id = uuid.uuid4() + r10 = await client.get(f"/api/v1/assets/{fake_id}/events", headers=headers) + print(f" Status: {r10.status_code}") + if r10.status_code == 404: + print(" ✅ PASS: Invalid asset ID returns 404") + else: + print(f" ❌ FAIL: Expected 404, got {r10.status_code}") + return + + print("\n" + "=" * 60) + print("🎉 ALL TESTS PASSED!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(run_tests())