refaktor címtár

This commit is contained in:
Roo
2026-07-01 19:49:58 +00:00
parent 6e627d0ebe
commit 7654913d21
198 changed files with 3485 additions and 1609 deletions

View File

@@ -8,318 +8,124 @@ Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok fe
### 🔧 Eredmények
**1. ADATBÁZIS TISZTASÁG:** ✅ PASS
- API modul import: ✅ Sikeres (2 route: GET, POST)
- Sync Engine: ✅ 1210 OK, 0 Fixed, 0 Extra
- E2E test: ⚠️ Pre-existing conftest hiba (verification token timeout - nem kapcsolódó)
## 2026-06-30 - [#343] Fázis 4/7: Gamification kulcsok felvétele backend lokációs fájlokba
### 🎯 Cél
GAMIFICATION szekció létrehozása a `backend/static/locales/en.json` és `hu.json` fájlokban a `docs/gamification_i18n_audit_and_fix_proposal.md` dokumentáció alapján.
### 🔧 Módosított fájlok
## 2026-07-01 - Opening Hours (JSONB) Admin UI támogatás
### 🎯 Cél
Nyitvatartás (Opening Hours) JSONB mező adminisztrációs támogatásának megvalósítása: backend API (GET/PATCH), frontend edit UI (7 napos grid), frontend detail display.
### 🔧 Módosított fájlok
- `backend/app/api/v1/endpoints/admin_providers.py` - ProviderDetail + ProviderUpdateInput sémák `opening_hours` mezővel; get_provider_detail() és update_provider() végpontok kiegészítése a ServiceProfile JSONB oszlop olvasásával/írásával
- `frontend_admin/pages/providers/[id]/edit.vue` - "Nyitvatartás" tab (4. tab) 7 napos grid-del, checkbox + time input párokkal, 24/7 gyorsgombbal, hétköznap másolással, összes törléssel
- `frontend_admin/pages/providers/[id]/index.vue` - Nyitvatartás megjelenítő tábla a részletes nézetben
### ✅ Verifikáció
- Sync Engine: 1297 OK, 0 Fixed, 0 Extra - rendszer tökéletesen szinkronban
- GET /admin/providers/1 → 200, `opening_hours` mező visszatér
- PATCH /admin/providers/1 `opening_hours` → 200, adatok perzisztálódnak és visszaolvashatók
## 2026-06-30 - [#377] M4: Többnyelvű támogatás bevezetése (DE, FR, RO, CZ, SK)
### 🎯 Cél
5 új nyelv (Német, Francia, Román, Cseh, Szlovák) bevezetése a backend és frontend admin i18n rendszerekbe.
### 🔧 Létrehozott fájlok
- `backend/static/locales/de.json` - Backend német lokációs fájl (7 szekció)
- `backend/static/locales/fr.json` - Backend francia lokációs fájl (7 szekció)
- `backend/static/locales/ro.json` - Backend román lokációs fájl (7 szekció)
- `backend/static/locales/cz.json` - Backend cseh lokációs fájl (7 szekció)
- `backend/static/locales/sk.json` - Backend szlovák lokációs fájl (7 szekció)
- `frontend_admin/locales/de.json` - Frontend admin német fő lokációs fájl
- `frontend_admin/locales/fr.json` - Frontend admin francia fő lokációs fájl
- `frontend_admin/locales/ro.json` - Frontend admin román fő lokációs fájl
- `frontend_admin/locales/cz.json` - Frontend admin cseh fő lokációs fájl
- `frontend_admin/locales/sk.json` - Frontend admin szlovák fő lokációs fájl
- `frontend_admin/i18n/locales/de.json` - Frontend admin német közös lokációs fájl
- `frontend_admin/i18n/locales/fr.json` - Frontend admin francia közös lokációs fájl
- `frontend_admin/i18n/locales/ro.json` - Frontend admin román közös lokációs fájl
- `frontend_admin/i18n/locales/cz.json` - Frontend admin cseh közös lokációs fájl
- `frontend_admin/i18n/locales/sk.json` - Frontend admin szlovák közös lokációs fájl
### 🔧 Módosított fájlok
- `frontend_admin/nuxt.config.ts` - 5 új nyelv regisztrálása a @nuxtjs/i18n konfigurációban
### ✅ Verifikáció
- Backend i18n: Mind az 5 nyelv betöltődik (7-7 szekció), leaf key fordítések működnek
- Sync Engine: 1293 OK, 0 Fixed, 0 Extra - rendszer tökéletesen szinkronban
### 🎯 Cél
GAMIFICATION szekció létrehozása a `backend/static/locales/en.json` és `hu.json` fájlokban a `docs/gamification_i18n_audit_and_fix_proposal.md` dokumentáció alapján.
### 🔧 Módosított fájlok
## 2026-06-30 - [#376] M3: Admin nyelvválasztó átalakítása legördülő menüvé
### 🎯 Cél
A jelenlegi kétállású HU/EN kapcsoló átalakítása bővíthető, legördülő nyelvválasztó dropdownná, amely a backend-ből érkező támogatott nyelveket listázza.
### 🔧 Módosított fájlok
- `frontend_admin/components/LanguageSwitcher.vue`**LÉTREHOZVA**: Új önálló LanguageSwitcher dropdown komponens zászló emoji-kkal, nyelv nevével, localStorage + backend perzisztálással
- `frontend_admin/layouts/default.vue`**MÓDOSÍTVA**: 157-170. sorokban a régi gomb-alapú kapcsoló kicserélve `<LanguageSwitcher />` komponensre; felesleges `useI18n` import és `switchLocale`/`getAuthHeaders` függvények eltávolítva
### 📊 Eredmény
- LanguageSwitcher komponens: ✅ Létrehozva (zászló emoji, dinamikus locale lista, backend PATCH perzisztálás, localStorage)
- default.vue cleanup: ✅ 25 sor kód eltávolítva (i18n logika kiszervezve)
- Nuxt production build: ✅ Sikeres
## 2026-06-30 - Admin Provider Root Cause Analysis (#379)
**Vizsgálat típusa:** Rendszer-Architect root cause analysis
**Gitea kártya:** #379
### Felfedezett probléma
Az admin felület (providers list & pending queue) csak 6 rekordot mutat a `marketplace.service_providers` táblából. A robotok által gyűjtött 8569 rekord a `marketplace.service_staging`-ben láthatatlan.
### Root Cause
A `backend/app/api/v1/endpoints/admin_providers.py` `list_providers()` és `get_provider_stats()` endpointjai kizárólag a `ServiceProvider` modellt kérdezik le. A `provider_service.py` `search_providers()` (public API) már helyesen használ UNION ALL-t mindhárom forrásból.
### Javítási terv
1. `ProviderListItem` response model kiegészítése `source_table` mezővel
2. `list_providers()` UNION ALL kiterjesztése ServiceStaging-re
3. `get_provider_stats()` kiterjesztése staging rekordokkal
4. Nem kell új validációs mező - a `provider_validations`, `service_profiles.trust_score` stb. már létezik
### Dokumentáció
`docs/admin_providers_root_cause_analysis.md`
## 2026-06-30 - Admin Provider Discovery Implementáció
### 🎯 Cél
A `list_providers()` admin végpont kiterjesztése UNION ALL lekérdezéssel, hogy a bot által gyűjtött ServiceStaging rekordok (8569 db) és a verified organization-ök is megjelenjenek az admin felületen. Új `rejected.vue` oldal az elutasított szolgáltatók visszaállításához.
### 🔧 Változtatások
1. `backend/app/api/v1/endpoints/admin_providers.py`:
- `_build_unified_providers_query()`: UNION ALL a ServiceProvider, ServiceStaging és Organizations táblákból
- `source_filter` és `source_table_filter` a UNION ALL eredményére alkalmazva (nem az egyes részekre) - kijavítva a SourceType enum ütközési hibát
- `get_provider_stats()`: staging_pending mező hozzáadása
- `restore_provider()`: POST végpont rejected->approved státuszváltáshoz
- `get_provider_history()`, `update_provider()` végpontok
2. `frontend_admin/pages/providers/rejected.vue`: új oldal az elutasított szolgáltatók listázásához és visszaállításához
3. `frontend_admin/pages/providers/index.vue`: source_table, bot/verified_org szűrés támogatás
4. `frontend_admin/pages/providers/pending.vue`: source_table mező hozzáadása
### ✅ Tesztek
- `source=bot` filter: 200 OK, 5 staging rekord visszaadva
- `source=verified_org` filter: 200 OK, 1 organization rekord
- `source_table=service_staging` filter: 200 OK, 3 staging rekord
- Stats: total=8575, staging_pending=5096
- Rejected filter: 200 OK, 2 rejected rekord
## 2026-06-30 - Provider Admin Tabbed Edit UI (Status, Validations, History)
### 🎯 Cél
A szolgáltatók admin felületének bővítése: többfülű szerkesztési nézet, státuszkezelés, gamification validációs lista és állapotváltoztatási előzmények megjelenítése.
### 🔧 Módosított fájlok
**1. `backend/app/api/v1/endpoints/admin_providers.py`**
- `ProviderUpdateInput` séma bővítése: `status` (pending/approved/rejected) és `validation_score` (0-100) mezőkkel
- `ProviderValidationEntry` response séma létrehozása (id, provider_id, voter_user_id, validation_type, weight, validation_metadata, created_at, voter_name)
- `GET /admin/providers/{id}/validations` végpont: gamification validációk lekérése voter névvel
- `PATCH /admin/providers/{id}` módosítása: státuszváltás kezelése (ModerationStatus enum), old_data audit snapshot, validation_score auto-állítás restore/reject esetén
**2. `frontend_admin/pages/providers/[id].vue`**
- Teljes átírás: egysávos layout helyett 4 fül (Adatok, Státusz, Validációk, Előzmények)
- **Adatok fül**: változatlan alapadatok, cím, elérhetőség, moderációs műveletek
- **Státusz fül**: új űrlap státusz select, validation_score range slider (0-100), indoklás textarea
- **Validációk fül**: gamification validációs lista típus-badge-ekkel, szavazó név, súly, metadata, timestamp
- **Előzmények fül**: idővonal action-badge-ekkel, státusz átmenetek (régi→új), user info, expandálható részletek
- Elutasított/Megjelölt szekció: "Szerkesztés" és "Visszaállítás" gombok minden státuszhoz
### ✅ Verifikáció
- Sync Engine: ✅ 1293 OK, 0 Fixed, 0 Extra - teljes szinkron
- Backend syntax: ✅ admin_providers.py AST parse OK
- Backend import: ✅ router modul betöltés sikeres
## 2026-06-30 - P0 Rescue: Admin Providers API Blocker Fixes
### 🎯 Cél
4 darab kritikus blokkoló hiba javítása az Admin Providers flow-ban.
### 🔧 Változtatások
**FIX 1 - Silent Rollback (PATCH endpoint):**
- [`update_provider()`](backend/app/api/v1/endpoints/admin_providers.py:1147) `db.commit()` köré `try/except IntegrityError` blokk került
- IntegrityError esetén `db.rollback()` + `HTTPException(400)` a csendes 200 OK helyett
**FIX 2 - Unique Constraint on Restore:**
- [`_create_provider_validation()`](backend/app/api/v1/endpoints/admin_providers.py:198) átírva PostgreSQL `INSERT ... ON CONFLICT DO UPDATE` (UPSERT) használatára
- A `uq_voter_provider_validation` constraint tiszteletben tartva
**FIX 3 - Wrong Frontend URL:**
- Ellenőrizve: a frontend [`[id].vue`](frontend_admin/pages/providers/[id].vue:761) már a helyes `/api/v1/admin/providers/{id}/validations` URL-t használja
**FIX 4 - AuditLog Attribute Error:**
- [`get_provider_history()`](backend/app/api/v1/endpoints/admin_providers.py:1029) `AuditLog.created_at``AuditLog.timestamp`
- `timestamp=log.created_at``timestamp=log.timestamp`
### ✅ Verifikáció
- Backend syntax: ✅ `py_compile` OK
- Modul import: ✅ `admin_providers` modul betöltés sikeres
- Container: ✅ `sf_api` restart sikeres, státusz Up
## 2026-06-30 - [#381] P0 Fix: Provider validations 500 error & missing edit/flag modals
### 🎯 Cél
Két P0 hibajavítás az Admin Provider Details oldalon.
### 🔧 Módosított fájlok
**Bug #1 - `get_provider_validations()` 500 error:**
- [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1018) — `v.voter.display_name``v.voter.email`
- Root cause: `User` modellben nincs `display_name` mező
**Bug #2 - Dead "Szerkesztés" gomb:**
- [`[id].vue`](frontend_admin/pages/providers/[id].vue:540) — Edit modal template hozzáadva (name, city, address, contact, category mezőkkel)
- [`[id].vue`](frontend_admin/pages/providers/[id].vue:603) — Flag modal template hozzáadva (indoklás textarea-val)
- Root cause: A `<template>` szekcióból hiányzott mindkét modal, bár a JavaScript logika (`showEditModal`, `openEditModal`, `saveEdit`, `showFlagModal`, `flagProvider`) definiálva volt
### ✅ Verifikáció
- Backend syntax: ✅ `ast.parse` OK
- Frontend template: ✅ Mind a 3 modal (Reject, Edit, Flag) jelen van a `<template>`-ben
## 2026-07-01 - [#382] P0: Backend Database Models - 3D Filtering Matrix
### 🎯 Cél
A ServiceProvider és ServiceProfile modellek bővítése `supported_vehicle_classes` (ARRAY) és `specializations` (JSONB) oszlopokkal a 3D szűrési mátrix támogatásához. Pydantic schemák frissítése az új mezőkkel.
### 🔧 Módosított fájlok
**1. [`backend/app/models/identity/social.py`](backend/app/models/identity/social.py:55)**
- `ARRAY` import hozzáadva `sqlalchemy.dialects.postgresql`-ből
- `supported_vehicle_classes: Mapped[Optional[list]] = mapped_column(ARRAY(String), server_default=text("'{}'"), nullable=True)`
- `specializations: Mapped[Optional[dict]] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), nullable=True)`
**2. [`backend/app/models/marketplace/service.py`](backend/app/models/marketplace/service.py:40)**
- `ARRAY` import hozzáadva `sqlalchemy.dialects.postgresql`-ből
- Azonos 2 oszlop hozzáadva a ServiceProfile modellhez
**3. [`backend/app/api/v1/endpoints/admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:126)**
- `ProviderUpdateInput`: `category_ids`, `supported_vehicle_classes`, `specializations` mezők
- `ProviderDetail`: `category_ids`, `supported_vehicle_classes`, `specializations` mezők
- `get_provider_detail()` és `update_provider()` response-ok frissítve
**4. [`backend/app/schemas/provider.py`](backend/app/schemas/provider.py:137)**
- `ProviderQuickAddIn`: `supported_vehicle_classes`, `specializations` mezők
- `ProviderUpdateIn`: `supported_vehicle_classes`, `specializations` mezők
- `ProviderSearchResult`: `supported_vehicle_classes`, `specializations` mezők
### ✅ Verifikáció
- Sync Engine: ✅ 1293 OK, 4 Fixed (4 új oszlop), 0 Extra
- Adatbázis oszlopok ellenőrizve: `marketplace.service_providers.supported_vehicle_classes` (ARRAY), `marketplace.service_providers.specializations` (JSONB), `marketplace.service_profiles.supported_vehicle_classes` (ARRAY), `marketplace.service_profiles.specializations` (JSONB) — mind jelen van megfelelő típussal és default értékkel
## 2026-07-01 - [#383] P0: Admin PATCH endpoint - ServiceExpertise sync & Array persistence
### 🎯 Cél
A `PATCH /admin/providers/{id}` végpont kiegészítése: `category_ids`, `supported_vehicle_classes`, `specializations` mezők mentése mind a ServiceProvider, mind a ServiceProfile modellekbe, valamint `_sync_service_expertises()` meghívása a ServiceProfile ID-val a taxonomy fa szinkronizálásához.
### 🔧 Módosított fájlok
**1. [`backend/app/api/v1/endpoints/admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1098)**
- **Importok bővítése**: `ServiceProfile` import `app.models.marketplace.service`-ből, `_sync_service_expertises` import `app.services.provider_service`-ből
- **`update_provider()`**: `category_ids`, `supported_vehicle_classes`, `specializations` kiemelése a payload-ból `update_fields.pop()` segítségével; tömbök mentése ServiceProvider-re; ServiceProfile lekérése `service_provider_id` alapján; tömbök mentése ServiceProfile-ra; `_sync_service_expertises(db, profile.id, category_ids)` hívása
- **`get_provider_detail()`**: `category_ids` feloldása a ServiceExpertise join táblából ServiceProfile-on keresztül (korábban hardcoded `None`)
### ✅ Verifikáció
- Backend syntax: ✅ `py_compile` OK (exit code 0)
- Container restart: ✅ `sf_api` sikeresen újraindítva
- Import verifikáció: ✅ `ProviderUpdateInput` séma tartalmazza a `category_ids`, `supported_vehicle_classes`, `specializations` mezőket
## 2026-07-01 - [#384] P0: Service Provider Dedicated Full-Page Editor
### 🎯 Cél
Dedikált teljes oldalas szerkesztő létrehozása a szolgáltatók adminisztrációjához, amely felváltja a hiányzó modális szerkezetet. A szerkesztő 3 tabos struktúrával rendelkezik: Alapadatok & Kapcsolat, Szolgáltatások & Járművek (3D Mátrix), Helyszín & Lokáció.
### 🔧 Létrehozott fájlok
- `frontend_admin/pages/providers/[id]/edit.vue` - Teljes oldalas 3-tabos szerkesztő (Alapadatok, 3D Mátrix, Helyszín)
- `frontend_admin/components/TreeNode.vue` - Rekurzív fa komponens a kategória hierarchia megjelenítéséhez
### 🔧 Módosított fájlok
- `frontend_admin/pages/providers/[id].vue` - "Szerkesztés" gomb átirányítása a modális helyett az új `/providers/{id}/edit` útvonalra
### 📝 Technikai részletek
- **Tab 1 (Alapadatok & Kapcsolat):** Név, Kategória, Forrás (read-only), Telefon, Email, Weboldal
- **Tab 2 (Szolgáltatások & Járművek):** 3D Képesség Mátrix - A: Járműosztályok (8 checkbox), B: Szolgáltatási Kategóriák (rekurzív fa), C: Specializációk (márka + hajtáslánc tag inputok)
- **Tab 3 (Helyszín & Lokáció):** Teljes cím, Város, Irányítószám, Utca, Házszám, Plus Code
- **API integráció:** PATCH `/api/v1/admin/providers/{id}` végpont használata a `ProviderUpdateInput` séma szerint
- **Adatfolyam:** GET provider detail → form mapping → user edit → PATCH save → visszanavigálás a detail oldalra
- **Build:** Sikeres Nuxt build hibák nélkül
## 2026-07-01 - P0 HOTFIX: Edit page data modification fix (3 bugs)
### 🎯 Cél
A `/providers/[id]/edit` oldalon a felhasználó nem tudott adatokat módosítani és menteni. 3 összefüggő hibát azonosítottam és javítottam.
### 🔧 Javítások
**1. Backend - Hiányzó `address` mező a `ProviderUpdateInput` sémában**
- Hely: `backend/app/api/v1/endpoints/admin_providers.py:127`
- Probléma: A PATCH végpont Pydantic sémája nem tartalmazta az `address` mezőt, így a "Teljes cím" módosítása sosem került mentésre.
- Javítás: `address: Optional[str] = Field(None, max_length=500)` hozzáadva a sémához.
**2. Frontend - Hiányzó `address` és `address_street_type` a `hasChanges` computed-ból**
- Hely: `frontend_admin/pages/providers/[id]/edit.vue:579`
- Probléma: A Mentés gomb disabled maradt, ha csak a "Teljes cím" vagy "Utca típusa" mezőt módosították.
- Javítás: `form.address !== (p.address || '')` és `form.address_street_type !== (p.address_street_type || '')` ellenőrzések hozzáadva.
**3. Frontend - Hiányzó `address` a `saveForm` payload-ból**
- Hely: `frontend_admin/pages/providers/[id]/edit.vue:803`
- Probléma: A PATCH kérés törzséből hiányzott az `address` mező.
- Javítás: `address: form.address || null` hozzáadva a payload objektumhoz.
### ✅ Verifikáció
- Backend Python szintaxis: OK
- Sync Engine: 1297 elem OK, 0 hiba, teljes szinkronban
---
## 2026-07-01 - P0 Feature: "Always Open" (0-24) nyitvatartás a provider admin felületen
### 🎯 Cél
A provider admin szerkesztő felületen egy "Folyamatos (0-24) nyitvatartás" checkbox bevezetése, amely:
- Backend: `is_always_open: Optional[bool]` mező a `ProviderUpdateInput` sémában
- Backend: Ha `is_always_open=True`, a `update_provider()` PATCH endpoint automatikusan 00:00-23:59-re állítja mind a 7 napot, és `always_open: true` metadatát tárol a JSONB `opening_hours` mezőben
- Backend: Ha `is_always_open=False`, a meglévő `opening_hours` mentése változatlan, az `always_open` kulcs törlődik
- Frontend: Checkbox a Nyitvatartás tabon, amely bekapcsoláskor letiltja a naponkénti bevitelt és 00:00-23:59-re állít mindent
- Adatbázis: Nincs új migráció - a `always_open` metadata a meglévő JSONB `opening_hours` oszlopban tárolódik
### 🔧 Módosított fájlok
**1. Backend - `backend/app/api/v1/endpoints/admin_providers.py`**
- `ProviderUpdateInput` osztály: `is_always_open: Optional[bool] = Field(None, ...)` hozzáadva (148. sor)
- `update_provider()` PATCH endpoint: `is_always_open` kinyerése `update_fields`-ből, 0-24 kényszerítés True esetén, metadata törlés False esetén (1150-1170. sor)
**2. Frontend - `frontend_admin/pages/providers/[id]/edit.vue`**
- `form` reactive objektum: `is_always_open: false` mező hozzáadva
- Template: "Folyamatos (0-24) nyitvatartás" checkbox a Nyitvatartás tab tetején, amely letiltja a naponkénti grid-et
- `onAlwaysOpenChange()`: Checkbox toggle handler - bekapcsoláskor `setAllDaysOpen('00:00', '23:59')`, kikapcsoláskor `clearAllHours()`
- `mapProviderToForm()`: `always_open` metadata felismerése a JSONB-ből (mind a régi flat, mind az új nested formátum támogatott)
- `hasChanges` computed: `is_always_open` összehasonlítás a `p.opening_hours?.always_open`-val
- `saveForm()`: `is_always_open: form.is_always_open` a PATCH payload-ban
### ✅ Verifikáció
- Backend Python szintaxis: OK
- Sync Engine: 1297 elem OK, 0 hiba, teljes szinkronban
- API Test 1: `PATCH is_always_open=True``{"always_open": true, "days": {"monday": {"open": "00:00", "close": "23:59"}, ...}}`
- API Test 2: `PATCH is_always_open=False``{"monday": {"open": "09:00", "close": "17:00"}}` (always_open metadata stripped) ✅
## 2026-07-01 - Unified Address API Refactoring (Gitea #386-#391)
### 🎯 Cél
Teljes körű cím API egységesítés a backend összes endpointján. Az audit során 4 különböző címformátumot és 5 kritikus inkonzisztenciát azonosítottunk. A refaktoring 3 fázisban (P1/P2/P3) 6 Gitea kártyán keresztül valósult meg.
### 🔧 Eredmények
**1. Audit & Tervezés:**
- Audit dokumentum: `docs/address_api_consistency_audit.md`
- Logic spec: `plans/logic_spec_unified_address_refactoring.md`
- 6 Gitea kártya létrehozva (#386-#391)
**2. P1 #386 — Admin endpointok (admin.py, admin_persons.py):**
- `admin.py list_users`: Flat dict → `AddressOut.model_validate(address)` nested object
- `admin_persons.py`: `AddressResponse``AddressIn`/`AddressOut`, `PersonAddressUpdate` törölve
- 3 `AddressResponse(...)``AddressOut.model_validate(person.address)`
- Address update field mapping javítva (address_zip→zip, address_hrsz→parcel_id)
**3. P2 #387 — User endpointok (users.py, schemas/user.py):**
- `PersonUpdate`: 9 flat `address_*` field → `address: Optional[AddressIn]`
- `_build_user_response`: Manual dict → `AddressOut.model_validate(person.address)`
- `.dict()``.model_dump()` (Pydantic v2)
**4. P2 #388 — Admin providers (admin_providers.py):**
- `ProviderDetail`: Flat fields → `address_detail: Optional[AddressOut]`
- `ProviderUpdateInput`: Flat fields → `address_detail: Optional[AddressIn]`
- STEP 0: address_detail extraction + flat field mapping (zip→address_zip, city→city, stb.)
**5. P2 #389 — Admin Organizations (admin_organizations.py):**
- `GarageDetailsResponse`: 15 flat address fields → `address_detail: Optional[AddressOut]`, `billing_address_detail: Optional[AddressOut]`, `notification_address_detail: Optional[AddressOut]` (3 AddressOut nested objects)
- `OrganizationUpdate`: 15 flat address fields → `address_detail: Optional[AddressIn]`, `billing_address_detail: Optional[AddressIn]`, `notification_address_detail: Optional[AddressIn]` (3 AddressIn nested objects)
- `get_org_detail`: AddressOut construction from denormalized org fields for all 3 address types
- `update_organization`: STEP 0 with 3 address_detail extraction + flat field mapping blocks (primary/billing/notification)
- Response includes `address_detail` as unified AddressOut object
**6. P3 #390 — Public providers (schemas/provider.py, provider_service.py):**
- `ProviderSearchResult`: `address_detail: Optional[AddressOut]`
- `ProviderQuickAddIn`: `address_detail: Optional[AddressIn]`
- `ProviderUpdateIn`: `address_detail: Optional[AddressIn]`
- `search_providers`: AddressOut construction from flat SQL columns
**7. P3 #391 — Gamification (gamification.py):**
- `submit_new_service` uses `city`/`address` as plain `Body()` string params
- Maps directly to `ServiceStaging.city`/`ServiceStaging.address_line1`
- Simple crowdsourcing — AddressIn conversion would be over-engineering
**8. Phase 4 — AddressResponse removal:**
- `AddressResponse` class completely removed from `schemas/user.py`
- `PersonResponse.address`: `Optional[AddressResponse]``Optional[AddressOut]`
### ✅ Verifikáció
- Backend Python szintaxis: OK (all files pass py_compile)
- Sync Engine: 1274 OK, 0 Fixed, 26 Shadow Data (pre-existing)
- All 6 Gitea cards (#386-#391): CLOSED ✅
## 2026-07-01 - Service Provider Auto-Discovery (Gitea #348, #347)
### 🎯 Cél
Implementálni a `find_or_create_provider_by_name()` service függvényt (#348) és az auto-discovery hook-ot az expense creation-ben (#347) a logic_spec alapján.
### 🔧 Eredmények
**1. find_or_create_provider_by_name() (#348):**
- **Exact match:** ILIKE keresés a ServiceProvider.name mezőn
- **Fuzzy match:** pg_trgm similarity > 0.3 threshold, legjobb találat kiválasztása
- **Create new:** Ha nincs találat, új ServiceProvider létrehozása `pending` státusszal, `source=SourceType.api_import` ("api_import"), `validation_score=0`
- **Gamification:** PROVIDER_DISCOVERY (300 XP), PROVIDER_CONFIRMATION (150 XP), PROVIDER_VERIFIED_USE (100 XP), USE_UNVERIFIED_PROVIDER (200 XP)
**2. Auto-discovery hook expense creation-ben (#347):**
- Már implementálva volt a `POST /expenses/` végpontban
- Ha `external_vendor_name` meg van adva, de `service_provider_id` nincs, meghívja a `find_or_create_provider_by_name()`-t
- Hiba esetén warning log, nem blokkolja a költség létrehozását
**3. Adatbázis módosítások:**
- `pg_trgm` extension telepítve
- `marketplace.source_type` enum kiegészítve `'api_import'` értékkel
### ✅ Verifikáció
- Sync Engine: 1297 elem OK, 0 hiba, teljes szinkronban
- Test `test_use_unverified_provider.py`: ALL TESTS PASSED ✅
- PROVIDER_DISCOVERY (300 XP): ✅ User A created provider
- PROVIDER_CONFIRMATION (150 XP): ✅ User A reused own provider
- USE_UNVERIFIED_PROVIDER (200 XP): ✅ User B used pending provider
## 2026-07-01 - P0 ARCHITECTURE CLEANUP: Final Address Refactor (Ghost Columns & AddressManager)
### 🎯 Cél
A `docs/p0_address_manager_usage_audit_2026-07-01.md` auditban feltárt P0 kritikus hiba és P1 hiányosságok javítása.
### 🔧 Eredmények
**1. P0 FIX — `admin.py:search_organizations_by_name`:**
- `Organization.address_city``GeoPostalCode.city` (JOIN `Address``GeoPostalCode`)
- Hozzáadva: `outerjoin(Address, Organization.address_id == Address.id)`
- Hozzáadva: `outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)`
- Változás: `row.address_city``row.city` a response builderben
**2. P1 FIX — `admin_persons.py:update_person`:**
- 50+ sor direkt Address mező írás → egyetlen `AddressManager.create_or_update()` hívás
- Import hozzáadva: `from app.services.address_manager import AddressManager`
**3. P1 FIX — `admin_organizations.py:list_organizations`:**
- `org.address_city``org.address.city if org.address else None` (relationship-based)
**4. P1 FIX — `OrganizationUpdate` séma:**
- Minden flat address field `[DEPRECATED]` prefixet kapott a description-ben
**5. SQL Cleanup Script:**
- `docs/sql/cleanup_ghost_columns_2026_07.sql` — 15 DROP COLUMN utasítás `IF EXISTS` védelemmel
### ✅ Verifikáció
- Backend Python szintaxis: OK (all 3 files pass py_compile)
- Import verifikáció: `Organization.address_id`=True, `Organization.address_city`=False (confirmed removed)
- Sync Engine: 1274 OK, 0 Fixed, 26 Shadow Data (pre-existing ghost columns confirmed)

View File

@@ -11,7 +11,7 @@ logger = logging.getLogger("admin-endpoints")
from app.api import deps
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
from app.models.identity.address import Address
from app.models.identity.address import Address, GeoPostalCode
from app.models.system import SystemParameter, ParameterScope
from app.services.system_service import system_service
# JAVÍTVA: Security audit modellek
@@ -623,23 +623,12 @@ async def list_users(
seen_ids.add(row.id)
unique_users.append(row)
from app.schemas.address import AddressOut
user_list = []
for u in unique_users:
person = u.person
address = person.address if person else None
# Cím összefűzése
address_str = None
if address:
parts = []
if address.zip:
parts.append(str(address.zip))
if address.city:
parts.append(str(address.city))
if address.street_name:
parts.append(str(address.street_name))
if address.house_number:
parts.append(str(address.house_number))
address_str = ', '.join(parts) if parts else None
# Személy nevének összefűzése
person_name = None
@@ -663,12 +652,8 @@ async def list_users(
"first_name": person.first_name if person else None,
"last_name": person.last_name if person else None,
"phone": person.phone if person else None,
# Címadatok
"postal_code": str(address.zip) if address and address.zip else None,
"city": address.city if address and address.city else None,
"street": address.street_name if address and address.street_name else None,
"house_number": address.house_number if address and address.house_number else None,
"address": address_str,
# Címadatok — egységes AddressOut formátumban
"address": AddressOut.model_validate(address) if address else None,
})
return {
@@ -943,6 +928,8 @@ async def search_organizations(
"""
from app.models.marketplace.organization import Organization
# P0 FIX: Join with system.addresses and system.geo_postal_codes
# instead of selecting the ghost column Organization.address_city.
stmt = (
select(
Organization.id,
@@ -955,11 +942,13 @@ async def search_organizations(
Organization.is_verified,
Organization.is_active,
Organization.is_deleted,
Organization.address_city,
GeoPostalCode.city.label("city"),
Organization.region,
Organization.segment,
Organization.created_at,
)
.outerjoin(Address, Organization.address_id == Address.id)
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
.where(
or_(
Organization.full_name.ilike(f"%{name}%"),
@@ -987,7 +976,7 @@ async def search_organizations(
"is_verified": row.is_verified,
"is_active": row.is_active,
"is_deleted": row.is_deleted,
"city": row.address_city,
"city": row.city,
"region": row.region,
"segment": row.segment,
"created_at": row.created_at.isoformat() if row.created_at else None,

View File

@@ -28,7 +28,9 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.fleet.organization import ContactPerson
from app.models.identity import User, Person
from app.services.address_manager import AddressManager
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.address import AddressIn, AddressOut
from app.schemas.organization import (
OrganizationMemberCreate,
OrganizationMemberUpdate,
@@ -207,30 +209,36 @@ class GarageDetailsResponse(BaseModel):
is_active: bool
is_verified: bool
org_type: str
# Cím adatok (elsődleges)
# Cím adatok (elsődleges) — denormalizált mezők (backward compatible)
address_city: 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
# P0: Unified AddressOut nested object
address_detail: Optional[AddressOut] = None
tax_number: Optional[str] = None
reg_number: Optional[str] = None
# ── Kapcsolattartó (Contact Person) ──
contact_person_name: Optional[str] = None
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
# ── Számlázási cím (Billing Address) ──
# ── Számlázási cím (Billing Address) — denormalizált mezők (backward compatible) ──
billing_zip: Optional[str] = None
billing_city: Optional[str] = None
billing_street_name: Optional[str] = None
billing_street_type: Optional[str] = None
billing_house_number: Optional[str] = None
# ── Értesítési cím (Notification Address) ──
# P0: Unified AddressOut nested object for billing address
billing_address_detail: Optional[AddressOut] = None
# ── Értesítési cím (Notification Address) — denormalizált mezők (backward compatible) ──
notification_zip: Optional[str] = None
notification_city: Optional[str] = None
notification_street_name: Optional[str] = None
notification_street_type: Optional[str] = None
notification_house_number: Optional[str] = None
# P0: Unified AddressOut nested object for notification address
notification_address_detail: Optional[AddressOut] = None
# Előfizetés
subscription: Optional[SubscriptionSummary] = None
# Kapcsolattartó (ContactPerson modellből)
@@ -421,6 +429,9 @@ async def list_organizations(
# Resolve email: prefer contact_email, fallback to owner's email
resolved_email = org.contact_email or (org.owner.email if org.owner else None)
# P1 FIX: Use relationship-based city instead of ghost column org.address_city
resolved_city = org.address.city if org.address else None
garage_list.append(GarageListItem(
id=org.id,
name=org.name,
@@ -432,7 +443,7 @@ async def list_organizations(
is_verified=org.is_verified,
is_deleted=org.is_deleted,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
city=org.address_city,
city=resolved_city,
region=org.region,
segment=org.segment,
member_count=member_counts.get(org.id, 0),
@@ -1078,6 +1089,19 @@ async def get_organization_details(
f"({org.full_name}) — {len(members_list)} members"
)
# ── P0 REFACTORED: Build unified AddressOut objects from relationships ──
address_detail = None
if org.address:
address_detail = AddressOut.model_validate(org.address)
billing_address_detail = None
if org.billing_address:
billing_address_detail = AddressOut.model_validate(org.billing_address)
notification_address_detail = None
if org.notification_address:
notification_address_detail = AddressOut.model_validate(org.notification_address)
return GarageDetailsResponse(
id=org.id,
name=org.name,
@@ -1087,11 +1111,13 @@ async def get_organization_details(
is_active=org.is_active,
is_verified=org.is_verified,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
address_city=org.address_city,
address_zip=org.address_zip,
address_street_name=org.address_street_name,
address_street_type=org.address_street_type,
address_house_number=org.address_house_number,
# ── P0 REFACTORED: Denormalized fields populated from relationship ──
address_city=org.address.city if org.address else None,
address_zip=org.address.zip if org.address else None,
address_street_name=org.address.street_name if org.address else None,
address_street_type=org.address.street_type if org.address else None,
address_house_number=org.address.house_number if org.address else None,
address_detail=address_detail,
tax_number=org.tax_number,
reg_number=org.reg_number,
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
@@ -1099,17 +1125,19 @@ async def get_organization_details(
contact_email=resolved_contact_email,
contact_phone=resolved_contact_phone,
# ── Számlázási cím ──
billing_zip=org.billing_zip,
billing_city=org.billing_city,
billing_street_name=org.billing_street_name,
billing_street_type=org.billing_street_type,
billing_house_number=org.billing_house_number,
billing_zip=org.billing_address.zip if org.billing_address else None,
billing_city=org.billing_address.city if org.billing_address else None,
billing_street_name=org.billing_address.street_name if org.billing_address else None,
billing_street_type=org.billing_address.street_type if org.billing_address else None,
billing_house_number=org.billing_address.house_number if org.billing_address else None,
billing_address_detail=billing_address_detail,
# ── Értesítési cím ──
notification_zip=org.notification_zip,
notification_city=org.notification_city,
notification_street_name=org.notification_street_name,
notification_street_type=org.notification_street_type,
notification_house_number=org.notification_house_number,
notification_zip=org.notification_address.zip if org.notification_address else None,
notification_city=org.notification_address.city if org.notification_address else None,
notification_street_name=org.notification_address.street_name if org.notification_address else None,
notification_street_type=org.notification_address.street_type if org.notification_address else None,
notification_house_number=org.notification_address.house_number if org.notification_address else None,
notification_address_detail=notification_address_detail,
subscription=subscription_summary,
primary_contact=primary_contact,
created_at=org.created_at.isoformat() if org.created_at else None,
@@ -1224,28 +1252,34 @@ class OrganizationUpdate(BaseModel):
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)")
phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)")
# ── Elsődleges cím (Primary Address) ──
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
address_city: Optional[str] = Field(None, max_length=100, description="Város")
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve")
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
# ── Elsődleges cím (Primary Address) — DEPRECATED: Use address_detail instead ──
address_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd az address_detail mezőt! Irányítószám")
address_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd az address_detail mezőt! Város")
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd az address_detail mezőt! Utca neve")
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd az address_detail mezőt! Utca típusa (utca, tér, stb.)")
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd az address_detail mezőt! Házszám")
# P0: Unified AddressIn nested object for primary address
address_detail: Optional[AddressIn] = None
# ── Kapcsolattartó (Contact Person) ──
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe")
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
# ── Számlázási cím (Billing Address) ──
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város")
billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve")
billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa")
billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám")
# ── Értesítési cím (Notification Address) ──
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
# ── Számlázási cím (Billing Address) — DEPRECATED: Use billing_address_detail instead ──
billing_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási irányítószám")
billing_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási város")
billing_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca neve")
billing_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca típusa")
billing_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási házszám")
# P0: Unified AddressIn nested object for billing address
billing_address_detail: Optional[AddressIn] = None
# ── Értesítési cím (Notification Address) — DEPRECATED: Use notification_address_detail instead ──
notification_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési irányítószám")
notification_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési város")
notification_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca neve")
notification_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca típusa")
notification_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési házszám")
# P0: Unified AddressIn nested object for notification address
notification_address_detail: Optional[AddressIn] = None
@router.put(
@@ -1279,10 +1313,34 @@ async def update_organization(
detail=f"Szervezet nem található ID-vel: {org_id}",
)
# 2. Összegyűjtjük a változásokat
# 2. P0 REFACTORED: Address FK logic via AddressManager
raw_data = payload.model_dump(exclude_none=True)
# ── Primary address via AddressManager ──
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
if address_detail:
org.address_id = await AddressManager.create_or_update(
db, org.address_id, address_detail
)
# ── Billing address via AddressManager ──
billing_address_detail: Optional[AddressIn] = raw_data.pop("billing_address_detail", None)
if billing_address_detail:
org.billing_address_id = await AddressManager.create_or_update(
db, org.billing_address_id, billing_address_detail
)
# ── Notification address via AddressManager ──
notification_address_detail: Optional[AddressIn] = raw_data.pop("notification_address_detail", None)
if notification_address_detail:
org.notification_address_id = await AddressManager.create_or_update(
db, org.notification_address_id, notification_address_detail
)
# 3. Összegyűjtjük a változásokat (non-address fields)
update_fields = {}
for field_name in payload.model_dump(exclude_none=True):
value = getattr(payload, field_name)
for field_name in raw_data:
value = raw_data[field_name]
if value is not None and hasattr(org, field_name):
setattr(org, field_name, value)
update_fields[field_name] = value
@@ -1333,6 +1391,11 @@ async def update_organization(
f"({org.full_name}): fields={list(update_fields.keys())}"
)
# ── P0 REFACTORED: Build unified AddressOut from relationship ──
resp_address_detail = None
if org.address:
resp_address_detail = AddressOut.model_validate(org.address)
return {
"status": "success",
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
@@ -1345,11 +1408,7 @@ async def update_organization(
"display_name": org.display_name,
"tax_number": org.tax_number,
"reg_number": org.reg_number,
"address_zip": org.address_zip,
"address_city": org.address_city,
"address_street_name": org.address_street_name,
"address_street_type": org.address_street_type,
"address_house_number": org.address_house_number,
"address_detail": resp_address_detail,
"status": org.status,
"is_active": org.is_active,
"contact_email": org.contact_email,

View File

@@ -26,7 +26,8 @@ from app.models.identity import User, Person, UserRole
from app.models.identity.address import Address
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.user import AddressResponse
from app.schemas.address import AddressIn, AddressOut
from app.services.address_manager import AddressManager
logger = logging.getLogger("admin-persons")
router = APIRouter()
@@ -59,7 +60,7 @@ class PersonListItem(BaseModel):
merged_into_id: Optional[int] = None
users_count: int = 0
active_user: Optional[PersonListItemActiveUser] = None
address: Optional[AddressResponse] = None
address: Optional[AddressOut] = None
class PersonListResponse(BaseModel):
@@ -214,20 +215,7 @@ async def list_persons(
# Cím adatok
address_data = None
if person.address:
address_data = AddressResponse(
address_zip=person.address.zip,
address_city=person.address.city,
address_street_name=person.address.street_name,
address_street_type=person.address.street_type,
address_house_number=person.address.house_number,
address_stairwell=person.address.stairwell,
address_floor=person.address.floor,
address_door=person.address.door,
address_hrsz=person.address.parcel_id,
full_address_text=person.address.full_address_text,
latitude=person.address.latitude,
longitude=person.address.longitude,
)
address_data = AddressOut.model_validate(person.address)
items.append(PersonListItem(
id=person.id,
@@ -338,7 +326,7 @@ class PersonDetailResponse(BaseModel):
updated_at: Optional[str] = None
# Kapcsolódó entitások
address: Optional[AddressResponse] = None
address: Optional[AddressOut] = None
users: List[PersonDetailUserInfo] = []
memberships: List[PersonDetailMembership] = []
owned_organizations: List[PersonDetailOwnedOrganization] = []
@@ -394,20 +382,7 @@ async def get_person_detail(
# ── Cím adatok ──
address_data = None
if person.address:
address_data = AddressResponse(
address_zip=person.address.zip,
address_city=person.address.city,
address_street_name=person.address.street_name,
address_street_type=person.address.street_type,
address_house_number=person.address.house_number,
address_stairwell=person.address.stairwell,
address_floor=person.address.floor,
address_door=person.address.door,
address_hrsz=person.address.parcel_id,
full_address_text=person.address.full_address_text,
latitude=person.address.latitude,
longitude=person.address.longitude,
)
address_data = AddressOut.model_validate(person.address)
# ── Kapcsolódó User-ek ──
users_data: List[PersonDetailUserInfo] = []
@@ -520,22 +495,6 @@ async def get_person_detail(
# =============================================================================
class PersonAddressUpdate(BaseModel):
"""Cím adatok a Person szerkesztéshez."""
address_zip: Optional[str] = Field(default=None, description="Irányítószám")
address_city: Optional[str] = Field(default=None, description="Város")
address_street_name: Optional[str] = Field(default=None, description="Utca név")
address_street_type: Optional[str] = Field(default=None, description="Közterület jellege")
address_house_number: Optional[str] = Field(default=None, description="Házszám")
address_stairwell: Optional[str] = Field(default=None, description="Lépcsőház")
address_floor: Optional[str] = Field(default=None, description="Emelet")
address_door: Optional[str] = Field(default=None, description="Ajtó")
address_hrsz: Optional[str] = Field(default=None, description="Helyrajzi szám")
full_address_text: Optional[str] = Field(default=None, description="Teljes cím szöveg")
latitude: Optional[float] = Field(default=None, description="GPS szélesség")
longitude: Optional[float] = Field(default=None, description="GPS hosszúság")
class PersonUpdateRequest(BaseModel):
"""Admin által szerkeszthető Person mezők.
@@ -554,8 +513,8 @@ class PersonUpdateRequest(BaseModel):
is_active: Optional[bool] = Field(default=None, description="Aktív státusz")
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
# Cím adatok (opcionális, nested object)
address: Optional[PersonAddressUpdate] = Field(default=None, description="Cím adatok")
# Cím adatok (opcionális, nested object) — egységes AddressIn séma
address: Optional[AddressIn] = Field(default=None, description="Cím adatok")
@field_validator("phone")
@classmethod
@@ -692,62 +651,14 @@ async def update_person(
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
person.is_ghost = update_data.is_ghost
# ── 5. Cím adatok frissítése ──
# ── 5. Cím adatok frissítése (P1 REFACTORED: AddressManager) ──
if update_data.address is not None:
addr_data = update_data.address
# Ha van már cím, frissítjük, különben létrehozzuk
if person.address:
address = person.address
else:
# Új cím létrehozása
address = Address(
created_at=datetime.utcnow()
)
db.add(address)
person.address = address
# Cím mezők frissítése
addr_changes: Dict[str, Any] = {}
if addr_data.address_zip is not None:
addr_changes["address_zip"] = {"old": address.zip, "new": addr_data.address_zip}
# Megjegyzés: a zip a GeoPostalCode kapcsolatból jön, itt csak a postal_code_id-t tudnánk keresni
# Egyszerűsítés: a frontend által küldött adatokat tároljuk a full_address_text-ben is
if addr_data.address_city is not None:
addr_changes["address_city"] = {"old": address.city, "new": addr_data.address_city}
if addr_data.address_street_name is not None:
addr_changes["address_street_name"] = {"old": address.street_name, "new": addr_data.address_street_name}
address.street_name = addr_data.address_street_name
if addr_data.address_street_type is not None:
addr_changes["address_street_type"] = {"old": address.street_type, "new": addr_data.address_street_type}
address.street_type = addr_data.address_street_type
if addr_data.address_house_number is not None:
addr_changes["address_house_number"] = {"old": address.house_number, "new": addr_data.address_house_number}
address.house_number = addr_data.address_house_number
if addr_data.address_stairwell is not None:
addr_changes["address_stairwell"] = {"old": address.stairwell, "new": addr_data.address_stairwell}
address.stairwell = addr_data.address_stairwell
if addr_data.address_floor is not None:
addr_changes["address_floor"] = {"old": address.floor, "new": addr_data.address_floor}
address.floor = addr_data.address_floor
if addr_data.address_door is not None:
addr_changes["address_door"] = {"old": address.door, "new": addr_data.address_door}
address.door = addr_data.address_door
if addr_data.address_hrsz is not None:
addr_changes["address_hrsz"] = {"old": address.parcel_id, "new": addr_data.address_hrsz}
address.parcel_id = addr_data.address_hrsz
if addr_data.full_address_text is not None:
addr_changes["full_address_text"] = {"old": address.full_address_text, "new": addr_data.full_address_text}
address.full_address_text = addr_data.full_address_text
if addr_data.latitude is not None:
addr_changes["latitude"] = {"old": address.latitude, "new": addr_data.latitude}
address.latitude = addr_data.latitude
if addr_data.longitude is not None:
addr_changes["longitude"] = {"old": address.longitude, "new": addr_data.longitude}
address.longitude = addr_data.longitude
if addr_changes:
changes["address"] = addr_changes
# P1 FIX: Use AddressManager.create_or_update() instead of
# writing directly to Address model fields.
person.address_id = await AddressManager.create_or_update(
db, person.address_id, update_data.address
)
changes["address"] = {"old": None, "new": "updated via AddressManager"}
# ── 6. Mentés ──
if changes:
@@ -768,20 +679,7 @@ async def update_person(
# Cím adatok
address_data = None
if person.address:
address_data = AddressResponse(
address_zip=person.address.zip,
address_city=person.address.city,
address_street_name=person.address.street_name,
address_street_type=person.address.street_type,
address_house_number=person.address.house_number,
address_stairwell=person.address.stairwell,
address_floor=person.address.floor,
address_door=person.address.door,
address_hrsz=person.address.parcel_id,
full_address_text=person.address.full_address_text,
latitude=person.address.latitude,
longitude=person.address.longitude,
)
address_data = AddressOut.model_validate(person.address)
# Kapcsolódó User-ek
users_data: List[PersonDetailUserInfo] = []

View File

@@ -29,6 +29,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.schemas.address import AddressIn, AddressOut
from app.db.session import get_db
from app.models.identity import User
from app.models.identity.social import (
@@ -75,10 +76,7 @@ class ProviderDetail(BaseModel):
name: str
address: str
city: 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
address_detail: Optional[AddressOut] = None
plus_code: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
@@ -130,10 +128,7 @@ class ProviderUpdateInput(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve")
address: Optional[str] = Field(None, max_length=500, description="Teljes cím")
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=255, description="Utca név")
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (pl. utca, tér, út)")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
address_detail: Optional[AddressIn] = Field(None, description="Cím részletes adatai (egységes AddressIn séma)")
plus_code: Optional[str] = Field(None, max_length=50, description="Plus Code (Google Maps)")
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")
@@ -290,6 +285,7 @@ def _build_unified_providers_query(
alkalmazzuk (nem az egyes részekre), hogy elkerüljük az enum típusütközéseket.
"""
from app.models.marketplace.organization import Organization
from app.models.identity.address import Address, GeoPostalCode
# --- 1. ServiceProvider lekérdezés ---
provider_conditions = []
@@ -355,17 +351,17 @@ def _build_unified_providers_query(
Organization.id.label("id"),
Organization.name.label("name"),
func.concat(
Organization.address_zip,
GeoPostalCode.zip_code,
literal(' '),
Organization.address_city,
GeoPostalCode.city,
literal(', '),
Organization.address_street_name,
Address.street_name,
literal(' '),
Organization.address_street_type,
Address.street_type,
literal(' '),
Organization.address_house_number,
Address.house_number,
).label("address"),
Organization.address_city.label("city"),
GeoPostalCode.city.label("city"),
literal(None).label("category"),
literal("approved").label("status"),
literal("verified_org").label("source"),
@@ -373,6 +369,12 @@ def _build_unified_providers_query(
literal(100).label("validation_score"),
literal(None).label("added_by_user_id"),
Organization.created_at.label("created_at"),
).outerjoin(
Address,
Address.id == Organization.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_id,
)
if org_conditions:
org_stmt = org_stmt.where(*org_conditions)
@@ -562,10 +564,13 @@ async def get_provider_detail(
name=sp.name,
address=sp.address or "",
city=sp.city,
address_zip=sp.address_zip,
address_street_name=sp.address_street_name,
address_street_type=sp.address_street_type,
address_house_number=sp.address_house_number,
address_detail=AddressOut(
zip=sp.address_zip,
city=sp.city,
street_name=sp.address_street_name,
street_type=sp.address_street_type,
house_number=sp.address_house_number,
),
plus_code=sp.plus_code,
contact_phone=sp.contact_phone,
contact_email=sp.contact_email,
@@ -1148,6 +1153,39 @@ async def update_provider(
# Mezők frissítése (csak a nem None értékeket)
update_fields = update_data.model_dump(exclude_none=True)
# =====================================================================
# STEP 0: Extract address_detail (AddressIn) and map to flat model fields
# =====================================================================
address_detail: Optional[dict] = update_fields.pop("address_detail", None)
if address_detail:
# Map AddressIn fields to ServiceProvider flat columns
addr_mapping = {
"zip": "address_zip",
"city": None, # city is a direct column, handled separately
"street_name": "address_street_name",
"street_type": "address_street_type",
"house_number": "address_house_number",
"stairwell": "address_stairwell",
"floor": "address_floor",
"door": "address_door",
"parcel_id": "address_hrsz",
"full_address_text": None, # maps to address field
"latitude": None, # maps to latitude
"longitude": None, # maps to longitude
}
for addr_field, model_field in addr_mapping.items():
if addr_field in address_detail and address_detail[addr_field] is not None:
if model_field:
update_fields[model_field] = address_detail[addr_field]
elif addr_field == "full_address_text":
update_fields["address"] = address_detail[addr_field]
elif addr_field == "latitude":
update_fields["latitude"] = address_detail[addr_field]
elif addr_field == "longitude":
update_fields["longitude"] = address_detail[addr_field]
elif addr_field == "city":
update_fields["city"] = address_detail[addr_field]
# =====================================================================
# STEP 1: Extract multi-dimensional arrays and JSONB from payload
# =====================================================================
@@ -1298,10 +1336,13 @@ async def update_provider(
name=provider.name,
address=provider.address or "",
city=provider.city,
address_zip=provider.address_zip,
address_street_name=provider.address_street_name,
address_street_type=provider.address_street_type,
address_house_number=provider.address_house_number,
address_detail=AddressOut(
zip=provider.address_zip,
city=provider.city,
street_name=provider.address_street_name,
street_type=provider.address_street_type,
house_number=provider.address_house_number,
),
plus_code=provider.plus_code,
contact_phone=provider.contact_phone,
contact_email=provider.contact_email,

View File

@@ -10,6 +10,7 @@ from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
from app.services.provider_service import find_or_create_provider_by_name
from app.services.gamification_service import gamification_service
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -567,20 +568,42 @@ async def create_expense(
# ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ──
# Ha external_vendor_name meg van adva, de service_provider_id nincs,
# automatikusan felfedezzük vagy létrehozzuk a providert.
# A find_or_create_provider_by_name() kezeli a gamification pontok kiosztását is
# (PROVIDER_DISCOVERY / PROVIDER_CONFIRMATION / PROVIDER_VERIFIED_USE).
#
# P0 ARCHITECTURE CLEANUP (2026-07-01):
# A gamification/XP logika ELTÁVOLÍTVA a service rétegből.
# A find_or_create_provider_by_name() már csak (provider, created) tuple-t ad vissza.
# Ha created=True (új provider felfedezve), itt az API rétegben hívjuk meg
# a gamification_service.award_points()-t a PROVIDER_DISCOVERY action_key-kel.
resolved_provider_id = expense.service_provider_id
if expense.external_vendor_name and not expense.service_provider_id:
try:
provider, action_key = await find_or_create_provider_by_name(
provider, created = await find_or_create_provider_by_name(
db=db,
name=expense.external_vendor_name,
added_by_user_id=current_user.id,
)
resolved_provider_id = provider.id
# P0 ARCHITECTURE CLEANUP: Gamification XP kiosztása az API rétegben
# Csak akkor adunk XP-t, ha a provider újonnan lett felfedezve (created=True)
if created:
await gamification_service.award_points(
db=db,
user_id=current_user.id,
amount=0,
reason="PROVIDER_DISCOVERY",
commit=False, # A hívó (create_expense) kezeli a commit-ot
action_key="PROVIDER_DISCOVERY",
)
logger.info(
f"Gamification XP awarded for PROVIDER_DISCOVERY: "
f"name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, user_id={current_user.id}"
)
logger.info(
f"Provider auto-discovery: name='{expense.external_vendor_name}', "
f"provider_id={provider.id}, action_key='{action_key}', "
f"provider_id={provider.id}, created={created}, "
f"user_id={current_user.id}"
)
except Exception as e:

View File

@@ -17,9 +17,12 @@ from app.db.session import get_db
from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
from app.schemas.subscription import SubscriptionTierResponse
from app.schemas.address import AddressOut
from app.services.billing_engine import upgrade_org_subscription
from app.services.address_manager import AddressManager
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
from app.models.identity import User, OneTimePassword
from app.models.identity.address import Address
from app.core.config import settings
from app.services.security_service import security_service
from app.models import LogSeverity
@@ -49,7 +52,7 @@ async def onboard_organization(
if org_in.country_code == "HU":
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
status_code=status.HTTP_400_BAD_REQUEST,
detail=_("ORGANIZATION.ERROR.INVALID_TAX_FORMAT")
)
@@ -65,7 +68,10 @@ async def onboard_organization(
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
# 4. Mentés
# 4. P0 REFACTORED: Create Address record via AddressManager, then link via address_id FK
address_id = await AddressManager.create_or_update(db, None, org_in.address)
# 5. Mentés
new_org = Organization(
full_name=org_in.full_name,
name=org_in.name,
@@ -73,12 +79,7 @@ async def onboard_organization(
tax_number=org_in.tax_number,
reg_number=org_in.reg_number,
folder_slug=temp_slug,
address_zip=org_in.address_zip,
address_city=org_in.address_city,
address_street_name=org_in.address_street_name,
address_street_type=org_in.address_street_type,
address_house_number=org_in.address_house_number,
address_hrsz=org_in.address_hrsz,
address_id=address_id,
country_code=org_in.country_code,
org_type=OrgType.business,
status="pending_verification",
@@ -96,22 +97,17 @@ async def onboard_organization(
db.add(new_org)
await db.flush()
# 5. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA
# 6. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA (with address_id FK)
main_branch = Branch(
organization_id=new_org.id,
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
is_main=True,
postal_code=org_in.address_zip,
city=org_in.address_city,
street_name=org_in.address_street_name,
street_type=org_in.address_street_type,
house_number=org_in.address_house_number,
hrsz=org_in.address_hrsz,
address_id=address_id,
status="active"
)
db.add(main_branch)
# 6. TULAJDONOS RÖGZÍTÉSE
# 7. TULAJDONOS RÖGZÍTÉSE
owner_member = OrganizationMember(
organization_id=new_org.id,
user_id=current_user.id,
@@ -119,7 +115,7 @@ async def onboard_organization(
)
db.add(owner_member)
# 7. NAS Mappa létrehozása
# 8. NAS Mappa létrehozása
try:
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
org_path = os.path.join(base_path, "organizations", str(new_org.id))
@@ -185,6 +181,7 @@ async def get_my_organizations(
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
a subscription_tier JSONB rules alapján.
P0 REFACTORED: Address adatok az address relációból (AddressOut séma).
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
@@ -205,7 +202,10 @@ async def get_my_organizations(
stmt = (
select(Organization)
.where(Organization.id.in_(select(subq.c.id)))
.options(selectinload(Organization.members))
.options(
selectinload(Organization.members),
selectinload(Organization.address),
)
)
result = await db.execute(stmt)
orgs = result.scalars().all()
@@ -256,13 +256,8 @@ async def get_my_organizations(
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
"user_role": _get_user_role(o, current_user.id),
# ── Cím adatok (Address fields) ──
"address_zip": o.address_zip,
"address_city": o.address_city,
"address_street_name": o.address_street_name,
"address_street_type": o.address_street_type,
"address_house_number": o.address_house_number,
"address_hrsz": o.address_hrsz,
# ── P0 REFACTORED: Address from relationship ──
"address": AddressOut.model_validate(o.address).model_dump() if o.address else None,
}
for o in orgs
]
@@ -704,6 +699,12 @@ async def update_organization(
"""
Szervezet adatainak részleges frissítése (általános PATCH).
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
P0 REFACTORED: Address kezelés a unified AddressIn/AddressOut séma alapján.
- Ha payload.address meg van adva, akkor:
* Ha van meglévő address_id → frissíti a system.addresses rekordot
* Ha nincs → létrehoz egy új system.addresses rekordot és beállítja az address_id FK-t
- A régi denormalizált mezők (address_zip, address_city, stb.) ELTÁVOLÍTVA.
"""
# 1. Jogosultság ellenőrzése
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
@@ -745,7 +746,15 @@ async def update_organization(
org.visual_settings = current_vs
del update_dict["visual_settings"]
# 4. Többi mező frissítése
# 3. P0 REFACTORED: Address FK logic via AddressManager
if "address" in update_dict:
addr_in = update_dict["address"]
org.address_id = await AddressManager.create_or_update(
db, org.address_id, addr_in
)
del update_dict["address"]
# 4. Többi mező frissítése (kivéve a régi denormalizált címmezőket)
for field, value in update_dict.items():
if hasattr(org, field) and value is not None:
setattr(org, field, value)

View File

@@ -2,10 +2,11 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import selectinload, joinedload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models.marketplace.organization import Organization, Branch
from app.models.identity.address import Address, GeoPostalCode
from geoalchemy2 import WKTElement
from typing import Optional
@@ -23,16 +24,24 @@ async def match_service(
"""
Geofencing keresőmotor PostGIS segítségével.
Ha nincs megadva lat/lng, akkor nem alkalmazunk távolságszűrést.
P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A Branch.city denormalizált
mező helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
a város adatokat. LEFT JOIN-t használunk, mert az address_id lehet NULL.
"""
# Alap lekérdezés: aktív szervezetek és telephelyek
query = select(
Organization.id,
Organization.name,
Branch.city,
GeoPostalCode.city.label("city"),
Branch.branch_rating,
Branch.location
).join(
Branch, Organization.id == Branch.organization_id
).outerjoin(
Address, Address.id == Branch.address_id
).outerjoin(
GeoPostalCode, GeoPostalCode.id == Address.postal_code_id
).where(
Organization.is_active == True,
Branch.is_deleted == False

View File

@@ -12,6 +12,7 @@ from datetime import datetime
from app.api.deps import get_db, get_current_user
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
from app.schemas.address import AddressOut
from app.models.identity import User, Person
from app.models.identity.address import Address
from app.services.trust_engine import TrustEngine
@@ -69,20 +70,7 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
if person:
address_data = None
if person.address:
address_data = {
"address_zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None),
"address_city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None),
"address_street_name": person.address.street_name,
"address_street_type": person.address.street_type,
"address_house_number": person.address.house_number,
"address_stairwell": person.address.stairwell,
"address_floor": person.address.floor,
"address_door": person.address.door,
"address_hrsz": person.address.parcel_id,
"full_address_text": person.address.full_address_text,
"latitude": person.address.latitude,
"longitude": person.address.longitude,
}
address_data = AddressOut.model_validate(person.address)
person_data = {
"id": person.id,
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
@@ -343,14 +331,10 @@ async def update_my_person(
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
# ── Update Person fields ──
update_dict = update_data.dict(exclude_unset=True)
update_dict = update_data.model_dump(exclude_unset=True)
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
"mothers_first_name", "birth_place", "birth_date"]
address_fields = ["address_zip", "address_city", "address_street_name",
"address_street_type", "address_house_number",
"address_stairwell", "address_floor", "address_door",
"address_hrsz"]
for field in person_fields:
if field in update_dict and update_dict[field] is not None:
@@ -368,21 +352,21 @@ async def update_my_person(
person.identity_docs = update_dict["identity_docs"]
flag_modified(person, "identity_docs")
# ── Update Address fields ──
has_address_update = any(f in update_dict for f in address_fields)
if has_address_update:
# ── Update Address fields (via AddressIn) ──
addr_data = update_data.address
if addr_data is not None:
# Use GeoService to get or create address
addr_id = await GeoService.get_or_create_full_address(
db,
zip_code=update_dict.get("address_zip"),
city=update_dict.get("address_city"),
street_name=update_dict.get("address_street_name"),
street_type=update_dict.get("address_street_type"),
house_number=update_dict.get("address_house_number"),
stairwell=update_dict.get("address_stairwell"),
floor=update_dict.get("address_floor"),
door=update_dict.get("address_door"),
parcel_id=update_dict.get("address_hrsz"),
zip_code=addr_data.zip,
city=addr_data.city,
street_name=addr_data.street_name,
street_type=addr_data.street_type,
house_number=addr_data.house_number,
stairwell=addr_data.stairwell,
floor=addr_data.floor,
door=addr_data.door,
parcel_id=addr_data.parcel_id,
)
if addr_id:
person.address_id = addr_id

View File

@@ -103,6 +103,8 @@ class Organization(Base):
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
billing_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
notification_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
@@ -122,14 +124,6 @@ class Organization(Base):
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
address_city: Mapped[Optional[str]] = mapped_column(String(100))
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
@@ -137,20 +131,6 @@ class Organization(Base):
contact_person_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó neve")
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó e-mail címe")
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="Kapcsolattartó telefonszáma")
# ── P0 CRM: Számlázási cím (Billing Address) ──
billing_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
billing_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
billing_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
billing_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
billing_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
# ── P0 CRM: Értesítési cím (Notification Address) ──
notification_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
notification_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
notification_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
notification_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
notification_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
org_type: Mapped[OrgType] = mapped_column(
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
@@ -248,6 +228,26 @@ class Organization(Base):
# Kapcsolat az örök személy rekordhoz
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
# ── P0 ADDRESS RELATIONSHIPS (Code-First Refactor) ──
# Main/primary address
address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys=[address_id],
lazy="selectin"
)
# Billing address
billing_address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys=[billing_address_id],
lazy="selectin"
)
# Notification address
notification_address: Mapped[Optional["Address"]] = relationship(
"Address",
foreign_keys=[notification_address_id],
lazy="selectin"
)
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
"SubscriptionTier",
@@ -335,18 +335,7 @@ class Branch(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False)
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
# Denormalizált adatok a gyors lekérdezéshez
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
street_name: Mapped[Optional[str]] = mapped_column(String(150))
street_type: Mapped[Optional[str]] = mapped_column(String(50))
house_number: Mapped[Optional[str]] = mapped_column(String(20))
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
floor: Mapped[Optional[str]] = mapped_column(String(20))
door: Mapped[Optional[str]] = mapped_column(String(20))
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
# PostGIS location field for geographic queries
# PostGIS location field for geographic queries (KEPT INTACT)
location: Mapped[Optional[Any]] = mapped_column(
Geometry(geometry_type='POINT', srid=4326),
nullable=True

View File

@@ -177,9 +177,7 @@ class ServiceStaging(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
full_address: Mapped[Optional[str]] = mapped_column(String)
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
@@ -193,6 +191,9 @@ class ServiceStaging(Base):
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolat a címhez
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
class DiscoveryParameter(Base):
""" Robot vezérlési paraméterek adminból. """
__tablename__ = "discovery_parameters"

View File

@@ -0,0 +1,60 @@
"""
Unified Address Pydantic Schemas (P0 Refactoring).
AddressIn: For creation/updates — matches system.addresses columns.
AddressOut: For responses — returns the full Address object including id,
zip/city resolved via the postal_code relationship.
Usage:
from app.schemas.address import AddressIn, AddressOut
"""
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional
from datetime import datetime
class AddressIn(BaseModel):
"""Unified address input schema for creation and updates.
Maps to system.addresses table columns.
The `zip` and `city` fields are used to look up / create a
system.geo_postal_codes record; the resulting FK is stored as
postal_code_id on the Address record.
"""
zip: Optional[str] = Field(None, max_length=10, description="Irányítószám (postal code)")
city: Optional[str] = Field(None, max_length=100, description="Város")
street_name: Optional[str] = Field(None, max_length=200, description="Utca neve")
street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (utca, út, tér, stb.)")
house_number: Optional[str] = Field(None, max_length=50, description="Házszám")
stairwell: Optional[str] = Field(None, max_length=20, description="Lépcsőház")
floor: Optional[str] = Field(None, max_length=20, description="Emelet")
door: Optional[str] = Field(None, max_length=20, description="Ajtó")
parcel_id: Optional[str] = Field(None, max_length=50, description="Helyrajzi szám / parcella ID")
full_address_text: Optional[str] = Field(None, description="Teljes cím szövegesen")
latitude: Optional[float] = Field(None, description="GPS szélesség")
longitude: Optional[float] = Field(None, description="GPS hosszúság")
class AddressOut(BaseModel):
"""Unified address output schema for API responses.
Includes the full Address object data, with zip/city resolved
via the postal_code relationship.
"""
id: str # UUID as string for JSON serialization
zip: Optional[str] = None
city: Optional[str] = None
street_name: Optional[str] = None
street_type: Optional[str] = None
house_number: Optional[str] = None
stairwell: Optional[str] = None
floor: Optional[str] = None
door: Optional[str] = None
parcel_id: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -2,6 +2,8 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
from typing import Optional, List, Any
from datetime import datetime
from app.schemas.address import AddressIn, AddressOut
# =============================================================================
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
@@ -101,16 +103,8 @@ class CorpOnboardIn(BaseModel):
language: str = "hu"
default_currency: str = "HUF"
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
address_zip: str
address_city: str
address_street_name: str
address_street_type: str
address_house_number: str
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# --- P0 REFACTORED: Unified address via AddressIn ---
address: AddressIn = Field(..., description="Székhely címe (AddressIn struktúrában)")
contacts: List[ContactCreate] = []
@@ -132,13 +126,8 @@ class OrganizationUpdate(BaseModel):
name: Optional[str] = None
full_name: Optional[str] = None
tax_number: Optional[str] = None
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_hrsz: Optional[str] = None
# ── P0 REFACTORED: Unified address via AddressIn ──
address: Optional[AddressIn] = None
# ── Crowdsourced search fields ──
aliases: Optional[List[str]] = None
tags: Optional[List[str]] = None
@@ -170,12 +159,7 @@ class OrganizationResponse(BaseModel):
external_integration_config: Optional[Any] = None
aliases: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_hrsz: Optional[str] = None
# ── P0 REFACTORED: Unified address via AddressOut ──
address: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -6,6 +6,12 @@ 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.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): Egységes AddressIn/AddressOut.
- ProviderSearchResult: address_detail: Optional[AddressOut] a meglévő flat mezők mellett
- ProviderQuickAddIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
- ProviderUpdateIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK a backward compatibility miatt
4-Level Category Architecture (2026-06-17):
===========================================
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
@@ -15,6 +21,7 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List
from app.schemas.address import AddressIn, AddressOut
# ──────────────────────────────────────────────
@@ -97,6 +104,11 @@ class ProviderSearchResult(BaseModel):
- address_street_name, address_street_type, address_house_number
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressOut] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
FEATURE (2026-06-17): categories mező.
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
@@ -122,6 +134,8 @@ class ProviderSearchResult(BaseModel):
rating: Optional[float] = None
supported_vehicle_classes: Optional[List[str]] = None
specializations: Optional[dict] = None
# P3: Egységes cím objektum (a flat mezők mellett)
address_detail: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)
@@ -140,6 +154,11 @@ class ProviderQuickAddIn(BaseModel):
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
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressIn] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
- A 4-level kategória rendszer bevezetésével a frontend a category_ids
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
@@ -167,6 +186,8 @@ class ProviderQuickAddIn(BaseModel):
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
# P3: Egységes cím objektum (a flat mezők mellett)
address_detail: Optional[AddressIn] = None
class ProviderQuickAddResponse(BaseModel):
@@ -185,6 +206,11 @@ class ProviderUpdateIn(BaseModel):
- 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.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressIn] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
4-Level Category (2026-06-17):
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
- new_tags: A user által gépelt, új (még nem létező) címkék listája
@@ -212,6 +238,8 @@ class ProviderUpdateIn(BaseModel):
specializations: Optional[dict] = Field(
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
)
# P3: Egységes cím objektum (a flat mezők mellett)
address_detail: Optional[AddressIn] = None
class ProviderUpdateResponse(BaseModel):

View File

@@ -4,24 +4,7 @@ from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional, Any, Dict
from datetime import date, datetime
class AddressResponse(BaseModel):
"""Beágyazott cím adatok a PersonResponse számára.
A mezőnevek address_ előtaggal egyeznek a frontend által várt PersonUpdate formátummal."""
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
model_config = ConfigDict(from_attributes=True)
from app.schemas.address import AddressIn, AddressOut
class PersonResponse(BaseModel):
@@ -38,7 +21,7 @@ class PersonResponse(BaseModel):
identity_docs: Optional[Any] = None
ice_contact: Optional[Any] = None
is_active: bool = True
address: Optional[AddressResponse] = None
address: Optional[AddressOut] = None
model_config = ConfigDict(from_attributes=True)
@@ -90,16 +73,8 @@ class PersonUpdate(BaseModel):
# Okmány adatok (identity_docs)
identity_docs: Optional[Any] = None
# Cím adatok
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# Cím adatok — egységes AddressIn séma
address: Optional[AddressIn] = None
class ChangePasswordRequest(BaseModel):

View File

@@ -0,0 +1,267 @@
"""
Unified AddressManager Service (P0 Implementation).
Centralizes ALL address logic into a single class-based service.
No more direct denormalized field writes in API endpoints.
Methods:
AddressManager.resolve(db, address_data) -> UUID
Checks if the address exists (or if it's a duplicate),
resolves GeoPostalCode, and returns the address_id.
AddressManager.create_or_update(db, address_id, data) -> UUID
Atomic operation to create/update system.addresses and return the ID.
AddressManager.get_normalized(db, address_id) -> dict
Returns the full address object including postal code details.
Usage:
from app.services.address_manager import AddressManager
new_address_id = await AddressManager.create_or_update(
db, org.address_id, payload.address
)
org.address_id = new_address_id
"""
import uuid
import logging
from typing import Optional, Dict, Any
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.identity.address import Address, GeoPostalCode
from app.schemas.address import AddressIn, AddressOut
logger = logging.getLogger(__name__)
class AddressManager:
"""Centralized service for all address operations.
Replaces the legacy module-level functions in address_service.py
with a unified, class-based API that returns UUIDs for FK assignment.
"""
# ── Internal Helpers ──────────────────────────────────────────────
@staticmethod
async def _resolve_postal_code(
db: AsyncSession,
zip_code: str,
city: str,
country_code: str = "HU",
) -> Optional[int]:
"""Find or create a GeoPostalCode record and return its ID.
Looks up by zip_code first; if not found, creates a new record.
"""
stmt = select(GeoPostalCode).where(GeoPostalCode.zip_code == zip_code)
result = await db.execute(stmt)
postal = result.scalar_one_or_none()
if not postal:
postal = GeoPostalCode(
zip_code=zip_code,
city=city,
country_code=country_code,
)
db.add(postal)
await db.flush()
logger.info(
"AddressManager: Created GeoPostalCode "
"%s - %s (id=%s)", zip_code, city, postal.id
)
return postal.id
@staticmethod
async def _find_duplicate(
db: AsyncSession,
addr_in: AddressIn,
) -> Optional[Address]:
"""Check if an identical address already exists in system.addresses.
Uses a best-effort match on normalized fields:
- If zip+city+street_name+house_number all match, it's a duplicate.
- Partial matches (zip+city only) are NOT considered duplicates
to avoid false positives.
"""
if not addr_in.zip or not addr_in.city:
return None
# Resolve postal code first to get the FK
postal_code_id = await AddressManager._resolve_postal_code(
db, addr_in.zip, addr_in.city
)
if not postal_code_id:
return None
# Build match criteria
conditions = [Address.postal_code_id == postal_code_id]
if addr_in.street_name:
conditions.append(Address.street_name == addr_in.street_name)
if addr_in.house_number:
conditions.append(Address.house_number == addr_in.house_number)
stmt = select(Address).where(*conditions)
result = await db.execute(stmt)
return result.scalar_one_or_none()
# ── Public API ────────────────────────────────────────────────────
@staticmethod
async def resolve(
db: AsyncSession,
address_data: AddressIn,
) -> Optional[UUID]:
"""Resolve an address: check for duplicates, create if needed.
This is the primary method for 'find-or-create' semantics.
Returns the UUID of the existing or newly created Address record,
or None if address_data is None.
Args:
db: Database session.
address_data: The incoming address data.
Returns:
The Address UUID, or None if address_data was None.
"""
if address_data is None:
return None
# 1. Check for duplicate
existing = await AddressManager._find_duplicate(db, address_data)
if existing is not None:
logger.info(
"AddressManager.resolve: Found duplicate address %s", existing.id
)
return existing.id
# 2. No duplicate — create new
return await AddressManager.create_or_update(db, None, address_data)
@staticmethod
async def create_or_update(
db: AsyncSession,
address_id: Optional[UUID],
data: AddressIn,
) -> Optional[UUID]:
"""Atomic create-or-update for system.addresses records.
- If address_id is provided AND the record exists → UPDATE it.
- If address_id is None or the record doesn't exist → CREATE new.
- If data is None → return the existing address_id unchanged.
Args:
db: Database session.
address_id: The existing Address UUID (or None for create).
data: The incoming address data.
Returns:
The Address UUID, or None if data was None.
"""
if data is None:
return address_id # Keep existing
# ── Try to load existing ──
existing_address: Optional[Address] = None
if address_id is not None:
stmt = select(Address).where(Address.id == address_id)
result = await db.execute(stmt)
existing_address = result.scalar_one_or_none()
if existing_address is not None:
# ── UPDATE existing ──
update_fields = data.model_dump(exclude_unset=True)
# Handle zip/city → postal_code_id resolution
if "zip" in update_fields or "city" in update_fields:
new_zip = update_fields.get("zip") or existing_address.zip
new_city = update_fields.get("city") or existing_address.city
if new_zip and new_city:
postal_code_id = await AddressManager._resolve_postal_code(
db, new_zip, new_city
)
existing_address.postal_code_id = postal_code_id
# Map AddressIn field names to Address model attributes
field_mapping = {
"street_name": "street_name",
"street_type": "street_type",
"house_number": "house_number",
"stairwell": "stairwell",
"floor": "floor",
"door": "door",
"parcel_id": "parcel_id",
"full_address_text": "full_address_text",
"latitude": "latitude",
"longitude": "longitude",
}
for addr_field, model_attr in field_mapping.items():
if model_attr and addr_field in update_fields:
setattr(existing_address, model_attr, update_fields[addr_field])
await db.flush()
logger.info(
"AddressManager: Updated address %s", existing_address.id
)
return existing_address.id
# ── CREATE new ──
new_address = Address(
id=uuid.uuid4(),
street_name=data.street_name,
street_type=data.street_type,
house_number=data.house_number,
stairwell=data.stairwell,
floor=data.floor,
door=data.door,
parcel_id=data.parcel_id,
full_address_text=data.full_address_text,
latitude=data.latitude,
longitude=data.longitude,
)
# Resolve postal code if zip is provided
if data.zip and data.city:
postal_code_id = await AddressManager._resolve_postal_code(
db, data.zip, data.city
)
new_address.postal_code_id = postal_code_id
db.add(new_address)
await db.flush()
logger.info("AddressManager: Created address %s", new_address.id)
return new_address.id
@staticmethod
async def get_normalized(
db: AsyncSession,
address_id: UUID,
) -> Optional[Dict[str, Any]]:
"""Return the full address object including postal code details.
Returns a dict compatible with AddressOut schema, with zip/city
resolved via the postal_code relationship.
Args:
db: Database session.
address_id: The Address UUID to look up.
Returns:
A dict with all address fields, or None if not found.
"""
stmt = select(Address).where(Address.id == address_id)
result = await db.execute(stmt)
address = result.scalar_one_or_none()
if address is None:
return None
return AddressOut.model_validate(address).model_dump()

View File

@@ -0,0 +1,140 @@
"""
Address Service (P0 Refactoring).
Provides helper functions for creating, updating, and resolving
Address records in the system.addresses table.
Usage:
from app.services.address_service import create_or_update_address
"""
import uuid
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.identity.address import Address, GeoPostalCode
from app.schemas.address import AddressIn
logger = logging.getLogger(__name__)
async def _resolve_postal_code(db: AsyncSession, zip_code: str, city: str) -> Optional[int]:
"""Find or create a GeoPostalCode record and return its ID.
Looks up by zip_code first; if not found, creates a new record.
"""
stmt = select(GeoPostalCode).where(GeoPostalCode.zip_code == zip_code)
result = await db.execute(stmt)
postal = result.scalar_one_or_none()
if not postal:
postal = GeoPostalCode(
zip_code=zip_code,
city=city,
country_code="HU",
)
db.add(postal)
await db.flush()
logger.info(f"Created new GeoPostalCode: {zip_code} - {city} (id={postal.id})")
return postal.id
async def create_address(db: AsyncSession, addr_in: AddressIn) -> Address:
"""Create a new Address record from AddressIn data.
If zip and city are provided, resolves/creates a GeoPostalCode record
and links it via postal_code_id.
"""
address = Address(
id=uuid.uuid4(),
street_name=addr_in.street_name,
street_type=addr_in.street_type,
house_number=addr_in.house_number,
stairwell=addr_in.stairwell,
floor=addr_in.floor,
door=addr_in.door,
parcel_id=addr_in.parcel_id,
full_address_text=addr_in.full_address_text,
latitude=addr_in.latitude,
longitude=addr_in.longitude,
)
# Resolve postal code if zip is provided
if addr_in.zip and addr_in.city:
postal_code_id = await _resolve_postal_code(db, addr_in.zip, addr_in.city)
address.postal_code_id = postal_code_id
db.add(address)
await db.flush()
logger.info(f"Created Address {address.id}")
return address
async def update_address(db: AsyncSession, address: Address, addr_in: AddressIn) -> Address:
"""Update an existing Address record from AddressIn data.
Only updates fields that are explicitly set (not None) in addr_in.
If zip/city changes, resolves the new GeoPostalCode.
"""
update_fields = addr_in.model_dump(exclude_unset=True)
# Handle zip/city -> postal_code_id resolution
if "zip" in update_fields or "city" in update_fields:
new_zip = update_fields.get("zip") or address.zip
new_city = update_fields.get("city") or address.city
if new_zip and new_city:
postal_code_id = await _resolve_postal_code(db, new_zip, new_city)
address.postal_code_id = postal_code_id
# Map AddressIn field names to Address model attributes
field_mapping = {
"zip": None, # handled above
"city": None, # handled above
"street_name": "street_name",
"street_type": "street_type",
"house_number": "house_number",
"stairwell": "stairwell",
"floor": "floor",
"door": "door",
"parcel_id": "parcel_id",
"full_address_text": "full_address_text",
"latitude": "latitude",
"longitude": "longitude",
}
for addr_field, model_attr in field_mapping.items():
if model_attr and addr_field in update_fields:
setattr(address, model_attr, update_fields[addr_field])
await db.flush()
logger.info(f"Updated Address {address.id}")
return address
async def create_or_update_address(
db: AsyncSession,
addr_in: Optional[AddressIn],
existing_address: Optional[Address] = None,
) -> Optional[Address]:
"""Create or update an Address record based on whether one already exists.
This is the main helper used by PATCH endpoints.
Args:
db: Database session.
addr_in: The incoming address data (or None to skip).
existing_address: The existing Address record (or None to create new).
Returns:
The Address record, or None if addr_in was None.
"""
if addr_in is None:
return existing_address # Keep existing if no new data
if existing_address:
return await update_address(db, existing_address, addr_in)
else:
return await create_address(db, addr_in)

View File

@@ -34,14 +34,16 @@ 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, delete
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete, join
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.identity.address import Address, GeoPostalCode
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
from app.models.gamification.gamification import PointRule, UserStats
from app.schemas.address import AddressOut
from app.schemas.provider import (
ProviderSearchResult,
ProviderSearchResponse,
@@ -52,6 +54,8 @@ from app.schemas.provider import (
CategoryInfo,
)
from app.services.gamification_service import gamification_service
from app.services.address_manager import AddressManager
from app.schemas.address import AddressIn
logger = logging.getLogger(__name__)
@@ -126,17 +130,28 @@ async def find_or_create_provider_by_name(
"""
Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján.
P0 ARCHITECTURE CLEANUP (2026-07-01):
======================================
Gamification/XP logika ELTÁVOLÍTVA ebből a service rétegből.
A gamification hívások az API rétegbe (expenses.py) kerültek át.
Ez a függvény immár tiszta adatbázis service — nem tud a gamification-ről.
Visszatérési érték módosítva: (provider, created) tuple.
- created=True: új provider lett létrehozva (felfedezés)
- created=False: meglévő provider lett lekérve
pg_trgm similarity threshold: 0.3 -> 0.6 (hamis pozitív találatok csökkentése)
Ez a függvény az expense auto-discovery hook része. Amikor egy user
költséget rögzít és megad egy external_vendor_name-t, ez a függvény
megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza.
Logika:
1. Pontos match keresése ServiceProvider.name alapján (kisbetűs, space-sztrippelt)
2. Ha nem létezik → új provider létrehozása PENDING státusszal,
validation_score=10, source=import, vissza: PROVIDER_DISCOVERY
3. Ha létezik és APPROVED → vissza: PROVIDER_VERIFIED_USE
4. Ha létezik és nem APPROVED → vissza: PROVIDER_CONFIRMATION
5. A függvény meghívja a _award_provider_points()-t a megfelelő action_key-kel
1. Pontos match keresése ServiceProvider.name alapján (ILIKE, kisbetűs)
2. Fuzzy match trigram similarity-vel (pg_trgm) — ha similarity > 0.6
3. Ha nem létezik → új provider létrehozása PENDING státusszal,
validation_score=0, source=import, vissza: created=True
4. Ha létezik → vissza: created=False
Args:
db: AsyncSession
@@ -144,25 +159,37 @@ async def find_or_create_provider_by_name(
added_by_user_id: A költséget rögzítő user ID-ja
Returns:
tuple[ServiceProvider, str]: (provider, action_key)
- action_key: "PROVIDER_DISCOVERY" | "PROVIDER_CONFIRMATION" | "PROVIDER_VERIFIED_USE"
tuple[ServiceProvider, bool]: (provider, created)
- created=True: új provider lett létrehozva
- created=False: meglévő provider lett lekérve
"""
# 1. Pontos match keresése (kisbetűsen, space-sztrippelten)
# 1. Pontos match keresése (ILIKE, kisbetűsen, space-sztrippelten)
clean_name = name.strip()
provider_stmt = select(ServiceProvider).where(
func.lower(ServiceProvider.name) == func.lower(clean_name)
ServiceProvider.name.ilike(clean_name)
)
provider_result = await db.execute(provider_stmt)
provider = provider_result.scalar_one_or_none()
# 2. Fuzzy match trigram similarity-vel (pg_trgm), ha nincs pontos találat
# P0 ARCHITECTURE CLEANUP: threshold 0.3 -> 0.6 a hamis pozitívok csökkentésére
if not provider:
# 2. Ha nem létezik → létrehozás + PROVIDER_DISCOVERY
fuzzy_stmt = select(ServiceProvider).where(
func.similarity(ServiceProvider.name, clean_name) > 0.6
).order_by(
func.similarity(ServiceProvider.name, clean_name).desc()
).limit(1)
fuzzy_result = await db.execute(fuzzy_stmt)
provider = fuzzy_result.scalar_one_or_none()
if not provider:
# 3. Ha nem létezik → létrehozás, created=True
new_provider = ServiceProvider(
name=clean_name,
address=clean_name,
status=ModerationStatus.pending,
source=SourceType.api,
validation_score=10,
source=SourceType.api_import, # "import" — API import forrás
validation_score=0,
added_by_user_id=added_by_user_id,
)
db.add(new_provider)
@@ -173,44 +200,16 @@ async def find_or_create_provider_by_name(
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
)
# Gamification pont kiosztása
await _award_provider_points(
db=db,
user_id=added_by_user_id,
action_key="PROVIDER_DISCOVERY",
)
return new_provider, "PROVIDER_DISCOVERY"
# 3. Ha létezik, státusz alapján döntés
# P0 CRITICAL (Card #362): USE_UNVERIFIED_PROVIDER vs PROVIDER_CONFIRMATION
# - Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP)
# - Ha a provider PENDING es a hasznalo user a provider letrehozoja -> PROVIDER_CONFIRMATION (150 XP)
# - Ha a provider PENDING es a hasznalo user NEM a provider letrehozoja -> USE_UNVERIFIED_PROVIDER (200 XP)
# Ez megakadalyozza az XP farmingot (sajat provider hasznalata sajat maganak).
if provider.status == ModerationStatus.approved:
action_key = "PROVIDER_VERIFIED_USE"
elif provider.added_by_user_id == added_by_user_id:
# A provider letrehozoja hasznalja a sajat pending provideret
action_key = "PROVIDER_CONFIRMATION"
else:
# Mas user hasznal egy pending providert
action_key = "USE_UNVERIFIED_PROVIDER"
return new_provider, True
# 4. Ha létezik, visszaadjuk created=False
logger.info(
f"Meglevo provider hasznalata: name='{clean_name}', "
f"provider_id={provider.id}, action_key='{action_key}', "
f"provider_id={provider.id}, "
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
)
# Gamification pont kiosztása
await _award_provider_points(
db=db,
user_id=added_by_user_id,
action_key=action_key,
)
return provider, action_key
return provider, False
async def search_providers(
@@ -287,14 +286,16 @@ async def search_providers(
)
org_conditions.append(and_(*token_conditions))
if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
# a város és irányítószám adatokat.
city_clean = city.strip()
org_conditions.append(
or_(
Organization.address_city == city_clean,
Organization.address_city.ilike(f"{city_clean}%"),
Organization.address_zip == city_clean,
Organization.address_zip.ilike(f"{city_clean}%"),
GeoPostalCode.city == city_clean,
GeoPostalCode.city.ilike(f"{city_clean}%"),
GeoPostalCode.zip_code == city_clean,
GeoPostalCode.zip_code.ilike(f"{city_clean}%"),
)
)
@@ -319,30 +320,30 @@ async def search_providers(
ServiceProfile.id.in_(select(profile_subq.c.service_id))
)
# 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").
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
# a város, irányítószám és utca adatokat.
# LEFT JOIN-eket használunk, mert az address_id lehet NULL.
org_stmt = select(
Organization.id.label("id"),
Organization.name.label("name"),
Organization.address_city.label("city"),
GeoPostalCode.city.label("city"),
func.concat(
Organization.address_zip,
GeoPostalCode.zip_code,
literal(' '),
Organization.address_city,
GeoPostalCode.city,
literal(', '),
Organization.address_street_name,
Address.street_name,
literal(' '),
Organization.address_street_type,
Address.street_type,
literal(' '),
Organization.address_house_number,
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.plus_code.label("plus_code"),
GeoPostalCode.zip_code.label("address_zip"),
Address.street_name.label("address_street_name"),
Address.street_type.label("address_street_type"),
Address.house_number.label("address_house_number"),
literal(None).label("plus_code"),
Organization.is_verified.label("is_verified"),
ServiceProfile.contact_phone.label("contact_phone"),
ServiceProfile.contact_email.label("contact_email"),
@@ -355,6 +356,12 @@ async def search_providers(
).outerjoin(
ServiceProfile,
ServiceProfile.organization_id == Organization.id,
).outerjoin(
Address,
Address.id == Organization.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_id,
).where(*org_conditions)
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
@@ -517,17 +524,36 @@ async def search_providers(
for cat in row_categories
]
# P3: Egységes cím objektum összeállítása a flat mezőkből
row_address_zip = getattr(row, "address_zip", None)
row_address_street_name = getattr(row, "address_street_name", None)
row_address_street_type = getattr(row, "address_street_type", None)
row_address_house_number = getattr(row, "address_house_number", None)
row_city = row.city
row_plus_code = getattr(row, "plus_code", None)
address_detail = None
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
address_detail = AddressOut(
zip=row_address_zip,
city=row_city,
street_name=row_address_street_name,
street_type=row_address_street_type,
house_number=row_address_house_number,
full_address_text=row.address,
)
results.append(
ProviderSearchResult(
id=row.id,
name=row.name,
city=row.city,
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),
plus_code=getattr(row, "plus_code", None),
address_zip=row_address_zip,
address_street_name=row_address_street_name,
address_street_type=row_address_street_type,
address_house_number=row_address_house_number,
plus_code=row_plus_code,
contact_phone=getattr(row, "contact_phone", None),
contact_email=getattr(row, "contact_email", None),
website=getattr(row, "website", None),
@@ -535,6 +561,7 @@ async def search_providers(
categories=categories_out,
source=row.source,
is_verified=row.is_verified,
address_detail=address_detail,
)
)
@@ -954,17 +981,23 @@ async def update_provider(
if staging:
# Migráljuk Organization-be
now = datetime.now(timezone.utc)
# ── AddressManager: Cím létrehozása a staging adataiból ──
staging_addr_in = AddressIn(
city=staging.city or "",
zip=data.address_zip or "",
street_name=data.address_street_name or "",
street_type=data.address_street_type or "",
house_number=data.address_house_number or "",
)
staging_address_id = await AddressManager.create_or_update(db, None, staging_addr_in)
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,
address_id=staging_address_id,
status="pending_verification",
is_verified=False,
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
@@ -988,6 +1021,16 @@ async def update_provider(
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
crowd = await db.get(ServiceProvider, provider_id)
if crowd:
# ── AddressManager: Cím létrehozása a crowd adataiból ──
crowd_addr_in = AddressIn(
city=data.city or "",
zip=data.address_zip or "",
street_name=data.address_street_name or "",
street_type=data.address_street_type or "",
house_number=data.address_house_number or "",
)
crowd_address_id = await AddressManager.create_or_update(db, None, crowd_addr_in)
# Migráljuk Organization-be
now = datetime.now(timezone.utc)
org = Organization(
@@ -996,11 +1039,7 @@ async def update_provider(
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,
address_id=crowd_address_id,
status="pending_verification",
is_verified=False,
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
@@ -1024,26 +1063,41 @@ async def update_provider(
# 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.
# 2. Organization mezők frissítése AddressManager segítségével
# P3 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# helyett az AddressManager.create_or_update() kezeli a címet.
# A flat mezők (city, address_zip, stb.) továbbra is támogatottak
# a ProviderUpdateIn sémában a backward compatibility miatt.
org.name = data.name[:100]
org.full_name = data.name
org.display_name = data.name[:50]
# ── Cím frissítése AddressManager segítségével ──
# Összegyűjtjük a flat mezőkből az AddressIn adatokat
addr_fields = {}
if data.city is not None:
org.address_city = data.city
addr_fields["city"] = data.city
if data.address_zip is not None:
org.address_zip = data.address_zip
addr_fields["zip"] = data.address_zip
if data.address_street_name is not None:
org.address_street_name = data.address_street_name
addr_fields["street_name"] = data.address_street_name
if data.address_street_type is not None:
org.address_street_type = data.address_street_type
addr_fields["street_type"] = data.address_street_type
if data.address_house_number is not None:
org.address_house_number = data.address_house_number
addr_fields["house_number"] = data.address_house_number
if data.plus_code is not None:
org.plus_code = data.plus_code
addr_fields["plus_code"] = data.plus_code
# Ha van address_detail, az elsőbbséget élvez a flat mezőkkel szemben
if data.address_detail is not None:
org.address_id = await AddressManager.create_or_update(
db, org.address_id, data.address_detail
)
elif addr_fields:
addr_in = AddressIn(**addr_fields)
org.address_id = await AddressManager.create_or_update(
db, org.address_id, addr_in
)
# 3. ServiceProfile lekérdezése (ha létezik)
profile_stmt = select(ServiceProfile).where(

View File

@@ -1,13 +1,14 @@
"""
Test: USE_UNVERIFIED_PROVIDER pontszabály bekötése (Card #362)
=============================================================
Test: find_or_create_provider_by_name() with (provider, created) tuple
======================================================================
Ellenőrzi, hogy a find_or_create_provider_by_name() függvény helyesen
differenciál a PROVIDER_CONFIRMATION és USE_UNVERIFIED_PROVIDER között.
tér vissza a (provider, created) tuple-lel, ahol created bool jelzi,
hogy új provider jött-e létre vagy meglévő került elő.
Logika:
1. Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP)
2. Ha a provider PENDING és a használó user a provider létrehozója -> PROVIDER_CONFIRMATION (150 XP)
3. Ha a provider PENDING és a használó user NEM a provider létrehozója -> USE_UNVERIFIED_PROVIDER (200 XP)
1. Ha a provider nem létezik -> új provider, created=True
2. Ha a provider már létezik -> meglévő provider, created=False
3. A gamification/XP logika már az API rétegben (expenses.py) történik
Használat:
docker compose exec sf_api python3 /app/backend/tests/active/test_use_unverified_provider.py
@@ -24,44 +25,33 @@ from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.config import settings
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
from app.models.gamification.gamification import PointRule, UserStats, PointsLedger
from app.services.provider_service import find_or_create_provider_by_name, _award_provider_points
from app.services.provider_service import find_or_create_provider_by_name
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("Test-USE_UNVERIFIED_PROVIDER")
logger = logging.getLogger("Test-FIND_OR_CREATE_PROVIDER")
TEST_USER_ID = 1 # Provider creator (superadmin@profibot.hu)
TEST_OTHER_USER_ID = 2 # Other user using the provider (admin@profibot.hu)
TEST_USER_ID = 1
TEST_OTHER_USER_ID = 2
TEST_PROVIDER_NAME = "Test Unverified Provider Card362"
async def cleanup_test_data(db: AsyncSession):
"""Clean up any leftover test data."""
await db.execute(
delete(PointsLedger).where(
PointsLedger.user_id.in_([TEST_USER_ID, TEST_OTHER_USER_ID])
)
)
await db.execute(
delete(UserStats).where(
UserStats.user_id.in_([TEST_USER_ID, TEST_OTHER_USER_ID])
)
)
await db.execute(
delete(ServiceProvider).where(ServiceProvider.name == TEST_PROVIDER_NAME)
)
await db.commit()
async def test_use_unverified_provider():
async def test_find_or_create_provider():
"""
Test scenario:
1. User A (TEST_USER_ID) creates a provider via find_or_create_provider_by_name()
-> Should return PROVIDER_DISCOVERY (300 XP) - new provider
2. User A uses the same provider again
-> Should return PROVIDER_CONFIRMATION (150 XP) - same user, pending provider
3. User B (TEST_OTHER_USER_ID) uses the same provider
-> Should return USE_UNVERIFIED_PROVIDER (200 XP) - different user, pending provider
-> Should return (provider, True) - new provider created
2. User A calls the same provider again
-> Should return (provider, False) - existing provider found
3. User B (TEST_OTHER_USER_ID) calls the same provider
-> Should return (provider, False) - existing provider found
"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@@ -72,21 +62,21 @@ async def test_use_unverified_provider():
await cleanup_test_data(db)
# =========================================================
# STEP 1: User A creates a new provider (PROVIDER_DISCOVERY)
# STEP 1: User A creates a new provider (created=True)
# =========================================================
logger.info("=" * 60)
logger.info("STEP 1: User A creates a new provider")
logger.info("=" * 60)
provider, action_key = await find_or_create_provider_by_name(
provider, created = await find_or_create_provider_by_name(
db=db,
name=TEST_PROVIDER_NAME,
added_by_user_id=TEST_USER_ID,
)
assert provider is not None, "Provider should be created"
assert action_key == "PROVIDER_DISCOVERY", (
f"Expected PROVIDER_DISCOVERY, got {action_key}"
assert created is True, (
f"Expected created=True for new provider, got {created}"
)
assert provider.status == ModerationStatus.pending, (
f"Expected pending status, got {provider.status}"
@@ -94,19 +84,10 @@ async def test_use_unverified_provider():
assert provider.added_by_user_id == TEST_USER_ID, (
f"Expected added_by_user_id={TEST_USER_ID}, got {provider.added_by_user_id}"
)
logger.info(f"✅ STEP 1 PASS: action_key={action_key}, provider_id={provider.id}")
# Check that points were awarded
stats_stmt = select(UserStats).where(UserStats.user_id == TEST_USER_ID)
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
assert stats is not None, "UserStats should exist"
assert stats.total_xp >= 300, (
f"Expected at least 300 XP, got {stats.total_xp}"
)
logger.info(f"✅ STEP 1 XP CHECK: total_xp={stats.total_xp}")
logger.info(f"✅ STEP 1 PASS: created={created}, provider_id={provider.id}")
# =========================================================
# STEP 2: User A uses the same provider again (PROVIDER_CONFIRMATION)
# STEP 2: User A uses the same provider again (created=False)
# =========================================================
logger.info("=" * 60)
logger.info("STEP 2: User A uses the same provider again")
@@ -115,54 +96,45 @@ async def test_use_unverified_provider():
# Re-fetch to get clean state
await db.refresh(provider)
provider2, action_key2 = await find_or_create_provider_by_name(
provider2, created2 = await find_or_create_provider_by_name(
db=db,
name=TEST_PROVIDER_NAME,
added_by_user_id=TEST_USER_ID,
)
assert provider2.id == provider.id, "Should be the same provider"
assert action_key2 == "PROVIDER_CONFIRMATION", (
f"Expected PROVIDER_CONFIRMATION, got {action_key2}"
assert created2 is False, (
f"Expected created=False for existing provider, got {created2}"
)
logger.info(f"✅ STEP 2 PASS: action_key={action_key2}")
logger.info(f"✅ STEP 2 PASS: created={created2}")
# =========================================================
# STEP 3: User B uses the same provider (USE_UNVERIFIED_PROVIDER)
# STEP 3: User B uses the same provider (created=False)
# =========================================================
logger.info("=" * 60)
logger.info("STEP 3: User B uses the same provider")
logger.info("=" * 60)
provider3, action_key3 = await find_or_create_provider_by_name(
provider3, created3 = await find_or_create_provider_by_name(
db=db,
name=TEST_PROVIDER_NAME,
added_by_user_id=TEST_OTHER_USER_ID,
)
assert provider3.id == provider.id, "Should be the same provider"
assert action_key3 == "USE_UNVERIFIED_PROVIDER", (
f"Expected USE_UNVERIFIED_PROVIDER, got {action_key3}"
assert created3 is False, (
f"Expected created=False for existing provider, got {created3}"
)
logger.info(f"✅ STEP 3 PASS: action_key={action_key3}")
# Check that User B got points
stats_stmt2 = select(UserStats).where(UserStats.user_id == TEST_OTHER_USER_ID)
stats2 = (await db.execute(stats_stmt2)).scalar_one_or_none()
assert stats2 is not None, "UserStats should exist for User B"
assert stats2.total_xp >= 200, (
f"Expected at least 200 XP for User B, got {stats2.total_xp}"
)
logger.info(f"✅ STEP 3 XP CHECK: User B total_xp={stats2.total_xp}")
logger.info(f"✅ STEP 3 PASS: created={created3}")
# =========================================================
# SUMMARY
# =========================================================
logger.info("=" * 60)
logger.info("🎉 ALL TESTS PASSED!")
logger.info(f" - PROVIDER_DISCOVERY (300 XP): ✅ User A created provider")
logger.info(f" - PROVIDER_CONFIRMATION (150 XP): ✅ User A reused own provider")
logger.info(f" - USE_UNVERIFIED_PROVIDER (200 XP): ✅ User B used pending provider")
logger.info(f" - New provider creation (created=True): ✅")
logger.info(f" - Existing provider reuse (created=False): ✅")
logger.info(f" - Cross-user existing provider (created=False): ✅")
logger.info("=" * 60)
# Clean up
@@ -183,4 +155,4 @@ async def test_use_unverified_provider():
if __name__ == "__main__":
asyncio.run(test_use_unverified_provider())
asyncio.run(test_find_or_create_provider())

View File

@@ -0,0 +1,209 @@
# 🏗️ Cím API Végpontok Egységességi Auditja
**Dátum:** 2026-07-01
**Auditor:** Rendszer-Architect
**Cél:** Annak ellenőrzése, hogy a címet kezelő API végpontok be- és kimeneti formátuma egységes-e, és alkalmas-e egy központi címmodul (Address Module) kezelésére.
---
## 📊 Összefoglaló
**Verdikt: 🔴 NEM EGYSÉGES — 3 párhuzamos címformátum létezik, súlyos inkonzisztenciákkal.**
A rendszerben jelenleg **3 féle cím séma** és **2 teljesen eltérő címkezelési stratégia** fut párhuzamosan. Egy központi címmodul bevezetése **sürgős refaktorálást** igényel.
---
## 🔍 Feltárt címformátumok
### 1. `AddressIn` / `AddressOut` (Unified P0 Refactored)
**Fájl:** `backend/app/schemas/address.py`
| Mező | Típus | Leírás |
|------|-------|--------|
| `zip` | `Optional[str]` | Irányítószám |
| `city` | `Optional[str]` | Város |
| `street_name` | `Optional[str]` | Utca neve |
| `street_type` | `Optional[str]` | Közterület jellege |
| `house_number` | `Optional[str]` | Házszám |
| `stairwell` | `Optional[str]` | Lépcsőház |
| `floor` | `Optional[str]` | Emelet |
| `door` | `Optional[str]` | Ajtó |
| `parcel_id` | `Optional[str]` | Helyrajzi szám |
| `full_address_text` | `Optional[str]` | Teljes cím szöveg |
| `latitude` | `Optional[float]` | GPS szélesség |
| `longitude` | `Optional[float]` | GPS hosszúság |
**Használja:** `organizations.py` (P0 refactored — nested `AddressIn` bemenet, `AddressOut` kimenet)
### 2. `AddressResponse` (user.py séma)
**Fájl:** `backend/app/schemas/user.py`
| Mező | Típus | Eltérés |
|------|-------|---------|
| `address_zip` | `Optional[str]` | 🟡 **address_ előtaggal** |
| `address_city` | `Optional[str]` | 🟡 **address_ előtaggal** |
| `address_street_name` | `Optional[str]` | |
| `address_street_type` | `Optional[str]` | |
| `address_house_number` | `Optional[str]` | |
| `address_stairwell` | `Optional[str]` | |
| `address_floor` | `Optional[str]` | |
| `address_door` | `Optional[str]` | |
| `address_hrsz` | `Optional[str]` | 🟡 **eltérő név: hrsz vs parcel_id** |
| `full_address_text` | `Optional[str]` | |
| `latitude` | `Optional[float]` | |
| `longitude` | `Optional[float]` | |
**Használja:** `users.py` (response), `admin_persons.py` (response + input: `PersonAddressUpdate`)
### 3. Admin endpointok saját belső sémái
**Fájl:** `backend/app/api/v1/endpoints/admin_providers.py`
| Mező | Típus | Megjegyzés |
|------|-------|------------|
| `address` | `Optional[str]` | 🟡 **Szabad szöveges cím (legacy)** |
| `city` | `Optional[str]` | 🟡 **city (előtag nélkül)** |
| `address_zip` | `Optional[str]` | 🟡 **address_ előtaggal** |
| `address_street_name` | `Optional[str]` | |
| `address_street_type` | `Optional[str]` | |
| `address_house_number` | `Optional[str]` | |
| `plus_code` | `Optional[str]` | 🟢 Csak itt van |
| `contact_phone` | `Optional[str]` | |
**Használja:** `admin_providers.py` (`ProviderListItem`, `ProviderDetail`, `AdminProviderUpdate`), `admin_organizations.py`
### 4. Admin user list (`admin.py`)
**Fájl:** `backend/app/api/v1/endpoints/admin.py`
| Mező | Típus | Megjegyzés |
|------|-------|------------|
| `postal_code` | `Optional[str]` | 🔴 **Teljesen eltérő név** |
| `city` | `Optional[str]` | 🟡 **előtag nélkül** |
| `street` | `Optional[str]` | 🔴 **street (nem street_name)** |
| `house_number` | `Optional[str]` | 🟢 Ok |
| `address` | `Optional[str]` | 🟡 **Összefűzött string** |
---
## 🗺️ Érintett API Végpontok és Formátumuk
| # | Végpont fájl | Cím bemenet | Cím kimenet | Formátum azonosság |
|---|-------------|-------------|-------------|-------------------|
| 1 | `organizations.py` | `AddressIn` (nested) | `AddressOut` (nested) | ✅ Egységes (P0) |
| 2 | `users.py` | Denormalizált `address_*` | `AddressResponse` (`address_*`) | ⚠️ Részben |
| 3 | `admin.py` | — | Saját dict: `postal_code`, `city`, `street` | 🔴 **Eltérő** |
| 4 | `admin_providers.py` | Saját `AdminProviderUpdate` | Saját `ProviderDetail` | ⚠️ Részben |
| 5 | `admin_organizations.py` | Denormalizált `address_*` | Denormalizált `address_*` | ⚠️ Részben |
| 6 | `admin_persons.py` | `PersonAddressUpdate` (`address_*`) | `AddressResponse` (`address_*`) | ⚠️ Részben |
| 7 | `providers.py` | `ProviderQuickAddIn` / `ProviderUpdateIn` | `ProviderSearchResult` | ⚠️ Saját séma |
| 8 | `gamification.py` | `city`, `address` (string) | — | 🔴 **Eltérő** |
---
## 🚨 Kritikus Inkonzisztenciák
### 1. 🔴 Mezőelnevezési konfliktus: `zip` vs `address_zip` vs `postal_code`
A 3 különböző séma 3 különböző néven hivatkozik ugyanarra az adatra:
- `zip` (`AddressIn`/`AddressOut` — P0 refactored)
- `address_zip` (`AddressResponse`, `PersonUpdate` — user séma)
- `postal_code` (`admin.py` user list response)
**Következmény:** A frontend fejlesztőknek tudniuk kell, hogy melyik endpoint melyik mezőnevet várja/adja. Ez magas hibalehetőséget hordoz.
### 2. 🔴 `address_hrsz` vs `parcel_id` — névütközés
- `AddressResponse` / `PersonAddressUpdate`: `address_hrsz`
- `AddressIn` / `AddressOut`: `parcel_id`
- Modell (`Address`): `parcel_id`
**Következmény:** Ugyanaz az adat (helyrajzi szám) két különböző néven szerepel.
### 3. 🔴 Kétféle címkezelési stratégia (FK vs denormalizált)
**Stratégia A — Unified (P0 Refactored):**
- `system.addresses` tábla + `address_id` FK
- `AddressIn` / `AddressOut` séma
- **Használja:** `organizations.py`
**Stratégia B — Denormalizált:**
- Címmezők közvetlenül a szülő táblában (`address_city`, `address_zip`, stb.)
- Külön `Address` entitás nélkül
- **Használja:** `admin_organizations.py`, `admin_providers.py`, `providers.py`, `users.py`
### 4. 🟡 Duplikált séma definíciók
Az `admin_providers.py` fájlban a `ProviderListItem`, `ProviderDetail`, `AdminProviderCreate` és `AdminProviderUpdate` osztályok saját Pydantic sémákként vannak definiálva a fájlon belül. Ezek mezői részben átfednek az `AddressIn`/`AddressOut` sémákkal, de nem azokat használják.
### 5. 🟡 ProviderSearchResult duplikált címmezőkkel
A `ProviderSearchResult` (`backend/app/schemas/provider.py`) tartalmaz egy `address` (string) mezőt **és** atomizált mezőket is (`address_zip`, `address_street_name`, stb.). Ez redundáns.
---
## 📋 Javasolt Refaktor Terv
### Fázis 1: Egységes címmodul megerősítése
Az `AddressIn`/`AddressOut` séma megtartása és kiterjesztése:
```python
class AddressIn(BaseModel):
"""Unified address input schema."""
zip: Optional[str] = None
city: Optional[str] = None
street_name: Optional[str] = None
street_type: Optional[str] = None
house_number: Optional[str] = None
stairwell: Optional[str] = None
floor: Optional[str] = None
door: Optional[str] = None
parcel_id: Optional[str] = None
full_address_text: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
```
### Fázis 2: Érintett endpointok átállítása
| Prioritás | Végpont | Jelenlegi | Átállás |
|-----------|---------|-----------|---------|
| 🔴 P1 | `admin.py` user list | `postal_code`/`city`/`street` dict | `AddressOut`-ra váltás |
| 🔴 P1 | `admin_persons.py` | `PersonAddressUpdate` | `AddressIn` használata |
| 🟡 P2 | `users.py` | Denormalizált `address_*` | `AddressIn` + FK |
| 🟡 P2 | `admin_providers.py` | Saját `ProviderDetail` etc. | `AddressOut` beágyazás |
| 🟡 P2 | `admin_organizations.py` | Denormalizált `address_*` | `AddressIn`/`AddressOut` |
| 🟢 P3 | `providers.py` | `ProviderSearchResult` | `AddressOut` beágyazás |
| 🟢 P3 | `gamification.py` | `city`/`address` string | `AddressIn` használata |
### Fázis 3: `AddressResponse` eltávolítása
- `AddressResponse` (`backend/app/schemas/user.py`) → eltávolítani
- Helyette `AddressOut`-t használni mindenhol
- Frontend kompatibilitás: mapping réteg a frontenden, ha az `address_*` előtagot várja
---
## ⚙️ Technikai Megvalósítási Javaslat
### Backend oldalon:
1. `AddressIn` bővítése `country` mezővel
2. Az összes admin endpoint átállítása `AddressIn`/`AddressOut` használatára
3. `Organization` modell denormalizált `address_*` mezőinek deprecated flaggelése
4. `create_or_update_address` service kiterjesztése az összes használati esetre
### Frontend oldalon:
1. Egységes `address` komponens bevezetése, ami `AddressOut` formátumot vár
2. Mapping réteg a régi `address_*` formátumról az új `AddressOut` formátumra
---
## 📈 Következtetés
**A rendszer JELENLEG NEM alkalmas egy központi címmodul kezelésére.**
A P0 refactored `AddressIn`/`AddressOut` séma irányába történő elmozdulás elkezdődött (`organizations.py`), de a többi endpoint és séma még a régi, denormalizált megközelítést használja. A kettősség komoly karbantartási terhet és hibalehetőséget jelent.
**Javasolt lépések:**
1. 🔴 **Azonnal:** `admin.py` user list válasz átállítása `AddressOut` formátumra
2. 🔴 **Rövidtávon:** `AddressResponse` (`user.py`) és `PersonAddressUpdate` (`admin_persons.py`) lecserélése `AddressOut`/`AddressIn`-re
3. 🟡 **Középtávon:** Az összes admin provider/organization endpoint átállítása
4. 🟢 **Hosszútávon:** Provider sémák (`ProviderSearchResult`, `ProviderQuickAddIn`) átállítása

View File

@@ -0,0 +1,72 @@
# P0 Architecture Audit: AddressManager Usage Verification
**Date:** 2026-07-01
**Auditor:** Rendszer-Architect
**Scope:** Backend models, services, endpoints
**Goal:** Verify that no direct SQL/ORM inserts/updates are bypassing the AddressManager
---
## 1. Model Analysis
### ✅ Organization (`backend/app/models/marketplace/organization.py`)
- **Status: CLEAN** — Zero denormalized address fields
- Uses only FK columns: `address_id`, `billing_address_id`, `notification_address_id``system.addresses.id`
- Has proper SQLAlchemy relationship definitions for all three address types
### ✅ Branch (`backend/app/models/marketplace/organization.py:324-366`)
- **Status: CLEAN** — Zero denormalized address fields
- Uses only `address_id` FK → `system.addresses.id`
### ✅ ServiceStaging (in `backend/app/models/marketplace/service.py:170-195`)
- **Status: CLEAN** — Uses `address_id` FK → `system.addresses.id`
### ✅ Address (`backend/app/models/identity/address.py:39-74`)
- **Status: CLEAN** — Uses proper FK-based architecture
- `zip` and `city` are **properties** resolved via the `postal_code` relationship
### ⚠️ ServiceProvider (`backend/app/models/identity/social.py:23-71`)
- **Status: INTENTIONAL DENORMALIZATION** — Contains flat address fields
- Marked "P0 HYBRID VENDOR REFACTOR" design choice
---
## 2. Critical Bugs Found
### 🔴 P0 CRITICAL: `admin.py:943` — `Organization.address_city` REMOVED from ORM
**File:** `backend/app/api/v1/endpoints/admin.py:943`
The `search_organizations_by_name` endpoint selects `Organization.address_city` which is **not defined** in the `Organization` SQLAlchemy model. SQLAlchemy will raise an error at runtime.
**Impact:** Admin organization search (`/admin/organizations/search?name=...`) is broken.
### 🟡 P1: `admin_persons.py:670-699` — Direct Address writes bypassing AddressManager
Directly writes `address.street_name = ...` without calling `AddressManager.create_or_update()`. No `postal_code_id` resolution.
### 🟡 P1: `admin_organizations.py` — Flat address fields silently ignored
`OrganizationUpdate` schema has `address_zip`, `address_city` etc. but `hasattr(org, "address_zip")` returns False → silently ignored.
---
## 3. Database Ghost Columns
The following old denormalized columns **still exist** in `fleet.organizations` table but have NO ORM mapping:
`address_city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`, `billing_city/zip/street_name/street_type`, `notification_city/zip/street_name/street_type`
---
## 4. Summary
| Severity | Issue | File:Line |
|----------|-------|-----------|
| 🔴 P0 | `Organization.address_city` removed from ORM, still referenced | `admin.py:943` |
| 🟡 P1 | Direct Address writes bypassing AddressManager | `admin_persons.py:670-699` |
| 🟡 P1 | Flat address fields silently ignored on update | `admin_organizations.py:1242-1343` |
| 🟡 P2 | Ghost columns in DB from old schema | `fleet.organizations` |
| ✅ PASS | Organization model uses FKs only | `organization.py` |
| ✅ PASS | Branch model uses FK only | `organization.py:333` |
| ✅ PASS | admin_organizations.py correctly uses AddressManager | `admin_organizations.py:1313-1335` |
| ✅ PASS | Address model properly normalized | `address.py` |

View File

@@ -0,0 +1,65 @@
-- =============================================================================
-- P0 ARCHITECTURE CLEANUP: Ghost Column Removal Script
-- Generated: 2026-07-01
--
-- ⚠️ WARNING: Run this script ONLY after verifying that ALL code references
-- to these ghost columns have been removed from the codebase.
--
-- These columns were previously on fleet.organizations but have been
-- replaced by the unified address system (system.addresses FK via
-- address_id, billing_address_id, notification_address_id).
--
-- The Organization model now uses Python properties that delegate to
-- the `address` relationship (Address → GeoPostalCode).
--
-- Audit reference: docs/p0_address_manager_usage_audit_2026-07-01.md
-- =============================================================================
BEGIN;
-- ═════════════════════════════════════════════════════════════════════════════
-- fleet.organizations — Primary Address Ghost Columns
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_city;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_zip;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_street_name;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_street_type;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS address_house_number;
-- ═════════════════════════════════════════════════════════════════════════════
-- fleet.organizations — Billing Address Ghost Columns
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_zip;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_city;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_street_name;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_street_type;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS billing_house_number;
-- ═════════════════════════════════════════════════════════════════════════════
-- fleet.organizations — Notification Address Ghost Columns
-- ═════════════════════════════════════════════════════════════════════════════
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_zip;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_city;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_street_name;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_street_type;
ALTER TABLE fleet.organizations DROP COLUMN IF EXISTS notification_house_number;
-- ═════════════════════════════════════════════════════════════════════════════
-- Verification Queries (run after migration to confirm cleanup)
-- ═════════════════════════════════════════════════════════════════════════════
-- Should return 0 rows (all ghost columns removed):
-- SELECT column_name
-- FROM information_schema.columns
-- WHERE table_schema = 'fleet'
-- AND table_name = 'organizations'
-- AND column_name IN (
-- 'address_city', 'address_zip', 'address_street_name', 'address_street_type', 'address_house_number',
-- 'billing_zip', 'billing_city', 'billing_street_name', 'billing_street_type', 'billing_house_number',
-- 'notification_zip', 'notification_city', 'notification_street_name', 'notification_street_type', 'notification_house_number'
-- );
COMMIT;

View File

@@ -0,0 +1,305 @@
# 🏷️ Címke/Kategória Rendszer Egységességi Audit — Backend & API
**Dátum:** 2026-07-01
**Auditor:** Rendszer-Architect
**Hatáskör:** Backend modellek, Pydantic sémák, API végpontok, service réteg
**Cél:** A címke-kezelés (tag/category/specialization) egységességének vizsgálata
---
## 1. VEZETŐI ÖSSZEFOGLALÓ
A rendszerben **5 különböző címke koncepció** létezik egymás mellett, részben átfedő funkciókkal. A legkritikusabb probléma a **duális címke rendszer** (`ExpertiseTag` struktúrált fa vs. `specialization_tags` JSONB), valamint a **hiányzó `tags` mező** az admin API-ból. Az alábbi jelentés részletesen feltárja az összes anomáliát.
---
## 2. AZONOSÍTOTT CÍMKE RENDSZEREK
### 2.1 ExpertiseTag (4-szintű hierarchikus fa)
| Tulajdonság | Érték |
|---|---|
| **Tábla** | `marketplace.expertise_tags` |
| **Struktúra** | Adjacency List (`parent_id`) + Materialized Path (`path`) |
| **Szintek** | Level 0 (Járműtípus) → Level 3 (Specifikus feladat) |
| **Kötés** | `ServiceExpertise` junction táblán keresztül |
| **Többnyelvűség** | `name_hu`, `name_en`, `name_translations` (JSONB) |
| **Játékosítás** | `is_official`, `discovery_points`, `usage_count`, `suggested_by_id` |
| **API végpontok** | `GET /providers/categories/tree`, `/categories/autocomplete`, `/categories` |
**Státusz:** ✅ Jól strukturált, DDD-konform.
### 2.2 ServiceProfile.specialization_tags (JSONB — strukturálatlan)
| Tulajdonság | Érték |
|---|---|
| **Modell** | `ServiceProfile.specialization_tags` |
| **Típus** | `JSONB`, server_default=`'{}'::jsonb` |
| **Tartalom** | `{"primary_category": "str", "user_tags": ["cimke1", "cimke2"]}` |
| **API-ban** | `ProviderSearchResult.tags: List[str]` — csak a `user_tags` érték |
| **Service réteg** | `provider_service.py``spec_tags.get("user_tags", [])` |
**Státusz:** ⚠️ Átfedésben az ExpertiseTag rendszerrel.
### 2.3 ServiceProfile.specializations (JSONB — struktúrált)
| Tulajdonság | Érték |
|---|---|
| **Modell** | `ServiceProfile.specializations` |
| **Típus** | `JSONB`, server_default=`'{}'::jsonb` |
| **Tartalom** | `{"brands": ["Volvo"], "propulsion": ["EV"]}` |
| **Ugyanez a mező** | `ServiceProvider.specializations` — identikus struktúra |
**Státusz:** ✅ Konzisztens, jól elkülönített koncepció.
### 2.4 Organization.tags (JSONB tömb — közösségi értékelés)
| Tulajdonság | Érték |
|---|---|
| **Modell** | `Organization.tags` |
| **Típus** | `JSONB` tömb, server_default=`'[]'::jsonb` |
| **Tartalom** | `["gyors", "megbízható", "kedvező ár"]` |
| **API séma** | `OrganizationResponse.tags: List[str]` |
| **Frissítés** | `OrganizationUpdate.tags: Optional[List[str]]` |
**Státusz:** ⚠️ Nómenklatúra ütközés a `specialization_tags` "tags" elnevezéssel.
### 2.5 ServiceProvider.category (Egyszerű string)
| Tulajdonság | Érték |
|---|---|
| **Modell** | `ServiceProvider.category` |
| **Típus** | `Optional[str]` |
| **Tartalom** | Pl. `"brake_service"` — az ExpertiseTag `key` mezője |
| **API-ban** | `ProviderSearchResult.category: Optional[str]` |
**Státusz:** ⚠️ Legacy mező, az új 4-level rendszer ezt fokozatosan kiváltja.
### 2.6 További címke/kategória mezők
| Mező | Modell | Típus | Cél |
|---|---|---|---|
| `supported_vehicle_classes` | ServiceProfile, ServiceProvider | `ARRAY(String)` | Járműosztály kompatibilitás |
| `CategoryInfo` (schema) | `provider.py` | Pydantic | API válaszban kategória adatok |
| `SystemParameter.category` | `system.py` | `String` | Rendszerparaméter csoportosítás |
| `Cost.category` | `service.py` | `String` | Költség kategorizálás |
| `CostCategory` (tábla) | `fleet_finance.cost_categories` | SQL tábla | Struktúrált költségkategóriák |
---
## 3. FELTÁRT INKONZISZTENCIÁK
### 🔴 KRITIKUS (P0)
#### 3.1 Duális címke rendszer — ExpertiseTag vs specialization_tags
A ServiceProfile-on **KÉT** független címke tároló mező van:
```python
# 1. Strukturált (FK-alapú)
expertises: Mapped[List["ServiceExpertise"]] # → ExpertiseTag tábla
# 2. Strukturálatlan (JSONB)
specialization_tags: Mapped[Any] = mapped_column(JSONB, ...) # {"user_tags": [...]}
```
**Probléma:**
- Ugyanazt a koncepciót („milyen szolgáltatást nyújt a provider?”) két különböző módon tárolja
- A `specialization_tags.user_tags` értékei **NINCSENEK szinkronizálva** az `ExpertiseTag` táblával
- A `quick_add_provider` és `update_provider` a `category_ids`-t az ExpertiseTag-be teszi, de a `tags`-t csak a `specialization_tags` JSONB-be
**Hatás:** Adatduplikáció és inkonzisztencia a két rendszer között.
**Javaslat:** Konszolidáció — a `specialization_tags` fokozatos kivezetése és minden címke az `ExpertiseTag``ServiceExpertise` kapcsolaton keresztül történjen.
---
### 🟠 MAGAS (P1)
#### 3.2 ProviderSearchResult.specialization — Holt mező
`ProviderSearchResult.specialization: List[str]` deklarálva van, de **SEHOL** sincs beállítva:
```python
class ProviderSearchResult(BaseModel):
specialization: List[str] = Field(default_factory=list) # ← MINDIG üres lista!
```
A `provider_service.py` a `results.append(ProviderSearchResult(...))` hívásnál nem adja át a `specialization` paramétert, így az mindig a default `[]` értéket kapja.
**Hatás:** A frontend soha nem kap specializációs adatokat ezen a mezőn keresztül.
**Javaslat:** Távolítsd el, vagy implementáld a feltöltését.
---
#### 3.3 Admin API-ból hiányzó `tags` mező
A `ProviderDetail` séma **NEM tartalmaz** `tags` mezőt:
```python
class ProviderDetail(BaseModel):
category: Optional[str] = None # ✓ Van
category_ids: Optional[List[int]] = None # ✓ Van
supported_vehicle_classes: Optional[List[str]] = None # ✓ Van
# tags: Optional[List[str]] = None # ✗ HIÁNYZIK!
```
Ugyanez igaz a `ProviderUpdateInput` sémára is — nincs `tags` mező, pedig a user API (`ProviderUpdateIn`) tartalmazza.
**Hatás:** Az admin felületen nem lehet szerkeszteni a specialization_tags.user_tags értékeit.
**Javaslat:** Add hozzá a `tags: Optional[List[str]]` mezőt mindkét admin sémához.
---
#### 3.4 Nómenklatúra káosz
A rendszerben több, hasonló nevű, de eltérő koncepciójú mező van:
| Mező név | Hol | Típus | Koncepció |
|---|---|---|---|
| `specialization_tags` | ServiceProfile | JSONB dict | `{"primary_category", "user_tags"}` |
| `specializations` | ServiceProfile | JSONB dict | `{"brands", "propulsion"}` |
| `specialization` | ProviderSearchResult | `List[str]` | **HOLT MEZŐ** |
| `tags` | Organization | JSONB array | Közösségi értékelő címkék |
| `tags` | ProviderSearchResult | `List[str]` | `specialization_tags.user_tags` |
| `tags` | ProviderQuickAddIn | `List[str]` | `specialization_tags.user_tags` |
| `category` | ServiceProvider | `Optional[str]` | Egyszerű string |
| `category` | ExpertiseTag | `Optional[str]` | Csoportosítás |
| `category_ids` | Több séma | `List[int]` | ExpertiseTag ID-k |
**Javaslat:** Vezess be egységes nevezéktant — pl. `expertise_ids` a kategória ID-kra, `user_tags` a szabad szöveges címkékre.
---
### 🟡 KÖZEPES (P2)
#### 3.5 Organization.tags vs ServiceProfile.specialization_tags — Azonos név, eltérő koncepció
Az `Organization.tags` célja: "Crowdsourced evaluation characteristics / tags (e.g. ['gyors', 'megbízható', 'kedvező ár'])".
A `ServiceProfile.specialization_tags` célja: "Milyen szakmai szolgáltatást nyújt?"
**Mindkettőt `tags`-nek hívja a schema, de teljesen más a jelentésük.** Ez a frontend oldalán is zavart okoz.
---
#### 3.6 `new_tags` — Ingyenes string → ExpertiseTag konverzió
A `_create_new_tags` függvény a user által gépelt szöveges címkéket konvertálja ExpertiseTag rekordokk:
```python
new_tag = ExpertiseTag(
key=_slugify(tag_name), # pl. "gyors olajcsere" → "gyors_olajcsere"
category="user_created", # HARDCODED!
level=3, # HARDCODED!
is_official=False,
)
```
**Probléma:** A `category="user_created"` hardkódolt érték, ami eltér a rendszer által használt kategória értékektől. A `level=3` szintén minden új címkére fix.
**Javaslat:** A `category` mezőt az API-ból (vagy ML-alapú besorolásból) kellene kapnia.
---
#### 3.7 ServiceProvider nélkülözi a `specialization_tags`-et
A `ServiceProvider` modellben nincs `specialization_tags` JSONB mező, csak a `ServiceProfile`-ban. Amikor a `quick_add_provider` létrehoz egy ServiceProvider-t, a `specialization_tags` adatok a ServiceProfile-ba kerülnek. Az admin lekérdezés azonban a `ProviderDetail`-ben nem adja vissza ezeket.
---
### 🔵 ALACSONY (P3)
#### 3.8 Cost.category vs CostCategory hivatkozás
A `Cost` modellben van egy `category: str` mező, de a költségek ténylegesen egy külön `CostCategory` táblára hivatkoznak `category_id`-n keresztül. Ez a kettősség redundáns.
#### 3.9 VehicleModelDefinition.category
`vehicle_definitions.py` — Egyszerű `String(50)` mező, nem kapcsolódik a 4-level rendszerhez.
---
## 4. ADATFOLYAMAT ÁBRA
```mermaid
flowchart TB
subgraph "BEMENET (API)"
UI1["ProviderQuickAddIn<br/>{tags, category_ids, new_tags}"]
UI2["ProviderUpdateIn<br/>{tags, category_ids, new_tags}"]
ADMIN["ProviderUpdateInput<br/>HIÁNYZIK: tags mező"]
end
subgraph "SERVICE RÉTEG"
QAP["quick_add_provider()"]
UP["update_provider()"]
SP["search_providers()"]
end
subgraph "TÁROLÁS"
ET["ExpertiseTag<br/>(4-level fa)"]
SE["ServiceExpertise<br/>(junction)"]
SPT["ServiceProfile<br/>.specialization_tags"]
SPS["ServiceProfile<br/>.specializations"]
ORG["Organization<br/>.tags (crowd)"]
end
subgraph "KIMENET (API)"
PSR["ProviderSearchResult<br/>{tags, specializations, categories}"]
PD["ProviderDetail (admin)<br/>HIÁNYZIK: tags"]
end
UI1 --> QAP
UI2 --> UP
ADMIN --> UP
QAP -->|category_ids| SE
QAP -->|tags| SPT
QAP -->|specializations| SPS
QAP -->|new_tags| ET
UP -->|category_ids| SE
UP -->|tags| SPT
UP -->|specializations| SPS
UP -->|new_tags| ET
SE -->|categories| PSR
SPT -->|tags| PSR
SPS -->|specializations| PSR
ORG -->|tags| PSR
SE -.-> PD
SPS -.->|HIÁNYZIK| PD
```
---
## 5. JAVASLATOK ÖSSZEFOGLALÓ
| Priorítás | Javaslat | Érintett fájlok |
|---|---|---|
| **P0** | Konszolidáld a két címke rendszert — `specialization_tags``ExpertiseTag` | `service.py`, `provider_service.py` |
| **P1** | Távolítsd el vagy implementáld a `ProviderSearchResult.specialization` mezőt | `provider.py` |
| **P1** | Add hozzá a `tags` mezőt az `AdminProviderDetail` és `ProviderUpdateInput` sémákhoz | `admin_providers.py` |
| **P2** | Vezess be egységes nevezéktant — `expertise_ids`, `crowd_tags`, `user_tags` | Több fájl |
| **P2** | A `_create_new_tags` ne használjon hardkódolt `category="user_created"` értéket | `provider_service.py` |
| **P3** | Takarítsd el a `Cost.category` vs `CostCategory` duplikációt | `service.py` |
---
## 6. MELLÉKLET: TELJES MEZŐTÉRKÉP
| # | Mező | Modell/Schema | Típus | API-ban | Használat |
|---|---|---|---|---|---|
| 1 | `expertises` | ServiceProfile | `List[ServiceExpertise]` | `categories` | Strukturált kategória kapcsolat |
| 2 | `specialization_tags` | ServiceProfile | `JSONB` | `tags` | Strukturálatlan címkék |
| 3 | `specializations` | ServiceProfile | `JSONB` | `specializations` | Strukturált (brands, propulsion) |
| 4 | `supported_vehicle_classes` | ServiceProfile | `ARRAY[String]` | `supported_vehicle_classes` | Jármű kompatibilitás |
| 5 | `tags` | Organization | `JSONB[]` | `tags` | Közösségi értékelés |
| 6 | `aliases` | Organization | `JSONB[]` | `aliases` | Alternatív nevek |
| 7 | `category` | ServiceProvider | `Optional[str]` | `category` | Legacy string |
| 8 | `specializations` | ServiceProvider | `JSONB` | `specializations` | Strukturált specializáció |
| 9 | `category` | ExpertiseTag | `Optional[str]` | `category` | Címke csoport (pl. "vehicle") |
| 10 | `category` | SystemParameter | `String` | `category` | Rendszer paraméter csoport |
| 11 | `category` | Cost | `String` | - | Költség kategória string |
| 12 | `category_id` | AssetCost | `Integer (FK)` | `category_id` | FK a CostCategory táblába |
---
*Audit készült: 2026-07-01 — Architect mód*

View File

@@ -3654,7 +3654,22 @@ _1xyvuM2CQDhMlDxE8qY1t3Wi66ocgNUNDNce4pREOrU,
_wH6JrtIxmaSoA8lCPWFnE9z4lQeXW6H5z3l5aymEQw
];
const assets = {};
const assets = {
"/index.mjs": {
"type": "text/javascript; charset=utf-8",
"etag": "\"248e7-Igi10px+YQpoWileEHP4mJUJvPs\"",
"mtime": "2026-07-01T10:06:21.958Z",
"size": 149735,
"path": "index.mjs"
},
"/index.mjs.map": {
"type": "application/json",
"etag": "\"840e4-m5REc9B8WpnuwKHzj4TKXKSFgo0\"",
"mtime": "2026-07-01T10:06:21.958Z",
"size": 540900,
"path": "index.mjs.map"
}
};
function readAsset (id) {
const serverDir = dirname$1(fileURLToPath(globalThis._importMeta_.url));

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{S as a,h as i,G as o,s as l,U as c}from"./Duy9QquX.js";const h=a(async(s,f)=>{let t,r;if(s.path==="/login")return;if(!i("access_token").value)return o("/login");const e=l();if(!e.user)try{[t,r]=c(()=>e.fetchUser()),await t,r()}catch{return e.logout(),o("/login")}if(!e.user)return e.logout(),o("/login");const n=["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"],u=(e.user.role||"").toUpperCase();if(!n.includes(u))return e.logout(),o("/login")});export{h as default};
import{S as a,h as i,G as o,s as l,U as c}from"./BIbQr1Hg.js";const h=a(async(s,f)=>{let t,r;if(s.path==="/login")return;if(!i("access_token").value)return o("/login");const e=l();if(!e.user)try{[t,r]=c(()=>e.fetchUser()),await t,r()}catch{return e.logout(),o("/login")}if(!e.user)return e.logout(),o("/login");const n=["SUPERADMIN","ADMIN","MODERATOR","SALES_REP","SERVICE_MGR"],u=(e.user.role||"").toUpperCase();if(!n.includes(u))return e.logout(),o("/login")});export{h as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,V as r}from"./Duy9QquX.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};
import{_ as s}from"./DlAUqK2U.js";import{o,c as t,V as r}from"./BIbQr1Hg.js";const c={},n={class:"bg-slate-900 min-h-screen"};function a(e,l){return o(),t("div",n,[r(e.$slots,"default")])}const d=s(c,[["render",a]]);export{d as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{_ as y}from"./CuTzR9Y8.js";import{p as v,e as h,q as _,s as k,c as r,a as t,v as S,i as s,t as c,j as u,x as V,y as p,z as m,A as x,b as L,w as C,l as f,o as i,d as N}from"./Duy9QquX.js";const A=v("/sf_logo.png"),B={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},E={class:"w-full max-w-md"},R={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},j={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],D={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},F={class:"mt-6 text-center"},z=h({__name:"login",setup(I){const a=f(""),l=f(""),b=_(),o=k();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=y;return i(),r("div",B,[t("div",E,[e[6]||(e[6]=S('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+A+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",R,[s(o).error?(i(),r("div",j,c(s(o).error),1)):u("",!0),t("form",{onSubmit:V(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=n=>x(a)?a.value=n:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=n=>x(l)?l.value=n:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(l)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),r("svg",D,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,q)],32),t("div",F,[L(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:C(()=>[...e[5]||(e[5]=[N(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{z as default};
import{_ as y}from"./C2l6xpLd.js";import{p as v,e as h,q as _,s as k,c as r,a as t,v as S,i as s,t as c,j as u,x as V,y as p,z as m,A as x,b as L,w as C,l as f,o as i,d as N}from"./BIbQr1Hg.js";const A=v("/sf_logo.png"),B={class:"min-h-screen bg-slate-900 flex items-center justify-center px-4"},E={class:"w-full max-w-md"},R={class:"bg-slate-800 rounded-xl shadow-2xl border border-slate-700 p-8"},j={key:0,class:"mb-6 p-3 rounded-lg bg-red-900/40 border border-red-700 text-red-300 text-sm"},q=["disabled"],D={key:0,class:"animate-spin h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24"},F={class:"mt-6 text-center"},z=h({__name:"login",setup(I){const a=f(""),l=f(""),b=_(),o=k();async function g(){try{await o.login(a.value,l.value),b.push("/")}catch(d){console.error("Login failed:",d)}}return(d,e)=>{const w=y;return i(),r("div",B,[t("div",E,[e[6]||(e[6]=S('<div class="text-center mb-8"><div class="flex items-center justify-center gap-3 mb-4"><img src="'+A+'" class="h-12 w-auto drop-shadow-md" alt="ServiceFinder Logo"><span class="text-3xl font-extrabold tracking-wider"><span class="text-white">SERVICE</span><span class="text-cyan-400">FINDER</span></span></div><h1 class="text-xl font-semibold text-slate-300">Staff Portal</h1><p class="text-sm text-slate-500 mt-1">Authorised personnel only</p></div>',1)),t("div",R,[s(o).error?(i(),r("div",j,c(s(o).error),1)):u("",!0),t("form",{onSubmit:V(g,["prevent"]),class:"space-y-5"},[t("div",null,[e[2]||(e[2]=t("label",{for:"email",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Email Address ",-1)),p(t("input",{id:"email","onUpdate:modelValue":e[0]||(e[0]=n=>x(a)?a.value=n:null),type:"email",autocomplete:"email",required:"",placeholder:"admin@example.com",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(a)]])]),t("div",null,[e[3]||(e[3]=t("label",{for:"password",class:"block text-sm font-medium text-slate-300 mb-1.5"}," Password ",-1)),p(t("input",{id:"password","onUpdate:modelValue":e[1]||(e[1]=n=>x(l)?l.value=n:null),type:"password",autocomplete:"current-password",required:"",placeholder:"••••••••",class:"w-full px-4 py-2.5 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:border-transparent transition"},null,512),[[m,s(l)]])]),t("button",{type:"submit",disabled:s(o).isLoading,class:"w-full py-2.5 px-4 bg-cyan-600 hover:bg-cyan-500 disabled:bg-cyan-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition duration-200 flex items-center justify-center gap-2"},[s(o).isLoading?(i(),r("svg",D,[...e[4]||(e[4]=[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"},null,-1)])])):u("",!0),t("span",null,c(s(o).isLoading?"Signing in...":"Sign In"),1)],8,q)],32),t("div",F,[L(w,{to:"/",class:"text-sm text-slate-500 hover:text-cyan-400 transition"},{default:C(()=>[...e[5]||(e[5]=[N(" ← Back to ServiceFinder ",-1)])]),_:1})])])])])}}});export{z as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{_ as a}from"./CuTzR9Y8.js";import{_ as i}from"./DlAUqK2U.js";import{u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./Duy9QquX.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return u({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return c(),l("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};
import{_ as a}from"./C2l6xpLd.js";import{_ as i}from"./DlAUqK2U.js";import{u,o as c,c as l,a as e,t as r,b as d,w as p,d as f}from"./BIbQr1Hg.js";const m={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},g={class:"max-w-520px text-center z-20"},b=["textContent"],h=["textContent"],x={class:"flex items-center justify-center w-full"},y={__name:"error-404",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:404},statusText:{type:String,default:"Not Found"},description:{type:String,default:"Sorry, the page you are looking for could not be found."},backHome:{type:String,default:"Go back home"}},setup(t){const n=t;return u({title:`${n.status} - ${n.statusText} | ${n.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(k,o)=>{const s=a;return c(),l("div",m,[o[0]||(o[0]=e("div",{class:"fixed left-0 right-0 spotlight z-10"},null,-1)),e("div",g,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:r(t.status)},null,8,b),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:r(t.description)},null,8,h),e("div",x,[d(s,{to:"/",class:"cursor-pointer gradient-border px-4 py-2 sm:px-6 sm:py-3 sm:text-xl text-md"},{default:p(()=>[f(r(t.backHome),1)]),_:1})])])])}}},z=i(y,[["__scopeId","data-v-1bd9e11a"]]);export{z as default};

View File

@@ -1 +1 @@
import{ac as _,l as u,m as a}from"./Duy9QquX.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function m(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function y(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:m,getRegion:y}});export{h as u};
import{ac as _,l as u,m as a}from"./BIbQr1Hg.js";const h=_("region",()=>{const t=u([]),n=u(null),c=u(!1),l=u(null),o=a(()=>t.value.find(e=>e.country_code==="DEFAULT")||null),s=a(()=>{const e=new Map;for(const r of t.value)e.set(r.country_code,r);return e}),i=a(()=>n.value?.currency||o.value?.currency||"EUR"),v=a(()=>n.value?.locale_code||o.value?.locale_code||"en-EU"),f=a(()=>n.value?.default_vat_rate??o.value?.default_vat_rate??0),d=a(()=>n.value?.timezone||o.value?.timezone||"Europe/Brussels");async function g(){if(!(t.value.length>0)){c.value=!0,l.value=null;try{const e=await $fetch("/api/v1/system/regions");t.value=e||[]}catch(e){l.value=e?.data?.detail||e?.message||"Failed to load regions",console.error("[RegionStore] Fetch error:",e)}finally{c.value=!1}}}function m(e){const r=t.value.find(R=>R.country_code===e);n.value=r||o.value}function y(e){return s.value.get(e)}return{regions:t,activeRegion:n,isLoading:c,error:l,defaultRegion:o,regionMap:s,activeCurrency:i,activeLocale:v,activeVatRate:f,activeTimezone:d,fetchRegions:g,setActiveRegion:m,getRegion:y}});export{h as u};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{u as v}from"./BmPpcd_L.js";function h(){const o=v();function u(t,e){const r=o.activeLocale||"en-EU";try{return new Intl.NumberFormat(r,e).format(t)}catch{return new Intl.NumberFormat("en-EU",e).format(t)}}function a(t,e,r){const c=o.activeLocale||"en-EU",n=e||o.activeCurrency||"EUR";try{return new Intl.NumberFormat(c,{style:"currency",currency:n,minimumFractionDigits:2,maximumFractionDigits:2,...r}).format(t)}catch{return`${t.toFixed(2)} ${n}`}}function s(t,e){const r=o.activeLocale||"en-EU",c=o.activeTimezone||"Europe/Brussels",n=typeof t=="string"||typeof t=="number"?new Date(t):t;try{return new Intl.DateTimeFormat(r,{timezone:c,year:"numeric",month:"2-digit",day:"2-digit",...e}).format(n)}catch{return n.toLocaleDateString("en-GB")}}function l(t,e){const r=o.activeLocale||"en-EU",c=o.activeTimezone||"Europe/Brussels",n=typeof t=="string"||typeof t=="number"?new Date(t):t;try{return new Intl.DateTimeFormat(r,{timezone:c,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",...e}).format(n)}catch{return n.toLocaleString("en-GB")}}function f(t,e){const r=o.activeLocale||"en-EU";try{return new Intl.NumberFormat(r,{style:"percent",minimumFractionDigits:0,maximumFractionDigits:1,...e}).format(t/100)}catch{return`${t}%`}}function m(t,e){const r=e??o.activeVatRate??0,c=t*(r/100),n=t+c;return{net:t,vat:c,gross:n,vatRate:r}}function y(t,e,r){const{net:c,vat:n,gross:g,vatRate:F}=m(t,e),i=r||o.activeCurrency||"EUR";return`Net: ${a(c,i)} | VAT: ${a(n,i)} (${F}%) | Gross: ${a(g,i)}`}return{formatNumber:u,formatCurrency:a,formatDate:s,formatDateTime:l,formatPercent:f,calculatePriceWithVat:m,formatPriceWithVat:y}}export{h as u};
import{u as v}from"./DXATb271.js";function h(){const o=v();function u(t,e){const r=o.activeLocale||"en-EU";try{return new Intl.NumberFormat(r,e).format(t)}catch{return new Intl.NumberFormat("en-EU",e).format(t)}}function a(t,e,r){const c=o.activeLocale||"en-EU",n=e||o.activeCurrency||"EUR";try{return new Intl.NumberFormat(c,{style:"currency",currency:n,minimumFractionDigits:2,maximumFractionDigits:2,...r}).format(t)}catch{return`${t.toFixed(2)} ${n}`}}function s(t,e){const r=o.activeLocale||"en-EU",c=o.activeTimezone||"Europe/Brussels",n=typeof t=="string"||typeof t=="number"?new Date(t):t;try{return new Intl.DateTimeFormat(r,{timezone:c,year:"numeric",month:"2-digit",day:"2-digit",...e}).format(n)}catch{return n.toLocaleDateString("en-GB")}}function l(t,e){const r=o.activeLocale||"en-EU",c=o.activeTimezone||"Europe/Brussels",n=typeof t=="string"||typeof t=="number"?new Date(t):t;try{return new Intl.DateTimeFormat(r,{timezone:c,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",...e}).format(n)}catch{return n.toLocaleString("en-GB")}}function f(t,e){const r=o.activeLocale||"en-EU";try{return new Intl.NumberFormat(r,{style:"percent",minimumFractionDigits:0,maximumFractionDigits:1,...e}).format(t/100)}catch{return`${t}%`}}function m(t,e){const r=e??o.activeVatRate??0,c=t*(r/100),n=t+c;return{net:t,vat:c,gross:n,vatRate:r}}function y(t,e,r){const{net:c,vat:n,gross:g,vatRate:F}=m(t,e),i=r||o.activeCurrency||"EUR";return`Net: ${a(c,i)} | VAT: ${a(n,i)} (${F}%) | Gross: ${a(g,i)}`}return{formatNumber:u,formatCurrency:a,formatDate:s,formatDateTime:l,formatPercent:f,calculatePriceWithVat:m,formatPriceWithVat:y}}export{h as u};

View File

@@ -1 +0,0 @@
input[type=range][data-v-cbc29188]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}input[type=range][data-v-cbc29188]::-moz-range-thumb{background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}.timeline-enter-active[data-v-cbc29188]{transition:all .3s ease-out}.timeline-enter-from[data-v-cbc29188]{opacity:0;transform:translate(-10px)}

View File

@@ -1 +1 @@
import{_ as s}from"./DlAUqK2U.js";import{u as a,o as i,c as u,a as e,t as o}from"./Duy9QquX.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const r=t;return a({title:`${r.status} - ${r.statusText} | ${r.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,n)=>(i(),u("div",l,[n[0]||(n[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},h=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{h as default};
import{_ as s}from"./DlAUqK2U.js";import{u as a,o as i,c as u,a as e,t as o}from"./BIbQr1Hg.js";const l={class:"antialiased bg-white dark:bg-black dark:text-white font-sans grid min-h-screen overflow-hidden place-content-center text-black"},c={class:"max-w-520px text-center"},d=["textContent"],p=["textContent"],f={__name:"error-500",props:{appName:{type:String,default:"Nuxt"},version:{type:String,default:""},status:{type:Number,default:500},statusText:{type:String,default:"Server error"},description:{type:String,default:"This page is temporarily unavailable."}},setup(t){const r=t;return a({title:`${r.status} - ${r.statusText} | ${r.appName}`,script:[{innerHTML:`!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))r(e);new MutationObserver(e=>{for(const o of e)if("childList"===o.type)for(const e of o.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&r(e)}).observe(document,{childList:!0,subtree:!0})}function r(e){if(e.ep)return;e.ep=!0;const r=function(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?r.credentials="include":"anonymous"===e.crossOrigin?r.credentials="omit":r.credentials="same-origin",r}(e);fetch(e.href,r)}}();`}],style:[{innerHTML:'*,:after,:before{border-color:var(--un-default-border-color,#e5e7eb);border-style:solid;border-width:0;box-sizing:border-box}:after,:before{--un-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}h1{font-size:inherit;font-weight:inherit}h1,p{margin:0}*,:after,:before{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 transparent;--un-ring-shadow:0 0 transparent;--un-shadow-inset: ;--un-shadow:0 0 transparent;--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: }'}]}),(g,n)=>(i(),u("div",l,[n[0]||(n[0]=e("div",{class:"-bottom-1/2 fixed h-1/2 left-0 right-0 spotlight"},null,-1)),e("div",c,[e("h1",{class:"font-medium mb-8 sm:text-10xl text-8xl",textContent:o(t.status)},null,8,d),e("p",{class:"font-light leading-tight mb-16 px-8 sm:px-0 sm:text-4xl text-xl",textContent:o(t.description)},null,8,p)])]))}},h=s(f,[["__scopeId","data-v-a01dd0ba"]]);export{h as default};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar{width:6px}.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar-track{background:transparent}.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar-thumb{background-color:#64748b80;border-radius:3px}.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar-thumb:hover{background-color:#64748bb3}

View File

@@ -1 +0,0 @@
.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar{width:6px}.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar-track{background:transparent}.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar-thumb{background-color:#64748b80;border-radius:3px}.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar-thumb:hover{background-color:#64748bb3}

View File

@@ -0,0 +1 @@
input[type=range][data-v-aa1d3c94]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}input[type=range][data-v-aa1d3c94]::-moz-range-thumb{background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}.timeline-enter-active[data-v-aa1d3c94]{transition:all .3s ease-out}.timeline-enter-from[data-v-aa1d3c94]{opacity:0;transform:translate(-10px)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +0,0 @@
const _id__vue_vue_type_style_index_0_scoped_cbc29188_lang = "input[type=range][data-v-cbc29188]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}input[type=range][data-v-cbc29188]::-moz-range-thumb{background:#6366f1;border:2px solid #1e293b;border-radius:50%;cursor:pointer;height:16px;width:16px}.timeline-enter-active[data-v-cbc29188]{transition:all .3s ease-out}.timeline-enter-from[data-v-cbc29188]{opacity:0;transform:translate(-10px)}";
export {
_id__vue_vue_type_style_index_0_scoped_cbc29188_lang as default
};
//# sourceMappingURL=_id_-styles-1.mjs-QmIN6AFS.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"_id_-styles-1.mjs-QmIN6AFS.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"_id_-styles-1.mjs-QmIN6AFS.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -1,4 +0,0 @@
import style_0 from "./_id_-styles-1.mjs-QmIN6AFS.js";
export default [
style_0
]

View File

@@ -1,5 +1,5 @@
import { defineComponent, useSSRContext, computed, ref, reactive, createVNode, resolveDynamicComponent } from "vue";
import { ssrRenderAttrs, ssrRenderClass, ssrIncludeBooleanAttr, ssrInterpolate, ssrRenderList, ssrRenderComponent, ssrRenderVNode, ssrRenderAttr } from "vue/server-renderer";
import { ssrRenderAttrs, ssrRenderClass, ssrIncludeBooleanAttr, ssrInterpolate, ssrRenderList, ssrRenderComponent, ssrRenderVNode, ssrRenderAttr, ssrLooseContain } from "vue/server-renderer";
import { useRoute, useRouter } from "vue-router";
import "/app/node_modules/klona/dist/index.mjs";
import "/app/node_modules/hookable/dist/index.mjs";
@@ -83,7 +83,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
const tabs = [
{ key: "basic", label: "Alapadatok & Kapcsolat", icon: "svg" },
{ key: "matrix", label: "Szolgáltatások & Járművek", icon: "svg" },
{ key: "location", label: "Helyszín & Lokáció", icon: "svg" }
{ key: "location", label: "Helyszín & Lokáció", icon: "svg" },
{ key: "hours", label: "Nyitvatartás", icon: "svg" }
];
const categoryTree = ref([]);
const treeLoading = ref(false);
@@ -108,12 +109,53 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
specializations: {
brands: [],
propulsion: []
}
},
opening_hours: {},
is_always_open: false
});
const hasChanges = computed(() => {
if (!provider.value) return false;
const p = provider.value;
return form.name !== (p.name || "") || form.city !== (p.city || "") || form.address_zip !== (p.address_zip || "") || form.address_street_name !== (p.address_street_name || "") || form.address_house_number !== (p.address_house_number || "") || form.plus_code !== (p.plus_code || "") || form.contact_phone !== (p.contact_phone || "") || form.contact_email !== (p.contact_email || "") || form.website !== (p.website || "") || form.category !== (p.category || "") || JSON.stringify(form.category_ids.sort()) !== JSON.stringify((p.category_ids || []).sort()) || JSON.stringify(form.supported_vehicle_classes.sort()) !== JSON.stringify((p.supported_vehicle_classes || []).sort()) || JSON.stringify(form.specializations) !== JSON.stringify(p.specializations || { brands: [], propulsion: [] });
if (form.name !== (p.name || "")) return true;
if (form.address !== (p.address || "")) return true;
if (form.city !== (p.city || "")) return true;
if (form.address_zip !== (p.address_zip || "")) return true;
if (form.address_street_name !== (p.address_street_name || "")) return true;
if (form.address_street_type !== (p.address_street_type || "")) return true;
if (form.address_house_number !== (p.address_house_number || "")) return true;
if (form.plus_code !== (p.plus_code || "")) return true;
if (form.contact_phone !== (p.contact_phone || "")) return true;
if (form.contact_email !== (p.contact_email || "")) return true;
if (form.website !== (p.website || "")) return true;
if (form.category !== (p.category || "")) return true;
const sortedFormCategories = [...form.category_ids].sort();
const sortedOrigCategories = [...p.category_ids || []].sort();
if (JSON.stringify(sortedFormCategories) !== JSON.stringify(sortedOrigCategories)) return true;
const sortedFormClasses = [...form.supported_vehicle_classes].sort();
const sortedOrigClasses = [...p.supported_vehicle_classes || []].sort();
if (JSON.stringify(sortedFormClasses) !== JSON.stringify(sortedOrigClasses)) return true;
const normalizeSpec = (spec) => ({
brands: Array.isArray(spec?.brands) ? [...spec.brands].sort() : [],
propulsion: Array.isArray(spec?.propulsion) ? [...spec.propulsion].sort() : []
});
if (JSON.stringify(normalizeSpec(form.specializations)) !== JSON.stringify(normalizeSpec(p.specializations))) return true;
const DAYS2 = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
const normalizeHours = (hours) => {
const result = {};
const daysSource = hours?.days || hours;
for (const day of DAYS2) {
const slot = daysSource?.[day];
if (slot && typeof slot === "object" && slot.open && slot.close) {
result[day] = { open: slot.open, close: slot.close };
} else {
result[day] = null;
}
}
return result;
};
if (JSON.stringify(normalizeHours(form.opening_hours)) !== JSON.stringify(normalizeHours(p.opening_hours))) return true;
if (form.is_always_open !== (p.opening_hours?.always_open === true)) return true;
return false;
});
function findCategoryById(id, nodes) {
for (const node of nodes) {
@@ -150,44 +192,53 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
function isCategorySelected(id) {
return form.category_ids.includes(id);
}
const DAYS = [
{ key: "monday", label: "Hétfő" },
{ key: "tuesday", label: "Kedd" },
{ key: "wednesday", label: "Szerda" },
{ key: "thursday", label: "Csütörtök" },
{ key: "friday", label: "Péntek" },
{ key: "saturday", label: "Szombat" },
{ key: "sunday", label: "Vasárnap" }
];
return (_ctx, _push, _parent, _attrs) => {
const _component_TreeNode = _sfc_main$1;
_push(`<div${ssrRenderAttrs(_attrs)} data-v-03c94385><div class="mb-8" data-v-03c94385><button class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4" data-v-03c94385><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" data-v-03c94385></path></svg> Vissza a szolgáltatóhoz </button><h1 class="text-2xl font-bold text-white" data-v-03c94385>Szolgáltató szerkesztése</h1><p class="text-slate-400 mt-1" data-v-03c94385>Szolgáltató #${ssrInterpolate(_ctx.$route.params.id)}</p></div>`);
_push(`<div${ssrRenderAttrs(_attrs)} data-v-4a0954df><div class="mb-8" data-v-4a0954df><button class="inline-flex items-center gap-1.5 text-sm text-slate-400 hover:text-white transition mb-4" data-v-4a0954df><svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" data-v-4a0954df></path></svg> Vissza a szolgáltatóhoz </button><h1 class="text-2xl font-bold text-white" data-v-4a0954df>Szolgáltató szerkesztése</h1><p class="text-slate-400 mt-1" data-v-4a0954df>Szolgáltató #${ssrInterpolate(_ctx.$route.params.id)}</p></div>`);
if (loading.value) {
_push(`<div class="flex items-center justify-center py-20" data-v-03c94385><div class="text-slate-400 flex items-center gap-3" data-v-03c94385><svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" data-v-03c94385></path></svg><span data-v-03c94385>Szolgáltató betöltése...</span></div></div>`);
_push(`<div class="flex items-center justify-center py-20" data-v-4a0954df><div class="text-slate-400 flex items-center gap-3" data-v-4a0954df><svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" data-v-4a0954df></path></svg><span data-v-4a0954df>Szolgáltató betöltése...</span></div></div>`);
} else if (error.value) {
_push(`<div class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8" data-v-03c94385><p class="text-rose-400" data-v-03c94385>Hiba történt a szolgáltató betöltése közben.</p><button class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline" data-v-03c94385>Újrapróbálkozás</button></div>`);
_push(`<div class="bg-rose-500/10 border border-rose-500/30 rounded-xl p-6 mb-8" data-v-4a0954df><p class="text-rose-400" data-v-4a0954df>Hiba történt a szolgáltató betöltése közben.</p><button class="mt-3 text-sm text-rose-300 hover:text-rose-200 underline" data-v-4a0954df>Újrapróbálkozás</button></div>`);
} else if (!provider.value) {
_push(`<div class="bg-slate-800 rounded-xl border border-slate-700 p-6" data-v-03c94385><p class="text-slate-400 text-center py-12" data-v-03c94385>A szolgáltató nem található.</p></div>`);
_push(`<div class="bg-slate-800 rounded-xl border border-slate-700 p-6" data-v-4a0954df><p class="text-slate-400 text-center py-12" data-v-4a0954df>A szolgáltató nem található.</p></div>`);
} else {
_push(`<!--[--><div class="border-b border-slate-700 mb-6" data-v-03c94385><nav class="flex gap-6 -mb-px" data-v-03c94385><!--[-->`);
_push(`<!--[--><div class="border-b border-slate-700 mb-6" data-v-4a0954df><nav class="flex gap-6 -mb-px" data-v-4a0954df><!--[-->`);
ssrRenderList(tabs, (tab) => {
_push(`<button class="${ssrRenderClass([activeTab.value === tab.key ? "text-indigo-400 border-indigo-400" : "text-slate-400 border-transparent hover:text-slate-300", "pb-3 text-sm font-medium border-b-2 transition flex items-center gap-2"])}" data-v-03c94385>`);
_push(`<button class="${ssrRenderClass([activeTab.value === tab.key ? "text-indigo-400 border-indigo-400" : "text-slate-400 border-transparent hover:text-slate-300", "pb-3 text-sm font-medium border-b-2 transition flex items-center gap-2"])}" data-v-4a0954df>`);
ssrRenderVNode(_push, createVNode(resolveDynamicComponent(tab.icon), { class: "w-4 h-4" }, null), _parent);
_push(` ${ssrInterpolate(tab.label)}</button>`);
});
_push(`<!--]--></nav></div>`);
if (activeTab.value === "basic") {
_push(`<div class="max-w-3xl" data-v-03c94385><form data-v-03c94385><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-03c94385><h2 class="text-lg font-semibold text-white mb-4" data-v-03c94385>Alapadatok</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-v-03c94385><div class="md:col-span-2" data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Név *</label><input${ssrRenderAttr("value", form.name)} required class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Kategória</label><input${ssrRenderAttr("value", form.category)} placeholder="pl. Autószerviz, Gumiszerviz" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Forrás</label><div class="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-slate-400 text-sm" data-v-03c94385>${ssrInterpolate(provider.value.source)}</div></div></div></div><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-03c94385><h2 class="text-lg font-semibold text-white mb-4" data-v-03c94385>Elérhetőség</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-v-03c94385><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Telefon</label><input${ssrRenderAttr("value", form.contact_phone)} placeholder="+36 30 123 4567" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Email</label><input${ssrRenderAttr("value", form.contact_email)} type="email" placeholder="info@example.com" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div class="md:col-span-2" data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Weboldal</label><input${ssrRenderAttr("value", form.website)} placeholder="https://..." class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div></div></div></form></div>`);
_push(`<div class="max-w-3xl" data-v-4a0954df><form data-v-4a0954df><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-4a0954df><h2 class="text-lg font-semibold text-white mb-4" data-v-4a0954df>Alapadatok</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-v-4a0954df><div class="md:col-span-2" data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Név *</label><input${ssrRenderAttr("value", form.name)} required class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Kategória</label><input${ssrRenderAttr("value", form.category)} placeholder="pl. Autószerviz, Gumiszerviz" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Forrás</label><div class="w-full px-3 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-slate-400 text-sm" data-v-4a0954df>${ssrInterpolate(provider.value.source)}</div></div></div></div><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-4a0954df><h2 class="text-lg font-semibold text-white mb-4" data-v-4a0954df>Elérhetőség</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-v-4a0954df><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Telefon</label><input${ssrRenderAttr("value", form.contact_phone)} placeholder="+36 30 123 4567" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Email</label><input${ssrRenderAttr("value", form.contact_email)} type="email" placeholder="info@example.com" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div class="md:col-span-2" data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Weboldal</label><input${ssrRenderAttr("value", form.website)} placeholder="https://..." class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div></div></div></form></div>`);
} else {
_push(`<!---->`);
}
if (activeTab.value === "matrix") {
_push(`<div class="max-w-4xl" data-v-03c94385><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-03c94385><h2 class="text-lg font-semibold text-white mb-2" data-v-03c94385>3D Képesség Mátrix</h2><p class="text-sm text-slate-400 mb-6" data-v-03c94385> Állítsd be, hogy a szolgáltató milyen járműosztályokhoz, szolgáltatási kategóriákhoz és specializációkhoz tartozik. </p><div class="mb-8" data-v-03c94385><h3 class="text-md font-medium text-white mb-3 flex items-center gap-2" data-v-03c94385><span class="w-6 h-6 rounded-full bg-indigo-500/20 text-indigo-400 flex items-center justify-center text-xs font-bold" data-v-03c94385>A</span> Járműosztályok (Vehicle Classes) </h3><p class="text-xs text-slate-500 mb-3" data-v-03c94385>Mely járműtípusokat szervizeli a szolgáltató?</p><div class="grid grid-cols-2 md:grid-cols-4 gap-3" data-v-03c94385><!--[-->`);
_push(`<div class="max-w-4xl" data-v-4a0954df><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-4a0954df><h2 class="text-lg font-semibold text-white mb-2" data-v-4a0954df>3D Képesség Mátrix</h2><p class="text-sm text-slate-400 mb-6" data-v-4a0954df> Állítsd be, hogy a szolgáltató milyen járműosztályokhoz, szolgáltatási kategóriákhoz és specializációkhoz tartozik. </p><div class="mb-8" data-v-4a0954df><h3 class="text-md font-medium text-white mb-3 flex items-center gap-2" data-v-4a0954df><span class="w-6 h-6 rounded-full bg-indigo-500/20 text-indigo-400 flex items-center justify-center text-xs font-bold" data-v-4a0954df>A</span> Járműosztályok (Vehicle Classes) </h3><p class="text-xs text-slate-500 mb-3" data-v-4a0954df>Mely járműtípusokat szervizeli a szolgáltató?</p><div class="grid grid-cols-2 md:grid-cols-4 gap-3" data-v-4a0954df><!--[-->`);
ssrRenderList(VEHICLE_CLASSES, (vc) => {
_push(`<label class="${ssrRenderClass([form.supported_vehicle_classes.includes(vc.key) ? "bg-indigo-500/10 border-indigo-500/40 text-indigo-300" : "bg-slate-700/50 border-slate-600 hover:border-slate-500 text-slate-300", "flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition"])}" data-v-03c94385><input type="checkbox"${ssrRenderAttr("value", vc.key)}${ssrIncludeBooleanAttr(form.supported_vehicle_classes.includes(vc.key)) ? " checked" : ""} class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50" data-v-03c94385><span class="text-sm" data-v-03c94385>${ssrInterpolate(vc.label)}</span></label>`);
_push(`<label class="${ssrRenderClass([form.supported_vehicle_classes.includes(vc.key) ? "bg-indigo-500/10 border-indigo-500/40 text-indigo-300" : "bg-slate-700/50 border-slate-600 hover:border-slate-500 text-slate-300", "flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition"])}" data-v-4a0954df><input type="checkbox"${ssrRenderAttr("value", vc.key)}${ssrIncludeBooleanAttr(form.supported_vehicle_classes.includes(vc.key)) ? " checked" : ""} class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50" data-v-4a0954df><span class="text-sm" data-v-4a0954df>${ssrInterpolate(vc.label)}</span></label>`);
});
_push(`<!--]--></div></div><div class="mb-8" data-v-03c94385><h3 class="text-md font-medium text-white mb-3 flex items-center gap-2" data-v-03c94385><span class="w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs font-bold" data-v-03c94385>B</span> Szolgáltatási Kategóriák (Expertise Tags) </h3><p class="text-xs text-slate-500 mb-3" data-v-03c94385>Válaszd ki a szolgáltatóhoz tartozó kategóriákat a hierarchikus fából.</p>`);
_push(`<!--]--></div></div><div class="mb-8" data-v-4a0954df><h3 class="text-md font-medium text-white mb-3 flex items-center gap-2" data-v-4a0954df><span class="w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs font-bold" data-v-4a0954df>B</span> Szolgáltatási Kategóriák (Expertise Tags) </h3><p class="text-xs text-slate-500 mb-3" data-v-4a0954df>Válaszd ki a szolgáltatóhoz tartozó kategóriákat a hierarchikus fából.</p>`);
if (treeLoading.value) {
_push(`<div class="flex items-center gap-2 text-sm text-slate-400 py-4" data-v-03c94385><svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" data-v-03c94385></path></svg><span data-v-03c94385>Kategóriák betöltése...</span></div>`);
_push(`<div class="flex items-center gap-2 text-sm text-slate-400 py-4" data-v-4a0954df><svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" data-v-4a0954df></path></svg><span data-v-4a0954df>Kategóriák betöltése...</span></div>`);
} else {
_push(`<div class="space-y-1 max-h-96 overflow-y-auto pr-2" data-v-03c94385><!--[-->`);
_push(`<div class="space-y-1 max-h-96 overflow-y-auto pr-2" data-v-4a0954df><!--[-->`);
ssrRenderList(categoryTree.value, (node) => {
_push(`<div class="ml-0" data-v-03c94385><div class="flex items-center gap-2 py-1" data-v-03c94385><button class="text-slate-500 hover:text-slate-300 transition w-4 h-4 flex items-center justify-center" data-v-03c94385><svg class="${ssrRenderClass([expandedNodes.value.has(node.id) ? "rotate-90" : "", "w-3 h-3 transition-transform"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" data-v-03c94385></path></svg></button><label class="flex items-center gap-2 cursor-pointer flex-1" data-v-03c94385><input type="checkbox"${ssrIncludeBooleanAttr(isCategorySelected(node.id)) ? " checked" : ""} class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50" data-v-03c94385><span class="text-sm font-medium text-white" data-v-03c94385>${ssrInterpolate(node.name_hu || node.name_en || node.key)}</span><span class="text-xs text-slate-500" data-v-03c94385>(Szint ${ssrInterpolate(node.level)})</span></label></div>`);
_push(`<div class="ml-0" data-v-4a0954df><div class="flex items-center gap-2 py-1" data-v-4a0954df><button class="text-slate-500 hover:text-slate-300 transition w-4 h-4 flex items-center justify-center" data-v-4a0954df><svg class="${ssrRenderClass([expandedNodes.value.has(node.id) ? "rotate-90" : "", "w-3 h-3 transition-transform"])}" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" data-v-4a0954df></path></svg></button><label class="flex items-center gap-2 cursor-pointer flex-1" data-v-4a0954df><input type="checkbox"${ssrIncludeBooleanAttr(isCategorySelected(node.id)) ? " checked" : ""} class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50" data-v-4a0954df><span class="text-sm font-medium text-white" data-v-4a0954df>${ssrInterpolate(node.name_hu || node.name_en || node.key)}</span><span class="text-xs text-slate-500" data-v-4a0954df>(Szint ${ssrInterpolate(node.level)})</span></label></div>`);
if (expandedNodes.value.has(node.id) && node.children?.length) {
_push(`<div class="ml-6 border-l border-slate-700 pl-3" data-v-03c94385><!--[-->`);
_push(`<div class="ml-6 border-l border-slate-700 pl-3" data-v-4a0954df><!--[-->`);
ssrRenderList(node.children, (child) => {
_push(`<div data-v-03c94385>`);
_push(`<div data-v-4a0954df>`);
_push(ssrRenderComponent(_component_TreeNode, {
node: child,
"selected-ids": form.category_ids,
@@ -206,47 +257,62 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
_push(`<!--]--></div>`);
}
if (form.category_ids.length > 0) {
_push(`<div class="mt-4 flex flex-wrap gap-2" data-v-03c94385><!--[-->`);
_push(`<div class="mt-4 flex flex-wrap gap-2" data-v-4a0954df><!--[-->`);
ssrRenderList(form.category_ids, (id) => {
_push(`<span class="inline-flex items-center gap-1 px-2 py-1 bg-indigo-500/10 text-indigo-300 rounded-full text-xs" data-v-03c94385>${ssrInterpolate(getCategoryName(id))} <button class="hover:text-white transition" data-v-03c94385><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-v-03c94385></path></svg></button></span>`);
_push(`<span class="inline-flex items-center gap-1 px-2 py-1 bg-indigo-500/10 text-indigo-300 rounded-full text-xs" data-v-4a0954df>${ssrInterpolate(getCategoryName(id))} <button class="hover:text-white transition" data-v-4a0954df><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-v-4a0954df></path></svg></button></span>`);
});
_push(`<!--]--></div>`);
} else {
_push(`<!---->`);
}
_push(`</div><div class="mb-4" data-v-03c94385><h3 class="text-md font-medium text-white mb-3 flex items-center gap-2" data-v-03c94385><span class="w-6 h-6 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center text-xs font-bold" data-v-03c94385>C</span> Specializációk </h3><p class="text-xs text-slate-500 mb-3" data-v-03c94385>Adj meg speciális márkákat, hajtáslánc típusokat vagy egyéb címkéket.</p><div class="grid grid-cols-1 md:grid-cols-2 gap-6" data-v-03c94385><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Márkák (Brands)</label><div class="flex flex-wrap gap-2 mb-2" data-v-03c94385><!--[-->`);
_push(`</div><div class="mb-4" data-v-4a0954df><h3 class="text-md font-medium text-white mb-3 flex items-center gap-2" data-v-4a0954df><span class="w-6 h-6 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center text-xs font-bold" data-v-4a0954df>C</span> Specializációk </h3><p class="text-xs text-slate-500 mb-3" data-v-4a0954df>Adj meg speciális márkákat, hajtáslánc típusokat vagy egyéb címkéket.</p><div class="grid grid-cols-1 md:grid-cols-2 gap-6" data-v-4a0954df><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Márkák (Brands)</label><div class="flex flex-wrap gap-2 mb-2" data-v-4a0954df><!--[-->`);
ssrRenderList(form.specializations.brands, (brand, idx) => {
_push(`<span class="inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-300 rounded-full text-xs" data-v-03c94385>${ssrInterpolate(brand)} <button class="hover:text-white transition" data-v-03c94385><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-v-03c94385></path></svg></button></span>`);
_push(`<span class="inline-flex items-center gap-1 px-2 py-1 bg-amber-500/10 text-amber-300 rounded-full text-xs" data-v-4a0954df>${ssrInterpolate(brand)} <button class="hover:text-white transition" data-v-4a0954df><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-v-4a0954df></path></svg></button></span>`);
});
_push(`<!--]--></div><div class="flex gap-2" data-v-03c94385><input${ssrRenderAttr("value", brandInput.value)} placeholder="pl. Volvo, Scania" class="flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385><button${ssrIncludeBooleanAttr(!brandInput.value.trim()) ? " disabled" : ""} class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm transition" data-v-03c94385> Hozzáad </button></div></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Hajtáslánc (Propulsion)</label><div class="flex flex-wrap gap-2 mb-2" data-v-03c94385><!--[-->`);
_push(`<!--]--></div><div class="flex gap-2" data-v-4a0954df><input${ssrRenderAttr("value", brandInput.value)} placeholder="pl. Volvo, Scania" class="flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df><button${ssrIncludeBooleanAttr(!brandInput.value.trim()) ? " disabled" : ""} class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm transition" data-v-4a0954df> Hozzáad </button></div></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Hajtáslánc (Propulsion)</label><div class="flex flex-wrap gap-2 mb-2" data-v-4a0954df><!--[-->`);
ssrRenderList(form.specializations.propulsion, (prop, idx) => {
_push(`<span class="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/10 text-emerald-300 rounded-full text-xs" data-v-03c94385>${ssrInterpolate(prop)} <button class="hover:text-white transition" data-v-03c94385><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-v-03c94385></path></svg></button></span>`);
_push(`<span class="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/10 text-emerald-300 rounded-full text-xs" data-v-4a0954df>${ssrInterpolate(prop)} <button class="hover:text-white transition" data-v-4a0954df><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-v-4a0954df></path></svg></button></span>`);
});
_push(`<!--]--></div><div class="flex gap-2" data-v-03c94385><input${ssrRenderAttr("value", propulsionInput.value)} placeholder="pl. EV, Hybrid, Diesel" class="flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385><button${ssrIncludeBooleanAttr(!propulsionInput.value.trim()) ? " disabled" : ""} class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm transition" data-v-03c94385> Hozzáad </button></div></div></div></div></div></div>`);
_push(`<!--]--></div><div class="flex gap-2" data-v-4a0954df><input${ssrRenderAttr("value", propulsionInput.value)} placeholder="pl. EV, Hybrid, Diesel" class="flex-1 px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df><button${ssrIncludeBooleanAttr(!propulsionInput.value.trim()) ? " disabled" : ""} class="px-3 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm transition" data-v-4a0954df> Hozzáad </button></div></div></div></div></div></div>`);
} else {
_push(`<!---->`);
}
if (activeTab.value === "location") {
_push(`<div class="max-w-3xl" data-v-03c94385><form data-v-03c94385><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-03c94385><h2 class="text-lg font-semibold text-white mb-4" data-v-03c94385>Cím adatok</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-v-03c94385><div class="md:col-span-2" data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Teljes cím</label><input${ssrRenderAttr("value", form.address)} placeholder="Teljes cím szövege" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Város</label><input${ssrRenderAttr("value", form.city)} placeholder="Budapest" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Irányítószám</label><input${ssrRenderAttr("value", form.address_zip)} placeholder="1011" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Utca</label><input${ssrRenderAttr("value", form.address_street_name)} placeholder="Egressy" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Házszám</label><input${ssrRenderAttr("value", form.address_house_number)} placeholder="4/b" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div><div class="md:col-span-2" data-v-03c94385><label class="block text-sm font-medium text-slate-300 mb-1" data-v-03c94385>Plus Code</label><input${ssrRenderAttr("value", form.plus_code)} placeholder="8FVC9X8V+XV" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-03c94385></div></div></div></form></div>`);
_push(`<div class="max-w-3xl" data-v-4a0954df><form data-v-4a0954df><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-4a0954df><h2 class="text-lg font-semibold text-white mb-4" data-v-4a0954df>Cím adatok</h2><div class="grid grid-cols-1 md:grid-cols-2 gap-4" data-v-4a0954df><div class="md:col-span-2" data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Teljes cím</label><input${ssrRenderAttr("value", form.address)} placeholder="Teljes cím szövege" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Város</label><input${ssrRenderAttr("value", form.city)} placeholder="Budapest" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Irányítószám</label><input${ssrRenderAttr("value", form.address_zip)} placeholder="1011" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Utca</label><input${ssrRenderAttr("value", form.address_street_name)} placeholder="Egressy" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Házszám</label><input${ssrRenderAttr("value", form.address_house_number)} placeholder="4/b" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div><div class="md:col-span-2" data-v-4a0954df><label class="block text-sm font-medium text-slate-300 mb-1" data-v-4a0954df>Plus Code</label><input${ssrRenderAttr("value", form.plus_code)} placeholder="8FVC9X8V+XV" class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm" data-v-4a0954df></div></div></div></form></div>`);
} else {
_push(`<!---->`);
}
_push(`<div class="sticky bottom-0 bg-slate-900/90 backdrop-blur-sm border-t border-slate-700 -mx-6 px-6 py-4 mt-8" data-v-03c94385><div class="flex items-center justify-between max-w-4xl" data-v-03c94385><div class="text-sm text-slate-400" data-v-03c94385>`);
if (hasChanges.value) {
_push(`<span class="text-amber-400" data-v-03c94385>● Módosítások el nem mentve</span>`);
if (activeTab.value === "hours") {
_push(`<div class="max-w-3xl" data-v-4a0954df><div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6" data-v-4a0954df><h2 class="text-lg font-semibold text-white mb-2" data-v-4a0954df>Nyitvatartás</h2><p class="text-sm text-slate-400 mb-6" data-v-4a0954df> Állítsd be a szolgáltató nyitvatartási idejét napokra bontva. </p><div class="mb-6 p-4 rounded-lg border bg-indigo-500/5 border-indigo-500/20" data-v-4a0954df><label class="flex items-center gap-3 cursor-pointer" data-v-4a0954df><input type="checkbox"${ssrIncludeBooleanAttr(Array.isArray(form.is_always_open) ? ssrLooseContain(form.is_always_open, null) : form.is_always_open) ? " checked" : ""} class="w-5 h-5 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50" data-v-4a0954df><div data-v-4a0954df><span class="text-sm font-medium text-white" data-v-4a0954df>Folyamatos (0-24) nyitvatartás</span><p class="text-xs text-slate-400 mt-0.5" data-v-4a0954df>A szolgáltató a hét minden napján 0-24 óráig nyitva van</p></div></label></div><div class="${ssrRenderClass([{ "opacity-50 pointer-events-none": form.is_always_open }, "flex flex-wrap gap-2 mb-6"])}" data-v-4a0954df><button class="px-3 py-1.5 text-xs font-medium rounded-lg border border-emerald-600/30 text-emerald-400 hover:bg-emerald-600/10 transition" data-v-4a0954df> 24/7 Folyamatos nyitvatartás </button><button class="px-3 py-1.5 text-xs font-medium rounded-lg border border-indigo-600/30 text-indigo-400 hover:bg-indigo-600/10" data-v-4a0954df> Hétköznapok másolása (Hétfő → K-P) </button><button class="px-3 py-1.5 text-xs font-medium rounded-lg border border-rose-600/30 text-rose-400 hover:bg-rose-600/10" data-v-4a0954df> Összes törlése </button></div><div class="${ssrRenderClass([{ "opacity-50 pointer-events-none": form.is_always_open }, "space-y-3"])}" data-v-4a0954df><!--[-->`);
ssrRenderList(DAYS, (day) => {
_push(`<div class="${ssrRenderClass([form.opening_hours[day.key] ? "bg-indigo-500/5 border-indigo-500/20" : "bg-slate-700/30 border-slate-600", "flex items-center gap-4 p-4 rounded-lg border"])}" data-v-4a0954df><label class="flex items-center gap-2 w-36 flex-shrink-0 cursor-pointer" data-v-4a0954df><input type="checkbox"${ssrIncludeBooleanAttr(!!form.opening_hours[day.key]) ? " checked" : ""}${ssrIncludeBooleanAttr(form.is_always_open) ? " disabled" : ""} class="w-4 h-4 rounded border-slate-500 bg-slate-700 text-indigo-500 focus:ring-indigo-500/50" data-v-4a0954df><span class="text-sm font-medium text-white" data-v-4a0954df>${ssrInterpolate(day.label)}</span></label>`);
if (form.opening_hours[day.key]) {
_push(`<div class="flex items-center gap-2" data-v-4a0954df><input type="time"${ssrRenderAttr("value", form.opening_hours[day.key]?.open || "08:00")}${ssrIncludeBooleanAttr(form.is_always_open) ? " disabled" : ""} class="w-28 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 disabled:opacity-50" data-v-4a0954df><span class="text-slate-500 text-sm" data-v-4a0954df></span><input type="time"${ssrRenderAttr("value", form.opening_hours[day.key]?.close || "17:00")}${ssrIncludeBooleanAttr(form.is_always_open) ? " disabled" : ""} class="w-28 px-2 py-1.5 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 disabled:opacity-50" data-v-4a0954df></div>`);
} else {
_push(`<span class="text-sm text-slate-500 italic" data-v-4a0954df>Zárva</span>`);
}
_push(`</div>`);
});
_push(`<!--]--></div></div></div>`);
} else {
_push(`<span class="text-slate-500" data-v-03c94385>Nincs változás</span>`);
_push(`<!---->`);
}
_push(`</div><div class="flex gap-3" data-v-03c94385><button class="px-4 py-2 text-sm text-slate-400 hover:text-white transition" data-v-03c94385> Mégse </button><button${ssrIncludeBooleanAttr(saveLoading.value || !hasChanges.value) ? " disabled" : ""} class="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2" data-v-03c94385>`);
if (saveLoading.value) {
_push(`<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" data-v-03c94385></path></svg>`);
_push(`<div class="sticky bottom-0 bg-slate-900/90 backdrop-blur-sm border-t border-slate-700 -mx-6 px-6 py-4 mt-8" data-v-4a0954df><div class="flex items-center justify-between max-w-4xl" data-v-4a0954df><div class="text-sm text-slate-400" data-v-4a0954df>`);
if (hasChanges.value) {
_push(`<span class="text-amber-400" data-v-4a0954df>● Módosítások el nem mentve</span>`);
} else {
_push(`<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-03c94385><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" data-v-03c94385></path></svg>`);
_push(`<span class="text-slate-500" data-v-4a0954df>Nincs változás</span>`);
}
_push(`</div><div class="flex gap-3" data-v-4a0954df><button class="px-4 py-2 text-sm text-slate-400 hover:text-white transition" data-v-4a0954df> Mégse </button><button${ssrIncludeBooleanAttr(saveLoading.value || !hasChanges.value) ? " disabled" : ""} class="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white rounded-lg text-sm font-medium transition flex items-center gap-2" data-v-4a0954df>`);
if (saveLoading.value) {
_push(`<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" data-v-4a0954df></path></svg>`);
} else {
_push(`<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-v-4a0954df><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" data-v-4a0954df></path></svg>`);
}
_push(` Mentés </button></div></div></div><!--]-->`);
}
if (toast.value) {
_push(`<div class="${ssrRenderClass([toast.value.type === "success" ? "bg-emerald-600 text-white" : "bg-rose-600 text-white", "fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-xl text-sm font-medium transition-all duration-300"])}" data-v-03c94385>${ssrInterpolate(toast.value.message)}</div>`);
_push(`<div class="${ssrRenderClass([toast.value.type === "success" ? "bg-emerald-600 text-white" : "bg-rose-600 text-white", "fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-xl text-sm font-medium transition-all duration-300"])}" data-v-4a0954df>${ssrInterpolate(toast.value.message)}</div>`);
} else {
_push(`<!---->`);
}
@@ -260,8 +326,8 @@ _sfc_main.setup = (props, ctx) => {
(ssrContext.modules || (ssrContext.modules = /* @__PURE__ */ new Set())).add("pages/providers/[id]/edit.vue");
return _sfc_setup ? _sfc_setup(props, ctx) : void 0;
};
const edit = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-03c94385"]]);
const edit = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-4a0954df"]]);
export {
edit as default
};
//# sourceMappingURL=edit-Db3uK3sd.js.map
//# sourceMappingURL=edit-DQMBygGu.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +0,0 @@
const edit_vue_vue_type_style_index_0_scoped_03c94385_lang = ".overflow-y-auto[data-v-03c94385]::-webkit-scrollbar{width:6px}.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar-track{background:transparent}.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar-thumb{background-color:#64748b80;border-radius:3px}.overflow-y-auto[data-v-03c94385]::-webkit-scrollbar-thumb:hover{background-color:#64748bb3}";
export {
edit_vue_vue_type_style_index_0_scoped_03c94385_lang as default
};
//# sourceMappingURL=edit-styles-1.mjs-BA90eYv2.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"edit-styles-1.mjs-BA90eYv2.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"edit-styles-1.mjs-BA90eYv2.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -0,0 +1,5 @@
const edit_vue_vue_type_style_index_0_scoped_4a0954df_lang = ".overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar{width:6px}.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar-track{background:transparent}.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar-thumb{background-color:#64748b80;border-radius:3px}.overflow-y-auto[data-v-4a0954df]::-webkit-scrollbar-thumb:hover{background-color:#64748bb3}";
export {
edit_vue_vue_type_style_index_0_scoped_4a0954df_lang as default
};
//# sourceMappingURL=edit-styles-1.mjs-YVNjgFq6.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"edit-styles-1.mjs-YVNjgFq6.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"edit-styles-1.mjs-YVNjgFq6.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -1,4 +0,0 @@
import style_0 from "./edit-styles-1.mjs-BA90eYv2.js";
export default [
style_0
]

View File

@@ -0,0 +1,4 @@
import style_0 from "./edit-styles-1.mjs-YVNjgFq6.js";
export default [
style_0
]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-1.mjs-BTLckatc.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"entry-styles-1.mjs-BTLckatc.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"version":3,"file":"entry-styles-1.mjs-Dwi00BYZ.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"entry-styles-1.mjs-Dwi00BYZ.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"version":3,"file":"entry-styles-2.mjs-CjBdWjhK.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -0,0 +1 @@
{"file":"entry-styles-2.mjs-CjBdWjhK.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"version":3,"file":"entry-styles-2.mjs-DlODQeqc.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}

View File

@@ -1 +0,0 @@
{"file":"entry-styles-2.mjs-DlODQeqc.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}

View File

@@ -0,0 +1,8 @@
import style_0 from "./entry-styles-1.mjs-BTLckatc.js";
import style_1 from "./entry-styles-2.mjs-CjBdWjhK.js";
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
export default [
style_0,
style_1,
style_2
]

View File

@@ -1,8 +0,0 @@
import style_0 from "./entry-styles-1.mjs-Dwi00BYZ.js";
import style_1 from "./entry-styles-2.mjs-DlODQeqc.js";
import style_2 from "./entry-styles-3.mjs-C1rWf53M.js";
export default [
style_0,
style_1,
style_2
]

Some files were not shown because too many files have changed in this diff Show More