refaktor címtár
This commit is contained in:
424
.roo/history.md
424
.roo/history.md
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
# 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
|
||||
)
|
||||
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
|
||||
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] = []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -138,20 +132,6 @@ class Organization(Base):
|
||||
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"),
|
||||
default=OrgType.individual
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
60
backend/app/schemas/address.py
Normal file
60
backend/app/schemas/address.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
267
backend/app/services/address_manager.py
Normal file
267
backend/app/services/address_manager.py
Normal 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()
|
||||
140
backend/app/services/address_service.py
Normal file
140
backend/app/services/address_service.py
Normal 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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
209
docs/address_api_consistency_audit.md
Normal file
209
docs/address_api_consistency_audit.md
Normal 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
|
||||
72
docs/p0_address_manager_usage_audit_2026-07-01.md
Normal file
72
docs/p0_address_manager_usage_audit_2026-07-01.md
Normal 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` |
|
||||
65
docs/sql/cleanup_ghost_columns_2026_07.sql
Normal file
65
docs/sql/cleanup_ghost_columns_2026_07.sql
Normal 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;
|
||||
305
docs/tag_category_system_audit_report.md
Normal file
305
docs/tag_category_system_audit_report.md
Normal 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*
|
||||
@@ -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
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
@@ -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
@@ -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
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
@@ -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
@@ -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};
|
||||
@@ -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};
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/D_wdxHVu.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/D_wdxHVu.js
vendored
Normal file
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
1
frontend_admin/.nuxt/dist/client/_nuxt/DlWXh2WJ.js
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/DlWXh2WJ.js
vendored
Normal file
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
@@ -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};
|
||||
@@ -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)}
|
||||
@@ -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
1
frontend_admin/.nuxt/dist/client/_nuxt/edit.BOptDYkB.css
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/edit.BOptDYkB.css
vendored
Normal 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}
|
||||
@@ -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}
|
||||
1
frontend_admin/.nuxt/dist/client/_nuxt/index.oH-TzkFb.css
vendored
Normal file
1
frontend_admin/.nuxt/dist/client/_nuxt/index.oH-TzkFb.css
vendored
Normal 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
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"_id_-styles-1.mjs-QmIN6AFS.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"_id_-styles-1.mjs-QmIN6AFS.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
@@ -1,4 +0,0 @@
|
||||
import style_0 from "./_id_-styles-1.mjs-QmIN6AFS.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
@@ -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-slate-500" data-v-03c94385>Nincs változás</span>`);
|
||||
_push(`<span class="text-sm text-slate-500 italic" data-v-4a0954df>Zárva</span>`);
|
||||
}
|
||||
_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>`);
|
||||
});
|
||||
_push(`<!--]--></div></div></div>`);
|
||||
} 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(`<!---->`);
|
||||
}
|
||||
_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(`<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
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-DQMBygGu.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-DQMBygGu.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-DQMBygGu.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-DQMBygGu.js.map.json
vendored
Normal file
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
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"edit-styles-1.mjs-BA90eYv2.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"edit-styles-1.mjs-BA90eYv2.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
5
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles-1.mjs-YVNjgFq6.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles-1.mjs-YVNjgFq6.js
vendored
Normal 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
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles-1.mjs-YVNjgFq6.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles-1.mjs-YVNjgFq6.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"edit-styles-1.mjs-YVNjgFq6.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles-1.mjs-YVNjgFq6.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles-1.mjs-YVNjgFq6.js.map.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"file":"edit-styles-1.mjs-YVNjgFq6.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
@@ -1,4 +0,0 @@
|
||||
import style_0 from "./edit-styles-1.mjs-BA90eYv2.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
4
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles.DRyZamYQ.mjs
vendored
Normal file
4
frontend_admin/.nuxt/dist/server/_nuxt/edit-styles.DRyZamYQ.mjs
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import style_0 from "./edit-styles-1.mjs-YVNjgFq6.js";
|
||||
export default [
|
||||
style_0
|
||||
]
|
||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-BTLckatc.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-BTLckatc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-BTLckatc.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-BTLckatc.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"entry-styles-1.mjs-BTLckatc.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-BTLckatc.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-1.mjs-BTLckatc.js.map.json
vendored
Normal 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
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"entry-styles-1.mjs-Dwi00BYZ.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"entry-styles-1.mjs-Dwi00BYZ.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CjBdWjhK.js
vendored
Normal file
5
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CjBdWjhK.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CjBdWjhK.js.map
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CjBdWjhK.js.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"entry-styles-2.mjs-CjBdWjhK.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CjBdWjhK.js.map.json
vendored
Normal file
1
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles-2.mjs-CjBdWjhK.js.map.json
vendored
Normal 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
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"entry-styles-2.mjs-DlODQeqc.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
|
||||
@@ -1 +0,0 @@
|
||||
{"file":"entry-styles-2.mjs-DlODQeqc.js","mappings":";","names":[],"sources":[],"sourcesContent":[],"version":3}
|
||||
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.CDharU4c.mjs
vendored
Normal file
8
frontend_admin/.nuxt/dist/server/_nuxt/entry-styles.CDharU4c.mjs
vendored
Normal 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
|
||||
]
|
||||
@@ -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
Reference in New Issue
Block a user