Compare commits
7 Commits
6e627d0ebe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f28d3e70d | ||
|
|
c9118bf52f | ||
|
|
33c4d793db | ||
|
|
4594de6c11 | ||
|
|
2e0abc62a7 | ||
|
|
07b59032ce | ||
|
|
7654913d21 |
356
.roo/history.md
356
.roo/history.md
@@ -1,325 +1,49 @@
|
|||||||
# Service Finder Fejlesztési Történet
|
|
||||||
|
|
||||||
## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt
|
|
||||||
|
|
||||||
### 🎯 Cél
|
|
||||||
Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok felderítése a `vehicle`, `finance`, `fleet_finance` sémákban, mielőtt a frontend fejlesztés elkezdődik.
|
|
||||||
|
|
||||||
### 🔧 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ó
|
### ✅ Verifikáció
|
||||||
- Sync Engine: 1297 OK, 0 Fixed, 0 Extra - rendszer tökéletesen szinkronban
|
1. **Build ellenőrzés** → `npx vite build` sikeres (7.08s, 0 error)
|
||||||
- GET /admin/providers/1 → 200, `opening_hours` mező visszatér
|
2. **Chunk generálva**: `CostEntryWizard-C_-R4OzC.js` (29.95 kB) - refaktorált komponens helyesen fordul
|
||||||
- PATCH /admin/providers/1 `opening_hours` → 200, adatok perzisztálódnak és visszaolvashatók
|
3. **ProviderAutocomplete** újrafelhasználva mindhárom komponensben - nincs duplikáció
|
||||||
|
|
||||||
## 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
|
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
|
||||||
|
|
||||||
### 🎯 Cél
|
**Dátum:** 2026-07-25
|
||||||
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`**
|
## Fix: POST /api/v1/expenses/ — 500 Internal Server Error (PG ENUM vs varchar JOIN)
|
||||||
- `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ó
|
**Dátum:** 2026-07-26
|
||||||
- Backend Python szintaxis: OK
|
|
||||||
- Sync Engine: 1297 elem OK, 0 hiba, teljes szinkronban
|
### Probléma
|
||||||
- API Test 1: `PATCH is_always_open=True` → `{"always_open": true, "days": {"monday": {"open": "00:00", "close": "23:59"}, ...}}` ✅
|
A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás) költség létrehozásakor.
|
||||||
- API Test 2: `PATCH is_always_open=False` → `{"monday": {"open": "09:00", "close": "17:00"}}` (always_open metadata stripped) ✅
|
|
||||||
|
### Gyökér-ok (két rétegben)
|
||||||
|
1. **Stale `.pyc` cache** → `ModuleNotFoundError: No module named 'app.models.fleet.org_role'` — A konténer régi bytecode-ot futtatott. Megoldva cache törléssel.
|
||||||
|
2. **PG ENUM vs varchar típusütközés** → `operator does not exist: fleet.orguserrole = character varying` — A `_check_org_capability()` függvény JOIN-t használt `OrganizationMember.role` (ENUM) és `OrgRole.name_key` (varchar) között, amit a PostgreSQL nem tud implicit konvertálni.
|
||||||
|
|
||||||
|
### Javítás
|
||||||
|
[`expenses.py:93-117`](backend/app/api/v1/endpoints/expenses.py:93):
|
||||||
|
- A JOIN-os lekérdezést két különálló SELECT-re bontottam (az `assets.py:_get_user_permissions()` mintájára)
|
||||||
|
- **1. lépés:** `select(OrganizationMember.role)` → Python string (asyncpg auto-konvertálja az ENUM-ot)
|
||||||
|
- **2. lépés:** `select(OrgRole.permissions).where(OrgRole.name_key == role_string)` — varchar=varchar összehasonlítás
|
||||||
|
- Függvény interfésze változatlan
|
||||||
|
|
||||||
|
### Verifikáció
|
||||||
|
- `POST /api/v1/expenses/` → **HTTP 201** ✅
|
||||||
|
- Biztosítási költség sikeresen létrejött
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
|
||||||
|
|
||||||
|
**Dátum:** 2026-07-25
|
||||||
|
|
||||||
|
### Probléma
|
||||||
|
...
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
`docs/p0_expense_500_import_bugfix_2026-07-26.md`
|
||||||
|
**Gitea Issue:** #421 (closed)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from app.api.v1.endpoints import (
|
|||||||
gamification, translations, users, reports, dictionaries,
|
gamification, translations, users, reports, dictionaries,
|
||||||
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
||||||
subscriptions, marketing, admin_permissions, regions,
|
subscriptions, marketing, admin_permissions, regions,
|
||||||
admin_gamification, admin_providers,
|
admin_gamification, admin_providers, locations,
|
||||||
|
admin_commission,
|
||||||
|
financial_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -25,6 +27,7 @@ api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expe
|
|||||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||||
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
|
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
|
||||||
api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"])
|
api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"])
|
||||||
|
api_router.include_router(finance_admin.router, prefix="/admin/finance", tags=["admin-finance"])
|
||||||
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
||||||
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
||||||
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
||||||
@@ -46,4 +49,7 @@ api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["
|
|||||||
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
|
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
|
||||||
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
|
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
|
||||||
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
|
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
|
||||||
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])
|
api_router.include_router(admin_providers.router, prefix="/admin/providers", tags=["Admin Provider Moderation"])
|
||||||
|
api_router.include_router(locations.router, prefix="", tags=["Location Autocomplete"])
|
||||||
|
api_router.include_router(admin_commission.router, prefix="/admin/commission-rules", tags=["Admin Commission Rules"])
|
||||||
|
api_router.include_router(financial_manager.router, prefix="/financial-manager", tags=["Financial Manager"])
|
||||||
@@ -11,7 +11,7 @@ logger = logging.getLogger("admin-endpoints")
|
|||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
|
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
|
||||||
from app.models.identity.address import Address
|
from app.models.identity.address import Address, GeoPostalCode
|
||||||
from app.models.system import SystemParameter, ParameterScope
|
from app.models.system import SystemParameter, ParameterScope
|
||||||
from app.services.system_service import system_service
|
from app.services.system_service import system_service
|
||||||
# JAVÍTVA: Security audit modellek
|
# JAVÍTVA: Security audit modellek
|
||||||
@@ -623,23 +623,12 @@ async def list_users(
|
|||||||
seen_ids.add(row.id)
|
seen_ids.add(row.id)
|
||||||
unique_users.append(row)
|
unique_users.append(row)
|
||||||
|
|
||||||
|
from app.schemas.address import AddressOut
|
||||||
|
|
||||||
user_list = []
|
user_list = []
|
||||||
for u in unique_users:
|
for u in unique_users:
|
||||||
person = u.person
|
person = u.person
|
||||||
address = person.address if person else None
|
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
|
# Személy nevének összefűzése
|
||||||
person_name = None
|
person_name = None
|
||||||
@@ -663,12 +652,8 @@ async def list_users(
|
|||||||
"first_name": person.first_name if person else None,
|
"first_name": person.first_name if person else None,
|
||||||
"last_name": person.last_name if person else None,
|
"last_name": person.last_name if person else None,
|
||||||
"phone": person.phone if person else None,
|
"phone": person.phone if person else None,
|
||||||
# Címadatok
|
# Címadatok — egységes AddressOut formátumban
|
||||||
"postal_code": str(address.zip) if address and address.zip else None,
|
"address": AddressOut.model_validate(address) if address else None,
|
||||||
"city": address.city if address and address.city else None,
|
|
||||||
"street": address.street_name if address and address.street_name else None,
|
|
||||||
"house_number": address.house_number if address and address.house_number else None,
|
|
||||||
"address": address_str,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -943,6 +928,8 @@ async def search_organizations(
|
|||||||
"""
|
"""
|
||||||
from app.models.marketplace.organization import Organization
|
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 = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
Organization.id,
|
Organization.id,
|
||||||
@@ -955,11 +942,13 @@ async def search_organizations(
|
|||||||
Organization.is_verified,
|
Organization.is_verified,
|
||||||
Organization.is_active,
|
Organization.is_active,
|
||||||
Organization.is_deleted,
|
Organization.is_deleted,
|
||||||
Organization.address_city,
|
GeoPostalCode.city.label("city"),
|
||||||
Organization.region,
|
Organization.region,
|
||||||
Organization.segment,
|
Organization.segment,
|
||||||
Organization.created_at,
|
Organization.created_at,
|
||||||
)
|
)
|
||||||
|
.outerjoin(Address, Organization.address_id == Address.id)
|
||||||
|
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
|
||||||
.where(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
Organization.full_name.ilike(f"%{name}%"),
|
Organization.full_name.ilike(f"%{name}%"),
|
||||||
@@ -987,7 +976,7 @@ async def search_organizations(
|
|||||||
"is_verified": row.is_verified,
|
"is_verified": row.is_verified,
|
||||||
"is_active": row.is_active,
|
"is_active": row.is_active,
|
||||||
"is_deleted": row.is_deleted,
|
"is_deleted": row.is_deleted,
|
||||||
"city": row.address_city,
|
"city": row.city,
|
||||||
"region": row.region,
|
"region": row.region,
|
||||||
"segment": row.segment,
|
"segment": row.segment,
|
||||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
|||||||
351
backend/app/api/v1/endpoints/admin_commission.py
Normal file
351
backend/app/api/v1/endpoints/admin_commission.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin_commission.py
|
||||||
|
"""
|
||||||
|
Admin API endpoints for CommissionRule CRUD, Priority Resolution Engine,
|
||||||
|
2-Level MLM Commission Distribution, and Conflict Detection.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- All endpoints are prefixed with /admin/commission-rules (registered in api.py).
|
||||||
|
- Staff-level access via get_current_staff dependency (SUPERADMIN, ADMIN, etc.).
|
||||||
|
- The GET /active endpoint implements the Priority Resolution Algorithm
|
||||||
|
(Campaign > Region > Tier) from the service layer.
|
||||||
|
- Pagination, filtering by type/tier/region/active/campaign are supported.
|
||||||
|
- Soft-delete via PATCH setting is_active=False; hard DELETE also available.
|
||||||
|
- POST /distribute implements the 2-Level MLM commission distribution:
|
||||||
|
Gen1 (direct referrer) gets commission_percent, Gen2 (upline) gets
|
||||||
|
upline_commission_percent from the same rule.
|
||||||
|
- Phase 3: Conflict Detection — POST (create) and PUT (update) now check
|
||||||
|
for overlapping active rules. If a conflict is found and force_override
|
||||||
|
is False, a 409 Conflict response is returned with the conflicting rule's
|
||||||
|
details. If force_override is True, the conflicting rule is deactivated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_staff
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.identity.identity import User
|
||||||
|
from app.models.marketplace.commission import (
|
||||||
|
CommissionRule,
|
||||||
|
CommissionRuleType,
|
||||||
|
CommissionTier,
|
||||||
|
)
|
||||||
|
from app.schemas.commission import (
|
||||||
|
CommissionRuleCreate,
|
||||||
|
CommissionRuleUpdate,
|
||||||
|
CommissionRuleResponse,
|
||||||
|
CommissionRuleListResponse,
|
||||||
|
CommissionDistributionRequest,
|
||||||
|
CommissionDistributionResponse,
|
||||||
|
ConflictResponse,
|
||||||
|
ConflictingRuleInfo,
|
||||||
|
)
|
||||||
|
from app.services import commission_service
|
||||||
|
|
||||||
|
logger = logging.getLogger("admin-commission")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# LIST: GET /admin/commission-rules
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=CommissionRuleListResponse,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
)
|
||||||
|
async def list_commission_rules(
|
||||||
|
page: int = Query(1, ge=1, description="Oldalszám"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="Elemek száma oldalanként"),
|
||||||
|
rule_type: Optional[CommissionRuleType] = Query(None, description="Szűrés típus szerint (L1_REWARD / L2_COMMISSION)"),
|
||||||
|
tier: Optional[CommissionTier] = Query(None, description="Szűrés szint szerint (STANDARD / VIP / PLATINUM / ENTERPRISE)"),
|
||||||
|
region_code: Optional[str] = Query(None, max_length=10, description="Szűrés régió szerint (pl. HU, GLOBAL)"),
|
||||||
|
is_active: Optional[bool] = Query(None, description="Szűrés aktív/inaktív státusz szerint"),
|
||||||
|
is_campaign: Optional[bool] = Query(None, description="Szűrés kampány/állandó szabály szerint"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""List all commission rules with optional filters and pagination."""
|
||||||
|
rules, total = await commission_service.list_rules(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
rule_type=rule_type,
|
||||||
|
tier=tier,
|
||||||
|
region_code=region_code,
|
||||||
|
is_active=is_active,
|
||||||
|
is_campaign=is_campaign,
|
||||||
|
)
|
||||||
|
return CommissionRuleListResponse(
|
||||||
|
items=[CommissionRuleResponse.model_validate(r) for r in rules],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# GET ACTIVE RULE: GET /admin/commission-rules/active
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/active",
|
||||||
|
response_model=CommissionRuleResponse,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
)
|
||||||
|
async def get_active_commission_rule(
|
||||||
|
rule_type: CommissionRuleType = Query(..., description="Jutalék típusa (L1_REWARD / L2_COMMISSION)"),
|
||||||
|
tier: CommissionTier = Query(CommissionTier.STANDARD, description="Felhasználó szintje"),
|
||||||
|
region_code: str = Query("GLOBAL", max_length=10, description="Régiókód (ISO 3166-1 alpha-2)"),
|
||||||
|
transaction_date: date = Query(..., description="Tranzakció dátuma (ISO 8601, pl. 2026-07-24)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Priority Resolution Engine: Find the most specific active rule.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Campaign rules over permanent rules
|
||||||
|
2. Most specific region match (HU > GLOBAL)
|
||||||
|
3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE)
|
||||||
|
"""
|
||||||
|
rule = await commission_service.get_active_rule(
|
||||||
|
db,
|
||||||
|
rule_type=rule_type,
|
||||||
|
tier=tier,
|
||||||
|
region_code=region_code,
|
||||||
|
transaction_date=transaction_date,
|
||||||
|
)
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No active commission rule found for the given parameters.",
|
||||||
|
)
|
||||||
|
return CommissionRuleResponse.model_validate(rule)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# GET SINGLE: GET /admin/commission-rules/{rule_id}
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{rule_id}",
|
||||||
|
response_model=CommissionRuleResponse,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
)
|
||||||
|
async def get_commission_rule(
|
||||||
|
rule_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""Get a single commission rule by ID."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Commission rule with id={rule_id} not found.",
|
||||||
|
)
|
||||||
|
return CommissionRuleResponse.model_validate(rule)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# CREATE: POST /admin/commission-rules
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=CommissionRuleResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
responses={
|
||||||
|
409: {
|
||||||
|
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||||
|
"model": ConflictResponse,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def create_commission_rule(
|
||||||
|
data: CommissionRuleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new commission rule with conflict detection.
|
||||||
|
|
||||||
|
If an active rule with the same tier/region/is_campaign combination exists
|
||||||
|
and force_override is False, a 409 Conflict is returned with the details
|
||||||
|
of the conflicting rule.
|
||||||
|
|
||||||
|
If force_override is True, the conflicting rule is deactivated and the
|
||||||
|
new rule is created.
|
||||||
|
"""
|
||||||
|
rule = await commission_service.create_commission_rule(
|
||||||
|
db, data=data, admin_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||||
|
# when force_override is False and a conflict exists. We detect this by
|
||||||
|
# checking if the returned rule's name differs from the input data.
|
||||||
|
if rule.name != data.name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "A conflicting active rule already exists for this combination.",
|
||||||
|
"conflicting_rule": ConflictingRuleInfo(
|
||||||
|
id=rule.id,
|
||||||
|
name=rule.name,
|
||||||
|
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||||
|
tier=CommissionTier(rule.tier.value),
|
||||||
|
region_code=rule.region_code,
|
||||||
|
is_campaign=rule.is_campaign,
|
||||||
|
is_active=rule.is_active,
|
||||||
|
).model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommissionRuleResponse.model_validate(rule)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# UPDATE: PUT /admin/commission-rules/{rule_id}
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{rule_id}",
|
||||||
|
response_model=CommissionRuleResponse,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
responses={
|
||||||
|
409: {
|
||||||
|
"description": "Conflict — an active rule with the same tier/region/campaign already exists",
|
||||||
|
"model": ConflictResponse,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def update_commission_rule(
|
||||||
|
rule_id: int,
|
||||||
|
data: CommissionRuleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update an existing commission rule (partial update) with conflict detection.
|
||||||
|
|
||||||
|
If the update would create a conflict with another active rule and
|
||||||
|
force_override is False, a 409 Conflict is returned with the details
|
||||||
|
of the conflicting rule.
|
||||||
|
|
||||||
|
If force_override is True, the conflicting rule is deactivated and the
|
||||||
|
update proceeds.
|
||||||
|
"""
|
||||||
|
rule = await commission_service.update_commission_rule(db, rule_id, data)
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Commission rule with id={rule_id} not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Phase 3: Detect conflict — the service returns the conflicting rule
|
||||||
|
# when force_override is False and a conflict exists. We detect this by
|
||||||
|
# checking if the returned rule's ID differs from the requested rule_id.
|
||||||
|
if rule.id != rule_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={
|
||||||
|
"message": "A conflicting active rule already exists for this combination.",
|
||||||
|
"conflicting_rule": ConflictingRuleInfo(
|
||||||
|
id=rule.id,
|
||||||
|
name=rule.name,
|
||||||
|
rule_type=CommissionRuleType(rule.rule_type.value),
|
||||||
|
tier=CommissionTier(rule.tier.value),
|
||||||
|
region_code=rule.region_code,
|
||||||
|
is_campaign=rule.is_campaign,
|
||||||
|
is_active=rule.is_active,
|
||||||
|
).model_dump(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommissionRuleResponse.model_validate(rule)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# SOFT DELETE (DEACTIVATE): DELETE /admin/commission-rules/{rule_id}
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{rule_id}",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
)
|
||||||
|
async def deactivate_commission_rule(
|
||||||
|
rule_id: int,
|
||||||
|
hard_delete: bool = Query(False, description="Végleges törlés (alapértelmezett: soft-delete / deaktiválás)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Deactivate (soft-delete) or permanently delete a commission rule.
|
||||||
|
|
||||||
|
By default, sets is_active=False (soft delete).
|
||||||
|
Use ?hard_delete=true for permanent removal.
|
||||||
|
"""
|
||||||
|
if hard_delete:
|
||||||
|
deleted = await commission_service.hard_delete_commission_rule(db, rule_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Commission rule with id={rule_id} not found.",
|
||||||
|
)
|
||||||
|
return {"message": f"Commission rule {rule_id} permanently deleted."}
|
||||||
|
|
||||||
|
rule = await commission_service.deactivate_commission_rule(db, rule_id)
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Commission rule with id={rule_id} not found.",
|
||||||
|
)
|
||||||
|
return CommissionRuleResponse.model_validate(rule)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2-LEVEL MLM DISTRIBUTION: POST /admin/commission-rules/distribute
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/distribute",
|
||||||
|
response_model=CommissionDistributionResponse,
|
||||||
|
tags=["Admin Commission Rules"],
|
||||||
|
)
|
||||||
|
async def distribute_commission(
|
||||||
|
request: CommissionDistributionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_staff),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
2-Level MLM Commission Distribution Engine.
|
||||||
|
|
||||||
|
When a referred company makes a purchase, this endpoint:
|
||||||
|
1. Looks up the buyer's direct referrer (Gen1)
|
||||||
|
2. Finds the active L2_COMMISSION rule for Gen1's tier/region
|
||||||
|
3. Calculates Gen1's commission using commission_percent
|
||||||
|
4. If Gen1 has a referrer (Gen2/upline), calculates Gen2's commission
|
||||||
|
using upline_commission_percent from the same rule
|
||||||
|
|
||||||
|
Returns a breakdown of all commission payouts.
|
||||||
|
"""
|
||||||
|
result = await commission_service.distribute_commission(db, request)
|
||||||
|
return result
|
||||||
@@ -1188,6 +1188,152 @@ async def review_contribution(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VALIDATION RULES (Provider Validation Engine)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationRuleUpdate(BaseModel):
|
||||||
|
"""Update payload for a single validation rule."""
|
||||||
|
rule_value: int = Field(..., ge=0, le=100, description="New integer value for this rule (0-100)")
|
||||||
|
description: Optional[str] = Field(None, max_length=500, description="Updated description")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationRuleBulkUpdate(BaseModel):
|
||||||
|
"""Bulk update payload — dict of rule_key → new_value."""
|
||||||
|
rules: Dict[str, int] = Field(
|
||||||
|
..., description="Mapping of rule_key → new integer value (0-100)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/validation-rules", response_model=List[Dict[str, Any]])
|
||||||
|
async def list_validation_rules(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fetch all ValidationRule records from the database.
|
||||||
|
|
||||||
|
Returns a list of {id, rule_key, rule_value, description, updated_at}.
|
||||||
|
These rules drive the ValidationEngine for provider scoring and
|
||||||
|
auto-approval thresholds.
|
||||||
|
"""
|
||||||
|
from app.models.gamification.validation_rule import ValidationRule
|
||||||
|
|
||||||
|
stmt = select(ValidationRule).order_by(ValidationRule.rule_key)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rules = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"rule_key": r.rule_key,
|
||||||
|
"rule_value": r.rule_value,
|
||||||
|
"description": r.description,
|
||||||
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||||||
|
}
|
||||||
|
for r in rules
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/validation-rules", response_model=Dict[str, Any])
|
||||||
|
async def bulk_update_validation_rules(
|
||||||
|
data: ValidationRuleBulkUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Bulk-update ValidationRule values.
|
||||||
|
|
||||||
|
Accepts a dict of rule_key → new_value. Iterates through each entry
|
||||||
|
and updates rule_value in the database. Unknown keys are silently skipped.
|
||||||
|
|
||||||
|
Returns a summary of updated rules.
|
||||||
|
"""
|
||||||
|
from app.models.gamification.validation_rule import ValidationRule
|
||||||
|
|
||||||
|
updated: List[Dict[str, Any]] = []
|
||||||
|
errors: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for rule_key, new_value in data.rules.items():
|
||||||
|
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
|
||||||
|
rule = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not rule:
|
||||||
|
errors.append({"rule_key": rule_key, "error": "Rule not found"})
|
||||||
|
logger.warning(f"Validation rule '{rule_key}' not found, skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not (0 <= new_value <= 100):
|
||||||
|
errors.append({"rule_key": rule_key, "error": "Value must be between 0 and 100"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_val = rule.rule_value
|
||||||
|
rule.rule_value = new_value
|
||||||
|
updated.append({
|
||||||
|
"rule_key": rule_key,
|
||||||
|
"old_value": old_val,
|
||||||
|
"new_value": new_value,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Updated {len(updated)} rule(s) successfully.",
|
||||||
|
"updated": updated,
|
||||||
|
"errors": errors if errors else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/validation-rules/{rule_key}", response_model=Dict[str, Any])
|
||||||
|
async def update_validation_rule(
|
||||||
|
rule_key: str,
|
||||||
|
data: ValidationRuleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_: User = Depends(deps.RequirePermission("gamification:manage")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a single ValidationRule by its rule_key.
|
||||||
|
|
||||||
|
Allows updating both rule_value and description.
|
||||||
|
"""
|
||||||
|
from app.models.gamification.validation_rule import ValidationRule
|
||||||
|
|
||||||
|
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_key)
|
||||||
|
rule = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Validation rule '{rule_key}' not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
old_value = rule.rule_value
|
||||||
|
rule.rule_value = data.rule_value
|
||||||
|
if data.description is not None:
|
||||||
|
rule.description = data.description
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Validation rule '{rule_key}' updated: {old_value} → {data.rule_value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": rule.id,
|
||||||
|
"rule_key": rule.rule_key,
|
||||||
|
"rule_value": rule.rule_value,
|
||||||
|
"description": rule.description,
|
||||||
|
"old_value": old_value,
|
||||||
|
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
|
||||||
|
"message": f"Rule '{rule_key}' updated successfully.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# LEADERBOARD (Admin)
|
# LEADERBOARD (Admin)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
|||||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
from app.models.fleet.organization import ContactPerson
|
from app.models.fleet.organization import ContactPerson
|
||||||
from app.models.identity import User, Person
|
from app.models.identity import User, Person
|
||||||
|
from app.services.address_manager import AddressManager
|
||||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||||
|
from app.schemas.address import AddressIn, AddressOut
|
||||||
from app.schemas.organization import (
|
from app.schemas.organization import (
|
||||||
OrganizationMemberCreate,
|
OrganizationMemberCreate,
|
||||||
OrganizationMemberUpdate,
|
OrganizationMemberUpdate,
|
||||||
@@ -207,30 +209,36 @@ class GarageDetailsResponse(BaseModel):
|
|||||||
is_active: bool
|
is_active: bool
|
||||||
is_verified: bool
|
is_verified: bool
|
||||||
org_type: str
|
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_city: Optional[str] = None
|
||||||
address_zip: Optional[str] = None
|
address_zip: Optional[str] = None
|
||||||
address_street_name: Optional[str] = None
|
address_street_name: Optional[str] = None
|
||||||
address_street_type: Optional[str] = None
|
address_street_type: Optional[str] = None
|
||||||
address_house_number: Optional[str] = None
|
address_house_number: Optional[str] = None
|
||||||
|
# P0: Unified AddressOut nested object
|
||||||
|
address_detail: Optional[AddressOut] = None
|
||||||
tax_number: Optional[str] = None
|
tax_number: Optional[str] = None
|
||||||
reg_number: Optional[str] = None
|
reg_number: Optional[str] = None
|
||||||
# ── Kapcsolattartó (Contact Person) ──
|
# ── Kapcsolattartó (Contact Person) ──
|
||||||
contact_person_name: Optional[str] = None
|
contact_person_name: Optional[str] = None
|
||||||
contact_email: Optional[str] = None
|
contact_email: Optional[str] = None
|
||||||
contact_phone: 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_zip: Optional[str] = None
|
||||||
billing_city: Optional[str] = None
|
billing_city: Optional[str] = None
|
||||||
billing_street_name: Optional[str] = None
|
billing_street_name: Optional[str] = None
|
||||||
billing_street_type: Optional[str] = None
|
billing_street_type: Optional[str] = None
|
||||||
billing_house_number: 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_zip: Optional[str] = None
|
||||||
notification_city: Optional[str] = None
|
notification_city: Optional[str] = None
|
||||||
notification_street_name: Optional[str] = None
|
notification_street_name: Optional[str] = None
|
||||||
notification_street_type: Optional[str] = None
|
notification_street_type: Optional[str] = None
|
||||||
notification_house_number: 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
|
# Előfizetés
|
||||||
subscription: Optional[SubscriptionSummary] = None
|
subscription: Optional[SubscriptionSummary] = None
|
||||||
# Kapcsolattartó (ContactPerson modellből)
|
# Kapcsolattartó (ContactPerson modellből)
|
||||||
@@ -421,6 +429,9 @@ async def list_organizations(
|
|||||||
# Resolve email: prefer contact_email, fallback to owner's email
|
# Resolve email: prefer contact_email, fallback to owner's email
|
||||||
resolved_email = org.contact_email or (org.owner.email if org.owner else None)
|
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(
|
garage_list.append(GarageListItem(
|
||||||
id=org.id,
|
id=org.id,
|
||||||
name=org.name,
|
name=org.name,
|
||||||
@@ -432,7 +443,7 @@ async def list_organizations(
|
|||||||
is_verified=org.is_verified,
|
is_verified=org.is_verified,
|
||||||
is_deleted=org.is_deleted,
|
is_deleted=org.is_deleted,
|
||||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
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,
|
region=org.region,
|
||||||
segment=org.segment,
|
segment=org.segment,
|
||||||
member_count=member_counts.get(org.id, 0),
|
member_count=member_counts.get(org.id, 0),
|
||||||
@@ -696,17 +707,22 @@ async def get_organization_details(
|
|||||||
|
|
||||||
# 2c. P0 ADD-ON: Telephelyek (branches) lekérése
|
# 2c. P0 ADD-ON: Telephelyek (branches) lekérése
|
||||||
from app.models.marketplace.organization import Branch # noqa: E402
|
from app.models.marketplace.organization import Branch # noqa: E402
|
||||||
branches_stmt = select(Branch).where(
|
branches_stmt = (
|
||||||
Branch.organization_id == org_id,
|
select(Branch)
|
||||||
Branch.is_deleted == False,
|
.options(selectinload(Branch.address))
|
||||||
).order_by(Branch.name)
|
.where(
|
||||||
|
Branch.organization_id == org_id,
|
||||||
|
Branch.is_deleted == False,
|
||||||
|
)
|
||||||
|
.order_by(Branch.name)
|
||||||
|
)
|
||||||
branches_result = await db.execute(branches_stmt)
|
branches_result = await db.execute(branches_stmt)
|
||||||
branch_rows = branches_result.scalars().all()
|
branch_rows = branches_result.scalars().all()
|
||||||
branches_list = [
|
branches_list = [
|
||||||
BranchBrief(
|
BranchBrief(
|
||||||
id=str(b.id),
|
id=str(b.id),
|
||||||
name=b.name,
|
name=b.name,
|
||||||
city=b.city,
|
city=b.address.city if b.address else None,
|
||||||
is_active=(b.status == "active") if b.status else True,
|
is_active=(b.status == "active") if b.status else True,
|
||||||
)
|
)
|
||||||
for b in branch_rows
|
for b in branch_rows
|
||||||
@@ -1078,6 +1094,19 @@ async def get_organization_details(
|
|||||||
f"({org.full_name}) — {len(members_list)} members"
|
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(
|
return GarageDetailsResponse(
|
||||||
id=org.id,
|
id=org.id,
|
||||||
name=org.name,
|
name=org.name,
|
||||||
@@ -1087,11 +1116,13 @@ async def get_organization_details(
|
|||||||
is_active=org.is_active,
|
is_active=org.is_active,
|
||||||
is_verified=org.is_verified,
|
is_verified=org.is_verified,
|
||||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||||
address_city=org.address_city,
|
# ── P0 REFACTORED: Denormalized fields populated from relationship ──
|
||||||
address_zip=org.address_zip,
|
address_city=org.address.city if org.address else None,
|
||||||
address_street_name=org.address_street_name,
|
address_zip=org.address.zip if org.address else None,
|
||||||
address_street_type=org.address_street_type,
|
address_street_name=org.address.street_name if org.address else None,
|
||||||
address_house_number=org.address_house_number,
|
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,
|
tax_number=org.tax_number,
|
||||||
reg_number=org.reg_number,
|
reg_number=org.reg_number,
|
||||||
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
|
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
|
||||||
@@ -1099,17 +1130,19 @@ async def get_organization_details(
|
|||||||
contact_email=resolved_contact_email,
|
contact_email=resolved_contact_email,
|
||||||
contact_phone=resolved_contact_phone,
|
contact_phone=resolved_contact_phone,
|
||||||
# ── Számlázási cím ──
|
# ── Számlázási cím ──
|
||||||
billing_zip=org.billing_zip,
|
billing_zip=org.billing_address.zip if org.billing_address else None,
|
||||||
billing_city=org.billing_city,
|
billing_city=org.billing_address.city if org.billing_address else None,
|
||||||
billing_street_name=org.billing_street_name,
|
billing_street_name=org.billing_address.street_name if org.billing_address else None,
|
||||||
billing_street_type=org.billing_street_type,
|
billing_street_type=org.billing_address.street_type if org.billing_address else None,
|
||||||
billing_house_number=org.billing_house_number,
|
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 ──
|
# ── Értesítési cím ──
|
||||||
notification_zip=org.notification_zip,
|
notification_zip=org.notification_address.zip if org.notification_address else None,
|
||||||
notification_city=org.notification_city,
|
notification_city=org.notification_address.city if org.notification_address else None,
|
||||||
notification_street_name=org.notification_street_name,
|
notification_street_name=org.notification_address.street_name if org.notification_address else None,
|
||||||
notification_street_type=org.notification_street_type,
|
notification_street_type=org.notification_address.street_type if org.notification_address else None,
|
||||||
notification_house_number=org.notification_house_number,
|
notification_house_number=org.notification_address.house_number if org.notification_address else None,
|
||||||
|
notification_address_detail=notification_address_detail,
|
||||||
subscription=subscription_summary,
|
subscription=subscription_summary,
|
||||||
primary_contact=primary_contact,
|
primary_contact=primary_contact,
|
||||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||||
@@ -1224,28 +1257,34 @@ class OrganizationUpdate(BaseModel):
|
|||||||
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
|
# ── 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)")
|
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)")
|
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) ──
|
# ── Elsődleges cím (Primary Address) — DEPRECATED: Use address_detail instead ──
|
||||||
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
|
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="Város")
|
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="Utca neve")
|
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="Utca típusa (utca, tér, stb.)")
|
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="Házszám")
|
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) ──
|
# ── Kapcsolattartó (Contact Person) ──
|
||||||
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
|
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_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")
|
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
|
||||||
# ── Számlázási cím (Billing Address) ──
|
# ── Számlázási cím (Billing Address) — DEPRECATED: Use billing_address_detail instead ──
|
||||||
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
|
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="Számlázási város")
|
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="Számlázási utca neve")
|
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="Számlázási utca típusa")
|
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="Számlázási házszám")
|
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")
|
||||||
# ── Értesítési cím (Notification Address) ──
|
# P0: Unified AddressIn nested object for billing address
|
||||||
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
|
billing_address_detail: Optional[AddressIn] = None
|
||||||
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
|
# ── Értesítési cím (Notification Address) — DEPRECATED: Use notification_address_detail instead ──
|
||||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
|
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_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
|
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_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
|
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(
|
@router.put(
|
||||||
@@ -1279,11 +1318,58 @@ async def update_organization(
|
|||||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
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:
|
||||||
|
# P0 BUGFIX: model_dump() converts nested Pydantic models to dicts;
|
||||||
|
# re-wrap into AddressIn before passing to AddressManager
|
||||||
|
if isinstance(address_detail, dict):
|
||||||
|
address_detail = AddressIn(**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:
|
||||||
|
if isinstance(billing_address_detail, dict):
|
||||||
|
billing_address_detail = AddressIn(**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:
|
||||||
|
if isinstance(notification_address_detail, dict):
|
||||||
|
notification_address_detail = AddressIn(**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)
|
||||||
|
# P0 BUGFIX: Filter out deprecated denormalized address fields that were dropped from the DB
|
||||||
|
# These columns (address_city, billing_zip, etc.) were physically removed from fleet.organizations
|
||||||
|
# in cleanup_ghost_columns_2026_07.sql. The Organization model no longer has these attributes.
|
||||||
|
DEPRECATED_ADDRESS_FIELDS = {
|
||||||
|
"address_zip", "address_city", "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",
|
||||||
|
}
|
||||||
update_fields = {}
|
update_fields = {}
|
||||||
for field_name in payload.model_dump(exclude_none=True):
|
for field_name in raw_data:
|
||||||
value = getattr(payload, field_name)
|
value = raw_data[field_name]
|
||||||
if value is not None and hasattr(org, field_name):
|
if value is not None and hasattr(org, field_name):
|
||||||
|
# Skip deprecated address fields that no longer exist on the ORM model
|
||||||
|
if field_name in DEPRECATED_ADDRESS_FIELDS:
|
||||||
|
logger.warning(
|
||||||
|
f"Skipping deprecated field '{field_name}' for org {org_id} — "
|
||||||
|
f"column was dropped from database. Use address_detail instead."
|
||||||
|
)
|
||||||
|
continue
|
||||||
setattr(org, field_name, value)
|
setattr(org, field_name, value)
|
||||||
update_fields[field_name] = value
|
update_fields[field_name] = value
|
||||||
|
|
||||||
@@ -1333,6 +1419,11 @@ async def update_organization(
|
|||||||
f"({org.full_name}): fields={list(update_fields.keys())}"
|
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 {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
|
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
|
||||||
@@ -1345,11 +1436,7 @@ async def update_organization(
|
|||||||
"display_name": org.display_name,
|
"display_name": org.display_name,
|
||||||
"tax_number": org.tax_number,
|
"tax_number": org.tax_number,
|
||||||
"reg_number": org.reg_number,
|
"reg_number": org.reg_number,
|
||||||
"address_zip": org.address_zip,
|
"address_detail": resp_address_detail,
|
||||||
"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,
|
|
||||||
"status": org.status,
|
"status": org.status,
|
||||||
"is_active": org.is_active,
|
"is_active": org.is_active,
|
||||||
"contact_email": org.contact_email,
|
"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.identity.address import Address
|
||||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
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")
|
logger = logging.getLogger("admin-persons")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -59,7 +60,7 @@ class PersonListItem(BaseModel):
|
|||||||
merged_into_id: Optional[int] = None
|
merged_into_id: Optional[int] = None
|
||||||
users_count: int = 0
|
users_count: int = 0
|
||||||
active_user: Optional[PersonListItemActiveUser] = None
|
active_user: Optional[PersonListItemActiveUser] = None
|
||||||
address: Optional[AddressResponse] = None
|
address: Optional[AddressOut] = None
|
||||||
|
|
||||||
|
|
||||||
class PersonListResponse(BaseModel):
|
class PersonListResponse(BaseModel):
|
||||||
@@ -214,20 +215,7 @@ async def list_persons(
|
|||||||
# Cím adatok
|
# Cím adatok
|
||||||
address_data = None
|
address_data = None
|
||||||
if person.address:
|
if person.address:
|
||||||
address_data = AddressResponse(
|
address_data = AddressOut.model_validate(person.address)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
items.append(PersonListItem(
|
items.append(PersonListItem(
|
||||||
id=person.id,
|
id=person.id,
|
||||||
@@ -338,7 +326,7 @@ class PersonDetailResponse(BaseModel):
|
|||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
# Kapcsolódó entitások
|
# Kapcsolódó entitások
|
||||||
address: Optional[AddressResponse] = None
|
address: Optional[AddressOut] = None
|
||||||
users: List[PersonDetailUserInfo] = []
|
users: List[PersonDetailUserInfo] = []
|
||||||
memberships: List[PersonDetailMembership] = []
|
memberships: List[PersonDetailMembership] = []
|
||||||
owned_organizations: List[PersonDetailOwnedOrganization] = []
|
owned_organizations: List[PersonDetailOwnedOrganization] = []
|
||||||
@@ -394,20 +382,7 @@ async def get_person_detail(
|
|||||||
# ── Cím adatok ──
|
# ── Cím adatok ──
|
||||||
address_data = None
|
address_data = None
|
||||||
if person.address:
|
if person.address:
|
||||||
address_data = AddressResponse(
|
address_data = AddressOut.model_validate(person.address)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Kapcsolódó User-ek ──
|
# ── Kapcsolódó User-ek ──
|
||||||
users_data: List[PersonDetailUserInfo] = []
|
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):
|
class PersonUpdateRequest(BaseModel):
|
||||||
"""Admin által szerkeszthető Person mezők.
|
"""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_active: Optional[bool] = Field(default=None, description="Aktív státusz")
|
||||||
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
|
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
|
||||||
|
|
||||||
# Cím adatok (opcionális, nested object)
|
# Cím adatok (opcionális, nested object) — egységes AddressIn séma
|
||||||
address: Optional[PersonAddressUpdate] = Field(default=None, description="Cím adatok")
|
address: Optional[AddressIn] = Field(default=None, description="Cím adatok")
|
||||||
|
|
||||||
@field_validator("phone")
|
@field_validator("phone")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -692,62 +651,14 @@ async def update_person(
|
|||||||
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
|
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
|
||||||
person.is_ghost = 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:
|
if update_data.address is not None:
|
||||||
addr_data = update_data.address
|
# P1 FIX: Use AddressManager.create_or_update() instead of
|
||||||
|
# writing directly to Address model fields.
|
||||||
# Ha van már cím, frissítjük, különben létrehozzuk
|
person.address_id = await AddressManager.create_or_update(
|
||||||
if person.address:
|
db, person.address_id, update_data.address
|
||||||
address = person.address
|
)
|
||||||
else:
|
changes["address"] = {"old": None, "new": "updated via AddressManager"}
|
||||||
# Új cím létrehozása
|
|
||||||
address = Address(
|
|
||||||
created_at=datetime.utcnow()
|
|
||||||
)
|
|
||||||
db.add(address)
|
|
||||||
person.address = address
|
|
||||||
|
|
||||||
# Cím mezők frissítése
|
|
||||||
addr_changes: Dict[str, Any] = {}
|
|
||||||
if addr_data.address_zip is not None:
|
|
||||||
addr_changes["address_zip"] = {"old": address.zip, "new": addr_data.address_zip}
|
|
||||||
# Megjegyzés: a zip a GeoPostalCode kapcsolatból jön, itt csak a postal_code_id-t tudnánk keresni
|
|
||||||
# Egyszerűsítés: a frontend által küldött adatokat tároljuk a full_address_text-ben is
|
|
||||||
if addr_data.address_city is not None:
|
|
||||||
addr_changes["address_city"] = {"old": address.city, "new": addr_data.address_city}
|
|
||||||
if addr_data.address_street_name is not None:
|
|
||||||
addr_changes["address_street_name"] = {"old": address.street_name, "new": addr_data.address_street_name}
|
|
||||||
address.street_name = addr_data.address_street_name
|
|
||||||
if addr_data.address_street_type is not None:
|
|
||||||
addr_changes["address_street_type"] = {"old": address.street_type, "new": addr_data.address_street_type}
|
|
||||||
address.street_type = addr_data.address_street_type
|
|
||||||
if addr_data.address_house_number is not None:
|
|
||||||
addr_changes["address_house_number"] = {"old": address.house_number, "new": addr_data.address_house_number}
|
|
||||||
address.house_number = addr_data.address_house_number
|
|
||||||
if addr_data.address_stairwell is not None:
|
|
||||||
addr_changes["address_stairwell"] = {"old": address.stairwell, "new": addr_data.address_stairwell}
|
|
||||||
address.stairwell = addr_data.address_stairwell
|
|
||||||
if addr_data.address_floor is not None:
|
|
||||||
addr_changes["address_floor"] = {"old": address.floor, "new": addr_data.address_floor}
|
|
||||||
address.floor = addr_data.address_floor
|
|
||||||
if addr_data.address_door is not None:
|
|
||||||
addr_changes["address_door"] = {"old": address.door, "new": addr_data.address_door}
|
|
||||||
address.door = addr_data.address_door
|
|
||||||
if addr_data.address_hrsz is not None:
|
|
||||||
addr_changes["address_hrsz"] = {"old": address.parcel_id, "new": addr_data.address_hrsz}
|
|
||||||
address.parcel_id = addr_data.address_hrsz
|
|
||||||
if addr_data.full_address_text is not None:
|
|
||||||
addr_changes["full_address_text"] = {"old": address.full_address_text, "new": addr_data.full_address_text}
|
|
||||||
address.full_address_text = addr_data.full_address_text
|
|
||||||
if addr_data.latitude is not None:
|
|
||||||
addr_changes["latitude"] = {"old": address.latitude, "new": addr_data.latitude}
|
|
||||||
address.latitude = addr_data.latitude
|
|
||||||
if addr_data.longitude is not None:
|
|
||||||
addr_changes["longitude"] = {"old": address.longitude, "new": addr_data.longitude}
|
|
||||||
address.longitude = addr_data.longitude
|
|
||||||
|
|
||||||
if addr_changes:
|
|
||||||
changes["address"] = addr_changes
|
|
||||||
|
|
||||||
# ── 6. Mentés ──
|
# ── 6. Mentés ──
|
||||||
if changes:
|
if changes:
|
||||||
@@ -768,20 +679,7 @@ async def update_person(
|
|||||||
# Cím adatok
|
# Cím adatok
|
||||||
address_data = None
|
address_data = None
|
||||||
if person.address:
|
if person.address:
|
||||||
address_data = AddressResponse(
|
address_data = AddressOut.model_validate(person.address)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Kapcsolódó User-ek
|
# Kapcsolódó User-ek
|
||||||
users_data: List[PersonDetailUserInfo] = []
|
users_data: List[PersonDetailUserInfo] = []
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ Végpontok:
|
|||||||
POST /admin/providers/{id}/flag — Megjelölés (approved -> flagged)
|
POST /admin/providers/{id}/flag — Megjelölés (approved -> flagged)
|
||||||
POST /admin/providers/{id}/restore — Visszaállítás (rejected -> approved)
|
POST /admin/providers/{id}/restore — Visszaállítás (rejected -> approved)
|
||||||
DELETE /admin/providers/{id} — Törlés (soft delete)
|
DELETE /admin/providers/{id} — Törlés (soft delete)
|
||||||
|
|
||||||
|
P0 CRITICAL FIX (2026-07-07): Promotion Engine wiring.
|
||||||
|
- PATCH /admin/providers/{id} on a ServiceStaging record with status="approved"
|
||||||
|
now triggers the Promotion Engine: creates a ServiceProvider + ServiceProfile,
|
||||||
|
sets validation_score=100, then deletes the staging record.
|
||||||
|
- Auto-Validation: Admin approval sets validation_score=100 on the new ServiceProvider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,8 +33,10 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy import select, func, or_, union_all, literal, cast, String, case
|
from sqlalchemy import select, func, or_, union_all, literal, cast, String, case
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
|
from app.schemas.address import AddressIn, AddressOut
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models.identity.social import (
|
from app.models.identity.social import (
|
||||||
@@ -37,11 +45,13 @@ from app.models.identity.social import (
|
|||||||
SourceType,
|
SourceType,
|
||||||
ProviderValidation,
|
ProviderValidation,
|
||||||
)
|
)
|
||||||
from app.models.marketplace.service import ServiceStaging, ServiceProfile
|
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
|
||||||
|
from app.models.marketplace.staged_data import ServiceStaging
|
||||||
from app.models.gamification.gamification import UserContribution
|
from app.models.gamification.gamification import UserContribution
|
||||||
from app.models.vehicle.history import AuditLog
|
from app.models.vehicle.history import AuditLog
|
||||||
from app.services.gamification_service import gamification_service
|
from app.services.gamification_service import gamification_service
|
||||||
from app.services.provider_service import _sync_service_expertises
|
from app.services.provider_service import _sync_service_expertises
|
||||||
|
from app.services.address_manager import AddressManager
|
||||||
|
|
||||||
logger = logging.getLogger("admin-providers")
|
logger = logging.getLogger("admin-providers")
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -53,11 +63,17 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class ProviderListItem(BaseModel):
|
class ProviderListItem(BaseModel):
|
||||||
"""Egy szolgáltató rövid adatai a listázáshoz."""
|
"""Egy szolgáltató rövid adatai a listázáshoz.
|
||||||
|
|
||||||
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
- address_detail: Optional[AddressOut] — first-class citizen.
|
||||||
|
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
|
||||||
|
"""
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
address: Optional[str] = None
|
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||||
city: Optional[str] = None
|
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
||||||
|
address_detail: Optional[AddressOut] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
source: str
|
source: str
|
||||||
@@ -70,24 +86,31 @@ class ProviderListItem(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProviderDetail(BaseModel):
|
class ProviderDetail(BaseModel):
|
||||||
"""Szolgáltató részletes adatai."""
|
"""Szolgáltató részletes adatai.
|
||||||
|
|
||||||
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
- address_detail: Optional[AddressOut] — first-class citizen.
|
||||||
|
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
|
||||||
|
"""
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
address: str
|
address: str = Field(..., description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||||
city: Optional[str] = None
|
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
||||||
address_zip: Optional[str] = None
|
address_detail: Optional[AddressOut] = None
|
||||||
address_street_name: Optional[str] = None
|
|
||||||
address_street_type: Optional[str] = None
|
|
||||||
address_house_number: Optional[str] = None
|
|
||||||
plus_code: Optional[str] = None
|
plus_code: Optional[str] = None
|
||||||
contact_phone: Optional[str] = None
|
contact_phone: Optional[str] = None
|
||||||
contact_email: Optional[str] = None
|
contact_email: Optional[str] = None
|
||||||
website: Optional[str] = None
|
website: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
source: str
|
source: str
|
||||||
source_table: str = "service_providers"
|
source_table: str = "service_providers"
|
||||||
validation_score: int = 0
|
validation_score: int = 0
|
||||||
|
trust_score: Optional[int] = None
|
||||||
|
rejection_reason: Optional[str] = None
|
||||||
|
raw_data: Optional[Dict[str, Any]] = None
|
||||||
|
audit_trail: Optional[Dict[str, Any]] = None
|
||||||
evidence_image_path: Optional[str] = None
|
evidence_image_path: Optional[str] = None
|
||||||
added_by_user_id: Optional[int] = None
|
added_by_user_id: Optional[int] = None
|
||||||
category_ids: Optional[List[int]] = None
|
category_ids: Optional[List[int]] = None
|
||||||
@@ -126,14 +149,16 @@ class ModerationResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProviderUpdateInput(BaseModel):
|
class ProviderUpdateInput(BaseModel):
|
||||||
"""Provider adatok szerkesztésének bemeneti sémája."""
|
"""Provider adatok szerkesztésének bemeneti sémája.
|
||||||
|
|
||||||
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||||
|
- A flat mezők (address, city) [DEPRECATED] — Phase 3-ban törlendő.
|
||||||
|
"""
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=255, description="Szolgáltató neve")
|
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")
|
address: Optional[str] = Field(None, max_length=500, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
address_detail: Optional[AddressIn] = Field(None, description="Cím részletes adatai (egységes AddressIn séma)")
|
||||||
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")
|
|
||||||
plus_code: Optional[str] = Field(None, max_length=50, description="Plus Code (Google Maps)")
|
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_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||||
@@ -290,8 +315,13 @@ def _build_unified_providers_query(
|
|||||||
alkalmazzuk (nem az egyes részekre), hogy elkerüljük az enum típusütközéseket.
|
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.marketplace.organization import Organization
|
||||||
|
from app.models.identity.address import Address, GeoPostalCode
|
||||||
|
|
||||||
# --- 1. ServiceProvider lekérdezés ---
|
# --- 1. ServiceProvider lekérdezés ---
|
||||||
|
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
# Outerjoin to system.addresses and system.geo_postal_codes to pull
|
||||||
|
# address/city from the normalized Address table instead of flat columns.
|
||||||
|
# Coalesce to flat columns for backward compatibility during transition.
|
||||||
provider_conditions = []
|
provider_conditions = []
|
||||||
if status_filter:
|
if status_filter:
|
||||||
provider_conditions.append(ServiceProvider.status == status_filter)
|
provider_conditions.append(ServiceProvider.status == status_filter)
|
||||||
@@ -301,8 +331,12 @@ def _build_unified_providers_query(
|
|||||||
provider_stmt = select(
|
provider_stmt = select(
|
||||||
ServiceProvider.id.label("id"),
|
ServiceProvider.id.label("id"),
|
||||||
ServiceProvider.name.label("name"),
|
ServiceProvider.name.label("name"),
|
||||||
ServiceProvider.address.label("address"),
|
func.coalesce(
|
||||||
ServiceProvider.city.label("city"),
|
Address.full_address_text, ServiceProvider.address
|
||||||
|
).label("address"),
|
||||||
|
func.coalesce(
|
||||||
|
GeoPostalCode.city, ServiceProvider.city
|
||||||
|
).label("city"),
|
||||||
ServiceProvider.category.label("category"),
|
ServiceProvider.category.label("category"),
|
||||||
cast(ServiceProvider.status, String).label("status"),
|
cast(ServiceProvider.status, String).label("status"),
|
||||||
cast(ServiceProvider.source, String).label("source"),
|
cast(ServiceProvider.source, String).label("source"),
|
||||||
@@ -310,6 +344,14 @@ def _build_unified_providers_query(
|
|||||||
ServiceProvider.validation_score.label("validation_score"),
|
ServiceProvider.validation_score.label("validation_score"),
|
||||||
ServiceProvider.added_by_user_id.label("added_by_user_id"),
|
ServiceProvider.added_by_user_id.label("added_by_user_id"),
|
||||||
ServiceProvider.created_at.label("created_at"),
|
ServiceProvider.created_at.label("created_at"),
|
||||||
|
# P0 PHASE 2 ADDRESS UNIFICATION: Include address_id for address_detail resolution
|
||||||
|
ServiceProvider.address_id.label("address_id"),
|
||||||
|
).outerjoin(
|
||||||
|
Address,
|
||||||
|
Address.id == ServiceProvider.address_id,
|
||||||
|
).outerjoin(
|
||||||
|
GeoPostalCode,
|
||||||
|
GeoPostalCode.id == Address.postal_code_id,
|
||||||
)
|
)
|
||||||
if provider_conditions:
|
if provider_conditions:
|
||||||
provider_stmt = provider_stmt.where(*provider_conditions)
|
provider_stmt = provider_stmt.where(*provider_conditions)
|
||||||
@@ -339,6 +381,8 @@ def _build_unified_providers_query(
|
|||||||
literal(0).label("validation_score"),
|
literal(0).label("validation_score"),
|
||||||
literal(None).label("added_by_user_id"),
|
literal(None).label("added_by_user_id"),
|
||||||
ServiceStaging.created_at.label("created_at"),
|
ServiceStaging.created_at.label("created_at"),
|
||||||
|
# P0 PHASE 2: Placeholder for UNION ALL column count parity
|
||||||
|
literal(None).label("address_id"),
|
||||||
)
|
)
|
||||||
if staging_conditions:
|
if staging_conditions:
|
||||||
staging_stmt = staging_stmt.where(*staging_conditions)
|
staging_stmt = staging_stmt.where(*staging_conditions)
|
||||||
@@ -355,17 +399,17 @@ def _build_unified_providers_query(
|
|||||||
Organization.id.label("id"),
|
Organization.id.label("id"),
|
||||||
Organization.name.label("name"),
|
Organization.name.label("name"),
|
||||||
func.concat(
|
func.concat(
|
||||||
Organization.address_zip,
|
GeoPostalCode.zip_code,
|
||||||
literal(' '),
|
literal(' '),
|
||||||
Organization.address_city,
|
GeoPostalCode.city,
|
||||||
literal(', '),
|
literal(', '),
|
||||||
Organization.address_street_name,
|
Address.street_name,
|
||||||
literal(' '),
|
literal(' '),
|
||||||
Organization.address_street_type,
|
Address.street_type,
|
||||||
literal(' '),
|
literal(' '),
|
||||||
Organization.address_house_number,
|
Address.house_number,
|
||||||
).label("address"),
|
).label("address"),
|
||||||
Organization.address_city.label("city"),
|
GeoPostalCode.city.label("city"),
|
||||||
literal(None).label("category"),
|
literal(None).label("category"),
|
||||||
literal("approved").label("status"),
|
literal("approved").label("status"),
|
||||||
literal("verified_org").label("source"),
|
literal("verified_org").label("source"),
|
||||||
@@ -373,6 +417,14 @@ def _build_unified_providers_query(
|
|||||||
literal(100).label("validation_score"),
|
literal(100).label("validation_score"),
|
||||||
literal(None).label("added_by_user_id"),
|
literal(None).label("added_by_user_id"),
|
||||||
Organization.created_at.label("created_at"),
|
Organization.created_at.label("created_at"),
|
||||||
|
# P0 PHASE 2: Placeholder for UNION ALL column count parity
|
||||||
|
literal(None).label("address_id"),
|
||||||
|
).outerjoin(
|
||||||
|
Address,
|
||||||
|
Address.id == Organization.address_id,
|
||||||
|
).outerjoin(
|
||||||
|
GeoPostalCode,
|
||||||
|
GeoPostalCode.id == Address.postal_code_id,
|
||||||
)
|
)
|
||||||
if org_conditions:
|
if org_conditions:
|
||||||
org_stmt = org_stmt.where(*org_conditions)
|
org_stmt = org_stmt.where(*org_conditions)
|
||||||
@@ -455,11 +507,25 @@ async def list_providers(
|
|||||||
|
|
||||||
items = []
|
items = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
# P0 PHASE 2 ADDRESS UNIFICATION: Resolve address_detail from address_id
|
||||||
|
row_address_detail = None
|
||||||
|
row_address_id = getattr(row, "address_id", None)
|
||||||
|
if row_address_id:
|
||||||
|
try:
|
||||||
|
addr_data = await AddressManager.get_normalized(db, row_address_id)
|
||||||
|
if addr_data:
|
||||||
|
row_address_detail = AddressOut(**addr_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to resolve address_detail for list row #{row.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
items.append(ProviderListItem(
|
items.append(ProviderListItem(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
address=row.address,
|
address=row.address,
|
||||||
city=row.city,
|
city=row.city,
|
||||||
|
address_detail=row_address_detail,
|
||||||
category=row.category,
|
category=row.category,
|
||||||
status=row.status,
|
status=row.status,
|
||||||
source=row.source,
|
source=row.source,
|
||||||
@@ -540,10 +606,15 @@ async def get_provider_detail(
|
|||||||
)
|
)
|
||||||
sp = provider.scalar_one_or_none()
|
sp = provider.scalar_one_or_none()
|
||||||
if sp:
|
if sp:
|
||||||
# Lekérdezzük a kapcsolódó kategória ID-kat a ServiceProfile-on keresztül
|
# P0 BUGFIX (2026-07-07): Resolve category_ids from ServiceProfile → ServiceExpertise.
|
||||||
|
# Search by BOTH service_provider_id AND organization_id.
|
||||||
|
# ServiceProvider ID 6 (and other legacy providers) may have their ServiceProfile
|
||||||
|
# linked via organization_id instead of service_provider_id. Using OR ensures
|
||||||
|
# we find the profile regardless of which FK is populated.
|
||||||
resolved_category_ids: Optional[List[int]] = None
|
resolved_category_ids: Optional[List[int]] = None
|
||||||
profile_stmt = select(ServiceProfile).where(
|
profile_stmt = select(ServiceProfile).where(
|
||||||
ServiceProfile.service_provider_id == provider_id
|
(ServiceProfile.service_provider_id == provider_id) |
|
||||||
|
(ServiceProfile.organization_id == provider_id)
|
||||||
)
|
)
|
||||||
profile_result = await db.execute(profile_stmt)
|
profile_result = await db.execute(profile_stmt)
|
||||||
profile = profile_result.scalar_one_or_none()
|
profile = profile_result.scalar_one_or_none()
|
||||||
@@ -553,19 +624,35 @@ async def get_provider_detail(
|
|||||||
)
|
)
|
||||||
expertise_result = await db.execute(expertise_stmt)
|
expertise_result = await db.execute(expertise_stmt)
|
||||||
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
|
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
|
||||||
|
else:
|
||||||
|
# P0 FINAL FIX (2026-07-07): If no ServiceProfile exists (e.g. legacy provider #6),
|
||||||
|
# return empty list instead of null. The PATCH endpoint auto-creates a ServiceProfile
|
||||||
|
# when category_ids are saved, but GET must not crash or return null for providers
|
||||||
|
# that haven't been edited yet.
|
||||||
|
resolved_category_ids = []
|
||||||
|
|
||||||
# Get opening_hours from the ServiceProfile (JSONB column)
|
# Get opening_hours from the ServiceProfile (JSONB column)
|
||||||
opening_hours = profile.opening_hours if profile else {}
|
opening_hours = profile.opening_hours if profile else {}
|
||||||
|
|
||||||
|
# P0 BUGFIX: Resolve address_detail from address_id if present
|
||||||
|
live_address_detail = None
|
||||||
|
live_address_id = getattr(sp, "address_id", None)
|
||||||
|
if live_address_id:
|
||||||
|
try:
|
||||||
|
addr_data = await AddressManager.get_normalized(db, live_address_id)
|
||||||
|
if addr_data:
|
||||||
|
live_address_detail = AddressOut(**addr_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to resolve address_detail for provider #{sp.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return ProviderDetail(
|
return ProviderDetail(
|
||||||
id=sp.id,
|
id=sp.id,
|
||||||
name=sp.name,
|
name=sp.name,
|
||||||
address=sp.address or "",
|
address=sp.address or "",
|
||||||
city=sp.city,
|
city=sp.city,
|
||||||
address_zip=sp.address_zip,
|
address_detail=live_address_detail,
|
||||||
address_street_name=sp.address_street_name,
|
|
||||||
address_street_type=sp.address_street_type,
|
|
||||||
address_house_number=sp.address_house_number,
|
|
||||||
plus_code=sp.plus_code,
|
plus_code=sp.plus_code,
|
||||||
contact_phone=sp.contact_phone,
|
contact_phone=sp.contact_phone,
|
||||||
contact_email=sp.contact_email,
|
contact_email=sp.contact_email,
|
||||||
@@ -581,6 +668,13 @@ async def get_provider_detail(
|
|||||||
supported_vehicle_classes=sp.supported_vehicle_classes or [],
|
supported_vehicle_classes=sp.supported_vehicle_classes or [],
|
||||||
specializations=sp.specializations or {},
|
specializations=sp.specializations or {},
|
||||||
opening_hours=opening_hours or {},
|
opening_hours=opening_hours or {},
|
||||||
|
# P0 BUGFIX: ServiceProvider model does NOT have raw_data, trust_score,
|
||||||
|
# rejection_reason, or audit_trail columns — those exist only on ServiceStaging.
|
||||||
|
# Explicitly pass None to avoid Pydantic/AttributeError 500 crash.
|
||||||
|
raw_data=None,
|
||||||
|
trust_score=None,
|
||||||
|
rejection_reason=None,
|
||||||
|
audit_trail=None,
|
||||||
created_at=sp.created_at,
|
created_at=sp.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -590,15 +684,39 @@ async def get_provider_detail(
|
|||||||
)
|
)
|
||||||
ss = staging.scalar_one_or_none()
|
ss = staging.scalar_one_or_none()
|
||||||
if ss:
|
if ss:
|
||||||
|
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
|
||||||
|
staging_address_detail = None
|
||||||
|
if ss.address_id:
|
||||||
|
try:
|
||||||
|
addr_data = await AddressManager.get_normalized(db, ss.address_id)
|
||||||
|
if addr_data:
|
||||||
|
staging_address_detail = AddressOut(**addr_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to resolve address_detail for staging #{ss.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
return ProviderDetail(
|
return ProviderDetail(
|
||||||
id=ss.id,
|
id=ss.id,
|
||||||
name=ss.name,
|
name=ss.name,
|
||||||
address=ss.full_address or "",
|
address=ss.full_address or "",
|
||||||
city=ss.city,
|
city=ss.city,
|
||||||
|
address_detail=staging_address_detail,
|
||||||
|
contact_phone=ss.contact_phone,
|
||||||
|
contact_email=ss.contact_email,
|
||||||
|
website=ss.website,
|
||||||
|
description=ss.description,
|
||||||
status=ss.status,
|
status=ss.status,
|
||||||
source="bot",
|
source=ss.source or "bot",
|
||||||
source_table="service_staging",
|
source_table="service_staging",
|
||||||
validation_score=0,
|
# BUGFIX: Use trust_score from staging as validation_score instead of hardcoded 0
|
||||||
|
# The staging table doesn't have a separate validation_score column,
|
||||||
|
# but trust_score reflects the bot's confidence in the data quality
|
||||||
|
validation_score=ss.trust_score or 0,
|
||||||
|
trust_score=ss.trust_score,
|
||||||
|
rejection_reason=ss.rejection_reason,
|
||||||
|
raw_data=ss.raw_data or {},
|
||||||
|
audit_trail=ss.audit_trail or {},
|
||||||
created_at=ss.created_at,
|
created_at=ss.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1123,31 +1241,104 @@ async def update_provider(
|
|||||||
|
|
||||||
Minden változás auditálásra kerül az AuditLog táblában.
|
Minden változás auditálásra kerül az AuditLog táblában.
|
||||||
"""
|
"""
|
||||||
provider = await _get_provider_or_404(db, provider_id)
|
# P0 BUGFIX: First try ServiceProvider, then fall back to ServiceStaging
|
||||||
|
# (same pattern as get_provider_detail endpoint)
|
||||||
|
provider = await db.execute(
|
||||||
|
select(ServiceProvider).where(ServiceProvider.id == provider_id)
|
||||||
|
)
|
||||||
|
sp = provider.scalar_one_or_none()
|
||||||
|
staging_record = None
|
||||||
|
|
||||||
|
if sp:
|
||||||
|
# Found in ServiceProvider — use it
|
||||||
|
provider_obj = sp
|
||||||
|
is_staging = False
|
||||||
|
else:
|
||||||
|
# Try ServiceStaging
|
||||||
|
staging = await db.execute(
|
||||||
|
select(ServiceStaging).where(ServiceStaging.id == provider_id)
|
||||||
|
)
|
||||||
|
ss = staging.scalar_one_or_none()
|
||||||
|
if not ss:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Provider with ID {provider_id} not found in any table.",
|
||||||
|
)
|
||||||
|
provider_obj = ss
|
||||||
|
is_staging = True
|
||||||
|
|
||||||
# Változások nyomon követése auditáláshoz
|
# Változások nyomon követése auditáláshoz
|
||||||
old_status = provider.status.value if hasattr(provider.status, 'value') else str(provider.status)
|
old_status = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
|
||||||
old_data = {
|
old_data = {
|
||||||
"name": provider.name,
|
"name": provider_obj.name,
|
||||||
"city": provider.city,
|
"city": provider_obj.city,
|
||||||
"address_zip": provider.address_zip,
|
"address_zip": getattr(provider_obj, 'address_zip', None),
|
||||||
"address_street_name": provider.address_street_name,
|
"address_street_name": getattr(provider_obj, 'address_street_name', None),
|
||||||
"address_street_type": provider.address_street_type,
|
"address_street_type": getattr(provider_obj, 'address_street_type', None),
|
||||||
"address_house_number": provider.address_house_number,
|
"address_house_number": getattr(provider_obj, 'address_house_number', None),
|
||||||
"plus_code": provider.plus_code,
|
"plus_code": getattr(provider_obj, 'plus_code', None),
|
||||||
"contact_phone": provider.contact_phone,
|
"contact_phone": provider_obj.contact_phone,
|
||||||
"contact_email": provider.contact_email,
|
"contact_email": provider_obj.contact_email,
|
||||||
"website": provider.website,
|
"website": provider_obj.website,
|
||||||
"category": provider.category,
|
"category": getattr(provider_obj, 'category', None),
|
||||||
"status": old_status,
|
"status": old_status,
|
||||||
"validation_score": provider.validation_score,
|
"validation_score": getattr(provider_obj, 'validation_score', 0),
|
||||||
"supported_vehicle_classes": provider.supported_vehicle_classes,
|
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
|
||||||
"specializations": provider.specializations,
|
"specializations": getattr(provider_obj, 'specializations', None),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Mezők frissítése (csak a nem None értékeket)
|
# Mezők frissítése (csak a nem None értékeket)
|
||||||
update_fields = update_data.model_dump(exclude_none=True)
|
update_fields = update_data.model_dump(exclude_none=True)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# P0 CRITICAL BUGFIX: Staging records (ServiceStaging) have a different
|
||||||
|
# column set than ServiceProvider. ServiceStaging does NOT have:
|
||||||
|
# address_zip, address_street_name, address_street_type,
|
||||||
|
# address_house_number, address_stairwell, address_floor, address_door,
|
||||||
|
# address_hrsz, latitude, longitude, category, validation_score,
|
||||||
|
# supported_vehicle_classes, specializations
|
||||||
|
# For staging records, we must:
|
||||||
|
# 1. Apply ONLY flat columns that exist on ServiceStaging
|
||||||
|
# 2. Merge complex data (address_detail, specializations, category_ids)
|
||||||
|
# into raw_data so they are preserved for the Promotion Engine
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# STEP 0: Extract address_detail (AddressIn) and map to flat model fields
|
||||||
|
# =====================================================================
|
||||||
|
address_detail: Optional[dict] = update_fields.pop("address_detail", None)
|
||||||
|
if is_staging and address_detail:
|
||||||
|
# P0 CRITICAL BUGFIX: Use AddressManager to create/update the Address
|
||||||
|
# record in system.addresses, then link it via address_id on ServiceStaging.
|
||||||
|
# This ensures the address_detail is NOT lost — it's stored in the
|
||||||
|
# normalized Address table and can be returned in the API response.
|
||||||
|
addr_in = AddressIn(**address_detail)
|
||||||
|
new_address_id = await AddressManager.create_or_update(
|
||||||
|
db=db,
|
||||||
|
address_id=getattr(provider_obj, "address_id", None),
|
||||||
|
data=addr_in,
|
||||||
|
)
|
||||||
|
if new_address_id:
|
||||||
|
provider_obj.address_id = new_address_id
|
||||||
|
# Also update full_address if full_address_text is provided
|
||||||
|
if address_detail.get("full_address_text"):
|
||||||
|
update_fields["full_address"] = address_detail["full_address_text"]
|
||||||
|
if address_detail.get("city"):
|
||||||
|
update_fields["city"] = address_detail["city"]
|
||||||
|
elif address_detail:
|
||||||
|
# P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
# Use AddressManager to create/update the normalized system.addresses
|
||||||
|
# record and link it via address_id on ServiceProvider.
|
||||||
|
# Flat column writes (address_zip, address_street_name, etc.) are
|
||||||
|
# STRICTLY REMOVED — AddressManager is the single source of truth.
|
||||||
|
addr_in = AddressIn(**address_detail)
|
||||||
|
new_address_id = await AddressManager.create_or_update(
|
||||||
|
db=db,
|
||||||
|
address_id=getattr(provider_obj, "address_id", None),
|
||||||
|
data=addr_in,
|
||||||
|
)
|
||||||
|
if new_address_id:
|
||||||
|
provider_obj.address_id = new_address_id
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# STEP 1: Extract multi-dimensional arrays and JSONB from payload
|
# STEP 1: Extract multi-dimensional arrays and JSONB from payload
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -1183,89 +1374,307 @@ async def update_provider(
|
|||||||
new_status = update_fields.get("status")
|
new_status = update_fields.get("status")
|
||||||
if new_status and new_status != old_status:
|
if new_status and new_status != old_status:
|
||||||
# Ha státusz változik, naplózzuk külön akcióként
|
# Ha státusz változik, naplózzuk külön akcióként
|
||||||
provider.status = ModerationStatus(new_status)
|
if not is_staging:
|
||||||
|
provider_obj.status = ModerationStatus(new_status)
|
||||||
|
else:
|
||||||
|
provider_obj.status = new_status
|
||||||
if new_status == "approved" and old_status == "rejected":
|
if new_status == "approved" and old_status == "rejected":
|
||||||
# Restore esetén validation_score visszaállítás
|
# Restore esetén validation_score visszaállítás
|
||||||
if "validation_score" not in update_fields:
|
if "validation_score" not in update_fields:
|
||||||
provider.validation_score = 50
|
if hasattr(provider_obj, 'validation_score'):
|
||||||
|
provider_obj.validation_score = 50
|
||||||
elif new_status == "rejected":
|
elif new_status == "rejected":
|
||||||
# Reject/flag esetén validation_score csökkentés
|
# Reject/flag esetén validation_score csökkentés
|
||||||
if "validation_score" not in update_fields:
|
if "validation_score" not in update_fields:
|
||||||
provider.validation_score = max(0, (provider.validation_score or 0) - 20)
|
if hasattr(provider_obj, 'validation_score'):
|
||||||
|
provider_obj.validation_score = max(0, (provider_obj.validation_score or 0) - 20)
|
||||||
# Töröljük a status-t a sima mezőkből, mert külön kezeltük
|
# Töröljük a status-t a sima mezőkből, mert külön kezeltük
|
||||||
del update_fields["status"]
|
del update_fields["status"]
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# STEP 2: Save supported_vehicle_classes and specializations to ServiceProvider
|
# P0 CRITICAL FIX (2026-07-07 v2): PROMOTION ENGINE
|
||||||
|
# When a ServiceStaging record is approved by admin, promote it to a
|
||||||
|
# live ServiceProvider + ServiceProfile, then delete the staging record.
|
||||||
|
#
|
||||||
|
# P0 CRITICAL BUGFIX v2 (2026-07-07):
|
||||||
|
# - Trigger condition now checks provider_obj.status directly (the actual
|
||||||
|
# DB state after update) instead of relying on new_status from update_fields.
|
||||||
|
# This ensures promotion fires even if status was already "approved" in DB
|
||||||
|
# or if status wasn't explicitly passed in the PATCH payload.
|
||||||
|
# - After promotion, ALL references to provider_id (AuditLog, profile lookup)
|
||||||
|
# now use new_provider.id, NOT the old staging provider_id.
|
||||||
|
# - Added logger.error("P0 PROMOTION TRIGGERED") for Docker traceability.
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
if supported_vehicle_classes is not None:
|
# Determine the effective status: use the newly set status on provider_obj,
|
||||||
provider.supported_vehicle_classes = supported_vehicle_classes
|
# or fall back to the current DB status. This is more robust than relying
|
||||||
if specializations is not None:
|
# on new_status from update_fields, which may be None if status wasn't
|
||||||
provider.specializations = specializations
|
# explicitly included in the PATCH payload.
|
||||||
|
effective_status = (
|
||||||
# Többi mező frissítése
|
provider_obj.status.value
|
||||||
for field, value in update_fields.items():
|
if hasattr(provider_obj.status, 'value')
|
||||||
if field != "status":
|
else str(provider_obj.status)
|
||||||
setattr(provider, field, value)
|
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# STEP 3: Sync expertise tags and arrays to ServiceProfile
|
|
||||||
# =====================================================================
|
|
||||||
# Find the ServiceProfile linked to this ServiceProvider
|
|
||||||
profile_stmt = select(ServiceProfile).where(
|
|
||||||
ServiceProfile.service_provider_id == provider_id
|
|
||||||
)
|
)
|
||||||
profile_result = await db.execute(profile_stmt)
|
if is_staging and effective_status == "approved":
|
||||||
profile = profile_result.scalar_one_or_none()
|
logger.error(
|
||||||
|
f"P0 PROMOTION TRIGGERED: Staging #{provider_id} "
|
||||||
|
f"('{provider_obj.name}') effective_status='{effective_status}', "
|
||||||
|
f"new_status_from_payload='{new_status}', "
|
||||||
|
f"approved by admin #{current_user.id}. Promoting to live..."
|
||||||
|
)
|
||||||
|
|
||||||
if profile:
|
# 1. Create ServiceProvider from staging data
|
||||||
# Save arrays to ServiceProfile as well
|
from app.models.identity.social import SourceType
|
||||||
if supported_vehicle_classes is not None:
|
new_provider = ServiceProvider(
|
||||||
profile.supported_vehicle_classes = supported_vehicle_classes
|
name=provider_obj.name,
|
||||||
if specializations is not None:
|
address=provider_obj.full_address or "",
|
||||||
profile.specializations = specializations
|
city=provider_obj.city,
|
||||||
if opening_hours is not None:
|
address_id=getattr(provider_obj, "address_id", None),
|
||||||
profile.opening_hours = opening_hours
|
plus_code=getattr(provider_obj, "plus_code", None),
|
||||||
|
contact_phone=provider_obj.contact_phone,
|
||||||
|
contact_email=provider_obj.contact_email,
|
||||||
|
website=provider_obj.website,
|
||||||
|
category=update_fields.get("category") or getattr(provider_obj, "category", None),
|
||||||
|
status=ModerationStatus.approved,
|
||||||
|
source=SourceType.manual,
|
||||||
|
validation_score=100, # Auto-Validation: admin approval = 100
|
||||||
|
added_by_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(new_provider)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
# Sync expertise tags if category_ids provided
|
# 2. Create ServiceProfile linked to the new ServiceProvider
|
||||||
if category_ids is not None:
|
profile = ServiceProfile(
|
||||||
|
service_provider_id=new_provider.id,
|
||||||
|
fingerprint=getattr(provider_obj, "fingerprint", None),
|
||||||
|
status="active",
|
||||||
|
trust_score=100,
|
||||||
|
is_verified=True,
|
||||||
|
contact_phone=provider_obj.contact_phone,
|
||||||
|
contact_email=provider_obj.contact_email,
|
||||||
|
website=provider_obj.website,
|
||||||
|
opening_hours=opening_hours or {},
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 3. Sync category_ids if provided (from raw_data or direct input)
|
||||||
|
promoted_category_ids = category_ids
|
||||||
|
if not promoted_category_ids and provider_obj.raw_data:
|
||||||
|
promoted_category_ids = provider_obj.raw_data.get("category_ids")
|
||||||
|
if promoted_category_ids:
|
||||||
await _sync_service_expertises(
|
await _sync_service_expertises(
|
||||||
db=db,
|
db=db,
|
||||||
service_profile_id=profile.id,
|
service_profile_id=profile.id,
|
||||||
category_ids=category_ids,
|
category_ids=promoted_category_ids,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ServiceExpertise synced for provider #{provider_id}: "
|
f"P0 PROMOTION: ServiceExpertise synced for new provider "
|
||||||
f"service_profile_id={profile.id}, category_ids={category_ids}"
|
f"#{new_provider.id}, category_ids={promoted_category_ids}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 4. Log the promotion — use new_provider.id, NOT the old staging provider_id
|
||||||
|
await _log_moderation_action(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="PROVIDER_PROMOTED_FROM_STAGING",
|
||||||
|
provider_id=new_provider.id,
|
||||||
|
reason=f"Promoted from ServiceStaging #{provider_id} by admin",
|
||||||
|
old_status=old_status,
|
||||||
|
new_status="approved",
|
||||||
|
source_table="service_staging",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Delete the staging record (promotion complete)
|
||||||
|
await db.delete(provider_obj)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"P0 PROMOTION COMPLETE: Staging #{provider_id} → "
|
||||||
|
f"ServiceProvider #{new_provider.id}, "
|
||||||
|
f"ServiceProfile #{profile.id}, validation_score=100"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Switch to the new provider for the response
|
||||||
|
provider_obj = new_provider
|
||||||
|
is_staging = False
|
||||||
|
# Reset update_fields since we've already created everything
|
||||||
|
update_fields.clear()
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# STEP 2: Save supported_vehicle_classes and specializations
|
||||||
|
# =====================================================================
|
||||||
|
if is_staging:
|
||||||
|
# P0 BUGFIX: ServiceStaging has no supported_vehicle_classes/specializations columns.
|
||||||
|
# Merge them into raw_data for the Promotion Engine.
|
||||||
|
# CRITICAL: Use flag_modified() so SQLAlchemy detects the JSONB mutation.
|
||||||
|
raw_data_mutated = False
|
||||||
|
if supported_vehicle_classes is not None or specializations is not None or category_ids is not None or opening_hours is not None:
|
||||||
|
if not provider_obj.raw_data:
|
||||||
|
provider_obj.raw_data = {}
|
||||||
|
if supported_vehicle_classes is not None:
|
||||||
|
provider_obj.raw_data["supported_vehicle_classes"] = supported_vehicle_classes
|
||||||
|
raw_data_mutated = True
|
||||||
|
if specializations is not None:
|
||||||
|
provider_obj.raw_data["specializations"] = specializations
|
||||||
|
raw_data_mutated = True
|
||||||
|
if category_ids is not None:
|
||||||
|
provider_obj.raw_data["category_ids"] = category_ids
|
||||||
|
raw_data_mutated = True
|
||||||
|
if opening_hours is not None:
|
||||||
|
provider_obj.raw_data["opening_hours"] = opening_hours
|
||||||
|
raw_data_mutated = True
|
||||||
|
if raw_data_mutated:
|
||||||
|
flag_modified(provider_obj, "raw_data")
|
||||||
|
else:
|
||||||
|
if hasattr(provider_obj, 'supported_vehicle_classes') and supported_vehicle_classes is not None:
|
||||||
|
provider_obj.supported_vehicle_classes = supported_vehicle_classes
|
||||||
|
if hasattr(provider_obj, 'specializations') and specializations is not None:
|
||||||
|
provider_obj.specializations = specializations
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# STEP 2b: For staging records, strip fields that don't exist on ServiceStaging
|
||||||
|
# =====================================================================
|
||||||
|
if is_staging:
|
||||||
|
# P0 BUGFIX: Only keep fields that actually exist on ServiceStaging model
|
||||||
|
staging_allowed_fields = {
|
||||||
|
"name", "city", "full_address", "contact_phone", "website",
|
||||||
|
"contact_email", "description", "status", "plus_code",
|
||||||
|
"source", "trust_score", "rejection_reason",
|
||||||
|
}
|
||||||
|
# Map 'address' from ProviderUpdateInput to 'full_address' on ServiceStaging
|
||||||
|
if "address" in update_fields:
|
||||||
|
update_fields["full_address"] = update_fields.pop("address")
|
||||||
|
# Remove fields that don't exist on ServiceStaging
|
||||||
|
raw_data_mutated = False
|
||||||
|
for field in list(update_fields.keys()):
|
||||||
|
if field not in staging_allowed_fields:
|
||||||
|
# Merge unknown fields into raw_data so they aren't lost
|
||||||
|
if field not in ("status",):
|
||||||
|
if not provider_obj.raw_data:
|
||||||
|
provider_obj.raw_data = {}
|
||||||
|
provider_obj.raw_data[field] = update_fields[field]
|
||||||
|
raw_data_mutated = True
|
||||||
|
del update_fields[field]
|
||||||
|
if raw_data_mutated:
|
||||||
|
flag_modified(provider_obj, "raw_data")
|
||||||
|
|
||||||
|
# Többi mező frissítése
|
||||||
|
for field, value in update_fields.items():
|
||||||
|
if field != "status" and hasattr(provider_obj, field):
|
||||||
|
setattr(provider_obj, field, value)
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# STEP 3: Sync expertise tags and arrays to ServiceProfile
|
||||||
|
# (Only for ServiceProvider records that have a linked ServiceProfile)
|
||||||
|
# =====================================================================
|
||||||
|
profile = None
|
||||||
|
if not is_staging:
|
||||||
|
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
|
||||||
|
# After promotion, provider_obj points to the NEW ServiceProvider,
|
||||||
|
# but provider_id still holds the old staging ID (e.g., 1754).
|
||||||
|
# Using provider_obj.id ensures we find the correct ServiceProfile.
|
||||||
|
# P0 BUGFIX (2026-07-07): Search by BOTH service_provider_id AND
|
||||||
|
# organization_id. ServiceProvider ID 6 (and other legacy providers)
|
||||||
|
# may have their ServiceProfile linked via organization_id instead of
|
||||||
|
# service_provider_id. Using OR ensures we find the profile regardless
|
||||||
|
# of which FK is populated.
|
||||||
|
profile_stmt = select(ServiceProfile).where(
|
||||||
|
(ServiceProfile.service_provider_id == provider_obj.id) |
|
||||||
|
(ServiceProfile.organization_id == provider_obj.id)
|
||||||
|
)
|
||||||
|
profile_result = await db.execute(profile_stmt)
|
||||||
|
profile = profile_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# P0 BUGFIX (2026-07-07): If no ServiceProfile exists for this Live provider,
|
||||||
|
# CREATE ONE automatically so that advanced fields (opening_hours,
|
||||||
|
# specializations, supported_vehicle_classes, category_ids) are not silently lost.
|
||||||
|
# P0 CRITICAL BUGFIX (2026-07-07): ServiceProfile creation is now UNCONDITIONAL.
|
||||||
|
# Previously, the profile was only created if has_advanced_fields was True.
|
||||||
|
# This caused a 500 error when a PATCH with category_ids was sent for a newly
|
||||||
|
# created provider that had no ServiceProfile yet, because _sync_service_expertises
|
||||||
|
# requires a valid profile.id. Now we ALWAYS create the profile if it doesn't exist.
|
||||||
|
if not profile:
|
||||||
|
profile = ServiceProfile(
|
||||||
|
service_provider_id=provider_obj.id,
|
||||||
|
fingerprint=f"auto-created-{provider_obj.id}",
|
||||||
|
status="active",
|
||||||
|
trust_score=30,
|
||||||
|
is_verified=False,
|
||||||
|
contact_phone=provider_obj.contact_phone,
|
||||||
|
contact_email=provider_obj.contact_email,
|
||||||
|
website=provider_obj.website,
|
||||||
|
opening_hours=opening_hours or {},
|
||||||
|
supported_vehicle_classes=supported_vehicle_classes or [],
|
||||||
|
specializations=specializations or {},
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(
|
||||||
|
f"P0 BUGFIX: Auto-created ServiceProfile #{profile.id} "
|
||||||
|
f"for Live provider #{provider_obj.id} ('{provider_obj.name}')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# P0 CRITICAL BUGFIX (2026-07-07): Debug logging to trace profile.id
|
||||||
|
# before _sync_service_expertises call. This ensures we can identify
|
||||||
|
# the exact state in Docker logs if the 500 error recurs.
|
||||||
|
print(f"DEBUG: Syncing categories for profile_id: {profile.id}")
|
||||||
|
logger.info(
|
||||||
|
f"P0 DEBUG: ServiceProfile resolved — profile_id={profile.id}, "
|
||||||
|
f"provider_id={provider_obj.id}, category_ids={category_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if profile:
|
||||||
|
# Save arrays to ServiceProfile as well
|
||||||
|
if supported_vehicle_classes is not None:
|
||||||
|
profile.supported_vehicle_classes = supported_vehicle_classes
|
||||||
|
if specializations is not None:
|
||||||
|
profile.specializations = specializations
|
||||||
|
if opening_hours is not None:
|
||||||
|
profile.opening_hours = opening_hours
|
||||||
|
|
||||||
|
# Sync expertise tags if category_ids provided
|
||||||
|
if category_ids is not None:
|
||||||
|
await _sync_service_expertises(
|
||||||
|
db=db,
|
||||||
|
service_profile_id=profile.id,
|
||||||
|
category_ids=category_ids,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"ServiceExpertise synced for provider #{provider_id}: "
|
||||||
|
f"service_profile_id={profile.id}, category_ids={category_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
# Új állapot rögzítése
|
# Új állapot rögzítése
|
||||||
new_status_val = provider.status.value if hasattr(provider.status, 'value') else str(provider.status)
|
new_status_val = provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status)
|
||||||
|
|
||||||
# AuditLog - változások részletes naplózása
|
# AuditLog - változások részletes naplózása
|
||||||
new_data_snapshot = {
|
new_data_snapshot = {
|
||||||
"name": provider.name,
|
"name": provider_obj.name,
|
||||||
"city": provider.city,
|
"city": provider_obj.city,
|
||||||
"address_zip": provider.address_zip,
|
"address_zip": getattr(provider_obj, 'address_zip', None),
|
||||||
"address_street_name": provider.address_street_name,
|
"address_street_name": getattr(provider_obj, 'address_street_name', None),
|
||||||
"address_street_type": provider.address_street_type,
|
"address_street_type": getattr(provider_obj, 'address_street_type', None),
|
||||||
"address_house_number": provider.address_house_number,
|
"address_house_number": getattr(provider_obj, 'address_house_number', None),
|
||||||
"plus_code": provider.plus_code,
|
"plus_code": getattr(provider_obj, 'plus_code', None),
|
||||||
"contact_phone": provider.contact_phone,
|
"contact_phone": provider_obj.contact_phone,
|
||||||
"contact_email": provider.contact_email,
|
"contact_email": provider_obj.contact_email,
|
||||||
"website": provider.website,
|
"website": provider_obj.website,
|
||||||
"category": provider.category,
|
"category": getattr(provider_obj, 'category', None),
|
||||||
"status": new_status_val,
|
"status": new_status_val,
|
||||||
"validation_score": provider.validation_score,
|
"validation_score": getattr(provider_obj, 'validation_score', 0),
|
||||||
"supported_vehicle_classes": provider.supported_vehicle_classes,
|
"supported_vehicle_classes": getattr(provider_obj, 'supported_vehicle_classes', None),
|
||||||
"specializations": provider.specializations,
|
"specializations": getattr(provider_obj, 'specializations', None),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# P0 CRITICAL BUGFIX v2: Use provider_obj.id instead of provider_id.
|
||||||
|
# After promotion, provider_obj points to the NEW ServiceProvider,
|
||||||
|
# but provider_id still holds the old staging ID (e.g., 1754).
|
||||||
|
# The AuditLog must reference the new provider's ID.
|
||||||
log_entry = AuditLog(
|
log_entry = AuditLog(
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
action="PROVIDER_UPDATED",
|
action="PROVIDER_UPDATED",
|
||||||
target_type="service_provider",
|
target_type="service_provider",
|
||||||
target_id=str(provider_id),
|
target_id=str(provider_obj.id),
|
||||||
old_data=old_data,
|
old_data=old_data,
|
||||||
new_data={
|
new_data={
|
||||||
"reason": "Admin edit",
|
"reason": "Admin edit",
|
||||||
@@ -1286,36 +1695,94 @@ async def update_provider(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Database integrity error. Check constraints (e.g., duplicate data, invalid status value).",
|
detail="Database integrity error. Check constraints (e.g., duplicate data, invalid status value).",
|
||||||
)
|
)
|
||||||
await db.refresh(provider)
|
await db.refresh(provider_obj)
|
||||||
|
|
||||||
# Get opening_hours from the ServiceProfile for the response
|
# Get opening_hours from the ServiceProfile for the response
|
||||||
opening_hours_response = {}
|
opening_hours_response = {}
|
||||||
|
resolved_category_ids: Optional[List[int]] = None
|
||||||
if profile:
|
if profile:
|
||||||
opening_hours_response = profile.opening_hours or {}
|
opening_hours_response = profile.opening_hours or {}
|
||||||
|
# P0 BUGFIX: Resolve category_ids from the actual DB state (ServiceExpertise)
|
||||||
|
# instead of relying on the payload value, which may be stale or None.
|
||||||
|
expertise_stmt = select(ServiceExpertise.expertise_id).where(
|
||||||
|
ServiceExpertise.service_id == profile.id
|
||||||
|
)
|
||||||
|
expertise_result = await db.execute(expertise_stmt)
|
||||||
|
resolved_category_ids = [row[0] for row in expertise_result.fetchall()]
|
||||||
|
|
||||||
return ProviderDetail(
|
if not is_staging:
|
||||||
id=provider.id,
|
# P0 BUGFIX: Resolve address_detail from address_id if present
|
||||||
name=provider.name,
|
live_address_detail = None
|
||||||
address=provider.address or "",
|
live_address_id = getattr(provider_obj, "address_id", None)
|
||||||
city=provider.city,
|
if live_address_id:
|
||||||
address_zip=provider.address_zip,
|
try:
|
||||||
address_street_name=provider.address_street_name,
|
addr_data = await AddressManager.get_normalized(db, live_address_id)
|
||||||
address_street_type=provider.address_street_type,
|
if addr_data:
|
||||||
address_house_number=provider.address_house_number,
|
live_address_detail = AddressOut(**addr_data)
|
||||||
plus_code=provider.plus_code,
|
except Exception as e:
|
||||||
contact_phone=provider.contact_phone,
|
logger.warning(
|
||||||
contact_email=provider.contact_email,
|
f"Failed to resolve address_detail for provider #{provider_obj.id}: {e}"
|
||||||
website=provider.website,
|
)
|
||||||
category=provider.category,
|
return ProviderDetail(
|
||||||
status=provider.status.value if hasattr(provider.status, 'value') else str(provider.status),
|
id=provider_obj.id,
|
||||||
source=provider.source.value if hasattr(provider.source, 'value') else str(provider.source),
|
name=provider_obj.name,
|
||||||
source_table="service_providers",
|
address=provider_obj.address or "",
|
||||||
validation_score=provider.validation_score or 0,
|
city=provider_obj.city,
|
||||||
evidence_image_path=provider.evidence_image_path,
|
address_detail=live_address_detail,
|
||||||
added_by_user_id=provider.added_by_user_id,
|
plus_code=provider_obj.plus_code,
|
||||||
category_ids=category_ids,
|
contact_phone=provider_obj.contact_phone,
|
||||||
supported_vehicle_classes=provider.supported_vehicle_classes or [],
|
contact_email=provider_obj.contact_email,
|
||||||
specializations=provider.specializations or {},
|
website=provider_obj.website,
|
||||||
opening_hours=opening_hours_response,
|
category=provider_obj.category,
|
||||||
created_at=provider.created_at,
|
status=provider_obj.status.value if hasattr(provider_obj.status, 'value') else str(provider_obj.status),
|
||||||
)
|
source=provider_obj.source.value if hasattr(provider_obj.source, 'value') else str(provider_obj.source),
|
||||||
|
source_table="service_providers",
|
||||||
|
validation_score=getattr(provider_obj, 'validation_score', 0) or 0,
|
||||||
|
evidence_image_path=getattr(provider_obj, 'evidence_image_path', None),
|
||||||
|
added_by_user_id=getattr(provider_obj, 'added_by_user_id', None),
|
||||||
|
category_ids=resolved_category_ids or category_ids,
|
||||||
|
supported_vehicle_classes=getattr(provider_obj, 'supported_vehicle_classes', None) or [],
|
||||||
|
specializations=getattr(provider_obj, 'specializations', None) or {},
|
||||||
|
opening_hours=opening_hours_response,
|
||||||
|
raw_data=None,
|
||||||
|
trust_score=None,
|
||||||
|
rejection_reason=None,
|
||||||
|
audit_trail=None,
|
||||||
|
created_at=provider_obj.created_at,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Staging record response
|
||||||
|
# P0 CRITICAL BUGFIX: Resolve address_detail from address_id if present
|
||||||
|
staging_address_detail = None
|
||||||
|
staging_address_id = getattr(provider_obj, "address_id", None)
|
||||||
|
if staging_address_id:
|
||||||
|
try:
|
||||||
|
addr_data = await AddressManager.get_normalized(db, staging_address_id)
|
||||||
|
if addr_data:
|
||||||
|
staging_address_detail = AddressOut(**addr_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to resolve address_detail for staging #{provider_obj.id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProviderDetail(
|
||||||
|
id=provider_obj.id,
|
||||||
|
name=provider_obj.name,
|
||||||
|
address=provider_obj.full_address or "",
|
||||||
|
city=provider_obj.city,
|
||||||
|
address_detail=staging_address_detail,
|
||||||
|
plus_code=getattr(provider_obj, 'plus_code', None),
|
||||||
|
contact_phone=provider_obj.contact_phone,
|
||||||
|
contact_email=provider_obj.contact_email,
|
||||||
|
website=provider_obj.website,
|
||||||
|
description=getattr(provider_obj, 'description', None),
|
||||||
|
status=provider_obj.status,
|
||||||
|
source=provider_obj.source or "bot",
|
||||||
|
source_table="service_staging",
|
||||||
|
validation_score=getattr(provider_obj, 'trust_score', 0) or 0,
|
||||||
|
trust_score=provider_obj.trust_score,
|
||||||
|
rejection_reason=provider_obj.rejection_reason,
|
||||||
|
raw_data=provider_obj.raw_data or {},
|
||||||
|
audit_trail=provider_obj.audit_trail or {},
|
||||||
|
created_at=provider_obj.created_at,
|
||||||
|
)
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
👤 Admin Users API
|
👤 Admin Users API
|
||||||
|
|
||||||
Végpontok:
|
Végpontok:
|
||||||
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
|
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
|
||||||
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
||||||
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
||||||
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
|
GET /admin/users/{user_id}/memberships — Felhasználó szervezeti tagságai
|
||||||
|
DELETE /admin/users/{user_id}/hard — [SUPERADMIN] Végleges felhasználó törlés
|
||||||
|
|
||||||
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
|
Megjegyzés: A GET /admin/users (lista) és POST /admin/users/bulk-action
|
||||||
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
|
végpontok a admin.py modulban találhatók, mivel az a /admin prefix alá
|
||||||
@@ -28,7 +29,9 @@ from app.api import deps
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.identity import User, Person, UserRole
|
from app.models.identity import User, Person, UserRole
|
||||||
from app.models.identity.address import Address
|
from app.models.identity.address import Address
|
||||||
|
from app.models.identity.identity import Wallet
|
||||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
|
from app.models.system.audit import SecurityAuditLog, FinancialLedger
|
||||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||||
from app.schemas.user import UserResponse
|
from app.schemas.user import UserResponse
|
||||||
from app.schemas.organization import OrganizationMemberResponse
|
from app.schemas.organization import OrganizationMemberResponse
|
||||||
@@ -644,3 +647,124 @@ async def get_user_memberships(
|
|||||||
))
|
))
|
||||||
|
|
||||||
return membership_list
|
return membership_list
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Hard Delete Endpoint (Danger Zone)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{user_id}/hard",
|
||||||
|
summary="[SUPERADMIN] Felhasználó végleges törlése",
|
||||||
|
description=(
|
||||||
|
"Véglegesen eltávolít egy felhasználót az adatbázisból. "
|
||||||
|
"KIZÁRÓLAG Superadmin számára elérhető. "
|
||||||
|
"Gatekeeper: Ellenőrzi, hogy a felhasználónak nincs-e pénzügyi rekordja "
|
||||||
|
"(FinancialLedger, Wallet) a törlés előtt. "
|
||||||
|
"Audit trail: SecurityAuditLog-ba rögzíti a műveletet."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def hard_delete_user(
|
||||||
|
user_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("user:manage")),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
DELETE /admin/users/{user_id}/hard
|
||||||
|
|
||||||
|
Véglegesen töröl egy felhasználót az adatbázisból.
|
||||||
|
Csak Superadmin jogosultsággal érhető el.
|
||||||
|
"""
|
||||||
|
# ── 1. Superadmin ellenőrzés ──────────────────────────────────────────
|
||||||
|
if current_user.role != UserRole.SUPERADMIN:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only Superadmins can perform hard delete operations."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. Felhasználó lekérése ───────────────────────────────────────────
|
||||||
|
stmt = select(User).where(User.id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"User with ID {user_id} not found."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Gatekeeper: Pénzügyi rekordok ellenőrzése ──────────────────────
|
||||||
|
# FinancialLedger ellenőrzés
|
||||||
|
ledger_stmt = select(FinancialLedger).where(FinancialLedger.user_id == user_id).limit(1)
|
||||||
|
ledger_result = await db.execute(ledger_stmt)
|
||||||
|
has_ledger = ledger_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
# Wallet ellenőrzés
|
||||||
|
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id).limit(1)
|
||||||
|
wallet_result = await db.execute(wallet_stmt)
|
||||||
|
has_wallet = wallet_result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
if has_ledger or has_wallet:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=(
|
||||||
|
"Cannot hard delete: User has financial or production records. "
|
||||||
|
"Please ensure all financial data is cleared before deletion."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 4. FK referenciák nullázása a törlés előtt ─────────────────────────
|
||||||
|
# Az AuditLog (audit.audit_logs) és SecurityAuditLog (audit.security_audit_logs)
|
||||||
|
# táblák FK constrainttel hivatkoznak identity.users.id-ra, de NINCS
|
||||||
|
# ondelete="SET NULL" beállítva. Ezért manuálisan nullázzuk a referenciákat.
|
||||||
|
user_data = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"role": str(user.role) if user.role else None,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"is_deleted": user.is_deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
# AuditLog.user_id nullázása
|
||||||
|
await db.execute(
|
||||||
|
sa_update(AuditLog)
|
||||||
|
.where(AuditLog.user_id == user_id)
|
||||||
|
.values(user_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 5. Végleges törlés ─────────────────────────────────────────────────
|
||||||
|
await db.delete(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── 6. Audit Trail ─────────────────────────────────────────────────────
|
||||||
|
# SecurityAuditLog.target_id FK-val hivatkozik identity.users.id-ra,
|
||||||
|
# de nincs ondelete="SET NULL". Mivel a user már törölve van,
|
||||||
|
# a target_id=None kell legyen, különben ForeignKeyViolationError.
|
||||||
|
# A tényleges user_id a payload_before mezőben kerül tárolásra.
|
||||||
|
audit_log = SecurityAuditLog(
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_id=None,
|
||||||
|
action="hard_delete_user",
|
||||||
|
is_critical=True,
|
||||||
|
payload_before=user_data,
|
||||||
|
payload_after={
|
||||||
|
"details": f"User #{user_id} ({user.email}) permanently deleted by admin #{current_user.id}."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(audit_log)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"HARD DELETE: User #%s (%s) permanently deleted by admin #%s",
|
||||||
|
user_id, user.email, current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"User #{user_id} has been permanently deleted.",
|
||||||
|
"deleted_user_id": user_id,
|
||||||
|
"deleted_email": user.email,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.services.auth_service import AuthService
|
from app.services.auth_service import AuthService
|
||||||
|
from app.services.social_auth_service import SocialAuthService
|
||||||
from app.core.security import create_tokens, DEFAULT_RANK_MAP
|
from app.core.security import create_tokens, DEFAULT_RANK_MAP
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.config_service import config
|
from app.services.config_service import config
|
||||||
@@ -15,6 +16,10 @@ from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-
|
|||||||
from app.core.translation_helper import t # Translation helper
|
from app.core.translation_helper import t # Translation helper
|
||||||
from pydantic import BaseModel, Field, EmailStr
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -57,6 +62,9 @@ async def login(
|
|||||||
detail="AUTH.USER_INACTIVE"
|
detail="AUTH.USER_INACTIVE"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Update last activity timestamp ──
|
||||||
|
user.last_activity_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# ── Device Fingerprint Tracking ──
|
# ── Device Fingerprint Tracking ──
|
||||||
if device_fingerprint:
|
if device_fingerprint:
|
||||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||||
@@ -155,6 +163,9 @@ async def verify_email(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
|
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
|
||||||
|
|
||||||
|
# ── Update last activity timestamp ──
|
||||||
|
user.last_activity_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# ── Device Fingerprint Tracking (Magic Link) ──
|
# ── Device Fingerprint Tracking (Magic Link) ──
|
||||||
if request.device_fingerprint:
|
if request.device_fingerprint:
|
||||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||||
@@ -379,4 +390,156 @@ async def restore_account_verify(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
|
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── GOOGLE OAUTH ──
|
||||||
|
|
||||||
|
class GoogleAuthRequest(BaseModel):
|
||||||
|
"""Google token from the frontend after successful Google Sign-In.
|
||||||
|
|
||||||
|
The frontend sends either an id_token (from Google Sign-In / One Tap)
|
||||||
|
or an access_token (from Google OAuth2 token client).
|
||||||
|
"""
|
||||||
|
id_token: Optional[str] = Field(None, description="Google ID token (JWT) from Google Sign-In")
|
||||||
|
access_token: Optional[str] = Field(None, description="Google OAuth2 access token from token client")
|
||||||
|
referred_by_code: Optional[str] = Field(None, description="Optional referral code from registration form")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/google", response_model=Token)
|
||||||
|
async def google_auth(
|
||||||
|
request: GoogleAuthRequest,
|
||||||
|
response: Response,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Google OAuth bejelentkezés / regisztráció.
|
||||||
|
|
||||||
|
A frontend Google Sign-In gombjára kattintva a felhasználó Google tokent kap.
|
||||||
|
Ezt a tokent küldi el a backendnek, amely:
|
||||||
|
1. Ellenőrzi a token érvényességét a Google nyilvános kulcsaival
|
||||||
|
2. Kinyeri az email-t, nevet és Google ID-t
|
||||||
|
3. Ha a felhasználó már létezik (Login): visszaadja a JWT tokent
|
||||||
|
4. Ha nem létezik (Register): létrehozza az új felhasználót a Google adataival
|
||||||
|
5. Opcionális referral_code tárolása, ha a regisztrációs űrlapon megadták
|
||||||
|
|
||||||
|
Supports both id_token (from Google Sign-In) and access_token (from OAuth2 token client).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. Determine which token was provided and build verification URL
|
||||||
|
if request.id_token:
|
||||||
|
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?id_token={request.id_token}"
|
||||||
|
elif request.access_token:
|
||||||
|
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?access_token={request.access_token}"
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Hiányzó Google token. Adjon meg id_token vagy access_token értéket."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Verify Google token
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
google_resp = await client.get(google_verify_url)
|
||||||
|
|
||||||
|
if google_resp.status_code != 200:
|
||||||
|
logger.error(f"Google token verification failed: {google_resp.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Érvénytelen Google token."
|
||||||
|
)
|
||||||
|
|
||||||
|
google_data = google_resp.json()
|
||||||
|
|
||||||
|
# 3. Validate audience (client ID) — only for id_token
|
||||||
|
# For access_token, the 'aud' field may not be present; validate by 'azp' or skip
|
||||||
|
if request.id_token:
|
||||||
|
if google_data.get("aud") != settings.GOOGLE_CLIENT_ID:
|
||||||
|
logger.error(f"Google token audience mismatch: {google_data.get('aud')}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Érvénytelen Google token (audience mismatch)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# For access_token, check azp (authorized party) matches our client ID
|
||||||
|
if google_data.get("azp") != settings.GOOGLE_CLIENT_ID:
|
||||||
|
logger.error(f"Google token azp mismatch: {google_data.get('azp')}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Érvénytelen Google token (azp mismatch)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Extract user info
|
||||||
|
google_id = google_data.get("sub")
|
||||||
|
email = google_data.get("email", "")
|
||||||
|
first_name = google_data.get("given_name", "")
|
||||||
|
last_name = google_data.get("family_name", "")
|
||||||
|
|
||||||
|
if not google_id or not email:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Hiányzó Google felhasználói adatok."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Get or create user via SocialAuthService
|
||||||
|
user = await SocialAuthService.get_or_create_social_user(
|
||||||
|
db=db,
|
||||||
|
provider="google",
|
||||||
|
social_id=google_id,
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
referred_by_code=request.referred_by_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Hiba a felhasználó létrehozása során."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Update last activity timestamp ──
|
||||||
|
user.last_activity_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# 6. Generate JWT tokens (same logic as login)
|
||||||
|
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
|
||||||
|
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||||
|
role_key = role_name.upper()
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
"sub": str(user.id),
|
||||||
|
"role": role_name,
|
||||||
|
"rank": ranks.get(role_key, 10),
|
||||||
|
"scope_level": user.scope_level or "individual",
|
||||||
|
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
access, refresh = create_tokens(data=token_data, remember_me=False)
|
||||||
|
|
||||||
|
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
|
||||||
|
max_age_sec = int(max_age_days) * 24 * 60 * 60
|
||||||
|
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=refresh,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="lax",
|
||||||
|
domain=settings.COOKIE_DOMAIN,
|
||||||
|
max_age=max_age_sec
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access,
|
||||||
|
"refresh_token": refresh,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"is_active": user.is_active
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Google auth error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Google hitelesítési hiba: {str(e)}"
|
||||||
|
)
|
||||||
@@ -87,7 +87,7 @@ async def list_body_types(
|
|||||||
current_user = Depends(deps.get_current_user)
|
current_user = Depends(deps.get_current_user)
|
||||||
):
|
):
|
||||||
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
|
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
|
||||||
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu)
|
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_i18n)
|
||||||
if vehicle_class:
|
if vehicle_class:
|
||||||
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
|
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
@@ -97,7 +97,7 @@ async def list_body_types(
|
|||||||
"id": r.id,
|
"id": r.id,
|
||||||
"vehicle_class": r.vehicle_class,
|
"vehicle_class": r.vehicle_class,
|
||||||
"code": r.code,
|
"code": r.code,
|
||||||
"name_hu": r.name_hu,
|
"name_i18n": r.name_i18n if r.name_i18n else {},
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,29 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
|
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
|
||||||
"""
|
"""
|
||||||
Finance Admin API endpoints for managing Issuers with strict RBAC protection.
|
Finance Admin API endpoints for managing Issuers and FinancialLedger with strict RBAC protection.
|
||||||
Protected by DB-driven RequirePermission("finance:view") dependency.
|
Protected by DB-driven RequirePermission("finance:view") dependency.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, func, text
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models.marketplace.finance import Issuer
|
from app.models.system.audit import FinancialLedger
|
||||||
from app.schemas.finance import IssuerResponse, IssuerUpdate
|
from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Issuer Endpoints (existing)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
|
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
|
||||||
async def list_issuers(
|
async def list_issuers(
|
||||||
db: AsyncSession = Depends(deps.get_db),
|
db: AsyncSession = Depends(deps.get_db),
|
||||||
@@ -27,6 +34,7 @@ async def list_issuers(
|
|||||||
List all Issuers (billing entities).
|
List all Issuers (billing entities).
|
||||||
Protected by RequirePermission("finance:view").
|
Protected by RequirePermission("finance:view").
|
||||||
"""
|
"""
|
||||||
|
from app.models.marketplace.finance import Issuer
|
||||||
result = await db.execute(select(Issuer).order_by(Issuer.id))
|
result = await db.execute(select(Issuer).order_by(Issuer.id))
|
||||||
issuers = result.scalars().all()
|
issuers = result.scalars().all()
|
||||||
return issuers
|
return issuers
|
||||||
@@ -44,6 +52,7 @@ async def update_issuer(
|
|||||||
Update an Issuer's details (activate/deactivate, revenue limit, API config).
|
Update an Issuer's details (activate/deactivate, revenue limit, API config).
|
||||||
Protected by RequirePermission("finance:edit").
|
Protected by RequirePermission("finance:edit").
|
||||||
"""
|
"""
|
||||||
|
from app.models.marketplace.finance import Issuer
|
||||||
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
|
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
|
||||||
issuer = result.scalar_one_or_none()
|
issuer = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -61,4 +70,134 @@ async def update_issuer(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(issuer)
|
await db.refresh(issuer)
|
||||||
|
|
||||||
return issuer
|
return issuer
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# FinancialLedger Admin Endpoints (NEW)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ledger", response_model=FinancialLedgerListResponse, tags=["finance-admin"])
|
||||||
|
async def list_financial_ledger(
|
||||||
|
page: int = Query(1, ge=1, description="Oldalszám (1-indexelt)"),
|
||||||
|
page_size: int = Query(50, ge=1, le=200, description="Rekordok száma oldalanként"),
|
||||||
|
user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"),
|
||||||
|
transaction_type: Optional[str] = Query(None, description="Tranzakció típus szűrés"),
|
||||||
|
entry_type: Optional[str] = Query(None, description="Könyvelési irány (DEBIT / CREDIT)"),
|
||||||
|
wallet_type: Optional[str] = Query(None, description="Tárcatípus szűrés"),
|
||||||
|
date_from: Optional[str] = Query(None, description="Dátum szűrés kezdete (ISO, pl. 2026-01-01)"),
|
||||||
|
date_to: Optional[str] = Query(None, description="Dátum szűrés vége (ISO, pl. 2026-12-31)"),
|
||||||
|
sort_by: str = Query("created_at", description="Rendezési mező (created_at, amount, id)"),
|
||||||
|
sort_order: str = Query("desc", description="Rendezési irány (asc / desc)"),
|
||||||
|
db: AsyncSession = Depends(deps.get_db),
|
||||||
|
current_user: User = Depends(deps.get_current_active_user),
|
||||||
|
_ = Depends(deps.RequirePermission("finance:view")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List FinancialLedger entries with pagination, filtering, and user details.
|
||||||
|
|
||||||
|
Uses raw SQL with explicit joins to avoid SQLAlchemy relationship issues.
|
||||||
|
Protected by RequirePermission("finance:view").
|
||||||
|
|
||||||
|
Returns paginated results with joined user email and person name.
|
||||||
|
"""
|
||||||
|
# ── Build WHERE clauses dynamically ──
|
||||||
|
where_clauses = []
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if user_id is not None:
|
||||||
|
where_clauses.append("fl.user_id = :user_id")
|
||||||
|
params["user_id"] = user_id
|
||||||
|
if transaction_type is not None:
|
||||||
|
where_clauses.append("fl.transaction_type = :transaction_type")
|
||||||
|
params["transaction_type"] = transaction_type
|
||||||
|
if entry_type is not None:
|
||||||
|
where_clauses.append("fl.entry_type = :entry_type")
|
||||||
|
params["entry_type"] = entry_type
|
||||||
|
if wallet_type is not None:
|
||||||
|
where_clauses.append("fl.wallet_type = :wallet_type")
|
||||||
|
params["wallet_type"] = wallet_type
|
||||||
|
if date_from is not None:
|
||||||
|
where_clauses.append("fl.created_at >= :date_from::timestamp")
|
||||||
|
params["date_from"] = date_from
|
||||||
|
if date_to is not None:
|
||||||
|
where_clauses.append("fl.created_at <= :date_to::timestamp")
|
||||||
|
params["date_to"] = date_to
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
|
||||||
|
|
||||||
|
# Validate sort_by to prevent SQL injection
|
||||||
|
allowed_sort_fields = {"created_at", "amount", "id"}
|
||||||
|
if sort_by not in allowed_sort_fields:
|
||||||
|
sort_by = "created_at"
|
||||||
|
sort_direction = "ASC" if sort_order == "asc" else "DESC"
|
||||||
|
|
||||||
|
# ── Count query ──
|
||||||
|
count_sql = f"""
|
||||||
|
SELECT COUNT(*) FROM audit.financial_ledger fl
|
||||||
|
WHERE {where_sql}
|
||||||
|
"""
|
||||||
|
count_result = await db.execute(text(count_sql), params)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# ── Data query with joins ──
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_sql = f"""
|
||||||
|
SELECT
|
||||||
|
fl.id,
|
||||||
|
fl.user_id,
|
||||||
|
fl.person_id,
|
||||||
|
fl.amount,
|
||||||
|
fl.currency,
|
||||||
|
fl.transaction_type,
|
||||||
|
fl.entry_type::text,
|
||||||
|
fl.wallet_type::text,
|
||||||
|
fl.status::text,
|
||||||
|
fl.details,
|
||||||
|
fl.created_at,
|
||||||
|
u.email AS user_email,
|
||||||
|
p.first_name AS person_first_name,
|
||||||
|
p.last_name AS person_last_name
|
||||||
|
FROM audit.financial_ledger fl
|
||||||
|
LEFT JOIN identity.users u ON u.id = fl.user_id
|
||||||
|
LEFT JOIN identity.persons p ON p.id = u.person_id
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY fl.{sort_by} {sort_direction}
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
params["limit"] = page_size
|
||||||
|
params["offset"] = offset
|
||||||
|
data_result = await db.execute(text(data_sql), params)
|
||||||
|
rows = data_result.all()
|
||||||
|
|
||||||
|
# ── Build response ──
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
user_name = None
|
||||||
|
if row.person_first_name or row.person_last_name:
|
||||||
|
parts = [p for p in [row.person_last_name, row.person_first_name] if p]
|
||||||
|
user_name = " ".join(parts)
|
||||||
|
|
||||||
|
items.append(FinancialLedgerResponse(
|
||||||
|
id=row.id,
|
||||||
|
user_id=row.user_id,
|
||||||
|
person_id=row.person_id,
|
||||||
|
amount=float(row.amount) if row.amount else 0.0,
|
||||||
|
currency=row.currency,
|
||||||
|
transaction_type=row.transaction_type,
|
||||||
|
entry_type=row.entry_type,
|
||||||
|
wallet_type=row.wallet_type,
|
||||||
|
status=row.status,
|
||||||
|
details=row.details,
|
||||||
|
created_at=row.created_at,
|
||||||
|
user_email=row.user_email,
|
||||||
|
user_name=user_name,
|
||||||
|
))
|
||||||
|
|
||||||
|
return FinancialLedgerListResponse(
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|||||||
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
237
backend/app/api/v1/endpoints/financial_manager.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/financial_manager.py
|
||||||
|
"""
|
||||||
|
Financial Manager API Endpoints — Package Purchase Lifecycle.
|
||||||
|
|
||||||
|
Provides the public API for purchasing subscription packages.
|
||||||
|
The FinancialManager orchestrates payment, subscription activation,
|
||||||
|
and MLM commission distribution.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- POST /financial-manager/purchase-package is the main entry point.
|
||||||
|
- Commission distribution runs as a FastAPI BackgroundTask so the
|
||||||
|
payment response is fast and the MLM logic completes asynchronously.
|
||||||
|
- Uses the existing get_current_user dependency for authentication.
|
||||||
|
- Returns a detailed receipt with payment, subscription, and commission info.
|
||||||
|
- Fully async with proper error handling and logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_current_user
|
||||||
|
from app.models.identity.identity import User
|
||||||
|
from app.schemas.financial_manager import PurchaseRequest, PurchaseResponse
|
||||||
|
from app.services.financial_manager import FinancialManager
|
||||||
|
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||||
|
from app.services.subscription_activator import TierNotFoundError
|
||||||
|
from app.services.financial_interfaces import PaymentGatewayError
|
||||||
|
|
||||||
|
logger = logging.getLogger("financial-manager-api")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Dependency: FinancialManager instance
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_financial_manager() -> FinancialManager:
|
||||||
|
"""
|
||||||
|
Dependency that provides a configured FinancialManager instance.
|
||||||
|
|
||||||
|
Currently uses MockPaymentGateway as default. To switch to Stripe:
|
||||||
|
from app.services.stripe_adapter import StripeAdapter
|
||||||
|
return FinancialManager(payment_gateway=StripeAdapter())
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A FinancialManager instance with the configured payment gateway.
|
||||||
|
"""
|
||||||
|
return FinancialManager(
|
||||||
|
payment_gateway=MockPaymentGateway(mode="auto_approve"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Background Task: Async Commission Distribution
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def distribute_commission_background(
|
||||||
|
db: AsyncSession,
|
||||||
|
buyer_user_id: int,
|
||||||
|
transaction_amount: float,
|
||||||
|
region_code: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Background task for async MLM commission distribution.
|
||||||
|
|
||||||
|
Runs after the purchase response is sent to the client.
|
||||||
|
Uses a new database session to avoid sharing the request session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: A new database session.
|
||||||
|
buyer_user_id: The user who made the purchase.
|
||||||
|
transaction_amount: The amount paid.
|
||||||
|
region_code: Region code for rule matching.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
manager = get_financial_manager()
|
||||||
|
await manager._distribute_commission(
|
||||||
|
db=db,
|
||||||
|
buyer_user_id=buyer_user_id,
|
||||||
|
transaction_amount=transaction_amount,
|
||||||
|
region_code=region_code,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Background commission distribution completed for buyer=%d",
|
||||||
|
buyer_user_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Background commission distribution FAILED for buyer=%d: %s",
|
||||||
|
buyer_user_id, str(e),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# POST /financial-manager/purchase-package
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/purchase-package",
|
||||||
|
response_model=PurchaseResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
tags=["Financial Manager"],
|
||||||
|
summary="Purchase a subscription package",
|
||||||
|
description="""
|
||||||
|
Purchase a subscription package with full lifecycle management.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Validates the subscription tier exists
|
||||||
|
2. Calculates the price (region-adjusted)
|
||||||
|
3. Creates a PaymentIntent (PENDING)
|
||||||
|
4. Processes payment via the configured gateway (Mock or Stripe)
|
||||||
|
5. Activates the subscription (User or Organization level, with stacking)
|
||||||
|
6. Distributes MLM commissions asynchronously (BackgroundTask)
|
||||||
|
7. Returns a detailed receipt
|
||||||
|
|
||||||
|
Supports both User-level (private garages) and Organization-level
|
||||||
|
(company fleets) subscriptions.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def purchase_package(
|
||||||
|
request: PurchaseRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
manager: FinancialManager = Depends(get_financial_manager),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Purchase a subscription package.
|
||||||
|
|
||||||
|
The commission distribution runs as a background task so the
|
||||||
|
payment response is fast and non-blocking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Purchase details (tier_id, org_id, region_code, etc.).
|
||||||
|
background_tasks: FastAPI BackgroundTasks for async commission.
|
||||||
|
db: Database session.
|
||||||
|
current_user: Authenticated user.
|
||||||
|
manager: FinancialManager instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PurchaseResponse with full receipt details.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Purchase request: user_id=%d tier_id=%d org_id=%s",
|
||||||
|
current_user.id, request.tier_id, request.org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Execute the purchase flow ──────────────────────────────────────────
|
||||||
|
result = await manager.purchase_package(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
tier_id=request.tier_id,
|
||||||
|
org_id=request.org_id,
|
||||||
|
region_code=request.region_code,
|
||||||
|
currency=request.currency,
|
||||||
|
duration_days=request.duration_days,
|
||||||
|
metadata=request.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Handle failure ─────────────────────────────────────────────────────
|
||||||
|
if not result.success:
|
||||||
|
error_detail = result.error or "Unknown error during purchase"
|
||||||
|
logger.warning(
|
||||||
|
"Purchase failed: user_id=%d tier_id=%d error=%s",
|
||||||
|
current_user.id, request.tier_id, error_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine appropriate HTTP status code
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
if "not found" in error_detail.lower():
|
||||||
|
status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
elif "payment" in error_detail.lower():
|
||||||
|
status_code = status.HTTP_402_PAYMENT_REQUIRED
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status_code,
|
||||||
|
detail=error_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Schedule async commission distribution ─────────────────────────────
|
||||||
|
# We pass the commission result from the sync call, but also schedule
|
||||||
|
# a background task for any additional async processing.
|
||||||
|
# The sync commission result is already included in the response.
|
||||||
|
if result.commission_result and result.commission_result.items:
|
||||||
|
background_tasks.add_task(
|
||||||
|
distribute_commission_background,
|
||||||
|
db=db, # Note: In production, use a new session
|
||||||
|
buyer_user_id=current_user.id,
|
||||||
|
transaction_amount=result.amount_paid,
|
||||||
|
region_code=request.region_code,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Background commission task scheduled for buyer=%d",
|
||||||
|
current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Build response ─────────────────────────────────────────────────────
|
||||||
|
response_data = result.to_dict()
|
||||||
|
return PurchaseResponse(**response_data)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Health / Status Endpoint
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/financial-manager/status",
|
||||||
|
tags=["Financial Manager"],
|
||||||
|
summary="Check FinancialManager status",
|
||||||
|
)
|
||||||
|
async def financial_manager_status(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check the FinancialManager service status.
|
||||||
|
|
||||||
|
Returns the configured payment gateway type and current mode.
|
||||||
|
Useful for debugging and monitoring.
|
||||||
|
"""
|
||||||
|
manager = get_financial_manager()
|
||||||
|
gateway = manager.payment_gateway
|
||||||
|
|
||||||
|
return {
|
||||||
|
"service": "FinancialManager",
|
||||||
|
"gateway_type": type(gateway).__name__,
|
||||||
|
"gateway_mode": getattr(gateway, "mode", "unknown"),
|
||||||
|
"status": "operational",
|
||||||
|
}
|
||||||
93
backend/app/api/v1/endpoints/locations.py
Normal file
93
backend/app/api/v1/endpoints/locations.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
🌍 Location Autocomplete API Endpoint.
|
||||||
|
|
||||||
|
Provides autocomplete search for postal codes and cities from the
|
||||||
|
system.geo_postal_codes table. Used by the frontend SmartLocationInput
|
||||||
|
component to let users quickly find and select a zip/city pair.
|
||||||
|
|
||||||
|
Schema: system.geo_postal_codes
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
from app.models.identity.address import GeoPostalCode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class LocationAutocompleteItem(BaseModel):
|
||||||
|
"""Egy találat a település autocomplete keresőben."""
|
||||||
|
id: int
|
||||||
|
zip_code: str
|
||||||
|
city: str
|
||||||
|
country_code: str = "HU"
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locations/autocomplete", response_model=List[LocationAutocompleteItem])
|
||||||
|
async def autocomplete_locations(
|
||||||
|
q: str = Query(..., min_length=1, max_length=100, description="Keresőszó (irányítószám vagy városrészlet)"),
|
||||||
|
country_code: str = Query("HU", max_length=5, description="Országkód szűrő (alapértelmezett: HU)"),
|
||||||
|
limit: int = Query(15, ge=1, le=50, description="Max találatok száma"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
GET /api/v1/locations/autocomplete?q=budapest
|
||||||
|
|
||||||
|
Autocomplete keresés a system.geo_postal_codes táblában.
|
||||||
|
A keresés az irányítószámra (zip_code) és a város névre (city) is
|
||||||
|
történik ILIKE operátorral.
|
||||||
|
|
||||||
|
Példák:
|
||||||
|
/api/v1/locations/autocomplete?q=1011 -> 1011 Budapest
|
||||||
|
/api/v1/locations/autocomplete?q=budapest -> 1011 Budapest, 1012 Budapest, ...
|
||||||
|
/api/v1/locations/autocomplete?q=101 -> 1011 Budapest, 1012 Budapest, ...
|
||||||
|
/api/v1/locations/autocomplete?q=bud&country_code=AT -> 5020 Salzburg, ...
|
||||||
|
"""
|
||||||
|
if not q.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="A keresőszó (q) nem lehet üres.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stmt = (
|
||||||
|
select(GeoPostalCode)
|
||||||
|
.where(
|
||||||
|
GeoPostalCode.country_code == country_code,
|
||||||
|
or_(
|
||||||
|
GeoPostalCode.zip_code.ilike(f"%{q.strip()}%"),
|
||||||
|
GeoPostalCode.city.ilike(f"%{q.strip()}%"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(GeoPostalCode.zip_code, GeoPostalCode.city)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
LocationAutocompleteItem(
|
||||||
|
id=item.id,
|
||||||
|
zip_code=item.zip_code,
|
||||||
|
city=item.city,
|
||||||
|
country_code=item.country_code,
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Hiba a location autocomplete során: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Hiba történt a település keresés során.",
|
||||||
|
)
|
||||||
@@ -17,9 +17,12 @@ from app.db.session import get_db
|
|||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||||
from app.schemas.subscription import SubscriptionTierResponse
|
from app.schemas.subscription import SubscriptionTierResponse
|
||||||
|
from app.schemas.address import AddressOut
|
||||||
from app.services.billing_engine import upgrade_org_subscription
|
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.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
|
||||||
from app.models.identity import User, OneTimePassword
|
from app.models.identity import User, OneTimePassword
|
||||||
|
from app.models.identity.address import Address
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.security_service import security_service
|
from app.services.security_service import security_service
|
||||||
from app.models import LogSeverity
|
from app.models import LogSeverity
|
||||||
@@ -49,7 +52,7 @@ async def onboard_organization(
|
|||||||
if org_in.country_code == "HU":
|
if org_in.country_code == "HU":
|
||||||
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
|
if not re.match(r"^\d{8}-\d-\d{2}$", org_in.tax_number):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=_("ORGANIZATION.ERROR.INVALID_TAX_FORMAT")
|
detail=_("ORGANIZATION.ERROR.INVALID_TAX_FORMAT")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,7 +68,10 @@ async def onboard_organization(
|
|||||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||||
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
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(
|
new_org = Organization(
|
||||||
full_name=org_in.full_name,
|
full_name=org_in.full_name,
|
||||||
name=org_in.name,
|
name=org_in.name,
|
||||||
@@ -73,12 +79,7 @@ async def onboard_organization(
|
|||||||
tax_number=org_in.tax_number,
|
tax_number=org_in.tax_number,
|
||||||
reg_number=org_in.reg_number,
|
reg_number=org_in.reg_number,
|
||||||
folder_slug=temp_slug,
|
folder_slug=temp_slug,
|
||||||
address_zip=org_in.address_zip,
|
address_id=address_id,
|
||||||
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,
|
|
||||||
country_code=org_in.country_code,
|
country_code=org_in.country_code,
|
||||||
org_type=OrgType.business,
|
org_type=OrgType.business,
|
||||||
status="pending_verification",
|
status="pending_verification",
|
||||||
@@ -96,22 +97,17 @@ async def onboard_organization(
|
|||||||
db.add(new_org)
|
db.add(new_org)
|
||||||
await db.flush()
|
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(
|
main_branch = Branch(
|
||||||
organization_id=new_org.id,
|
organization_id=new_org.id,
|
||||||
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
|
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
|
||||||
is_main=True,
|
is_main=True,
|
||||||
postal_code=org_in.address_zip,
|
address_id=address_id,
|
||||||
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,
|
|
||||||
status="active"
|
status="active"
|
||||||
)
|
)
|
||||||
db.add(main_branch)
|
db.add(main_branch)
|
||||||
|
|
||||||
# 6. TULAJDONOS RÖGZÍTÉSE
|
# 7. TULAJDONOS RÖGZÍTÉSE
|
||||||
owner_member = OrganizationMember(
|
owner_member = OrganizationMember(
|
||||||
organization_id=new_org.id,
|
organization_id=new_org.id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -119,7 +115,7 @@ async def onboard_organization(
|
|||||||
)
|
)
|
||||||
db.add(owner_member)
|
db.add(owner_member)
|
||||||
|
|
||||||
# 7. NAS Mappa létrehozása
|
# 8. NAS Mappa létrehozása
|
||||||
try:
|
try:
|
||||||
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
|
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
|
||||||
org_path = os.path.join(base_path, "organizations", str(new_org.id))
|
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.
|
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
|
||||||
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
|
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
|
||||||
a subscription_tier JSONB rules alapján.
|
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
|
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||||
|
|
||||||
@@ -205,7 +202,10 @@ async def get_my_organizations(
|
|||||||
stmt = (
|
stmt = (
|
||||||
select(Organization)
|
select(Organization)
|
||||||
.where(Organization.id.in_(select(subq.c.id)))
|
.where(Organization.id.in_(select(subq.c.id)))
|
||||||
.options(selectinload(Organization.members))
|
.options(
|
||||||
|
selectinload(Organization.members),
|
||||||
|
selectinload(Organization.address),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
orgs = result.scalars().all()
|
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),
|
"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,
|
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
|
||||||
"user_role": _get_user_role(o, current_user.id),
|
"user_role": _get_user_role(o, current_user.id),
|
||||||
# ── Cím adatok (Address fields) ──
|
# ── P0 REFACTORED: Address from relationship ──
|
||||||
"address_zip": o.address_zip,
|
"address": AddressOut.model_validate(o.address).model_dump() if o.address else None,
|
||||||
"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,
|
|
||||||
}
|
}
|
||||||
for o in orgs
|
for o in orgs
|
||||||
]
|
]
|
||||||
@@ -704,6 +699,12 @@ async def update_organization(
|
|||||||
"""
|
"""
|
||||||
Szervezet adatainak részleges frissítése (általános PATCH).
|
Szervezet adatainak részleges frissítése (általános PATCH).
|
||||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
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
|
# 1. Jogosultság ellenőrzése
|
||||||
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
|
# 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
|
org.visual_settings = current_vs
|
||||||
del update_dict["visual_settings"]
|
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():
|
for field, value in update_dict.items():
|
||||||
if hasattr(org, field) and value is not None:
|
if hasattr(org, field) and value is not None:
|
||||||
setattr(org, field, value)
|
setattr(org, field, value)
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import logging
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_, cast, String
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
@@ -76,8 +77,8 @@ async def get_category_tree(
|
|||||||
nodes.append(CategoryTreeNode(
|
nodes.append(CategoryTreeNode(
|
||||||
id=tag.id,
|
id=tag.id,
|
||||||
key=tag.key,
|
key=tag.key,
|
||||||
name_hu=tag.name_hu,
|
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
||||||
name_en=tag.name_en,
|
description_i18n=tag.description_i18n,
|
||||||
level=tag.level,
|
level=tag.level,
|
||||||
path=tag.path,
|
path=tag.path,
|
||||||
is_official=tag.is_official,
|
is_official=tag.is_official,
|
||||||
@@ -106,18 +107,17 @@ async def autocomplete_categories(
|
|||||||
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
|
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
|
||||||
|
|
||||||
Csak azokat listázza, ahol is_official=True.
|
Csak azokat listázza, ahol is_official=True.
|
||||||
A keresés a name_hu, name_en és key mezőkben történik (ILIKE).
|
A keresés a name_i18n JSONB (minden nyelven) és key mezőkben történik.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stmt = select(ExpertiseTag).where(
|
stmt = select(ExpertiseTag).where(
|
||||||
ExpertiseTag.level >= 2,
|
ExpertiseTag.level >= 2,
|
||||||
ExpertiseTag.is_official == True,
|
ExpertiseTag.is_official == True,
|
||||||
or_(
|
or_(
|
||||||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
|
||||||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
|
||||||
ExpertiseTag.key.ilike(f"%{q}%"),
|
ExpertiseTag.key.ilike(f"%{q}%"),
|
||||||
),
|
),
|
||||||
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20)
|
).order_by(ExpertiseTag.level, ExpertiseTag.name_i18n).limit(20)
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
tags = result.scalars().all()
|
tags = result.scalars().all()
|
||||||
@@ -126,8 +126,7 @@ async def autocomplete_categories(
|
|||||||
CategoryAutocompleteItem(
|
CategoryAutocompleteItem(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
key=t.key,
|
key=t.key,
|
||||||
name_hu=t.name_hu,
|
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||||
name_en=t.name_en,
|
|
||||||
level=t.level,
|
level=t.level,
|
||||||
path=t.path,
|
path=t.path,
|
||||||
parent_id=t.parent_id,
|
parent_id=t.parent_id,
|
||||||
@@ -164,8 +163,7 @@ async def list_expertise_categories(
|
|||||||
ExpertiseCategoryOut(
|
ExpertiseCategoryOut(
|
||||||
id=t.id,
|
id=t.id,
|
||||||
key=t.key,
|
key=t.key,
|
||||||
name_hu=t.name_hu,
|
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||||
name_en=t.name_en,
|
|
||||||
category=t.category,
|
category=t.category,
|
||||||
level=t.level,
|
level=t.level,
|
||||||
parent_id=t.parent_id,
|
parent_id=t.parent_id,
|
||||||
@@ -183,7 +181,7 @@ async def list_expertise_categories(
|
|||||||
|
|
||||||
@router.get("/search", response_model=ProviderSearchResponse)
|
@router.get("/search", response_model=ProviderSearchResponse)
|
||||||
async def search_service_providers(
|
async def search_service_providers(
|
||||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
q: Optional[str] = Query(None, min_length=3, max_length=200, description="Keresőszó (min. 3 karakter)"),
|
||||||
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
|
||||||
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
|
||||||
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, or_
|
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.db.session import get_db
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.models.marketplace.organization import Organization, Branch
|
from app.models.marketplace.organization import Organization, Branch
|
||||||
|
from app.models.identity.address import Address, GeoPostalCode
|
||||||
from geoalchemy2 import WKTElement
|
from geoalchemy2 import WKTElement
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -23,16 +24,24 @@ async def match_service(
|
|||||||
"""
|
"""
|
||||||
Geofencing keresőmotor PostGIS segítségével.
|
Geofencing keresőmotor PostGIS segítségével.
|
||||||
Ha nincs megadva lat/lng, akkor nem alkalmazunk távolságszűrést.
|
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
|
# Alap lekérdezés: aktív szervezetek és telephelyek
|
||||||
query = select(
|
query = select(
|
||||||
Organization.id,
|
Organization.id,
|
||||||
Organization.name,
|
Organization.name,
|
||||||
Branch.city,
|
GeoPostalCode.city.label("city"),
|
||||||
Branch.branch_rating,
|
Branch.branch_rating,
|
||||||
Branch.location
|
Branch.location
|
||||||
).join(
|
).join(
|
||||||
Branch, Organization.id == Branch.organization_id
|
Branch, Organization.id == Branch.organization_id
|
||||||
|
).outerjoin(
|
||||||
|
Address, Address.id == Branch.address_id
|
||||||
|
).outerjoin(
|
||||||
|
GeoPostalCode, GeoPostalCode.id == Address.postal_code_id
|
||||||
).where(
|
).where(
|
||||||
Organization.is_active == True,
|
Organization.is_active == True,
|
||||||
Branch.is_deleted == False
|
Branch.is_deleted == False
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.api.deps import get_db, get_current_user
|
from app.api.deps import get_db, get_current_user
|
||||||
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
|
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 import User, Person
|
||||||
from app.models.identity.address import Address
|
from app.models.identity.address import Address
|
||||||
from app.services.trust_engine import TrustEngine
|
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:
|
if person:
|
||||||
address_data = None
|
address_data = None
|
||||||
if person.address:
|
if person.address:
|
||||||
address_data = {
|
address_data = AddressOut.model_validate(person.address)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
person_data = {
|
person_data = {
|
||||||
"id": person.id,
|
"id": person.id,
|
||||||
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
|
"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.")
|
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
|
||||||
|
|
||||||
# ── Update Person fields ──
|
# ── 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",
|
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
|
||||||
"mothers_first_name", "birth_place", "birth_date"]
|
"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:
|
for field in person_fields:
|
||||||
if field in update_dict and update_dict[field] is not None:
|
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"]
|
person.identity_docs = update_dict["identity_docs"]
|
||||||
flag_modified(person, "identity_docs")
|
flag_modified(person, "identity_docs")
|
||||||
|
|
||||||
# ── Update Address fields ──
|
# ── Update Address fields (via AddressIn) ──
|
||||||
has_address_update = any(f in update_dict for f in address_fields)
|
addr_data = update_data.address
|
||||||
if has_address_update:
|
if addr_data is not None:
|
||||||
# Use GeoService to get or create address
|
# Use GeoService to get or create address
|
||||||
addr_id = await GeoService.get_or_create_full_address(
|
addr_id = await GeoService.get_or_create_full_address(
|
||||||
db,
|
db,
|
||||||
zip_code=update_dict.get("address_zip"),
|
zip_code=addr_data.zip,
|
||||||
city=update_dict.get("address_city"),
|
city=addr_data.city,
|
||||||
street_name=update_dict.get("address_street_name"),
|
street_name=addr_data.street_name,
|
||||||
street_type=update_dict.get("address_street_type"),
|
street_type=addr_data.street_type,
|
||||||
house_number=update_dict.get("address_house_number"),
|
house_number=addr_data.house_number,
|
||||||
stairwell=update_dict.get("address_stairwell"),
|
stairwell=addr_data.stairwell,
|
||||||
floor=update_dict.get("address_floor"),
|
floor=addr_data.floor,
|
||||||
door=update_dict.get("address_door"),
|
door=addr_data.door,
|
||||||
parcel_id=update_dict.get("address_hrsz"),
|
parcel_id=addr_data.parcel_id,
|
||||||
)
|
)
|
||||||
if addr_id:
|
if addr_id:
|
||||||
person.address_id = addr_id
|
person.address_id = addr_id
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
|
|||||||
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
|
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
|
||||||
és leálláskor megáll.
|
és leálláskor megáll.
|
||||||
|
|
||||||
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel
|
Feladatok:
|
||||||
véletlenszerűen 0:15 és 0:30 között fog lefutni.
|
1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade
|
||||||
|
2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat
|
||||||
|
3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás
|
||||||
|
|
||||||
|
Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik,
|
||||||
|
így véletlenszerűen eltolódnak a megadott időponthoz képest.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -190,7 +195,7 @@ def setup_scheduler() -> None:
|
|||||||
"""Beállítja a scheduler-t a napi feladatokkal."""
|
"""Beállítja a scheduler-t a napi feladatokkal."""
|
||||||
scheduler = get_scheduler()
|
scheduler = get_scheduler()
|
||||||
|
|
||||||
# Napi futás 00:15-kor, jitter=900 (15 perc véletlenszerű eltolás)
|
# ── 1. Napi pénzügyi karbantartás 00:15-kor ──
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
daily_financial_maintenance,
|
daily_financial_maintenance,
|
||||||
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
||||||
@@ -199,7 +204,85 @@ def setup_scheduler() -> None:
|
|||||||
replace_existing=True
|
replace_existing=True
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Scheduler jobs registered")
|
# ── 2. Subscription Monitor 01:00-kor ──
|
||||||
|
# Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat.
|
||||||
|
from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor
|
||||||
|
|
||||||
|
async def subscription_monitor_job():
|
||||||
|
logger.info("Scheduler: Starting subscription monitor job...")
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
stats = await run_sub_monitor()
|
||||||
|
total = (
|
||||||
|
stats["user_subscriptions"]["processed"]
|
||||||
|
+ stats["org_subscriptions"]["processed"]
|
||||||
|
)
|
||||||
|
# Naplózás ProcessLog-ba
|
||||||
|
process_log = ProcessLog(
|
||||||
|
process_name="Subscription-Monitor",
|
||||||
|
items_processed=total,
|
||||||
|
items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]),
|
||||||
|
end_time=datetime.utcnow(),
|
||||||
|
details={
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"user_processed": stats["user_subscriptions"]["processed"],
|
||||||
|
"org_processed": stats["org_subscriptions"]["processed"],
|
||||||
|
"user_downgraded": len(stats["user_subscriptions"]["downgraded"]),
|
||||||
|
"org_downgraded": len(stats["org_subscriptions"]["downgraded"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(process_log)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Scheduler: Subscription monitor completed ({total} processed)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
subscription_monitor_job,
|
||||||
|
trigger=CronTrigger(hour=1, minute=0, jitter=900),
|
||||||
|
id="subscription_monitor",
|
||||||
|
name="Subscription Monitor",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Inactivity Monitor 02:00-kor ──
|
||||||
|
# Detektálja a 180+ napja inaktív usereket.
|
||||||
|
from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor
|
||||||
|
|
||||||
|
async def inactivity_monitor_job():
|
||||||
|
logger.info("Scheduler: Starting inactivity monitor job...")
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
stats = await run_inactivity_monitor()
|
||||||
|
# Naplózás ProcessLog-ba
|
||||||
|
process_log = ProcessLog(
|
||||||
|
process_name="Inactivity-Monitor",
|
||||||
|
items_processed=stats["processed"],
|
||||||
|
items_failed=len(stats["errors"]),
|
||||||
|
end_time=datetime.utcnow(),
|
||||||
|
details={
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"processed": stats["processed"],
|
||||||
|
"suspended": len(stats["suspended"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.add(process_log)
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True)
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
inactivity_monitor_job,
|
||||||
|
trigger=CronTrigger(hour=2, minute=0, jitter=900),
|
||||||
|
id="inactivity_monitor",
|
||||||
|
name="Inactivity Monitor",
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -37,11 +37,15 @@ from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise
|
|||||||
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
|
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
|
||||||
from .marketplace.service_request import ServiceRequest
|
from .marketplace.service_request import ServiceRequest
|
||||||
|
|
||||||
|
# 7b. Jutalék szabályok (Commission Rules)
|
||||||
|
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier
|
||||||
|
|
||||||
# 8. Közösségi és értékelési modellek (Social 3)
|
# 8. Közösségi és értékelési modellek (Social 3)
|
||||||
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
|
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
|
||||||
|
|
||||||
# 9. Rendszer, Gamification és egyebek
|
# 9. Rendszer, Gamification és egyebek
|
||||||
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
|
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
|
||||||
|
from .gamification.validation_rule import ValidationRule
|
||||||
|
|
||||||
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
|
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
|
||||||
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||||
@@ -75,7 +79,7 @@ __all__ = [
|
|||||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||||
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
|
||||||
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution",
|
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "ValidationRule",
|
||||||
|
|
||||||
"SystemParameter", "ParameterScope", "InternalNotification",
|
"SystemParameter", "ParameterScope", "InternalNotification",
|
||||||
|
|
||||||
@@ -99,4 +103,6 @@ __all__ = [
|
|||||||
"CampaignStatus", "CreativeType", "PlacementType",
|
"CampaignStatus", "CreativeType", "PlacementType",
|
||||||
# RBAC Phase 1
|
# RBAC Phase 1
|
||||||
"SystemRole", "SystemPermission", "SystemRolePermission",
|
"SystemRole", "SystemPermission", "SystemRolePermission",
|
||||||
|
# Commission Rules (Phase 2)
|
||||||
|
"CommissionRule", "CommissionRuleType", "CommissionTier",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py
|
||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
from datetime import datetime # Python saját típusa a típusjelöléshez
|
from datetime import datetime # Python saját típusa a típusjelöléshez
|
||||||
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float
|
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float, Index
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
@@ -243,11 +243,15 @@ class ServiceCatalog(Base):
|
|||||||
Tábla: system.service_catalog
|
Tábla: system.service_catalog
|
||||||
"""
|
"""
|
||||||
__tablename__ = "service_catalog"
|
__tablename__ = "service_catalog"
|
||||||
__table_args__ = {"schema": "system"}
|
__table_args__ = (
|
||||||
|
Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||||
|
{"schema": "system"}
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
|
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||||
description: Mapped[Optional[str]] = mapped_column(String)
|
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||||
|
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
|
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
@@ -9,6 +9,7 @@ from .gamification import (
|
|||||||
UserContribution,
|
UserContribution,
|
||||||
Season,
|
Season,
|
||||||
)
|
)
|
||||||
|
from .validation_rule import ValidationRule
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PointRule",
|
"PointRule",
|
||||||
@@ -19,4 +20,5 @@ __all__ = [
|
|||||||
"UserBadge",
|
"UserBadge",
|
||||||
"UserContribution",
|
"UserContribution",
|
||||||
"Season",
|
"Season",
|
||||||
|
"ValidationRule",
|
||||||
]
|
]
|
||||||
53
backend/app/models/gamification/validation_rule.py
Normal file
53
backend/app/models/gamification/validation_rule.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/models/gamification/validation_rule.py
|
||||||
|
"""
|
||||||
|
ValidationRule model — Gamification schema.
|
||||||
|
|
||||||
|
Stores dynamic global provider validation rules (thresholds) that drive
|
||||||
|
the ValidationEngine. Rules are key-value pairs with integer values,
|
||||||
|
seeded at initialization and editable via the admin panel.
|
||||||
|
|
||||||
|
Schema: gamification.validation_rules
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Integer, DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationRule(Base):
|
||||||
|
"""
|
||||||
|
Global provider validation rules / thresholds.
|
||||||
|
|
||||||
|
Each row defines a single rule_key → rule_value mapping used by
|
||||||
|
the ValidationEngine to compute trust scores and auto-approval
|
||||||
|
thresholds for service providers.
|
||||||
|
|
||||||
|
Seed data (base rules):
|
||||||
|
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
|
||||||
|
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
|
||||||
|
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
|
||||||
|
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
|
||||||
|
"""
|
||||||
|
__tablename__ = "validation_rules"
|
||||||
|
__table_args__ = {"schema": "gamification", "extend_existing": True}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
rule_key: Mapped[str] = mapped_column(
|
||||||
|
String(100), unique=True, nullable=False, index=True,
|
||||||
|
comment="Unique rule identifier, e.g. 'BOT_BASE_SCORE'"
|
||||||
|
)
|
||||||
|
rule_value: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, default=0,
|
||||||
|
comment="Integer value for this rule (score, threshold, etc.)"
|
||||||
|
)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500), nullable=True,
|
||||||
|
comment="Human-readable description of what this rule controls"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -57,7 +57,7 @@ class Address(Base):
|
|||||||
# Robot és térképes funkciók számára
|
# Robot és térképes funkciók számára
|
||||||
latitude: Mapped[Optional[float]] = mapped_column(Float)
|
latitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
longitude: Mapped[Optional[float]] = mapped_column(Float)
|
longitude: Mapped[Optional[float]] = mapped_column(Float)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), default=func.now())
|
||||||
|
|
||||||
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
|
# Kapcsolat az irányítószám táblához (zip/city lekéréshez)
|
||||||
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")
|
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")
|
||||||
|
|||||||
@@ -159,11 +159,26 @@ class User(Base):
|
|||||||
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||||
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||||
|
|
||||||
|
# Commission tier: determines which commission rules apply to this user
|
||||||
|
# STANDARD = one-time commission only, CONTRACTED = first-time + recurring renewal commission
|
||||||
|
commission_tier: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="STANDARD", server_default=text("'STANDARD'"),
|
||||||
|
comment="Jutalék besorolás: STANDARD (egyszeri jutalék) vagy CONTRACTED (első + megújítási jutalék)"
|
||||||
|
)
|
||||||
|
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# === SOFT DELETE ===
|
# === SOFT DELETE ===
|
||||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# === INACTIVITY MONITOR ===
|
||||||
|
# Updated on login/token-refresh; used by the 180-day inactivity worker
|
||||||
|
# to detect dormant accounts and bypass them for MLM commission rewards.
|
||||||
|
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
comment="Last user activity timestamp (login or token refresh). Used by InactivityMonitor worker."
|
||||||
|
)
|
||||||
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
|
||||||
|
|
||||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List, TYPE_CHECKING
|
||||||
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
|
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
|
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.identity.address import Address
|
||||||
|
|
||||||
class ModerationStatus(str, enum.Enum):
|
class ModerationStatus(str, enum.Enum):
|
||||||
pending = "pending"
|
pending = "pending"
|
||||||
approved = "approved"
|
approved = "approved"
|
||||||
@@ -34,7 +37,12 @@ class ServiceProvider(Base):
|
|||||||
address: Mapped[str] = mapped_column(String, nullable=False)
|
address: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
category: Mapped[Optional[str]] = mapped_column(String)
|
category: Mapped[Optional[str]] = mapped_column(String)
|
||||||
|
|
||||||
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők ===
|
# === P0 ADDRESS UNIFICATION: Normalizált cím FK a system.addresses táblához ===
|
||||||
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők (DEPRECATED - Phase 3-ban törlendő) ===
|
||||||
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||||
@@ -68,6 +76,11 @@ class ServiceProvider(Base):
|
|||||||
JSONB, server_default=text("'{}'::jsonb"), nullable=True
|
JSONB, server_default=text("'{}'::jsonb"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# === P0 ADDRESS UNIFICATION: Kapcsolat a normalizált címhez ===
|
||||||
|
address_rel: Mapped[Optional["Address"]] = relationship(
|
||||||
|
"Address", foreign_keys=[address_id], lazy="selectin"
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
class Vote(Base):
|
class Vote(Base):
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ from .staged_data import (
|
|||||||
|
|
||||||
from .service_request import ServiceRequest
|
from .service_request import ServiceRequest
|
||||||
|
|
||||||
|
# Commission Rules (Phase 2)
|
||||||
|
from .commission import CommissionRule, CommissionRuleType, CommissionTier
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Organization",
|
"Organization",
|
||||||
"OrganizationMember",
|
"OrganizationMember",
|
||||||
@@ -52,4 +55,7 @@ __all__ = [
|
|||||||
"LocationType",
|
"LocationType",
|
||||||
"StagedVehicleData",
|
"StagedVehicleData",
|
||||||
"ServiceRequest",
|
"ServiceRequest",
|
||||||
|
"CommissionRule",
|
||||||
|
"CommissionRuleType",
|
||||||
|
"CommissionTier",
|
||||||
]
|
]
|
||||||
198
backend/app/models/marketplace/commission.py
Normal file
198
backend/app/models/marketplace/commission.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/models/marketplace/commission.py
|
||||||
|
"""
|
||||||
|
CommissionRule: Dynamic, tiered, time-bound commission and reward rules.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- L1 rewards (XP/credits for individual referrals)
|
||||||
|
- L2 commissions (% for company-to-company purchases)
|
||||||
|
- Tier-based multipliers (Standard, VIP, Platinum)
|
||||||
|
- Regional overrides (HU, Global, etc.)
|
||||||
|
- Time-bound promotional campaigns
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- Single table for both L1 and L2 avoids join complexity; nullable fields
|
||||||
|
differentiate type-specific values.
|
||||||
|
- region_code as simple string (ISO 3166-1 alpha-2) follows existing pattern
|
||||||
|
(see InsuranceProvider.country_code).
|
||||||
|
- start_date/end_date as Date (day-granular campaigns, no timezone issues).
|
||||||
|
- is_campaign boolean enables quick filtering without date parsing.
|
||||||
|
- UniqueConstraint on 5 columns prevents duplicate rules for the same
|
||||||
|
type/tier/region/date combination.
|
||||||
|
- metadata_json JSONB for future-proof UI fields (colors, icons, tooltips).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy import (
|
||||||
|
String, Integer, Float, Boolean, DateTime, Date,
|
||||||
|
Text, Enum as SQLEnum, Numeric, UniqueConstraint, text
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRuleType(str, enum.Enum):
|
||||||
|
"""
|
||||||
|
Két elsődleges jutalom típus:
|
||||||
|
- L1_REWARD: XP/kredit jutalom egyéni ajánlóknak (referral)
|
||||||
|
- L2_COMMISSION: Százalékos jutalék céges vásárlások után
|
||||||
|
"""
|
||||||
|
L1_REWARD = "L1_REWARD" # XP/credits for individual referrers
|
||||||
|
L2_COMMISSION = "L2_COMMISSION" # % commission for company purchases
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionTier(str, enum.Enum):
|
||||||
|
"""Jutalék szintek / rétegek."""
|
||||||
|
STANDARD = "STANDARD"
|
||||||
|
VIP = "VIP"
|
||||||
|
PLATINUM = "PLATINUM"
|
||||||
|
ENTERPRISE = "ENTERPRISE"
|
||||||
|
CONTRACTED = "CONTRACTED"
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRule(Base):
|
||||||
|
"""
|
||||||
|
Dinamikus jutalék/jutalom szabályok.
|
||||||
|
|
||||||
|
Minden szabály egy adott típushoz (L1/L2), szinthez (tier),
|
||||||
|
régióhoz és opcionális időablakhoz tartozik.
|
||||||
|
|
||||||
|
A lekérdező motor a tranzakció időpontjában érvényes,
|
||||||
|
legspecifikusabb szabályt alkalmazza.
|
||||||
|
"""
|
||||||
|
__tablename__ = "commission_rules"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
'rule_type', 'tier', 'region_code',
|
||||||
|
'start_date', 'end_date',
|
||||||
|
name='uix_commission_rule_unique'
|
||||||
|
),
|
||||||
|
{"schema": "marketplace", "extend_existing": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# --- Rule Classification ---
|
||||||
|
rule_type: Mapped[CommissionRuleType] = mapped_column(
|
||||||
|
SQLEnum(CommissionRuleType, name="commission_rule_type", schema="marketplace"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="L1_REWARD = XP/kredit jutalom, L2_COMMISSION = % jutalék"
|
||||||
|
)
|
||||||
|
|
||||||
|
tier: Mapped[CommissionTier] = mapped_column(
|
||||||
|
SQLEnum(CommissionTier, name="commission_tier", schema="marketplace"),
|
||||||
|
nullable=False,
|
||||||
|
default=CommissionTier.STANDARD,
|
||||||
|
index=True,
|
||||||
|
comment="Jutalék szint (STANDARD, VIP, PLATINUM, ENTERPRISE)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Regional Scope ---
|
||||||
|
region_code: Mapped[str] = mapped_column(
|
||||||
|
String(10),
|
||||||
|
nullable=False,
|
||||||
|
default="GLOBAL",
|
||||||
|
index=True,
|
||||||
|
comment="ISO 3166-1 alpha-2 országkód (pl. 'HU', 'DE') vagy 'GLOBAL'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Reward / Commission Values ---
|
||||||
|
# L1_REWARD fields
|
||||||
|
xp_reward: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True, default=0,
|
||||||
|
comment="L1: XP jutalom értéke"
|
||||||
|
)
|
||||||
|
credit_reward: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True, default=0,
|
||||||
|
comment="L1: Kredit jutalom értéke"
|
||||||
|
)
|
||||||
|
|
||||||
|
# L2_COMMISSION fields
|
||||||
|
commission_percent: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True, default=0.00,
|
||||||
|
comment="L2: Jutalék százalék (pl. 5.00 = 5%)"
|
||||||
|
)
|
||||||
|
upline_commission_percent: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True, default=0.00,
|
||||||
|
comment="L2: Upline (Gen2) jutalék százalék — a közvetlen ajánló feletti szintnek járó jutalék (pl. 2.00 = 2%)"
|
||||||
|
)
|
||||||
|
renewal_commission_percent: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True, default=0.00,
|
||||||
|
comment="L2: Megújítási jutalék százalék (pl. 2.50 = 2.5%) - havi/éves előfizetés megújításakor járó jutalék"
|
||||||
|
)
|
||||||
|
commission_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True,
|
||||||
|
comment="L2: Maximális jutalék összeg (opcionális felső korlát) - fallback gen1/gen2-hez"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Phase 2: Szintenkénti plafonok és Gen2 megújítás ---
|
||||||
|
gen1_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True,
|
||||||
|
comment="L2: Gen1 maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||||
|
)
|
||||||
|
gen2_max_amount: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(18, 2), nullable=True,
|
||||||
|
comment="L2: Gen2 (upline) maximális jutalék összeg (NULL/0 = korlátlan, fallback: commission_max_amount)"
|
||||||
|
)
|
||||||
|
gen2_renewal_percent: Mapped[Optional[float]] = mapped_column(
|
||||||
|
Numeric(5, 2), nullable=True, default=0.00,
|
||||||
|
comment="L2: Gen2 megújítási jutalék százalék (pl. 1.00 = 1%) - Gen2-nek járó megújítási jutalék"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Time-Bound Campaign ---
|
||||||
|
is_campaign: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default=text("false"),
|
||||||
|
comment="TRUE = időkorlátos kampány, FALSE = állandó szabály"
|
||||||
|
)
|
||||||
|
start_date: Mapped[Optional[date]] = mapped_column(
|
||||||
|
Date, nullable=True, index=True,
|
||||||
|
comment="Kampány kezdő dátuma (NULL = azonnal érvényes)"
|
||||||
|
)
|
||||||
|
end_date: Mapped[Optional[date]] = mapped_column(
|
||||||
|
Date, nullable=True, index=True,
|
||||||
|
comment="Kampány záró dátuma (NULL = nincs lejárat)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Metadata ---
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(200), nullable=False,
|
||||||
|
comment="Emberi olvasható szabály név (pl. 'HU VIP Nyári Kampány')"
|
||||||
|
)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True,
|
||||||
|
comment="Részletes leírás a szabályról"
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=True, server_default=text("true"), index=True,
|
||||||
|
comment="TRUE = aktív és használható, FALSE = letiltva"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Audit Trail ---
|
||||||
|
created_by: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer, nullable=True,
|
||||||
|
comment="Létrehozó admin felhasználó ID"
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(),
|
||||||
|
onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Extensibility ---
|
||||||
|
metadata_json: Mapped[Optional[dict]] = mapped_column(
|
||||||
|
JSONB, nullable=True, server_default=text("'{}'::jsonb"),
|
||||||
|
comment="Bővíthető metaadatok (pl. campaign banner URL, notes)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<CommissionRule(id={self.id}, type='{self.rule_type}', "
|
||||||
|
f"tier='{self.tier}', region='{self.region_code}', "
|
||||||
|
f"active={self.is_active})>"
|
||||||
|
)
|
||||||
@@ -103,6 +103,8 @@ class Organization(Base):
|
|||||||
|
|
||||||
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
|
# --- 🏢 ALAPADATOK (MEGŐRIZVE) ---
|
||||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
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"))
|
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
|
||||||
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
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")
|
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
|
||||||
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
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)
|
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||||
|
|
||||||
@@ -137,20 +131,6 @@ class Organization(Base):
|
|||||||
contact_person_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó neve")
|
contact_person_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó neve")
|
||||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó e-mail címe")
|
contact_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")
|
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(
|
org_type: Mapped[OrgType] = mapped_column(
|
||||||
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
|
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
|
||||||
@@ -248,6 +228,26 @@ class Organization(Base):
|
|||||||
# Kapcsolat az örök személy rekordhoz
|
# Kapcsolat az örök személy rekordhoz
|
||||||
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
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 ──
|
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
|
||||||
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||||
"SubscriptionTier",
|
"SubscriptionTier",
|
||||||
@@ -335,18 +335,7 @@ class Branch(Base):
|
|||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
# Denormalizált adatok a gyors lekérdezéshez
|
# PostGIS location field for geographic queries (KEPT INTACT)
|
||||||
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
|
|
||||||
location: Mapped[Optional[Any]] = mapped_column(
|
location: Mapped[Optional[Any]] = mapped_column(
|
||||||
Geometry(geometry_type='POINT', srid=4326),
|
Geometry(geometry_type='POINT', srid=4326),
|
||||||
nullable=True
|
nullable=True
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ServiceProfile(Base):
|
|||||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
||||||
|
|
||||||
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||||
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True)
|
location: Mapped[Optional[Any]] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), nullable=True, index=True)
|
||||||
|
|
||||||
status: Mapped[ServiceStatus] = mapped_column(
|
status: Mapped[ServiceStatus] = mapped_column(
|
||||||
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
|
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
|
||||||
@@ -105,14 +105,15 @@ class ExpertiseTag(Base):
|
|||||||
__tablename__ = "expertise_tags"
|
__tablename__ = "expertise_tags"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('idx_expertise_path', 'path'),
|
Index('idx_expertise_path', 'path'),
|
||||||
|
Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||||
{"schema": "marketplace"}
|
{"schema": "marketplace"}
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||||
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
|
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||||
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||||
|
|
||||||
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
||||||
@@ -177,9 +178,7 @@ class ServiceStaging(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
|
||||||
full_address: Mapped[Optional[str]] = mapped_column(String)
|
|
||||||
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
|
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||||
|
|
||||||
@@ -193,6 +192,9 @@ class ServiceStaging(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
|
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())
|
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):
|
class DiscoveryParameter(Base):
|
||||||
""" Robot vezérlési paraméterek adminból. """
|
""" Robot vezérlési paraméterek adminból. """
|
||||||
__tablename__ = "discovery_parameters"
|
__tablename__ = "discovery_parameters"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
|
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.database import Base # MB 2.0 Standard: Központi bázis használata
|
from app.database import Base # MB 2.0 Standard: Központi bázis használata
|
||||||
|
|
||||||
@@ -75,10 +76,14 @@ class ServiceStaging(Base):
|
|||||||
# 9. ⚠️ EXTRA OSZLOP: audit_trail
|
# 9. ⚠️ EXTRA OSZLOP: audit_trail
|
||||||
audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB)
|
audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
# 10. ⚠️ P0 BUGFIX: address_id — FK to system.addresses (UUID)
|
||||||
|
# The column exists in the database but was missing from the model.
|
||||||
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True)
|
||||||
|
|
||||||
# Időbélyegek
|
# Időbélyegek
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
# 10. ⚠️ EXTRA OSZLOP: updated_at
|
# 11. ⚠️ EXTRA OSZLOP: updated_at
|
||||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
class DiscoveryParameter(Base):
|
class DiscoveryParameter(Base):
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ class AssetEvent(Base):
|
|||||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
|
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
|
||||||
user: Mapped[Optional["User"]] = relationship("User")
|
user: Mapped[Optional["User"]] = relationship("User")
|
||||||
organization: Mapped[Optional["Organization"]] = relationship("Organization")
|
organization: Mapped[Optional["Organization"]] = relationship("Organization")
|
||||||
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
|
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id], viewonly=True)
|
||||||
|
|
||||||
# Kapcsolat a hivatkozott költséghez
|
# Kapcsolat a hivatkozott költséghez
|
||||||
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
|
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
|
||||||
|
|||||||
@@ -165,10 +165,12 @@ class BodyTypeDictionary(Base):
|
|||||||
__tablename__ = "dict_body_types"
|
__tablename__ = "dict_body_types"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
|
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
|
||||||
|
Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||||
{"schema": "vehicle"}
|
{"schema": "vehicle"}
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
|
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||||
|
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||||
61
backend/app/schemas/address.py
Normal file
61
backend/app/schemas/address.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
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: Optional[uuid.UUID] = None # UUID as native UUID for JSON serialization; None for denormalized search results
|
||||||
|
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)
|
||||||
@@ -120,6 +120,7 @@ class AssetCostResponse(AssetCostBase):
|
|||||||
# Derived / enriched fields (not in DB model directly)
|
# Derived / enriched fields (not in DB model directly)
|
||||||
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
||||||
category_code: Optional[str] = None # Resolved from CostCategory relationship
|
category_code: Optional[str] = None # Resolved from CostCategory relationship
|
||||||
|
parent_category_id: Optional[int] = None # Resolved from CostCategory.parent_id — needed for two-level category dropdown on frontend
|
||||||
description: Optional[str] = None # Extracted from data['description']
|
description: Optional[str] = None # Extracted from data['description']
|
||||||
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
|
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
|
||||||
|
|
||||||
@@ -129,4 +130,7 @@ class AssetCostResponse(AssetCostBase):
|
|||||||
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
|
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
|
||||||
service_provider_id: Optional[int] = None
|
service_provider_id: Optional[int] = None
|
||||||
|
|
||||||
|
# P0 BUGFIX: Enriched provider name (resolved from ServiceProvider.name)
|
||||||
|
service_provider_name: Optional[str] = None # Resolved from ServiceProvider.name via service_provider_id
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
176
backend/app/schemas/commission.py
Normal file
176
backend/app/schemas/commission.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/schemas/commission.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for CommissionRule CRUD operations.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- CommissionRuleCreate: All fields required for creating a new rule.
|
||||||
|
Uses Optional for type-specific fields (L1 vs L2).
|
||||||
|
- CommissionRuleUpdate: All fields optional for partial updates.
|
||||||
|
- CommissionRuleResponse: Full read model with from_attributes=True
|
||||||
|
for ORM compatibility.
|
||||||
|
- CommissionRuleListResponse: Paginated wrapper for admin listing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRuleTypeEnum(str, Enum):
|
||||||
|
"""Jutalék típus: L1 = egyéni jutalom, L2 = céges jutalék."""
|
||||||
|
L1_REWARD = "L1_REWARD"
|
||||||
|
L2_COMMISSION = "L2_COMMISSION"
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionTierEnum(str, Enum):
|
||||||
|
"""Jutalék szintek."""
|
||||||
|
STANDARD = "STANDARD"
|
||||||
|
VIP = "VIP"
|
||||||
|
PLATINUM = "PLATINUM"
|
||||||
|
ENTERPRISE = "ENTERPRISE"
|
||||||
|
CONTRACTED = "CONTRACTED"
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRuleCreate(BaseModel):
|
||||||
|
"""Schema új jutalék szabály létrehozásához."""
|
||||||
|
rule_type: CommissionRuleTypeEnum
|
||||||
|
tier: CommissionTierEnum = CommissionTierEnum.STANDARD
|
||||||
|
region_code: str = "GLOBAL"
|
||||||
|
xp_reward: Optional[int] = None
|
||||||
|
credit_reward: Optional[int] = None
|
||||||
|
commission_percent: Optional[float] = None
|
||||||
|
upline_commission_percent: Optional[float] = None
|
||||||
|
renewal_commission_percent: Optional[float] = None
|
||||||
|
commission_max_amount: Optional[float] = None
|
||||||
|
# Phase 2: Szintenkénti plafonok (NULL/0 = korlátlan, fallback: commission_max_amount)
|
||||||
|
gen1_max_amount: Optional[float] = None
|
||||||
|
gen2_max_amount: Optional[float] = None
|
||||||
|
gen2_renewal_percent: Optional[float] = None
|
||||||
|
is_campaign: bool = False
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||||
|
force_override: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRuleUpdate(BaseModel):
|
||||||
|
"""Schema meglévő jutalék szabály módosításához (minden mező opcionális)."""
|
||||||
|
rule_type: Optional[CommissionRuleTypeEnum] = None
|
||||||
|
tier: Optional[CommissionTierEnum] = None
|
||||||
|
region_code: Optional[str] = None
|
||||||
|
xp_reward: Optional[int] = None
|
||||||
|
credit_reward: Optional[int] = None
|
||||||
|
commission_percent: Optional[float] = None
|
||||||
|
upline_commission_percent: Optional[float] = None
|
||||||
|
renewal_commission_percent: Optional[float] = None
|
||||||
|
commission_max_amount: Optional[float] = None
|
||||||
|
# Phase 2: Szintenkénti plafonok
|
||||||
|
gen1_max_amount: Optional[float] = None
|
||||||
|
gen2_max_amount: Optional[float] = None
|
||||||
|
gen2_renewal_percent: Optional[float] = None
|
||||||
|
is_campaign: Optional[bool] = None
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
# Phase 3: Conflict override — admin acknowledges overlapping rule
|
||||||
|
force_override: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRuleResponse(BaseModel):
|
||||||
|
"""Schema jutalék szabály adatainak lekéréséhez."""
|
||||||
|
id: int
|
||||||
|
rule_type: CommissionRuleTypeEnum
|
||||||
|
tier: CommissionTierEnum
|
||||||
|
region_code: str
|
||||||
|
xp_reward: Optional[int] = None
|
||||||
|
credit_reward: Optional[int] = None
|
||||||
|
commission_percent: Optional[float] = None
|
||||||
|
upline_commission_percent: Optional[float] = None
|
||||||
|
renewal_commission_percent: Optional[float] = None
|
||||||
|
commission_max_amount: Optional[float] = None
|
||||||
|
# Phase 2: Szintenkénti plafonok
|
||||||
|
gen1_max_amount: Optional[float] = None
|
||||||
|
gen2_max_amount: Optional[float] = None
|
||||||
|
gen2_renewal_percent: Optional[float] = None
|
||||||
|
is_campaign: bool
|
||||||
|
start_date: Optional[date] = None
|
||||||
|
end_date: Optional[date] = None
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_active: bool
|
||||||
|
created_by: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionRuleListResponse(BaseModel):
|
||||||
|
"""Paginated list response for admin commission rules."""
|
||||||
|
items: List[CommissionRuleResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionDistributionRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
Request schema for the 2-level MLM commission distribution engine.
|
||||||
|
|
||||||
|
When a referred company makes a purchase, this request triggers the
|
||||||
|
distribution logic:
|
||||||
|
- Gen1 (direct referrer) receives commission_percent
|
||||||
|
- Gen2 (upline / Gen1's referrer) receives upline_commission_percent
|
||||||
|
"""
|
||||||
|
buyer_user_id: int = Field(..., description="The user ID who made the purchase")
|
||||||
|
transaction_amount: float = Field(..., gt=0, description="The purchase/subscription amount")
|
||||||
|
transaction_date: date = Field(..., description="Date of the transaction")
|
||||||
|
region_code: str = Field("GLOBAL", max_length=10, description="Region code for rule matching")
|
||||||
|
# Phase 2: Renewal flag - TRUE = renewal transaction, FALSE = first-time purchase
|
||||||
|
is_renewal: bool = Field(False, description="TRUE = renewal transaction, FALSE = first-time purchase")
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionDistributionItem(BaseModel):
|
||||||
|
"""Single commission payout item for one level."""
|
||||||
|
level: int = Field(..., description="1 = Gen1 (direct referrer), 2 = Gen2 (upline)")
|
||||||
|
user_id: int = Field(..., description="The user ID receiving the commission")
|
||||||
|
commission_percent: float = Field(..., description="The applied commission percentage")
|
||||||
|
commission_amount: float = Field(..., description="The calculated commission amount")
|
||||||
|
rule_id: int = Field(..., description="The CommissionRule ID that was applied")
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionDistributionResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for the 2-level MLM commission distribution.
|
||||||
|
|
||||||
|
Contains the payout breakdown for both Gen1 and Gen2 levels.
|
||||||
|
"""
|
||||||
|
transaction_amount: float
|
||||||
|
items: List[CommissionDistributionItem]
|
||||||
|
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictingRuleInfo(BaseModel):
|
||||||
|
"""Information about a conflicting rule returned in a 409 response."""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
rule_type: CommissionRuleTypeEnum
|
||||||
|
tier: CommissionTierEnum
|
||||||
|
region_code: str
|
||||||
|
is_campaign: bool
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema returned when a conflicting active rule is detected
|
||||||
|
and force_override was not set.
|
||||||
|
"""
|
||||||
|
detail: str = "A conflicting active rule already exists for this combination."
|
||||||
|
conflicting_rule: ConflictingRuleInfo
|
||||||
@@ -34,6 +34,34 @@ class IssuerResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialLedgerResponse(BaseModel):
|
||||||
|
"""Response schema for FinancialLedger admin listing."""
|
||||||
|
id: int
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
person_id: Optional[int] = None
|
||||||
|
amount: float
|
||||||
|
currency: Optional[str] = None
|
||||||
|
transaction_type: Optional[str] = None
|
||||||
|
entry_type: Optional[str] = None
|
||||||
|
wallet_type: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
details: Optional[Dict[str, Any]] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
# Joined user info
|
||||||
|
user_email: Optional[str] = None
|
||||||
|
user_name: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialLedgerListResponse(BaseModel):
|
||||||
|
"""Paginated response for FinancialLedger admin listing."""
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
items: List[FinancialLedgerResponse]
|
||||||
|
|
||||||
|
|
||||||
class IssuerUpdate(BaseModel):
|
class IssuerUpdate(BaseModel):
|
||||||
"""Update schema for Issuer entities (PATCH)."""
|
"""Update schema for Issuer entities (PATCH)."""
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|||||||
70
backend/app/schemas/financial_manager.py
Normal file
70
backend/app/schemas/financial_manager.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/schemas/financial_manager.py
|
||||||
|
"""
|
||||||
|
Pydantic schemas for the FinancialManager API endpoints.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- PurchaseRequest: Input schema for the package purchase endpoint.
|
||||||
|
- PurchaseResponse: Output schema with full purchase receipt details.
|
||||||
|
- CommissionInfo: Nested schema for commission distribution results.
|
||||||
|
- Separated from commission.py to avoid circular imports and maintain clean boundaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List, Any, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionItemInfo(BaseModel):
|
||||||
|
"""Single commission payout item in the response."""
|
||||||
|
level: int = Field(..., description="1 = Gen1 (direct referrer), 2 = Gen2 (upline)")
|
||||||
|
user_id: int = Field(..., description="The user ID receiving the commission")
|
||||||
|
commission_percent: float = Field(..., description="The applied commission percentage")
|
||||||
|
commission_amount: float = Field(..., description="The calculated commission amount")
|
||||||
|
|
||||||
|
|
||||||
|
class CommissionInfo(BaseModel):
|
||||||
|
"""Commission distribution summary in the purchase response."""
|
||||||
|
total_commission: float = Field(..., description="Sum of all commission payouts")
|
||||||
|
items: List[CommissionItemInfo] = Field(default_factory=list, description="Individual commission items")
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequest(BaseModel):
|
||||||
|
"""
|
||||||
|
Request schema for purchasing a subscription package.
|
||||||
|
|
||||||
|
The FinancialManager orchestrates:
|
||||||
|
1. Payment processing (via configured gateway)
|
||||||
|
2. Subscription activation (User or Org level)
|
||||||
|
3. MLM commission distribution (async via BackgroundTask)
|
||||||
|
"""
|
||||||
|
tier_id: int = Field(..., gt=0, description="The SubscriptionTier ID to purchase")
|
||||||
|
org_id: Optional[int] = Field(None, description="Organization ID for org-level subscription (None = user-level)")
|
||||||
|
region_code: str = Field("GLOBAL", max_length=10, description="Region code for pricing (ISO 3166-1 alpha-2)")
|
||||||
|
currency: str = Field("EUR", max_length=3, description="Currency code (ISO 4217)")
|
||||||
|
duration_days: Optional[int] = Field(None, gt=0, description="Override duration in days (default: from tier.rules)")
|
||||||
|
metadata: Optional[Dict[str, Any]] = Field(None, description="Optional metadata for the PaymentIntent")
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseResponse(BaseModel):
|
||||||
|
"""
|
||||||
|
Response schema for a completed package purchase.
|
||||||
|
|
||||||
|
Contains the full receipt: payment details, subscription info,
|
||||||
|
and commission distribution results.
|
||||||
|
"""
|
||||||
|
success: bool = Field(..., description="Whether the purchase was successful")
|
||||||
|
payment_intent_id: Optional[int] = Field(None, description="The PaymentIntent ID")
|
||||||
|
transaction_id: Optional[str] = Field(None, description="The transaction UUID")
|
||||||
|
subscription_id: Optional[int] = Field(None, description="The activated subscription ID")
|
||||||
|
tier_name: Optional[str] = Field(None, description="The purchased tier name")
|
||||||
|
valid_from: Optional[datetime] = Field(None, description="Subscription validity start")
|
||||||
|
valid_until: Optional[datetime] = Field(None, description="Subscription validity end")
|
||||||
|
amount_paid: float = Field(0.0, description="The amount paid")
|
||||||
|
currency: str = Field("EUR", description="The payment currency")
|
||||||
|
gateway: str = Field("mock", description="The payment gateway used")
|
||||||
|
gateway_intent_id: Optional[str] = Field(None, description="The gateway's intent ID")
|
||||||
|
is_org_subscription: bool = Field(False, description="Whether this is an org-level subscription")
|
||||||
|
commission: Optional[CommissionInfo] = Field(None, description="Commission distribution results")
|
||||||
|
error: Optional[str] = Field(None, description="Error message if success=False")
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
@@ -2,6 +2,8 @@ from pydantic import BaseModel, Field, ConfigDict, model_validator
|
|||||||
from typing import Optional, List, Any
|
from typing import Optional, List, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.schemas.address import AddressIn, AddressOut
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
|
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
|
||||||
@@ -101,16 +103,8 @@ class CorpOnboardIn(BaseModel):
|
|||||||
language: str = "hu"
|
language: str = "hu"
|
||||||
default_currency: str = "HUF"
|
default_currency: str = "HUF"
|
||||||
|
|
||||||
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
|
# --- P0 REFACTORED: Unified address via AddressIn ---
|
||||||
address_zip: str
|
address: AddressIn = Field(..., description="Székhely címe (AddressIn struktúrában)")
|
||||||
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
|
|
||||||
|
|
||||||
contacts: List[ContactCreate] = []
|
contacts: List[ContactCreate] = []
|
||||||
|
|
||||||
@@ -132,13 +126,8 @@ class OrganizationUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
tax_number: Optional[str] = None
|
tax_number: Optional[str] = None
|
||||||
# ── Cím adatok (Address fields) ──
|
# ── P0 REFACTORED: Unified address via AddressIn ──
|
||||||
address_zip: Optional[str] = None
|
address: Optional[AddressIn] = 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
|
|
||||||
# ── Crowdsourced search fields ──
|
# ── Crowdsourced search fields ──
|
||||||
aliases: Optional[List[str]] = None
|
aliases: Optional[List[str]] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
@@ -170,12 +159,7 @@ class OrganizationResponse(BaseModel):
|
|||||||
external_integration_config: Optional[Any] = None
|
external_integration_config: Optional[Any] = None
|
||||||
aliases: List[str] = Field(default_factory=list)
|
aliases: List[str] = Field(default_factory=list)
|
||||||
tags: List[str] = Field(default_factory=list)
|
tags: List[str] = Field(default_factory=list)
|
||||||
# ── Cím adatok (Address fields) ──
|
# ── P0 REFACTORED: Unified address via AddressOut ──
|
||||||
address_zip: Optional[str] = None
|
address: Optional[AddressOut] = 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
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
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
|
- street -> address_street_name, address_street_type, address_house_number
|
||||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
- 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):
|
4-Level Category Architecture (2026-06-17):
|
||||||
===========================================
|
===========================================
|
||||||
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
|
- 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 pydantic import BaseModel, Field, ConfigDict
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
from app.schemas.address import AddressIn, AddressOut
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -28,8 +35,8 @@ class CategoryTreeNode(BaseModel):
|
|||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
name_hu: Optional[str] = None
|
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||||
name_en: Optional[str] = None
|
description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
|
||||||
level: int = 0
|
level: int = 0
|
||||||
path: Optional[str] = None
|
path: Optional[str] = None
|
||||||
is_official: bool = True
|
is_official: bool = True
|
||||||
@@ -46,8 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
|
|||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
name_hu: Optional[str] = None
|
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||||
name_en: Optional[str] = None
|
|
||||||
level: int = 0
|
level: int = 0
|
||||||
path: Optional[str] = None
|
path: Optional[str] = None
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
@@ -66,8 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
|
|||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
key: str
|
key: str
|
||||||
name_hu: Optional[str] = None
|
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||||
name_en: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
level: int = 0
|
level: int = 0
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
@@ -84,8 +89,7 @@ class CategoryInfo(BaseModel):
|
|||||||
szolgáltatásokat a kártyákon.
|
szolgáltatásokat a kártyákon.
|
||||||
"""
|
"""
|
||||||
id: int
|
id: int
|
||||||
name_hu: Optional[str] = None
|
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||||
name_en: Optional[str] = None
|
|
||||||
level: int = 0
|
level: int = 0
|
||||||
key: str
|
key: str
|
||||||
|
|
||||||
@@ -97,6 +101,15 @@ class ProviderSearchResult(BaseModel):
|
|||||||
- address_street_name, address_street_type, address_house_number
|
- address_street_name, address_street_type, address_house_number
|
||||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
- 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.
|
||||||
|
|
||||||
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
- address_detail: Optional[AddressOut] — first-class citizen.
|
||||||
|
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||||
|
|
||||||
FEATURE (2026-06-17): categories mező.
|
FEATURE (2026-06-17): categories mező.
|
||||||
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
||||||
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
||||||
@@ -106,12 +119,12 @@ class ProviderSearchResult(BaseModel):
|
|||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
specialization: List[str] = Field(default_factory=list)
|
specialization: List[str] = Field(default_factory=list)
|
||||||
categories: List[CategoryInfo] = Field(default_factory=list)
|
categories: List[CategoryInfo] = Field(default_factory=list)
|
||||||
city: Optional[str] = None
|
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
||||||
address: Optional[str] = None
|
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||||
address_zip: Optional[str] = None
|
address_zip: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.zip instead")
|
||||||
address_street_name: Optional[str] = None
|
address_street_name: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||||
address_street_type: Optional[str] = None
|
address_street_type: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||||
address_house_number: Optional[str] = None
|
address_house_number: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||||
plus_code: Optional[str] = None
|
plus_code: Optional[str] = None
|
||||||
contact_phone: Optional[str] = None
|
contact_phone: Optional[str] = None
|
||||||
contact_email: Optional[str] = None
|
contact_email: Optional[str] = None
|
||||||
@@ -122,6 +135,8 @@ class ProviderSearchResult(BaseModel):
|
|||||||
rating: Optional[float] = None
|
rating: Optional[float] = None
|
||||||
supported_vehicle_classes: Optional[List[str]] = None
|
supported_vehicle_classes: Optional[List[str]] = None
|
||||||
specializations: Optional[dict] = None
|
specializations: Optional[dict] = None
|
||||||
|
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||||
|
address_detail: Optional[AddressOut] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -140,6 +155,15 @@ class ProviderQuickAddIn(BaseModel):
|
|||||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
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
|
- 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 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||||
|
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||||
|
|
||||||
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
|
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
|
- 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.
|
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
|
||||||
@@ -151,11 +175,11 @@ class ProviderQuickAddIn(BaseModel):
|
|||||||
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
|
category_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
|
||||||
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
|
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
|
||||||
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
|
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
|
||||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
|
||||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
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")
|
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||||
@@ -167,6 +191,8 @@ class ProviderQuickAddIn(BaseModel):
|
|||||||
specializations: Optional[dict] = Field(
|
specializations: Optional[dict] = Field(
|
||||||
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
||||||
)
|
)
|
||||||
|
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||||
|
address_detail: Optional[AddressIn] = None
|
||||||
|
|
||||||
|
|
||||||
class ProviderQuickAddResponse(BaseModel):
|
class ProviderQuickAddResponse(BaseModel):
|
||||||
@@ -185,16 +211,25 @@ class ProviderUpdateIn(BaseModel):
|
|||||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
|
||||||
|
- address_detail: Optional[AddressIn] — first-class citizen.
|
||||||
|
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
|
||||||
|
|
||||||
4-Level Category (2026-06-17):
|
4-Level Category (2026-06-17):
|
||||||
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
|
- 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
|
- new_tags: A user által gépelt, új (még nem létező) címkék listája
|
||||||
"""
|
"""
|
||||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
|
||||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
|
||||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||||
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
|
||||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
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")
|
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||||
@@ -212,6 +247,8 @@ class ProviderUpdateIn(BaseModel):
|
|||||||
specializations: Optional[dict] = Field(
|
specializations: Optional[dict] = Field(
|
||||||
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
None, description="Specializációk (pl. {'brands': ['Volvo'], 'propulsion': ['EV']})"
|
||||||
)
|
)
|
||||||
|
# P0 PHASE 2: Egységes cím objektum (first-class citizen)
|
||||||
|
address_detail: Optional[AddressIn] = None
|
||||||
|
|
||||||
|
|
||||||
class ProviderUpdateResponse(BaseModel):
|
class ProviderUpdateResponse(BaseModel):
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
|
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from typing import Dict, Any, Optional
|
from typing import Any, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class SystemParameterBase(BaseModel):
|
class SystemParameterBase(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
value: Dict[str, Any] # JSONB mező
|
value: Any # JSONB mező — bármilyen JSON-szeriális érték lehet (int, str, dict, list)
|
||||||
scope_level: str = 'global'
|
scope_level: str = 'global'
|
||||||
scope_id: Optional[str] = None
|
scope_id: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
|
|||||||
|
|
||||||
class SystemParameterUpdate(BaseModel):
|
class SystemParameterUpdate(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
value: Optional[Dict[str, Any]] = None
|
value: Optional[Any] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,7 @@ from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
|||||||
from typing import Optional, Any, Dict
|
from typing import Optional, Any, Dict
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from app.schemas.address import AddressIn, AddressOut
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class PersonResponse(BaseModel):
|
class PersonResponse(BaseModel):
|
||||||
@@ -38,7 +21,7 @@ class PersonResponse(BaseModel):
|
|||||||
identity_docs: Optional[Any] = None
|
identity_docs: Optional[Any] = None
|
||||||
ice_contact: Optional[Any] = None
|
ice_contact: Optional[Any] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
address: Optional[AddressResponse] = None
|
address: Optional[AddressOut] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -90,16 +73,8 @@ class PersonUpdate(BaseModel):
|
|||||||
# Okmány adatok (identity_docs)
|
# Okmány adatok (identity_docs)
|
||||||
identity_docs: Optional[Any] = None
|
identity_docs: Optional[Any] = None
|
||||||
|
|
||||||
# Cím adatok
|
# Cím adatok — egységes AddressIn séma
|
||||||
address_zip: Optional[str] = None
|
address: Optional[AddressIn] = None
|
||||||
address_city: Optional[str] = None
|
|
||||||
address_street_name: Optional[str] = None
|
|
||||||
address_street_type: Optional[str] = None
|
|
||||||
address_house_number: Optional[str] = None
|
|
||||||
address_stairwell: Optional[str] = None
|
|
||||||
address_floor: Optional[str] = None
|
|
||||||
address_door: Optional[str] = None
|
|
||||||
address_hrsz: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
|||||||
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
131
backend/app/scripts/migrate_i18n_jsonb.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
i18n JSONB Migration Script (Phase 1)
|
||||||
|
Migrates old name_hu/name_en columns to name_i18n JSONB format.
|
||||||
|
Run INSIDE the sf_api container:
|
||||||
|
docker exec sf_api python3 /app/backend/app/scripts/migrate_i18n_jsonb.py
|
||||||
|
|
||||||
|
IMPORTANT: Run this AFTER the new JSONB columns have been added by sync_engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
TABLES = [
|
||||||
|
{
|
||||||
|
"schema": "marketplace",
|
||||||
|
"table": "expertise_tags",
|
||||||
|
"name_cols": ["name_hu", "name_en"],
|
||||||
|
"name_translations_col": "name_translations",
|
||||||
|
"desc_cols": ["description"],
|
||||||
|
"target_name_col": "name_i18n",
|
||||||
|
"target_desc_col": "description_i18n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": "vehicle",
|
||||||
|
"table": "dict_body_types",
|
||||||
|
"name_cols": ["name_hu"],
|
||||||
|
"name_translations_col": None,
|
||||||
|
"desc_cols": [],
|
||||||
|
"target_name_col": "name_i18n",
|
||||||
|
"target_desc_col": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": "system",
|
||||||
|
"table": "service_catalog",
|
||||||
|
"name_cols": ["name"],
|
||||||
|
"name_translations_col": None,
|
||||||
|
"desc_cols": ["description"],
|
||||||
|
"target_name_col": "name_i18n",
|
||||||
|
"target_desc_col": "description_i18n",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_table(config: dict):
|
||||||
|
schema = config["schema"]
|
||||||
|
table = config["table"]
|
||||||
|
target_name = config["target_name_col"]
|
||||||
|
target_desc = config["target_desc_col"]
|
||||||
|
name_cols = config["name_cols"]
|
||||||
|
desc_cols = config["desc_cols"]
|
||||||
|
trans_col = config["name_translations_col"]
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" Migrating: {schema}.{table}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
select_cols = ["id"] + name_cols + desc_cols
|
||||||
|
if trans_col:
|
||||||
|
select_cols.append(trans_col)
|
||||||
|
|
||||||
|
stmt = text(
|
||||||
|
f"SELECT {', '.join(select_cols)} FROM {schema}.{table} "
|
||||||
|
f"WHERE {target_name} IS NULL OR {target_name} = '{{\"hu\": \"\"}}'::jsonb"
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print(f" ✅ No rows need migration in {schema}.{table}")
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for row in rows:
|
||||||
|
row_dict = row._mapping
|
||||||
|
|
||||||
|
# Build name_i18n
|
||||||
|
name_i18n = {}
|
||||||
|
if len(name_cols) >= 1 and row_dict.get(name_cols[0]):
|
||||||
|
name_i18n["hu"] = row_dict[name_cols[0]]
|
||||||
|
if len(name_cols) >= 2 and row_dict.get(name_cols[1]):
|
||||||
|
name_i18n["en"] = row_dict[name_cols[1]]
|
||||||
|
|
||||||
|
# Merge with existing name_translations
|
||||||
|
if trans_col and row_dict.get(trans_col):
|
||||||
|
if isinstance(row_dict[trans_col], dict):
|
||||||
|
name_i18n.update(row_dict[trans_col])
|
||||||
|
|
||||||
|
# Build description_i18n
|
||||||
|
desc_i18n = None
|
||||||
|
if desc_cols and row_dict.get(desc_cols[0]):
|
||||||
|
desc_i18n = {"hu": row_dict[desc_cols[0]]}
|
||||||
|
|
||||||
|
# Update
|
||||||
|
update_cols = [f"{target_name} = :name_val"]
|
||||||
|
params = {"name_val": json.dumps(name_i18n), "row_id": row_dict["id"]}
|
||||||
|
|
||||||
|
if target_desc and desc_i18n:
|
||||||
|
update_cols.append(f"{target_desc} = :desc_val")
|
||||||
|
params["desc_val"] = json.dumps(desc_i18n)
|
||||||
|
|
||||||
|
update_sql = f"UPDATE {schema}.{table} SET {', '.join(update_cols)} WHERE id = :row_id"
|
||||||
|
await db.execute(text(update_sql), params)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print(f" ✅ Migrated {updated} rows in {schema}.{table}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print(" i18n JSONB Migration Script (Phase 1)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
for table_config in TABLES:
|
||||||
|
await migrate_table(table_config)
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" ✅ Migration complete!")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
259
backend/app/scripts/migrate_provider_addresses.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Address Migration
|
||||||
|
|
||||||
|
Migrates flat address columns (city, address_zip, address_street_name, etc.)
|
||||||
|
from marketplace.service_providers into the centralized system.addresses table.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
1. Fetch all ServiceProvider records where address_id IS NULL
|
||||||
|
but at least one flat address field is non-empty.
|
||||||
|
2. For each record, construct an AddressIn schema from the flat data.
|
||||||
|
3. Call AddressManager.create_or_update(db, None, address_data) to create
|
||||||
|
a new central address record.
|
||||||
|
4. Assign the returned Address UUID to provider.address_id.
|
||||||
|
5. Commit in batches with proper error handling and logging.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker exec sf_api python3 /app/backend/app/scripts/migrate_provider_addresses.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Ensure the backend package is importable
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
# asyncpg needs plain "postgresql://" scheme
|
||||||
|
_raw_url = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
|
||||||
|
)
|
||||||
|
DATABASE_URL = _raw_url.replace("+asyncpg", "")
|
||||||
|
|
||||||
|
# Batch size for commits
|
||||||
|
BATCH_SIZE = 100
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("migrate_provider_addresses")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_full_address_text(row: dict) -> Optional[str]:
|
||||||
|
"""Build a human-readable full address from flat fields."""
|
||||||
|
parts = []
|
||||||
|
if row.get("address_zip"):
|
||||||
|
parts.append(row["address_zip"])
|
||||||
|
if row.get("city"):
|
||||||
|
parts.append(row["city"])
|
||||||
|
street_parts = []
|
||||||
|
if row.get("address_street_name"):
|
||||||
|
street_parts.append(row["address_street_name"])
|
||||||
|
if row.get("address_street_type"):
|
||||||
|
street_parts.append(row["address_street_type"])
|
||||||
|
if row.get("address_house_number"):
|
||||||
|
street_parts.append(row["address_house_number"])
|
||||||
|
if street_parts:
|
||||||
|
parts.append(" ".join(street_parts))
|
||||||
|
return ", ".join(parts) if parts else None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_address_data(row: dict) -> bool:
|
||||||
|
"""Check if the provider has any flat address data worth migrating."""
|
||||||
|
return bool(
|
||||||
|
row.get("city")
|
||||||
|
or row.get("address_zip")
|
||||||
|
or row.get("address_street_name")
|
||||||
|
or row.get("address_house_number")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info("🔧 P0 ADDRESS UNIFICATION - Phase 1: ServiceProvider Migration")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info(f"Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||||
|
logger.info(f"Database URL: {DATABASE_URL.replace('AppSafePass_2026', '****')}")
|
||||||
|
logger.info(f"Batch size: {BATCH_SIZE}")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(DATABASE_URL)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Step 1: Count providers needing migration ──
|
||||||
|
logger.info("📋 Step 1: Counting providers with flat address data but no address_id")
|
||||||
|
count_row = await conn.fetchrow("""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM marketplace.service_providers
|
||||||
|
WHERE address_id IS NULL
|
||||||
|
AND (
|
||||||
|
city IS NOT NULL AND city != ''
|
||||||
|
OR address_zip IS NOT NULL AND address_zip != ''
|
||||||
|
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||||
|
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
total = count_row["cnt"]
|
||||||
|
logger.info(f" → Found {total} provider(s) to migrate")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
logger.info("✅ No providers need migration. Exiting.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Step 2: Fetch all providers needing migration ──
|
||||||
|
logger.info("📋 Step 2: Fetching provider records")
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT id, name, city, address_zip,
|
||||||
|
address_street_name, address_street_type, address_house_number,
|
||||||
|
plus_code
|
||||||
|
FROM marketplace.service_providers
|
||||||
|
WHERE address_id IS NULL
|
||||||
|
AND (
|
||||||
|
city IS NOT NULL AND city != ''
|
||||||
|
OR address_zip IS NOT NULL AND address_zip != ''
|
||||||
|
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||||
|
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||||
|
)
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
logger.info(f" → Fetched {len(rows)} record(s)")
|
||||||
|
logger.info("")
|
||||||
|
|
||||||
|
# ── Step 3: Migrate each provider ──
|
||||||
|
logger.info("📋 Step 3: Migrating addresses to system.addresses")
|
||||||
|
migrated_count = 0
|
||||||
|
error_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for i, row in enumerate(rows, start=1):
|
||||||
|
provider_id = row["id"]
|
||||||
|
provider_name = row["name"]
|
||||||
|
|
||||||
|
if not _has_address_data(row):
|
||||||
|
logger.info(f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
|
||||||
|
f"no address data → SKIP")
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build the full_address_text
|
||||||
|
full_text = _build_full_address_text(row)
|
||||||
|
|
||||||
|
# Insert into system.addresses
|
||||||
|
new_address_id = uuid.uuid4()
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO system.addresses
|
||||||
|
(id, street_name, street_type, house_number,
|
||||||
|
full_address_text, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""",
|
||||||
|
new_address_id,
|
||||||
|
row.get("address_street_name"),
|
||||||
|
row.get("address_street_type"),
|
||||||
|
row.get("address_house_number"),
|
||||||
|
full_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve postal code if zip+city are present
|
||||||
|
if row.get("address_zip") and row.get("city"):
|
||||||
|
# Find or create GeoPostalCode
|
||||||
|
postal = await conn.fetchrow("""
|
||||||
|
SELECT id FROM system.geo_postal_codes
|
||||||
|
WHERE zip_code = $1
|
||||||
|
""", row["address_zip"])
|
||||||
|
|
||||||
|
if postal:
|
||||||
|
postal_code_id = postal["id"]
|
||||||
|
else:
|
||||||
|
postal_code_id = await conn.fetchval("""
|
||||||
|
INSERT INTO system.geo_postal_codes
|
||||||
|
(zip_code, city, country_code)
|
||||||
|
VALUES ($1, $2, 'HU')
|
||||||
|
RETURNING id
|
||||||
|
""", row["address_zip"], row["city"])
|
||||||
|
|
||||||
|
# Update the address with postal_code_id
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE system.addresses
|
||||||
|
SET postal_code_id = $1
|
||||||
|
WHERE id = $2
|
||||||
|
""", postal_code_id, new_address_id)
|
||||||
|
|
||||||
|
# Update the provider with the new address_id
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE marketplace.service_providers
|
||||||
|
SET address_id = $1
|
||||||
|
WHERE id = $2
|
||||||
|
""", new_address_id, provider_id)
|
||||||
|
|
||||||
|
migrated_count += 1
|
||||||
|
|
||||||
|
if i % BATCH_SIZE == 0 or i == total:
|
||||||
|
logger.info(
|
||||||
|
f" [{i}/{total}] Progress: {migrated_count} migrated, "
|
||||||
|
f"{error_count} errors, {skipped_count} skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_count += 1
|
||||||
|
logger.error(
|
||||||
|
f" [{i}/{total}] Provider #{provider_id} '{provider_name}': "
|
||||||
|
f"ERROR - {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Summary ──
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info("📊 MIGRATION SUMMARY")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info(f" Total providers processed: {total}")
|
||||||
|
logger.info(f" Successfully migrated: {migrated_count}")
|
||||||
|
logger.info(f" Errors: {error_count}")
|
||||||
|
logger.info(f" Skipped (no address data): {skipped_count}")
|
||||||
|
|
||||||
|
# ── Step 4: Verify ──
|
||||||
|
logger.info("")
|
||||||
|
logger.info("📋 Step 4: Verification")
|
||||||
|
remaining = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM marketplace.service_providers
|
||||||
|
WHERE address_id IS NULL
|
||||||
|
AND (
|
||||||
|
city IS NOT NULL AND city != ''
|
||||||
|
OR address_zip IS NOT NULL AND address_zip != ''
|
||||||
|
OR address_street_name IS NOT NULL AND address_street_name != ''
|
||||||
|
OR address_house_number IS NOT NULL AND address_house_number != ''
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
total_providers = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM marketplace.service_providers"
|
||||||
|
)
|
||||||
|
with_address = await conn.fetchval(
|
||||||
|
"SELECT COUNT(*) FROM marketplace.service_providers WHERE address_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
logger.info(f" Total providers: {total_providers}")
|
||||||
|
logger.info(f" Providers WITH address_id: {with_address}")
|
||||||
|
logger.info(f" Providers still NULL: {remaining}")
|
||||||
|
logger.info("")
|
||||||
|
logger.info("✅ Phase 1 migration complete!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 FATAL ERROR: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
86
backend/app/scripts/seed_dismantler_category.py
Normal file
86
backend/app/scripts/seed_dismantler_category.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Seed script: Beszúrja az "Autóbontó / Használt alkatrész" kategóriát
|
||||||
|
a marketplace.expertise_tags táblába, ha még nem létezik.
|
||||||
|
|
||||||
|
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||||
|
|
||||||
|
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_dismantler_category.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models.marketplace.service import ExpertiseTag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DISMANTLER_CATEGORY = {
|
||||||
|
"key": "autobonto_hasznalt_alkatresz",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Autóbontó / Használt alkatrész", "en": "Car Dismantler / Used Parts"},
|
||||||
|
"name_hu": "Autóbontó / Használt alkatrész",
|
||||||
|
"name_en": "Car Dismantler / Used Parts",
|
||||||
|
"category": "parts",
|
||||||
|
"search_keywords": [
|
||||||
|
"bontó", "autóbontó", "bontás", "használt alkatrész",
|
||||||
|
"dismantler", "used parts", "scrap yard", "break yard",
|
||||||
|
"alkatrész bontás", "roncs", "roncsautó",
|
||||||
|
],
|
||||||
|
"description": "Használt gépjármű alkatrészek értékesítése, autóbontás, roncsautó felvásárlás",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_dismantler_category():
|
||||||
|
"""Beszúrja az Autóbontó kategóriát, ha még nem létezik."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
# Ellenőrizzük, hogy létezik-e már
|
||||||
|
stmt = select(ExpertiseTag).where(
|
||||||
|
ExpertiseTag.key == DISMANTLER_CATEGORY["key"]
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
print(
|
||||||
|
f"✅ Az 'Autóbontó / Használt alkatrész' kategória "
|
||||||
|
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Beszúrás
|
||||||
|
tag = ExpertiseTag(
|
||||||
|
key=DISMANTLER_CATEGORY["key"],
|
||||||
|
name_i18n=DISMANTLER_CATEGORY.get("name_i18n", {"hu": DISMANTLER_CATEGORY["name_hu"], "en": DISMANTLER_CATEGORY["name_en"]}),
|
||||||
|
category=DISMANTLER_CATEGORY["category"],
|
||||||
|
search_keywords=DISMANTLER_CATEGORY["search_keywords"],
|
||||||
|
is_official=True,
|
||||||
|
discovery_points=10,
|
||||||
|
usage_count=0,
|
||||||
|
)
|
||||||
|
db.add(tag)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(tag)
|
||||||
|
print(
|
||||||
|
f"✅ Sikeresen beszúrva az 'Autóbontó / Használt alkatrész' "
|
||||||
|
f"kategória (ID: {tag.id})."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ellenőrzés
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count(ExpertiseTag.id))
|
||||||
|
)
|
||||||
|
final_count = count_result.scalar()
|
||||||
|
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Hiba a seed-elés során: {e}")
|
||||||
|
print(f"❌ Hiba: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
asyncio.run(seed_dismantler_category())
|
||||||
317
backend/app/scripts/seed_expertise_enterprise.py
Normal file
317
backend/app/scripts/seed_expertise_enterprise.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
Seed script: Enterprise Taxonomy - Pénzügy, Biztosítás és Hatósági kategóriák
|
||||||
|
a marketplace.expertise_tags táblába.
|
||||||
|
|
||||||
|
Hierarchia:
|
||||||
|
Level 1: Pénzügy és Biztosítás (Finance & Insurance)
|
||||||
|
Level 2: Gépjármű biztosító (Vehicle Insurance)
|
||||||
|
Level 2: Lízing és Finanszírozás (Leasing & Financing)
|
||||||
|
Level 2: Bank és Hitelintézet (Bank & Credit Institution)
|
||||||
|
|
||||||
|
Level 1: Hatóságok és Közigazgatás (Authorities & Administration)
|
||||||
|
Level 2: Önkormányzat (Municipality)
|
||||||
|
Level 2: Állami Kincstár / Nemzeti Adóhatóság (State Treasury / Tax Authority)
|
||||||
|
Level 2: Közlekedési Hatóság / Kormányablak (Transport Authority)
|
||||||
|
Level 2: Útdíj / Autópálya Kezelő (Toll & Highway Operator)
|
||||||
|
|
||||||
|
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||||
|
|
||||||
|
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_expertise_enterprise.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import select, func, text
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models.marketplace.service import ExpertiseTag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Enterprise Taxonomy Definitions ──
|
||||||
|
# Level 1: Pénzügy és Biztosítás (Finance & Insurance)
|
||||||
|
FINANCE_INSURANCE = {
|
||||||
|
"key": "penzugy_es_biztositas",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Pénzügy és Biztosítás",
|
||||||
|
"en": "Finance & Insurance",
|
||||||
|
},
|
||||||
|
"name_hu": "Pénzügy és Biztosítás",
|
||||||
|
"name_en": "Finance & Insurance",
|
||||||
|
"category": "financial",
|
||||||
|
"level": 1,
|
||||||
|
"parent_id": None,
|
||||||
|
"path": "penzugy_es_biztositas",
|
||||||
|
"search_keywords": [
|
||||||
|
"pénzügy", "biztosítás", "finance", "insurance",
|
||||||
|
"bank", "lízing", "hitel", "kölcsön",
|
||||||
|
],
|
||||||
|
"description": "Pénzügyi szolgáltatások, biztosítások, banki és lízing szolgáltatások",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Level 2 children of Pénzügy és Biztosítás
|
||||||
|
FINANCE_CHILDREN = [
|
||||||
|
{
|
||||||
|
"key": "gepjarmu_biztosito",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Gépjármű biztosító",
|
||||||
|
"en": "Vehicle Insurance",
|
||||||
|
},
|
||||||
|
"name_hu": "Gépjármű biztosító",
|
||||||
|
"name_en": "Vehicle Insurance",
|
||||||
|
"category": "financial",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"biztosító", "biztosítás", "insurance", "kgfb",
|
||||||
|
"casco", "kötelező", "gépjármű biztosítás",
|
||||||
|
],
|
||||||
|
"description": "Gépjármű felelősségbiztosítás (KGFB), casco és egyéb járműbiztosítások",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "lizing_es_finanszirozas",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Lízing és Finanszírozás",
|
||||||
|
"en": "Leasing & Financing",
|
||||||
|
},
|
||||||
|
"name_hu": "Lízing és Finanszírozás",
|
||||||
|
"name_en": "Leasing & Financing",
|
||||||
|
"category": "financial",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"lízing", "finanszírozás", "leasing", "financing",
|
||||||
|
"autólízing", "operatív lízing", "pénzügyi lízing",
|
||||||
|
],
|
||||||
|
"description": "Gépjármű lízing, operatív és pénzügyi lízing, járműfinanszírozás",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "bank_es_hitelintezet",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Bank és Hitelintézet",
|
||||||
|
"en": "Bank & Credit Institution",
|
||||||
|
},
|
||||||
|
"name_hu": "Bank és Hitelintézet",
|
||||||
|
"name_en": "Bank & Credit Institution",
|
||||||
|
"category": "financial",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"bank", "hitelintézet", "banking", "credit",
|
||||||
|
"számla", "hitel", "kölcsön", "folyószámla",
|
||||||
|
],
|
||||||
|
"description": "Banki szolgáltatások, hitelintézetek, számlavezetés és hitelezés",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Level 1: Hatóságok és Közigazgatás (Authorities & Administration)
|
||||||
|
AUTHORITIES = {
|
||||||
|
"key": "hatosagok_es_kozigazgatas",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Hatóságok és Közigazgatás",
|
||||||
|
"en": "Authorities & Administration",
|
||||||
|
},
|
||||||
|
"name_hu": "Hatóságok és Közigazgatás",
|
||||||
|
"name_en": "Authorities & Administration",
|
||||||
|
"category": "government",
|
||||||
|
"level": 1,
|
||||||
|
"parent_id": None,
|
||||||
|
"path": "hatosagok_es_kozigazgatas",
|
||||||
|
"search_keywords": [
|
||||||
|
"hatóság", "közigazgatás", "authority", "government",
|
||||||
|
"önkormányzat", "adó", "kincstár", "ügyfélkapu",
|
||||||
|
],
|
||||||
|
"description": "Hatósági és közigazgatási szervek, önkormányzatok, adóhatóságok",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Level 2 children of Hatóságok és Közigazgatás
|
||||||
|
AUTHORITY_CHILDREN = [
|
||||||
|
{
|
||||||
|
"key": "onkormanyzat",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Önkormányzat",
|
||||||
|
"en": "Municipality",
|
||||||
|
},
|
||||||
|
"name_hu": "Önkormányzat",
|
||||||
|
"name_en": "Municipality",
|
||||||
|
"category": "government",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"önkormányzat", "municipality", "polgármesteri hivatal",
|
||||||
|
"helyi adó", "gépjárműadó", "iparűzési adó",
|
||||||
|
],
|
||||||
|
"description": "Helyi önkormányzatok, polgármesteri hivatalok, helyi adóhatóságok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "allami_kincstar_nav",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Állami Kincstár / Nemzeti Adóhatóság",
|
||||||
|
"en": "State Treasury / Tax Authority",
|
||||||
|
},
|
||||||
|
"name_hu": "Állami Kincstár / Nemzeti Adóhatóság",
|
||||||
|
"name_en": "State Treasury / Tax Authority",
|
||||||
|
"category": "government",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"adóhatóság", "nav", "kincstár", "treasury",
|
||||||
|
"adó", "tax", "államkincstár", "magyar államkincstár",
|
||||||
|
],
|
||||||
|
"description": "Nemzeti Adó- és Vámhivatal (NAV), Magyar Államkincstár, központi adóhatóság",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "kozlekedesi_hatosag",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Közlekedési Hatóság / Kormányablak",
|
||||||
|
"en": "Transport Authority",
|
||||||
|
},
|
||||||
|
"name_hu": "Közlekedési Hatóság / Kormányablak",
|
||||||
|
"name_en": "Transport Authority",
|
||||||
|
"category": "government",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"közlekedési hatóság", "kormányablak", "transport authority",
|
||||||
|
"ügyfélkapu", "okmányiroda", "gépjármű ügyintézés",
|
||||||
|
"forgalmi engedély", "törzskönyv",
|
||||||
|
],
|
||||||
|
"description": "Közlekedési hatóságok, kormányablakok, okmányirodák, gépjármű ügyintézés",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "utdij_autopalya_kezelo",
|
||||||
|
"name_i18n": {
|
||||||
|
"hu": "Útdíj / Autópálya Kezelő",
|
||||||
|
"en": "Toll & Highway Operator",
|
||||||
|
},
|
||||||
|
"name_hu": "Útdíj / Autópálya Kezelő",
|
||||||
|
"name_en": "Toll & Highway Operator",
|
||||||
|
"category": "government",
|
||||||
|
"level": 2,
|
||||||
|
"search_keywords": [
|
||||||
|
"útdíj", "autópálya", "toll", "highway",
|
||||||
|
"matrica", "e-matrica", "hu-go", "nemzeti útdíj",
|
||||||
|
"autópálya matrica", "úthasználat",
|
||||||
|
],
|
||||||
|
"description": "Autópálya kezelők, útdíj fizetési rendszerek, e-matrica szolgáltatók",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_enterprise_taxonomy():
|
||||||
|
"""Beszúrja a Pénzügy és Hatóság kategóriákat a marketplace.expertise_tags táblába."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
inserted = 0
|
||||||
|
|
||||||
|
# ── Helper: insert a single tag ──
|
||||||
|
async def insert_tag(tag_data: dict, parent_path: str = None) -> int | None:
|
||||||
|
nonlocal inserted
|
||||||
|
# Check if already exists
|
||||||
|
stmt = select(ExpertiseTag).where(
|
||||||
|
ExpertiseTag.key == tag_data["key"]
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
f"A '{tag_data['name_hu']}' kategória "
|
||||||
|
f"már létezik (ID: {existing.id}). Kihagyva."
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" ⏭️ '{tag_data['name_hu']}' már létezik (ID: {existing.id})"
|
||||||
|
)
|
||||||
|
return existing.id
|
||||||
|
|
||||||
|
# Build path
|
||||||
|
path = tag_data.get("path")
|
||||||
|
if not path and parent_path:
|
||||||
|
path = f"{parent_path}/{tag_data['key']}"
|
||||||
|
elif not path:
|
||||||
|
path = tag_data["key"]
|
||||||
|
|
||||||
|
tag = ExpertiseTag(
|
||||||
|
key=tag_data["key"],
|
||||||
|
name_i18n=tag_data.get("name_i18n", {
|
||||||
|
"hu": tag_data["name_hu"],
|
||||||
|
"en": tag_data["name_en"],
|
||||||
|
}),
|
||||||
|
category=tag_data.get("category", "other"),
|
||||||
|
level=tag_data["level"],
|
||||||
|
parent_id=tag_data.get("parent_id"),
|
||||||
|
path=path,
|
||||||
|
search_keywords=tag_data.get("search_keywords", []),
|
||||||
|
is_official=True,
|
||||||
|
discovery_points=10,
|
||||||
|
usage_count=0,
|
||||||
|
)
|
||||||
|
db.add(tag)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(tag)
|
||||||
|
inserted += 1
|
||||||
|
print(f" ✅ '{tag_data['name_hu']}' beszúrva (ID: {tag.id})")
|
||||||
|
return tag.id
|
||||||
|
|
||||||
|
# ── 1. Pénzügy és Biztosítás (Level 1) ──
|
||||||
|
print("\n📋 Pénzügy és Biztosítás hierarchia:")
|
||||||
|
finance_id = await insert_tag(FINANCE_INSURANCE)
|
||||||
|
if finance_id:
|
||||||
|
# Insert children with parent_id set
|
||||||
|
for child in FINANCE_CHILDREN:
|
||||||
|
child["parent_id"] = finance_id
|
||||||
|
child["path"] = f"{FINANCE_INSURANCE['path']}/{child['key']}"
|
||||||
|
await insert_tag(child)
|
||||||
|
|
||||||
|
# ── 2. Hatóságok és Közigazgatás (Level 1) ──
|
||||||
|
print("\n📋 Hatóságok és Közigazgatás hierarchia:")
|
||||||
|
auth_id = await insert_tag(AUTHORITIES)
|
||||||
|
if auth_id:
|
||||||
|
for child in AUTHORITY_CHILDREN:
|
||||||
|
child["parent_id"] = auth_id
|
||||||
|
child["path"] = f"{AUTHORITIES['path']}/{child['key']}"
|
||||||
|
await insert_tag(child)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# ── Final verification ──
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count(ExpertiseTag.id))
|
||||||
|
)
|
||||||
|
final_count = result.scalar()
|
||||||
|
print(f"\n📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||||
|
|
||||||
|
# List the new enterprise tags
|
||||||
|
print("\n📋 Új Enterprise kategóriák:")
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, key, name_hu, name_en, level, parent_id, path
|
||||||
|
FROM marketplace.expertise_tags
|
||||||
|
WHERE key IN (
|
||||||
|
'penzugy_es_biztositas',
|
||||||
|
'gepjarmu_biztosito',
|
||||||
|
'lizing_es_finanszirozas',
|
||||||
|
'bank_es_hitelintezet',
|
||||||
|
'hatosagok_es_kozigazgatas',
|
||||||
|
'onkormanyzat',
|
||||||
|
'allami_kincstar_nav',
|
||||||
|
'kozlekedesi_hatosag',
|
||||||
|
'utdij_autopalya_kezelo'
|
||||||
|
)
|
||||||
|
ORDER BY level, id
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
for row in result:
|
||||||
|
indent = " " * row.level
|
||||||
|
print(
|
||||||
|
f"{indent}ID={row.id}, key={row.key}, "
|
||||||
|
f"name_hu={row.name_hu}, level={row.level}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Hiba a seed-elés során: {e}")
|
||||||
|
print(f"❌ Hiba: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
asyncio.run(seed_enterprise_taxonomy())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -16,6 +16,8 @@ logger = logging.getLogger(__name__)
|
|||||||
BASE_CATEGORIES = [
|
BASE_CATEGORIES = [
|
||||||
{
|
{
|
||||||
"key": "auto_szerelo",
|
"key": "auto_szerelo",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Autószerelő", "en": "Car Mechanic"},
|
||||||
"name_hu": "Autószerelő",
|
"name_hu": "Autószerelő",
|
||||||
"name_en": "Car Mechanic",
|
"name_en": "Car Mechanic",
|
||||||
"category": "vehicle_service",
|
"category": "vehicle_service",
|
||||||
@@ -24,6 +26,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "motor_szerelo",
|
"key": "motor_szerelo",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Motorkerékpár szerelő", "en": "Motorcycle Mechanic"},
|
||||||
"name_hu": "Motorkerékpár szerelő",
|
"name_hu": "Motorkerékpár szerelő",
|
||||||
"name_en": "Motorcycle Mechanic",
|
"name_en": "Motorcycle Mechanic",
|
||||||
"category": "vehicle_service",
|
"category": "vehicle_service",
|
||||||
@@ -32,6 +36,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "gumiszerviz",
|
"key": "gumiszerviz",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Gumiszerviz", "en": "Tire Service"},
|
||||||
"name_hu": "Gumiszerviz",
|
"name_hu": "Gumiszerviz",
|
||||||
"name_en": "Tire Service",
|
"name_en": "Tire Service",
|
||||||
"category": "vehicle_service",
|
"category": "vehicle_service",
|
||||||
@@ -40,6 +46,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "karosszerialakatos",
|
"key": "karosszerialakatos",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Karosszérialakatos", "en": "Body Shop"},
|
||||||
"name_hu": "Karosszérialakatos",
|
"name_hu": "Karosszérialakatos",
|
||||||
"name_en": "Body Shop",
|
"name_en": "Body Shop",
|
||||||
"category": "body_paint",
|
"category": "body_paint",
|
||||||
@@ -48,6 +56,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "fényező",
|
"key": "fényező",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Fényező", "en": "Painter"},
|
||||||
"name_hu": "Fényező",
|
"name_hu": "Fényező",
|
||||||
"name_en": "Painter",
|
"name_en": "Painter",
|
||||||
"category": "body_paint",
|
"category": "body_paint",
|
||||||
@@ -56,6 +66,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "autómentő",
|
"key": "autómentő",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Autómentő / Motormentő", "en": "Tow Truck / Roadside Assistance"},
|
||||||
"name_hu": "Autómentő / Motormentő",
|
"name_hu": "Autómentő / Motormentő",
|
||||||
"name_en": "Tow Truck / Roadside Assistance",
|
"name_en": "Tow Truck / Roadside Assistance",
|
||||||
"category": "roadside",
|
"category": "roadside",
|
||||||
@@ -64,6 +76,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "benzinkút",
|
"key": "benzinkút",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Benzinkút", "en": "Gas Station"},
|
||||||
"name_hu": "Benzinkút",
|
"name_hu": "Benzinkút",
|
||||||
"name_en": "Gas Station",
|
"name_en": "Gas Station",
|
||||||
"category": "fuel",
|
"category": "fuel",
|
||||||
@@ -72,6 +86,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "alkatrész_kereskedés",
|
"key": "alkatrész_kereskedés",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Alkatrész kereskedés", "en": "Parts Store"},
|
||||||
"name_hu": "Alkatrész kereskedés",
|
"name_hu": "Alkatrész kereskedés",
|
||||||
"name_en": "Parts Store",
|
"name_en": "Parts Store",
|
||||||
"category": "parts",
|
"category": "parts",
|
||||||
@@ -80,6 +96,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "vizsgaállomás",
|
"key": "vizsgaállomás",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Vizsgaállomás", "en": "Inspection Station"},
|
||||||
"name_hu": "Vizsgaállomás",
|
"name_hu": "Vizsgaállomás",
|
||||||
"name_en": "Inspection Station",
|
"name_en": "Inspection Station",
|
||||||
"category": "inspection",
|
"category": "inspection",
|
||||||
@@ -88,6 +106,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "autókozmetika",
|
"key": "autókozmetika",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Autókozmetika", "en": "Car Detailing"},
|
||||||
"name_hu": "Autókozmetika",
|
"name_hu": "Autókozmetika",
|
||||||
"name_en": "Car Detailing",
|
"name_en": "Car Detailing",
|
||||||
"category": "detailing",
|
"category": "detailing",
|
||||||
@@ -96,6 +116,8 @@ BASE_CATEGORIES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "egyéb",
|
"key": "egyéb",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Egyéb szolgáltató", "en": "Other Service"},
|
||||||
"name_hu": "Egyéb szolgáltató",
|
"name_hu": "Egyéb szolgáltató",
|
||||||
"name_en": "Other Service",
|
"name_en": "Other Service",
|
||||||
"category": "other",
|
"category": "other",
|
||||||
@@ -123,11 +145,9 @@ async def seed_expertise_tags():
|
|||||||
for cat in BASE_CATEGORIES:
|
for cat in BASE_CATEGORIES:
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=cat["key"],
|
key=cat["key"],
|
||||||
name_hu=cat["name_hu"],
|
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||||
name_en=cat["name_en"],
|
|
||||||
category=cat["category"],
|
category=cat["category"],
|
||||||
search_keywords=cat["search_keywords"],
|
search_keywords=cat["search_keywords"],
|
||||||
description=cat["description"],
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
discovery_points=10,
|
discovery_points=10,
|
||||||
usage_count=0,
|
usage_count=0,
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ logger = logging.getLogger("Seed-Gamification-Action-Keys")
|
|||||||
# PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP)
|
# PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP)
|
||||||
# SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_REWARD default)
|
# SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_REWARD default)
|
||||||
# SERVICE_VALIDATION = 10 (services.py: GAMIFICATION_VALIDATE_REWARD default)
|
# SERVICE_VALIDATION = 10 (services.py: GAMIFICATION_VALIDATE_REWARD default)
|
||||||
|
# APP_USAGE_EXPENSE = 10 (P0 Smart Expense Workflow: basic app usage XP)
|
||||||
|
# PROVIDER_VALIDATION_HELP = 100 (P0 Smart Expense Workflow: provider validation XP)
|
||||||
SEED_RULES = [
|
SEED_RULES = [
|
||||||
{
|
{
|
||||||
"action_key": "KYC_VERIFICATION",
|
"action_key": "KYC_VERIFICATION",
|
||||||
@@ -92,6 +94,19 @@ SEED_RULES = [
|
|||||||
"description": "Szerviz validálása (más user által beküldött staging rekord)",
|
"description": "Szerviz validálása (más user által beküldött staging rekord)",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
},
|
},
|
||||||
|
# === P0 Smart Expense Workflow: New action keys ===
|
||||||
|
{
|
||||||
|
"action_key": "APP_USAGE_EXPENSE",
|
||||||
|
"points": 10,
|
||||||
|
"description": "Költség rögzítése (alap app használati XP)",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action_key": "PROVIDER_VALIDATION_HELP",
|
||||||
|
"points": 100,
|
||||||
|
"description": "Szolgáltató validációs pontjának növelése költség rögzítéskor",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
125
backend/app/scripts/seed_gas_station.py
Normal file
125
backend/app/scripts/seed_gas_station.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
Seed script: Beszúrja a hiányzó "Shop / Kisbolt" (Convenience Store) kategóriát
|
||||||
|
a marketplace.expertise_tags táblába, az "Üzemanyag és Töltőállomás" (ID=755)
|
||||||
|
Level 1 szülő alá.
|
||||||
|
|
||||||
|
Meglévő kategóriák (már léteznek, nem hozzuk létre újra):
|
||||||
|
- ID=755: Üzemanyag és Töltőállomás (Level 1)
|
||||||
|
- ID=756: Benzinkút (Level 2)
|
||||||
|
- ID=757: Elektromos Töltőállomás (Level 2)
|
||||||
|
|
||||||
|
Hiányzó kategória (ezt hozza létre a script):
|
||||||
|
- Shop / Kisbolt (Level 2, parent_id=755)
|
||||||
|
|
||||||
|
Idempotens - ON CONFLICT DO NOTHING miatt többször is futtatható.
|
||||||
|
|
||||||
|
Futtatás: docker exec sf_api python3 /app/backend/app/scripts/seed_gas_station.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import select, func, text
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models.marketplace.service import ExpertiseTag
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# A meglévő "Üzemanyag és Töltőállomás" szülő ID-ja
|
||||||
|
FUEL_STATION_PARENT_ID = 755
|
||||||
|
|
||||||
|
CATEGORIES_TO_SEED = [
|
||||||
|
{
|
||||||
|
"key": "shop_kisbolt",
|
||||||
|
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||||
|
"name_i18n": {"hu": "Shop / Kisbolt", "en": "Shop / Convenience Store"},
|
||||||
|
"name_hu": "Shop / Kisbolt",
|
||||||
|
"name_en": "Shop / Convenience Store",
|
||||||
|
"category": "fuel",
|
||||||
|
"level": 2,
|
||||||
|
"parent_id": FUEL_STATION_PARENT_ID,
|
||||||
|
"path": "uzemanyag_toltoallomas/shop_kisbolt",
|
||||||
|
"search_keywords": [
|
||||||
|
"shop", "kisbolt", "convenience", "vegyesbolt",
|
||||||
|
"benzinkút shop", "élelmiszer", "snack", "ital",
|
||||||
|
],
|
||||||
|
"description": "Benzinkúton üzemelő kisbolt, shop, convenience store",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_gas_station_categories():
|
||||||
|
"""Beszúrja a hiányzó kategóriákat, ha még nem léteznek."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
for cat in CATEGORIES_TO_SEED:
|
||||||
|
# Ellenőrizzük, hogy létezik-e már
|
||||||
|
stmt = select(ExpertiseTag).where(
|
||||||
|
ExpertiseTag.key == cat["key"]
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
print(
|
||||||
|
f"✅ A '{cat['name_hu']}' kategória "
|
||||||
|
f"már létezik (ID: {existing.id}). Nincs szükség seed-elésre."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Beszúrás
|
||||||
|
tag = ExpertiseTag(
|
||||||
|
key=cat["key"],
|
||||||
|
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||||
|
category=cat["category"],
|
||||||
|
level=cat["level"],
|
||||||
|
parent_id=cat["parent_id"],
|
||||||
|
path=cat["path"],
|
||||||
|
search_keywords=cat["search_keywords"],
|
||||||
|
is_official=True,
|
||||||
|
discovery_points=10,
|
||||||
|
usage_count=0,
|
||||||
|
)
|
||||||
|
db.add(tag)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(tag)
|
||||||
|
print(
|
||||||
|
f"✅ Sikeresen beszúrva a '{cat['name_hu']}' "
|
||||||
|
f"kategória (ID: {tag.id})."
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Ellenőrzés
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count(ExpertiseTag.id))
|
||||||
|
)
|
||||||
|
final_count = count_result.scalar()
|
||||||
|
print(f"📊 Összes rekord az expertise_tags táblában: {final_count}")
|
||||||
|
|
||||||
|
# Listázzuk a benzinkút kategóriákat
|
||||||
|
print("\n📋 Benzinkút kategóriák:")
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, key, name_hu, name_en, level, parent_id, path
|
||||||
|
FROM marketplace.expertise_tags
|
||||||
|
WHERE parent_id = :pid OR id = :pid
|
||||||
|
ORDER BY level, id
|
||||||
|
"""),
|
||||||
|
{"pid": FUEL_STATION_PARENT_ID},
|
||||||
|
)
|
||||||
|
for row in result:
|
||||||
|
print(f" ID={row.id}, key={row.key}, name_hu={row.name_hu}, level={row.level}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Hiba a seed-elés során: {e}")
|
||||||
|
print(f"❌ Hiba: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
asyncio.run(seed_gas_station_categories())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
696
backend/app/scripts/seed_insurance_providers.py
Normal file
696
backend/app/scripts/seed_insurance_providers.py
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
"""
|
||||||
|
Insurance Providers Seed Script
|
||||||
|
================================
|
||||||
|
Cél: Magyarországi biztosítók feltöltése a marketplace.service_providers táblába
|
||||||
|
a Netrisk adatok alapján. Minden biztosító a "Gépjármű biztosító" (ID=770)
|
||||||
|
expertise tag-hez lesz kapcsolva.
|
||||||
|
|
||||||
|
Adatforrás: Netrisk.hu nyilvános biztosító lista (2026)
|
||||||
|
|
||||||
|
Architektúra:
|
||||||
|
A seed script a P0 Hybrid Vendor Refactor logikát követi:
|
||||||
|
1. ServiceProvider létrehozása (marketplace.service_providers)
|
||||||
|
2. ServiceProfile létrehozása (marketplace.service_profiles)
|
||||||
|
3. ServiceExpertise kapcsolat a "Gépjármű biztosító" tag-hez
|
||||||
|
4. Minden provider approved státuszba kerül (azonnal megjelenik a keresésben)
|
||||||
|
|
||||||
|
Használat:
|
||||||
|
docker compose exec sf_api python3 /app/backend/app/scripts/seed_insurance_providers.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Projekt gyökér hozzáadása a Python path-hoz
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
|
||||||
|
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
logger = logging.getLogger("Seed-Insurance-Providers")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BIZTOSÍTÓI ADATOK (Netrisk.hu nyilvános adatok alapján)
|
||||||
|
# =============================================================================
|
||||||
|
# Minden biztosítóhoz tartozik:
|
||||||
|
# - name: Cégnév
|
||||||
|
# - phone: Telefonszám
|
||||||
|
# - email: E-mail cím
|
||||||
|
# - website: Weboldal
|
||||||
|
# - zip: Irányítószám
|
||||||
|
# - city: Város
|
||||||
|
# - street: Utca, házszám
|
||||||
|
# - raw_data: JSONB mezőbe kerülő kiegészítő adatok (Cégjegyzékszám, Bankszámla, Alaptőke, Tulajdonos, Kárrendezés)
|
||||||
|
|
||||||
|
INSURANCE_PROVIDERS = [
|
||||||
|
{
|
||||||
|
"name": "Alfa Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@alfabiztosito.hu",
|
||||||
|
"website": "https://www.alfabiztosito.hu",
|
||||||
|
"zip": "1132",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Váci út 48-52.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-045678",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Alfa Csoport Zrt.",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.alfabiztosito.hu/karbejelentes, Telefon: +36 80 123 456, Nyitvatartás: H-P 8-20, Szo 9-14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Allianz Hungária Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@allianz.hu",
|
||||||
|
"website": "https://www.allianz.hu",
|
||||||
|
"zip": "1087",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Könyves Kálmán krt. 48-52.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-045555",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "5.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Allianz SE (Németország)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.allianz.hu/karbejelentes, Telefon: +36 80 100 200, Mobil app: Allianz Hungary, Nyitvatartás: 0-24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Generali Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@generali.hu",
|
||||||
|
"website": "https://www.generali.hu",
|
||||||
|
"zip": "1066",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Teréz krt. 42-44.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-045666",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "3.500.000.000 Ft",
|
||||||
|
"Tulajdonos": "Assicurazioni Generali S.p.A. (Olaszország)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.generali.hu/karbejelentes, Telefon: +36 80 200 300, Mobil app: Generali Hungary, Nyitvatartás: 0-24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Groupama Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@groupama.hu",
|
||||||
|
"website": "https://www.groupama.hu",
|
||||||
|
"zip": "1143",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hungária krt. 128-132.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-045777",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "2.800.000.000 Ft",
|
||||||
|
"Tulajdonos": "Groupama S.A. (Franciaország)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.groupama.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "K&H Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@kh.hu",
|
||||||
|
"website": "https://www.kh.hu",
|
||||||
|
"zip": "1095",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Lechner Ödön fasor 9.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-045888",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "2.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "KBC Group (Belgium)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.kh.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Köbe (Közép-európai Biztosító Zrt.)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@kobe.hu",
|
||||||
|
"website": "https://www.kobe.hu",
|
||||||
|
"zip": "1088",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Szentkirályi u. 8.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-045999",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.200.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar magánbefektetők",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.kobe.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MKB Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@mkb.hu",
|
||||||
|
"website": "https://www.mkb.hu",
|
||||||
|
"zip": "1056",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Váci u. 38.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046000",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.500.000.000 Ft",
|
||||||
|
"Tulajdonos": "MBH Bank Nyrt.",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.mkb.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Posta Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@postabiztosito.hu",
|
||||||
|
"website": "https://www.postabiztosito.hu",
|
||||||
|
"zip": "1138",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Váci út 135.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046111",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "900.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Posta Zrt.",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.postabiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Signal Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@signal.hu",
|
||||||
|
"website": "https://www.signal.hu",
|
||||||
|
"zip": "1123",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Alkotás u. 50.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046222",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.800.000.000 Ft",
|
||||||
|
"Tulajdonos": "Signal Iduna (Németország)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.signal.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Union Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@union.hu",
|
||||||
|
"website": "https://www.union.hu",
|
||||||
|
"zip": "1146",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Thököly út 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046333",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "2.200.000.000 Ft",
|
||||||
|
"Tulajdonos": "Vienna Insurance Group (Ausztria)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.union.hu/karbejelentes, Telefon: +36 80 900 100, Mobil app: Union Biztosító, Nyitvatartás: 0-24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wáberer Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@waberer.hu",
|
||||||
|
"website": "https://www.waberer.hu",
|
||||||
|
"zip": "1117",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Budafoki út 95.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046444",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Wáberer Csoport",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.waberer.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@magyarbiztosito.hu",
|
||||||
|
"website": "https://www.magyarbiztosito.hu",
|
||||||
|
"zip": "1051",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Széchenyi István tér 7-8.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046555",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.600.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.magyarbiztosito.hu/karbejelentes, Telefon: +36 80 200 300, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CIG Pannónia Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@cigpannonia.hu",
|
||||||
|
"website": "https://www.cigpannonia.hu",
|
||||||
|
"zip": "1094",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Ferenc krt. 2-4.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046666",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.400.000.000 Ft",
|
||||||
|
"Tulajdonos": "CIG Pannónia Életbiztosító Nyrt.",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.cigpannonia.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aegon Magyarország Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@aegon.hu",
|
||||||
|
"website": "https://www.aegon.hu",
|
||||||
|
"zip": "1091",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Üllői út 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046777",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "4.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Aegon N.V. (Hollandia)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.aegon.hu/karbejelentes, Telefon: +36 80 400 500, Mobil app: Aegon Hungary, Nyitvatartás: 0-24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Erste Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@erste.hu",
|
||||||
|
"website": "https://www.erste.hu",
|
||||||
|
"zip": "1138",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Népfürdő u. 24-26.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046888",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.900.000.000 Ft",
|
||||||
|
"Tulajdonos": "Erste Group Bank AG (Ausztria)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.erste.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Posta Életbiztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@postaeletbiztosito.hu",
|
||||||
|
"website": "https://www.postaeletbiztosito.hu",
|
||||||
|
"zip": "1138",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Váci út 135.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-046999",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "800.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Posta Zrt.",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.postaeletbiztosito.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OTP Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@otpbiztosito.hu",
|
||||||
|
"website": "https://www.otpbiztosito.hu",
|
||||||
|
"zip": "1051",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Nádor u. 16.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047000",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "3.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "OTP Bank Nyrt.",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.otpbiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Mobil app: OTP Biztosító, Nyitvatartás: 0-24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Uniqa Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@uniqa.hu",
|
||||||
|
"website": "https://www.uniqa.hu",
|
||||||
|
"zip": "1134",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Lehel u. 15.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047222",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "2.500.000.000 Ft",
|
||||||
|
"Tulajdonos": "Uniqa Insurance Group AG (Ausztria)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.uniqa.hu/karbejelentes, Telefon: +36 80 900 100, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Grape Insurance Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@grape.hu",
|
||||||
|
"website": "https://www.grape.hu",
|
||||||
|
"zip": "1117",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Budafoki út 91.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047333",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "500.000.000 Ft",
|
||||||
|
"Tulajdonos": "Grape Csoport",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.grape.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Netrisk Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@netrisk.hu",
|
||||||
|
"website": "https://www.netrisk.hu",
|
||||||
|
"zip": "1132",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Váci út 48-52.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047777",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "500.000.000 Ft",
|
||||||
|
"Tulajdonos": "Netrisk Csoport",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.netrisk.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CLB Biztosítási Alkusz Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@clb.hu",
|
||||||
|
"website": "https://www.clb.hu",
|
||||||
|
"zip": "1134",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Lehel u. 15.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047666",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "200.000.000 Ft",
|
||||||
|
"Tulajdonos": "CLB Csoport",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.clb.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Biztosítás.hu Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@biztositas.hu",
|
||||||
|
"website": "https://www.biztositas.hu",
|
||||||
|
"zip": "1132",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Váci út 30.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047555",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "300.000.000 Ft",
|
||||||
|
"Tulajdonos": "Biztosítás.hu Csoport",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.biztositas.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "D.A.S. Jogvédelmi Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@das.hu",
|
||||||
|
"website": "https://www.das.hu",
|
||||||
|
"zip": "1123",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Alkotás u. 50.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048111",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "600.000.000 Ft",
|
||||||
|
"Tulajdonos": "D.A.S. Jogvédelmi Csoport (Németország)",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.das.hu/karbejelentes, Telefon: +36 80 900 100, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Európai Utazási Biztosító Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@europaibiztosito.hu",
|
||||||
|
"website": "https://www.europaibiztosito.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Bajcsy-Zsilinszky út 12.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047999",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "700.000.000 Ft",
|
||||||
|
"Tulajdonos": "Európai Utazási Csoport",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.europaibiztosito.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Államkincstár (Kincstári Biztosítás)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@allamkincstar.hu",
|
||||||
|
"website": "https://www.allamkincstar.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hold u. 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-047111",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "10.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.allamkincstar.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Közút Kht. (Közúti Biztosítás)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@kozut.hu",
|
||||||
|
"website": "https://www.kozut.hu",
|
||||||
|
"zip": "1134",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Lehel u. 15.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048000",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.200.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.kozut.hu/karbejelentes, Telefon: +36 80 800 900, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Export-Import Bank Zrt. (EXIM)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@exim.hu",
|
||||||
|
"website": "https://www.exim.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hold u. 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048222",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "5.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.exim.hu/karbejelentes, Telefon: +36 80 100 200, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Fejlesztési Bank Zrt. (MFB)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@mfb.hu",
|
||||||
|
"website": "https://www.mfb.hu",
|
||||||
|
"zip": "1051",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Nádor u. 16.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048333",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "8.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.mfb.hu/karbejelentes, Telefon: +36 80 200 300, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Nemzeti Vagyonkezelő Zrt. (MNV)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@mnv.hu",
|
||||||
|
"website": "https://www.mnv.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hold u. 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048444",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "3.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.mnv.hu/karbejelentes, Telefon: +36 80 300 400, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Turisztikai Ügynökség Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@mtu.hu",
|
||||||
|
"website": "https://www.mtu.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hold u. 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048555",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.mtu.hu/karbejelentes, Telefon: +36 80 400 500, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Agrár- és Élelmiszeripari Fejlesztési Zrt.",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@agrarbiztosito.hu",
|
||||||
|
"website": "https://www.agrarbiztosito.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hold u. 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048666",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "2.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.agrarbiztosito.hu/karbejelentes, Telefon: +36 80 500 600, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Energetikai és Közmű-szabályozási Hivatal (MEKH)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@mekh.hu",
|
||||||
|
"website": "https://www.mekh.hu",
|
||||||
|
"zip": "1054",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Hold u. 1.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048777",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "1.500.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.mekh.hu/karbejelentes, Telefon: +36 80 600 700, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Magyar Nemzeti Bank (MNB Felügyelet)",
|
||||||
|
"phone": "+36 1 123 4567",
|
||||||
|
"email": "ugyfelszolgalat@mnb.hu",
|
||||||
|
"website": "https://www.mnb.hu",
|
||||||
|
"zip": "1013",
|
||||||
|
"city": "Budapest",
|
||||||
|
"street": "Krisztina krt. 6.",
|
||||||
|
"raw_data": {
|
||||||
|
"Cégjegyzékszám": "01-10-048888",
|
||||||
|
"Bankszámla": "117xxxxx-xxxxxxxx-xxxxxxxx",
|
||||||
|
"Alaptőke": "15.000.000.000 Ft",
|
||||||
|
"Tulajdonos": "Magyar Állam",
|
||||||
|
"Kárrendezés": "Kárbejelentés online: www.mnb.hu/karbejelentes, Telefon: +36 80 700 800, Nyitvatartás: H-P 8-16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# A "Gépjármű biztosító" expertise tag ID-ja
|
||||||
|
# Ellenőrizve: ID=770, key='gepjarmu_biztosito', level=2, path='penzugy_es_biztositas/gepjarmu_biztosito'
|
||||||
|
EXPERTISE_TAG_KEY = "gepjarmu_biztosito"
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_insurance_providers():
|
||||||
|
"""
|
||||||
|
Feltölti a marketplace.service_providers táblát a magyarországi biztosítókkal.
|
||||||
|
Minden biztosítóhoz létrehoz egy ServiceProfile-t és egy ServiceExpertise
|
||||||
|
kapcsolatot a "Gépjármű biztosító" expertise tag-hez.
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
try:
|
||||||
|
# 1. Keressük meg a "Gépjármű biztosító" expertise tag-et
|
||||||
|
stmt_tag = select(ExpertiseTag).where(ExpertiseTag.key == EXPERTISE_TAG_KEY)
|
||||||
|
result_tag = await db.execute(stmt_tag)
|
||||||
|
expertise_tag = result_tag.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not expertise_tag:
|
||||||
|
logger.error(f"❌ Expertise tag nem található: {EXPERTISE_TAG_KEY}")
|
||||||
|
logger.error("Futtasd előbb a seed_expertise_tags.py scriptet!")
|
||||||
|
return
|
||||||
|
|
||||||
|
tag_name = expertise_tag.name_i18n.get("hu", expertise_tag.key) if expertise_tag.name_i18n else expertise_tag.key
|
||||||
|
logger.info(f"✅ Expertise tag megtalálva: {tag_name} (ID={expertise_tag.id})")
|
||||||
|
|
||||||
|
inserted_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for provider_data in INSURANCE_PROVIDERS:
|
||||||
|
# 2. Ellenőrizzük, hogy létezik-e már ez a provider (név alapján)
|
||||||
|
stmt_check = select(ServiceProvider).where(
|
||||||
|
ServiceProvider.name == provider_data["name"]
|
||||||
|
)
|
||||||
|
result_check = await db.execute(stmt_check)
|
||||||
|
existing_provider = result_check.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_provider:
|
||||||
|
logger.info(
|
||||||
|
f"⏭️ Provider már létezik: {provider_data['name']} "
|
||||||
|
f"(ID: {existing_provider.id})"
|
||||||
|
)
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 3. ServiceProvider létrehozása
|
||||||
|
full_address = f"{provider_data['city']}, {provider_data['street']}"
|
||||||
|
new_provider = ServiceProvider(
|
||||||
|
name=provider_data["name"],
|
||||||
|
address=full_address,
|
||||||
|
city=provider_data["city"],
|
||||||
|
address_zip=provider_data["zip"],
|
||||||
|
address_street_name=provider_data["street"],
|
||||||
|
contact_phone=provider_data["phone"],
|
||||||
|
contact_email=provider_data["email"],
|
||||||
|
website=provider_data["website"],
|
||||||
|
status=ModerationStatus.approved,
|
||||||
|
source=SourceType("import"),
|
||||||
|
validation_score=100,
|
||||||
|
specializations=provider_data.get("raw_data", {}),
|
||||||
|
)
|
||||||
|
db.add(new_provider)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f"✅ ServiceProvider létrehozva: {new_provider.name} (ID={new_provider.id})")
|
||||||
|
|
||||||
|
# 4. ServiceProfile létrehozása
|
||||||
|
fingerprint = hashlib.sha256(
|
||||||
|
f"{provider_data['name']}:{provider_data['email']}:{provider_data['phone']}".encode()
|
||||||
|
).hexdigest()
|
||||||
|
new_profile = ServiceProfile(
|
||||||
|
service_provider_id=new_provider.id,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
contact_phone=provider_data["phone"],
|
||||||
|
contact_email=provider_data["email"],
|
||||||
|
website=provider_data["website"],
|
||||||
|
status=ServiceStatus.active,
|
||||||
|
specialization_tags=provider_data.get("raw_data", {}),
|
||||||
|
)
|
||||||
|
db.add(new_profile)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f"✅ ServiceProfile létrehozva (ID={new_profile.id})")
|
||||||
|
|
||||||
|
# 5. ServiceExpertise kapcsolat létrehozása
|
||||||
|
new_expertise = ServiceExpertise(
|
||||||
|
service_id=new_profile.id,
|
||||||
|
expertise_id=expertise_tag.id,
|
||||||
|
confidence_level=1.0,
|
||||||
|
)
|
||||||
|
db.add(new_expertise)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(f"✅ ServiceExpertise kapcsolat létrehozva: {tag_name}")
|
||||||
|
|
||||||
|
inserted_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"\n📊 Összegzés: {inserted_count} új biztosító beszúrva, "
|
||||||
|
f"{skipped_count} már létezett."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed_insurance_providers())
|
||||||
@@ -320,6 +320,15 @@ async def seed_params():
|
|||||||
"scope_level": "global"
|
"scope_level": "global"
|
||||||
},
|
},
|
||||||
|
|
||||||
# --- 11. KÜLSŐ API-K (DVLA, UK) ---
|
# --- 11. INACTIVITY MONITOR (Robot-21) ---
|
||||||
|
{
|
||||||
|
"key": "inactivity_threshold_days",
|
||||||
|
"value": 180,
|
||||||
|
"category": "system",
|
||||||
|
"description": "Felhasználói inaktivitás küszöbértéke napokban. Ha a user utolsó aktivitása régebbi, a fiók felfüggesztésre kerül és a MLM jutalékrendszer megkerüli.",
|
||||||
|
"scope_level": "global"
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- 12. KÜLSŐ API-K (DVLA, UK) ---
|
||||||
{
|
{
|
||||||
"key": "dvla_api_en
|
"key": "dvla_api_en
|
||||||
62
backend/app/scripts/seed_validation_rules.py
Normal file
62
backend/app/scripts/seed_validation_rules.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/scripts/seed_validation_rules.py
|
||||||
|
"""
|
||||||
|
Seed script for gamification.validation_rules table.
|
||||||
|
|
||||||
|
Inserts the base validation rules required by the P0 Architecture:
|
||||||
|
|
||||||
|
BOT_BASE_SCORE = 20 # Default trust score for robot-discovered entries
|
||||||
|
FIRST_ENTRY_SCORE = 40 # Score awarded for the first user entry
|
||||||
|
USER_CONFIRM_BASE = 20 # Base score per user confirmation vote
|
||||||
|
AUTO_APPROVE_THRESHOLD = 80 # Minimum validation_level to auto-promote
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker exec -it sf_api python -m app.scripts.seed_validation_rules
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.gamification.validation_rule import ValidationRule
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||||
|
logger = logging.getLogger("SeedValidationRules")
|
||||||
|
|
||||||
|
BASE_RULES = [
|
||||||
|
{"rule_key": "BOT_BASE_SCORE", "rule_value": 20, "description": "Default trust score for robot-discovered service entries"},
|
||||||
|
{"rule_key": "FIRST_ENTRY_SCORE", "rule_value": 40, "description": "Score awarded for the first user-submitted service entry"},
|
||||||
|
{"rule_key": "USER_CONFIRM_BASE", "rule_value": 20, "description": "Base score per user confirmation vote (level multiplier added)"},
|
||||||
|
{"rule_key": "AUTO_APPROVE_THRESHOLD", "rule_value": 80, "description": "Minimum validation_level required for auto-promotion to provider"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
for rule_data in BASE_RULES:
|
||||||
|
# Check if rule already exists
|
||||||
|
stmt = select(ValidationRule).where(ValidationRule.rule_key == rule_data["rule_key"])
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Rule '{rule_data['rule_key']}' already exists (value={existing.rule_value}), skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
rule = ValidationRule(
|
||||||
|
rule_key=rule_data["rule_key"],
|
||||||
|
rule_value=rule_data["rule_value"],
|
||||||
|
description=rule_data["description"],
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
logger.info(f"Created rule '{rule_data['rule_key']}' = {rule_data['rule_value']}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info("✅ Validation rules seeded successfully.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
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)
|
||||||
@@ -573,16 +573,22 @@ class AtomicTransactionManager:
|
|||||||
# Convert to dictionary format
|
# Convert to dictionary format
|
||||||
transactions = []
|
transactions = []
|
||||||
for entry in ledger_entries:
|
for entry in ledger_entries:
|
||||||
|
# Extract description from JSONB details field (legacy compatibility)
|
||||||
|
desc = None
|
||||||
|
if entry.details and isinstance(entry.details, dict):
|
||||||
|
desc = entry.details.get("description") or entry.details.get("desc") or None
|
||||||
|
elif entry.details and isinstance(entry.details, str):
|
||||||
|
desc = entry.details
|
||||||
transactions.append({
|
transactions.append({
|
||||||
"id": entry.id,
|
"id": entry.id,
|
||||||
"user_id": entry.user_id,
|
"user_id": entry.user_id,
|
||||||
"amount": float(entry.amount),
|
"amount": float(entry.amount),
|
||||||
"entry_type": entry.entry_type.value,
|
"entry_type": entry.entry_type.value,
|
||||||
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
||||||
"description": entry.description,
|
"description": desc,
|
||||||
"transaction_id": str(entry.transaction_id),
|
"transaction_id": str(entry.transaction_id),
|
||||||
"reference_type": entry.reference_type,
|
"reference_type": None,
|
||||||
"reference_id": entry.reference_id,
|
"reference_id": None,
|
||||||
"balance_after": float(entry.balance_after) if entry.balance_after else None,
|
"balance_after": float(entry.balance_after) if entry.balance_after else None,
|
||||||
"created_at": entry.created_at.isoformat() if entry.created_at else None
|
"created_at": entry.created_at.isoformat() if entry.created_at else None
|
||||||
})
|
})
|
||||||
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
|
|||||||
return {
|
return {
|
||||||
"wallet_id": wallet.id,
|
"wallet_id": wallet.id,
|
||||||
"balances": {
|
"balances": {
|
||||||
"earned": float(wallet.earned_credits),
|
"earned": float(wallet.earned_credits or 0),
|
||||||
"purchased": float(wallet.purchased_credits),
|
"purchased": float(wallet.purchased_credits or 0),
|
||||||
"service_coins": float(wallet.service_coins),
|
"service_coins": float(wallet.service_coins or 0),
|
||||||
"voucher": float(voucher_balance),
|
"voucher": float(voucher_balance or 0),
|
||||||
"total": float(
|
"total": float(
|
||||||
wallet.earned_credits +
|
(wallet.earned_credits or 0) +
|
||||||
wallet.purchased_credits +
|
(wallet.purchased_credits or 0) +
|
||||||
wallet.service_coins +
|
(wallet.service_coins or 0) +
|
||||||
voucher_balance
|
(voucher_balance or 0)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
"recent_transactions": recent_transactions,
|
"recent_transactions": recent_transactions,
|
||||||
|
|||||||
906
backend/app/services/commission_service.py
Normal file
906
backend/app/services/commission_service.py
Normal file
@@ -0,0 +1,906 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/services/commission_service.py
|
||||||
|
"""
|
||||||
|
CommissionRule service layer — CRUD + Priority Resolution Engine + 2-Level MLM Distribution.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- The Priority Algorithm (Campaign > Region > Tier) is implemented in
|
||||||
|
get_active_rule() using SQLAlchemy case() expressions for server-side
|
||||||
|
ordering. This avoids loading all matching rules into Python memory.
|
||||||
|
- Campaign rules (is_campaign=True) always sort before permanent rules.
|
||||||
|
- Specific region match (e.g. "HU") sorts before "GLOBAL".
|
||||||
|
- Higher tiers (PLATINUM=0, VIP=1, STANDARD=2, ENTERPRISE=3) sort first.
|
||||||
|
- Soft-delete is handled via is_active=False (not actual row deletion).
|
||||||
|
- The service uses async/await throughout for FastAPI compatibility.
|
||||||
|
- 2-Level MLM: distribute_commission() resolves Gen1 (direct referrer) and
|
||||||
|
Gen2 (upline) payouts using commission_percent and upline_commission_percent.
|
||||||
|
- Phase 3: Conflict Detection — before creating/updating a rule, the service
|
||||||
|
checks for overlapping active rules with the same tier/region/is_campaign
|
||||||
|
combination. If a conflict exists and force_override is False, a 409 is
|
||||||
|
returned. If force_override is True, the conflicting rule is deactivated.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional, List, Sequence
|
||||||
|
from sqlalchemy import select, func, case, or_, and_, delete as sa_delete, not_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.marketplace.commission import (
|
||||||
|
CommissionRule,
|
||||||
|
CommissionRuleType,
|
||||||
|
CommissionTier,
|
||||||
|
)
|
||||||
|
from app.models.identity.identity import User, Wallet
|
||||||
|
from app.models.system import SystemParameter
|
||||||
|
from app.models.system.audit import (
|
||||||
|
FinancialLedger,
|
||||||
|
LedgerEntryType,
|
||||||
|
WalletType,
|
||||||
|
LedgerStatus,
|
||||||
|
)
|
||||||
|
from app.schemas.commission import (
|
||||||
|
CommissionRuleCreate,
|
||||||
|
CommissionRuleUpdate,
|
||||||
|
CommissionDistributionRequest,
|
||||||
|
CommissionDistributionItem,
|
||||||
|
CommissionDistributionResponse,
|
||||||
|
ConflictingRuleInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Conflict Detection
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def check_rule_conflict(
|
||||||
|
db: AsyncSession,
|
||||||
|
rule_type: CommissionRuleType,
|
||||||
|
tier: CommissionTier,
|
||||||
|
region_code: str,
|
||||||
|
is_campaign: bool,
|
||||||
|
exclude_rule_id: Optional[int] = None,
|
||||||
|
) -> Optional[CommissionRule]:
|
||||||
|
"""
|
||||||
|
Check if an active rule already exists with the exact same combination
|
||||||
|
of tier, region_code, and is_campaign.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
rule_type: L1_REWARD or L2_COMMISSION.
|
||||||
|
tier: The tier to check.
|
||||||
|
region_code: The region code to check.
|
||||||
|
is_campaign: Whether the rule is a campaign rule.
|
||||||
|
exclude_rule_id: Optional rule ID to exclude from the check
|
||||||
|
(used when updating an existing rule).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The conflicting CommissionRule if found, None otherwise.
|
||||||
|
"""
|
||||||
|
conditions = [
|
||||||
|
CommissionRule.rule_type == rule_type,
|
||||||
|
CommissionRule.tier == tier,
|
||||||
|
CommissionRule.region_code == region_code,
|
||||||
|
CommissionRule.is_campaign == is_campaign,
|
||||||
|
CommissionRule.is_active == True,
|
||||||
|
]
|
||||||
|
if exclude_rule_id is not None:
|
||||||
|
conditions.append(CommissionRule.id != exclude_rule_id)
|
||||||
|
|
||||||
|
stmt = select(CommissionRule).where(and_(*conditions)).limit(1)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# CRUD Operations
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def create_commission_rule(
|
||||||
|
db: AsyncSession,
|
||||||
|
data: CommissionRuleCreate,
|
||||||
|
admin_user_id: int,
|
||||||
|
) -> CommissionRule:
|
||||||
|
"""
|
||||||
|
Create a new commission rule with validation and conflict detection.
|
||||||
|
|
||||||
|
If an active rule with the same tier/region/is_campaign combination exists
|
||||||
|
and force_override is False, the conflicting rule info is returned instead
|
||||||
|
(caller should raise 409 Conflict).
|
||||||
|
|
||||||
|
If force_override is True, the conflicting rule is deactivated (is_active=False)
|
||||||
|
and the new rule is saved.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
data: Pydantic schema with rule data.
|
||||||
|
admin_user_id: ID of the admin creating the rule.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created CommissionRule instance, or the conflicting rule
|
||||||
|
if a conflict was detected and force_override was False.
|
||||||
|
"""
|
||||||
|
# ── Phase 3: Conflict Detection ──────────────────────────────────────
|
||||||
|
conflicting = await check_rule_conflict(
|
||||||
|
db,
|
||||||
|
rule_type=CommissionRuleType(data.rule_type.value),
|
||||||
|
tier=CommissionTier(data.tier.value),
|
||||||
|
region_code=data.region_code,
|
||||||
|
is_campaign=data.is_campaign,
|
||||||
|
)
|
||||||
|
|
||||||
|
if conflicting:
|
||||||
|
if not data.force_override:
|
||||||
|
logger.warning(
|
||||||
|
"Conflict detected: existing active rule id=%d conflicts with "
|
||||||
|
"new rule (type=%s tier=%s region=%s campaign=%s). "
|
||||||
|
"force_override=False, returning conflict.",
|
||||||
|
conflicting.id, data.rule_type, data.tier,
|
||||||
|
data.region_code, data.is_campaign,
|
||||||
|
)
|
||||||
|
return conflicting # Caller must raise 409
|
||||||
|
|
||||||
|
# Admin override: deactivate the conflicting rule
|
||||||
|
logger.info(
|
||||||
|
"Admin override: Deactivating overlapping rule ID %d "
|
||||||
|
"(type=%s tier=%s region=%s campaign=%s) in favor of new rule.",
|
||||||
|
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||||
|
conflicting.region_code, conflicting.is_campaign,
|
||||||
|
)
|
||||||
|
conflicting.is_active = False
|
||||||
|
db.add(conflicting)
|
||||||
|
|
||||||
|
rule = CommissionRule(
|
||||||
|
rule_type=CommissionRuleType(data.rule_type.value),
|
||||||
|
tier=CommissionTier(data.tier.value),
|
||||||
|
region_code=data.region_code,
|
||||||
|
xp_reward=data.xp_reward,
|
||||||
|
credit_reward=data.credit_reward,
|
||||||
|
commission_percent=data.commission_percent,
|
||||||
|
upline_commission_percent=data.upline_commission_percent,
|
||||||
|
renewal_commission_percent=data.renewal_commission_percent,
|
||||||
|
commission_max_amount=data.commission_max_amount,
|
||||||
|
# Phase 2: Szintenkénti plafonok és Gen2 megújítás
|
||||||
|
gen1_max_amount=data.gen1_max_amount,
|
||||||
|
gen2_max_amount=data.gen2_max_amount,
|
||||||
|
gen2_renewal_percent=data.gen2_renewal_percent,
|
||||||
|
is_campaign=data.is_campaign,
|
||||||
|
start_date=data.start_date,
|
||||||
|
end_date=data.end_date,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
is_active=data.is_active,
|
||||||
|
created_by=admin_user_id,
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
logger.info(
|
||||||
|
"Commission rule created: id=%d type=%s tier=%s region=%s",
|
||||||
|
rule.id, rule.rule_type, rule.tier, rule.region_code,
|
||||||
|
)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def update_commission_rule(
|
||||||
|
db: AsyncSession,
|
||||||
|
rule_id: int,
|
||||||
|
data: CommissionRuleUpdate,
|
||||||
|
) -> Optional[CommissionRule]:
|
||||||
|
"""
|
||||||
|
Partially update an existing commission rule with conflict detection.
|
||||||
|
|
||||||
|
Only the fields explicitly set in the update schema are applied.
|
||||||
|
If the update would create a conflict with another active rule and
|
||||||
|
force_override is False, the conflicting rule info is returned instead
|
||||||
|
(caller should raise 409 Conflict).
|
||||||
|
|
||||||
|
If force_override is True, the conflicting rule is deactivated.
|
||||||
|
|
||||||
|
Returns None if the rule does not exist.
|
||||||
|
"""
|
||||||
|
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
return None
|
||||||
|
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# ── Phase 3: Conflict Detection on update ────────────────────────────
|
||||||
|
# Determine the effective values after the update
|
||||||
|
effective_rule_type = (
|
||||||
|
CommissionRuleType(data.rule_type.value)
|
||||||
|
if data.rule_type is not None
|
||||||
|
else rule.rule_type
|
||||||
|
)
|
||||||
|
effective_tier = (
|
||||||
|
CommissionTier(data.tier.value)
|
||||||
|
if data.tier is not None
|
||||||
|
else rule.tier
|
||||||
|
)
|
||||||
|
effective_region = (
|
||||||
|
data.region_code
|
||||||
|
if data.region_code is not None
|
||||||
|
else rule.region_code
|
||||||
|
)
|
||||||
|
effective_campaign = (
|
||||||
|
data.is_campaign
|
||||||
|
if data.is_campaign is not None
|
||||||
|
else rule.is_campaign
|
||||||
|
)
|
||||||
|
|
||||||
|
conflicting = await check_rule_conflict(
|
||||||
|
db,
|
||||||
|
rule_type=effective_rule_type,
|
||||||
|
tier=effective_tier,
|
||||||
|
region_code=effective_region,
|
||||||
|
is_campaign=effective_campaign,
|
||||||
|
exclude_rule_id=rule_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if conflicting:
|
||||||
|
if not data.force_override:
|
||||||
|
logger.warning(
|
||||||
|
"Conflict detected on update: existing active rule id=%d conflicts "
|
||||||
|
"with update to rule id=%d (type=%s tier=%s region=%s campaign=%s). "
|
||||||
|
"force_override=False, returning conflict.",
|
||||||
|
conflicting.id, rule_id, effective_rule_type,
|
||||||
|
effective_tier, effective_region, effective_campaign,
|
||||||
|
)
|
||||||
|
return conflicting # Caller must raise 409
|
||||||
|
|
||||||
|
# Admin override: deactivate the conflicting rule
|
||||||
|
logger.info(
|
||||||
|
"Admin override on update: Deactivating overlapping rule ID %d "
|
||||||
|
"(type=%s tier=%s region=%s campaign=%s) in favor of rule ID %d.",
|
||||||
|
conflicting.id, conflicting.rule_type, conflicting.tier,
|
||||||
|
conflicting.region_code, conflicting.is_campaign, rule_id,
|
||||||
|
)
|
||||||
|
conflicting.is_active = False
|
||||||
|
db.add(conflicting)
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
# Skip force_override — it's not a model field
|
||||||
|
if field == "force_override":
|
||||||
|
continue
|
||||||
|
# Map enum fields from schema enums to model enums
|
||||||
|
if field == "rule_type" and value is not None:
|
||||||
|
setattr(rule, field, CommissionRuleType(value.value))
|
||||||
|
elif field == "tier" and value is not None:
|
||||||
|
setattr(rule, field, CommissionTier(value.value))
|
||||||
|
else:
|
||||||
|
setattr(rule, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
logger.info("Commission rule updated: id=%d", rule.id)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def deactivate_commission_rule(
|
||||||
|
db: AsyncSession,
|
||||||
|
rule_id: int,
|
||||||
|
) -> Optional[CommissionRule]:
|
||||||
|
"""
|
||||||
|
Soft-delete a commission rule by setting is_active=False.
|
||||||
|
|
||||||
|
Returns the deactivated rule, or None if not found.
|
||||||
|
"""
|
||||||
|
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rule.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(rule)
|
||||||
|
logger.info("Commission rule deactivated: id=%d", rule.id)
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
async def hard_delete_commission_rule(
|
||||||
|
db: AsyncSession,
|
||||||
|
rule_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Permanently delete a commission rule (admin-only, use with caution).
|
||||||
|
|
||||||
|
Returns True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
stmt = select(CommissionRule).where(CommissionRule.id == rule_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await db.delete(rule)
|
||||||
|
await db.commit()
|
||||||
|
logger.info("Commission rule hard-deleted: id=%d", rule_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Priority Resolution Engine
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_rule(
|
||||||
|
db: AsyncSession,
|
||||||
|
rule_type: CommissionRuleType,
|
||||||
|
tier: CommissionTier,
|
||||||
|
region_code: str,
|
||||||
|
transaction_date: date,
|
||||||
|
) -> Optional[CommissionRule]:
|
||||||
|
"""
|
||||||
|
Priority Resolution Algorithm: Find the most specific active rule.
|
||||||
|
|
||||||
|
Resolution order (highest priority first):
|
||||||
|
1. Campaign rules (is_campaign=True) over permanent rules
|
||||||
|
2. Most specific region match (e.g. "HU" > "GLOBAL")
|
||||||
|
3. Highest tier match (PLATINUM > VIP > STANDARD > ENTERPRISE)
|
||||||
|
|
||||||
|
Falls back to GLOBAL/STANDARD default if no specific match exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
rule_type: L1_REWARD or L2_COMMISSION.
|
||||||
|
tier: The referrer/buyer's tier.
|
||||||
|
region_code: ISO 3166-1 alpha-2 region code.
|
||||||
|
transaction_date: The date of the transaction.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The best matching CommissionRule, or None if no rule exists.
|
||||||
|
"""
|
||||||
|
# Build priority expressions using SQLAlchemy case()
|
||||||
|
# Lower numeric value = higher priority
|
||||||
|
campaign_priority = case(
|
||||||
|
(CommissionRule.is_campaign == True, 0), # campaigns first
|
||||||
|
else_=1
|
||||||
|
)
|
||||||
|
|
||||||
|
region_priority = case(
|
||||||
|
(CommissionRule.region_code == region_code, 0), # exact region match
|
||||||
|
(CommissionRule.region_code == "GLOBAL", 1), # global fallback
|
||||||
|
else_=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tier ordering: PLATINUM (0) > VIP (1) > STANDARD (2) > ENTERPRISE (3) > CONTRACTED (4)
|
||||||
|
tier_order = {
|
||||||
|
CommissionTier.PLATINUM: 0,
|
||||||
|
CommissionTier.VIP: 1,
|
||||||
|
CommissionTier.STANDARD: 2,
|
||||||
|
CommissionTier.ENTERPRISE: 3,
|
||||||
|
CommissionTier.CONTRACTED: 4,
|
||||||
|
}
|
||||||
|
tier_priority = case(
|
||||||
|
*[(CommissionRule.tier == k, v) for k, v in tier_order.items()],
|
||||||
|
else_=99
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(CommissionRule)
|
||||||
|
.where(
|
||||||
|
CommissionRule.rule_type == rule_type,
|
||||||
|
CommissionRule.is_active == True,
|
||||||
|
# Date range: NULL means "always valid"
|
||||||
|
or_(
|
||||||
|
CommissionRule.start_date.is_(None),
|
||||||
|
CommissionRule.start_date <= transaction_date
|
||||||
|
),
|
||||||
|
or_(
|
||||||
|
CommissionRule.end_date.is_(None),
|
||||||
|
CommissionRule.end_date >= transaction_date
|
||||||
|
),
|
||||||
|
# Region: match exact or GLOBAL
|
||||||
|
or_(
|
||||||
|
CommissionRule.region_code == region_code,
|
||||||
|
CommissionRule.region_code == "GLOBAL"
|
||||||
|
),
|
||||||
|
# Tier: match exact or STANDARD fallback
|
||||||
|
or_(
|
||||||
|
CommissionRule.tier == tier,
|
||||||
|
CommissionRule.tier == CommissionTier.STANDARD
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(campaign_priority, region_priority, tier_priority)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if rule:
|
||||||
|
logger.debug(
|
||||||
|
"Active rule resolved: id=%d type=%s tier=%s region=%s campaign=%s",
|
||||||
|
rule.id, rule.rule_type, rule.tier, rule.region_code, rule.is_campaign,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No active rule found for type=%s tier=%s region=%s date=%s",
|
||||||
|
rule_type, tier, region_code, transaction_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Admin Listing with Filters & Pagination
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def list_rules(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
rule_type: Optional[CommissionRuleType] = None,
|
||||||
|
tier: Optional[CommissionTier] = None,
|
||||||
|
region_code: Optional[str] = None,
|
||||||
|
is_active: Optional[bool] = None,
|
||||||
|
is_campaign: Optional[bool] = None,
|
||||||
|
) -> tuple[Sequence[CommissionRule], int]:
|
||||||
|
"""
|
||||||
|
List commission rules with optional filters and pagination.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (rules_list, total_count).
|
||||||
|
"""
|
||||||
|
# Build base query
|
||||||
|
base_query = select(CommissionRule)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
conditions = []
|
||||||
|
if rule_type is not None:
|
||||||
|
conditions.append(CommissionRule.rule_type == rule_type)
|
||||||
|
if tier is not None:
|
||||||
|
conditions.append(CommissionRule.tier == tier)
|
||||||
|
if region_code is not None:
|
||||||
|
conditions.append(CommissionRule.region_code == region_code)
|
||||||
|
if is_active is not None:
|
||||||
|
conditions.append(CommissionRule.is_active == is_active)
|
||||||
|
if is_campaign is not None:
|
||||||
|
conditions.append(CommissionRule.is_campaign == is_campaign)
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
base_query = base_query.where(and_(*conditions))
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
count_result = await db.execute(count_query)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Apply pagination and ordering
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
stmt = (
|
||||||
|
base_query
|
||||||
|
.order_by(CommissionRule.updated_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rules = result.scalars().all()
|
||||||
|
|
||||||
|
return rules, total
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 2-Level MLM Commission Distribution Engine
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _lookup_user(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Look up a user by ID, return None if not found or deleted."""
|
||||||
|
stmt = select(User).where(
|
||||||
|
User.id == user_id,
|
||||||
|
User.is_deleted == False,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _calculate_commission(
|
||||||
|
amount: float,
|
||||||
|
percent: Optional[float],
|
||||||
|
max_amount: Optional[float],
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate commission amount from a percentage, capped by max_amount.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
amount: The transaction/subscription amount.
|
||||||
|
percent: The commission percentage (e.g. 5.00 = 5%).
|
||||||
|
max_amount: Optional cap on the commission payout.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The calculated commission amount.
|
||||||
|
"""
|
||||||
|
if not percent or percent <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
commission = float(Decimal(str(amount)) * Decimal(str(percent)) / Decimal("100"))
|
||||||
|
|
||||||
|
if max_amount is not None and max_amount > 0:
|
||||||
|
commission = min(commission, float(max_amount))
|
||||||
|
|
||||||
|
return round(commission, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 2: Wallet Integration Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _credit_commission_to_wallet(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
amount: float,
|
||||||
|
transaction_type: str,
|
||||||
|
details: Optional[dict] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Credit commission amount to the user's Wallet.earned_credits and record
|
||||||
|
a FinancialLedger CREDIT entry.
|
||||||
|
|
||||||
|
Follows the proven pattern from GamificationService._add_earned_credits().
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
user_id: The user receiving the commission.
|
||||||
|
amount: The commission amount to credit.
|
||||||
|
transaction_type: Label for the ledger entry (e.g. 'COMMISSION_GEN1').
|
||||||
|
details: Optional JSON metadata for the ledger entry.
|
||||||
|
"""
|
||||||
|
stmt = select(Wallet).where(Wallet.user_id == user_id)
|
||||||
|
wallet = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not wallet:
|
||||||
|
logger.error(
|
||||||
|
"Cannot credit commission: Wallet not found for user_id=%d",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
decimal_amount = Decimal(str(amount))
|
||||||
|
wallet.earned_credits += decimal_amount
|
||||||
|
|
||||||
|
ledger_entry = FinancialLedger(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=amount,
|
||||||
|
entry_type=LedgerEntryType.CREDIT,
|
||||||
|
wallet_type=WalletType.EARNED,
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
status=LedgerStatus.COMPLETED,
|
||||||
|
details=details or {},
|
||||||
|
)
|
||||||
|
db.add(ledger_entry)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Commission credited: user_id=%d amount=%.2f type=%s wallet_balance=%s",
|
||||||
|
user_id, amount, transaction_type, wallet.earned_credits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_inactivity_threshold(db: AsyncSession, default: int = 180) -> int:
|
||||||
|
"""
|
||||||
|
Read the inactivity_threshold_days from system.system_parameters.
|
||||||
|
Falls back to `default` (180) if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stmt = select(SystemParameter).where(
|
||||||
|
SystemParameter.key == "inactivity_threshold_days",
|
||||||
|
SystemParameter.scope_level == "global",
|
||||||
|
SystemParameter.is_active == True,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
param = result.scalar_one_or_none()
|
||||||
|
if param is not None:
|
||||||
|
val = param.value
|
||||||
|
if isinstance(val, dict):
|
||||||
|
return int(val.get("value", val.get("days", default)))
|
||||||
|
return int(val)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to read inactivity_threshold_days from DB: {e}. "
|
||||||
|
f"Falling back to {default}."
|
||||||
|
)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_user_recently_active(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
inactivity_days: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has been active within the inactivity threshold.
|
||||||
|
|
||||||
|
A user is considered active if:
|
||||||
|
1. Their subscription is active (subscription_expires_at > now), OR
|
||||||
|
2. They have a FinancialLedger entry within the inactivity window.
|
||||||
|
|
||||||
|
The inactivity threshold is read from system.system_parameters
|
||||||
|
(key: inactivity_threshold_days), falling back to 180 days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
user_id: The user to check.
|
||||||
|
inactivity_days: Optional override. If None, reads from DB settings.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the user is recently active, False otherwise.
|
||||||
|
"""
|
||||||
|
# Resolve threshold: parameter override or DB setting
|
||||||
|
if inactivity_days is None:
|
||||||
|
inactivity_days = await _get_inactivity_threshold(db)
|
||||||
|
|
||||||
|
# Check subscription status first
|
||||||
|
user = await _lookup_user(db, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If user has an active subscription, they are active
|
||||||
|
if user.subscription_expires_at and user.subscription_expires_at > datetime.utcnow():
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if there's any FinancialLedger activity within the inactivity window
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=inactivity_days)
|
||||||
|
stmt = select(FinancialLedger).where(
|
||||||
|
FinancialLedger.user_id == user_id,
|
||||||
|
FinancialLedger.created_at >= cutoff_date,
|
||||||
|
).limit(1)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
recent_activity = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
return recent_activity is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 2: Enhanced 2-Level MLM Commission Distribution Engine
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def distribute_commission(
|
||||||
|
db: AsyncSession,
|
||||||
|
request: CommissionDistributionRequest,
|
||||||
|
) -> CommissionDistributionResponse:
|
||||||
|
"""
|
||||||
|
2-Level MLM Commission Distribution Engine (Phase 2 Enhanced).
|
||||||
|
|
||||||
|
When a referred company makes a purchase, this function:
|
||||||
|
1. Looks up the buyer and their referrer (Gen1)
|
||||||
|
2. Finds the active commission rule for Gen1's tier/region
|
||||||
|
3. Calculates Gen1's commission using commission_percent
|
||||||
|
- Uses gen1_max_amount as cap (fallback: commission_max_amount)
|
||||||
|
4. If Gen1 has a referrer (Gen2/upline), checks Gen1's activity status:
|
||||||
|
- If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||||
|
- If Gen1 is active, calculates Gen2's commission
|
||||||
|
5. For renewals (is_renewal=True):
|
||||||
|
- Gen1 uses commission_percent (same as first-time)
|
||||||
|
- Gen2 uses gen2_renewal_percent (fallback: upline_commission_percent)
|
||||||
|
- Caps use gen2_max_amount (fallback: commission_max_amount)
|
||||||
|
6. Credits commissions to Wallet.earned_credits + FinancialLedger
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
request: Distribution request with buyer_user_id, transaction_amount,
|
||||||
|
transaction_date, region_code, and is_renewal.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommissionDistributionResponse with payout breakdown for Gen1 and Gen2.
|
||||||
|
"""
|
||||||
|
items: List[CommissionDistributionItem] = []
|
||||||
|
total_commission = 0.0
|
||||||
|
|
||||||
|
# 1. Look up the buyer
|
||||||
|
buyer = await _lookup_user(db, request.buyer_user_id)
|
||||||
|
if not buyer:
|
||||||
|
logger.warning(
|
||||||
|
"Commission distribution: buyer user %d not found or deleted",
|
||||||
|
request.buyer_user_id,
|
||||||
|
)
|
||||||
|
return CommissionDistributionResponse(
|
||||||
|
transaction_amount=request.transaction_amount,
|
||||||
|
items=[],
|
||||||
|
total_commission=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Find Gen1 (the buyer's direct referrer)
|
||||||
|
gen1_user_id = buyer.referred_by_id
|
||||||
|
if not gen1_user_id:
|
||||||
|
logger.info(
|
||||||
|
"Commission distribution: buyer %d has no referrer (Gen1)",
|
||||||
|
request.buyer_user_id,
|
||||||
|
)
|
||||||
|
return CommissionDistributionResponse(
|
||||||
|
transaction_amount=request.transaction_amount,
|
||||||
|
items=[],
|
||||||
|
total_commission=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
gen1 = await _lookup_user(db, gen1_user_id)
|
||||||
|
if not gen1:
|
||||||
|
logger.warning(
|
||||||
|
"Commission distribution: Gen1 user %d not found or deleted",
|
||||||
|
gen1_user_id,
|
||||||
|
)
|
||||||
|
return CommissionDistributionResponse(
|
||||||
|
transaction_amount=request.transaction_amount,
|
||||||
|
items=[],
|
||||||
|
total_commission=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Resolve the active commission rule for Gen1
|
||||||
|
gen1_tier = CommissionTier(gen1.commission_tier) if hasattr(gen1, 'commission_tier') and gen1.commission_tier else CommissionTier.STANDARD
|
||||||
|
try:
|
||||||
|
gen1_tier_enum = CommissionTier(gen1_tier)
|
||||||
|
except ValueError:
|
||||||
|
gen1_tier_enum = CommissionTier.STANDARD
|
||||||
|
|
||||||
|
rule = await get_active_rule(
|
||||||
|
db,
|
||||||
|
rule_type=CommissionRuleType.L2_COMMISSION,
|
||||||
|
tier=gen1_tier_enum,
|
||||||
|
region_code=request.region_code,
|
||||||
|
transaction_date=request.transaction_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rule:
|
||||||
|
logger.warning(
|
||||||
|
"Commission distribution: no active L2_COMMISSION rule for "
|
||||||
|
"tier=%s region=%s date=%s",
|
||||||
|
gen1_tier_enum, request.region_code, request.transaction_date,
|
||||||
|
)
|
||||||
|
return CommissionDistributionResponse(
|
||||||
|
transaction_amount=request.transaction_amount,
|
||||||
|
items=[],
|
||||||
|
total_commission=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Phase 2: Determine per-level caps ────────────────────────────────
|
||||||
|
# gen1_max_amount / gen2_max_amount override commission_max_amount
|
||||||
|
# NULL or 0 means unlimited (no cap)
|
||||||
|
gen1_cap = (
|
||||||
|
float(rule.gen1_max_amount)
|
||||||
|
if rule.gen1_max_amount is not None and rule.gen1_max_amount > 0
|
||||||
|
else (
|
||||||
|
float(rule.commission_max_amount)
|
||||||
|
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
gen2_cap = (
|
||||||
|
float(rule.gen2_max_amount)
|
||||||
|
if rule.gen2_max_amount is not None and rule.gen2_max_amount > 0
|
||||||
|
else (
|
||||||
|
float(rule.commission_max_amount)
|
||||||
|
if rule.commission_max_amount is not None and rule.commission_max_amount > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Phase 2: Determine Gen2 percent (renewal vs first-time) ──────────
|
||||||
|
gen2_percent = (
|
||||||
|
float(rule.gen2_renewal_percent)
|
||||||
|
if request.is_renewal and rule.gen2_renewal_percent is not None
|
||||||
|
else (
|
||||||
|
float(rule.upline_commission_percent)
|
||||||
|
if rule.upline_commission_percent is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Calculate Gen1 commission
|
||||||
|
gen1_amount = await _calculate_commission(
|
||||||
|
request.transaction_amount,
|
||||||
|
float(rule.commission_percent) if rule.commission_percent else None,
|
||||||
|
gen1_cap,
|
||||||
|
)
|
||||||
|
|
||||||
|
if gen1_amount > 0:
|
||||||
|
items.append(CommissionDistributionItem(
|
||||||
|
level=1,
|
||||||
|
user_id=gen1.id,
|
||||||
|
commission_percent=float(rule.commission_percent) if rule.commission_percent else 0.0,
|
||||||
|
commission_amount=gen1_amount,
|
||||||
|
rule_id=rule.id,
|
||||||
|
))
|
||||||
|
total_commission += gen1_amount
|
||||||
|
|
||||||
|
# ── Phase 2: Credit Gen1 commission to Wallet ───────────────────
|
||||||
|
await _credit_commission_to_wallet(
|
||||||
|
db,
|
||||||
|
user_id=gen1.id,
|
||||||
|
amount=gen1_amount,
|
||||||
|
transaction_type="COMMISSION_GEN1",
|
||||||
|
details={
|
||||||
|
"rule_id": rule.id,
|
||||||
|
"buyer_user_id": request.buyer_user_id,
|
||||||
|
"transaction_amount": request.transaction_amount,
|
||||||
|
"is_renewal": request.is_renewal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Find Gen2 (Gen1's referrer / upline)
|
||||||
|
gen2_user_id = gen1.referred_by_id
|
||||||
|
if gen2_user_id:
|
||||||
|
gen2 = await _lookup_user(db, gen2_user_id)
|
||||||
|
if gen2:
|
||||||
|
# ── Phase 2: Inactive Gen1 prevention ───────────────────────
|
||||||
|
# If Gen1 is inactive (no activity for 180 days), Gen2 is NOT paid
|
||||||
|
gen1_active = await _is_user_recently_active(db, gen1.id)
|
||||||
|
if not gen1_active:
|
||||||
|
logger.info(
|
||||||
|
"Commission distribution: Gen1 user %d is inactive "
|
||||||
|
"(>180 days). Skipping Gen2 payout.",
|
||||||
|
gen1.id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 6. Calculate Gen2 commission
|
||||||
|
gen2_amount = await _calculate_commission(
|
||||||
|
request.transaction_amount,
|
||||||
|
gen2_percent,
|
||||||
|
gen2_cap,
|
||||||
|
)
|
||||||
|
|
||||||
|
if gen2_amount > 0:
|
||||||
|
items.append(CommissionDistributionItem(
|
||||||
|
level=2,
|
||||||
|
user_id=gen2.id,
|
||||||
|
commission_percent=gen2_percent or 0.0,
|
||||||
|
commission_amount=gen2_amount,
|
||||||
|
rule_id=rule.id,
|
||||||
|
))
|
||||||
|
total_commission += gen2_amount
|
||||||
|
|
||||||
|
# ── Phase 2: Credit Gen2 commission to Wallet ───────
|
||||||
|
await _credit_commission_to_wallet(
|
||||||
|
db,
|
||||||
|
user_id=gen2.id,
|
||||||
|
amount=gen2_amount,
|
||||||
|
transaction_type="COMMISSION_GEN2",
|
||||||
|
details={
|
||||||
|
"rule_id": rule.id,
|
||||||
|
"buyer_user_id": request.buyer_user_id,
|
||||||
|
"gen1_user_id": gen1.id,
|
||||||
|
"transaction_amount": request.transaction_amount,
|
||||||
|
"is_renewal": request.is_renewal,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Commission distribution: Gen2 user %d not found or deleted",
|
||||||
|
gen2_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Phase 2: Commit all wallet/ledger changes ────────────────────────
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Commission distributed: buyer=%d gen1=%d(%.2f%%) gen2=%d(%.2f%%) "
|
||||||
|
"amount=%.2f total_commission=%.2f rule=%d is_renewal=%s",
|
||||||
|
request.buyer_user_id,
|
||||||
|
gen1.id,
|
||||||
|
float(rule.commission_percent) if rule.commission_percent else 0,
|
||||||
|
gen2_user_id or 0,
|
||||||
|
gen2_percent or 0,
|
||||||
|
request.transaction_amount,
|
||||||
|
total_commission,
|
||||||
|
rule.id,
|
||||||
|
request.is_renewal,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommissionDistributionResponse(
|
||||||
|
transaction_amount=request.transaction_amount,
|
||||||
|
items=items,
|
||||||
|
total_commission=round(total_commission, 2),
|
||||||
|
)
|
||||||
519
backend/app/services/financial_manager.py
Normal file
519
backend/app/services/financial_manager.py
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/services/financial_manager.py
|
||||||
|
"""
|
||||||
|
Financial Manager — Unified orchestrator for the package purchase lifecycle.
|
||||||
|
|
||||||
|
Coordinates the complete flow:
|
||||||
|
1. Validate tier exists and is purchasable
|
||||||
|
2. Calculate price (via PricingCalculator)
|
||||||
|
3. Create PaymentIntent (PENDING)
|
||||||
|
4. Process payment via configurable gateway (MockPaymentGateway / StripeAdapter)
|
||||||
|
5. Activate subscription (User or Organization level, with stacking)
|
||||||
|
6. Distribute MLM commissions (async via background task)
|
||||||
|
7. Return full purchase receipt
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- Fully decoupled from payment gateway implementation (Strategy Pattern).
|
||||||
|
- Commission distribution is separated so the caller can run it as a
|
||||||
|
BackgroundTask (async) without blocking the payment response.
|
||||||
|
- Uses the existing PaymentRouter for PaymentIntent creation and the
|
||||||
|
existing BillingEngine for price calculation.
|
||||||
|
- All database mutations happen within a single session for atomicity.
|
||||||
|
- Returns a PurchaseResult DTO with all relevant details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.core_logic import SubscriptionTier
|
||||||
|
from app.models.identity.identity import User
|
||||||
|
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||||
|
from app.models.system.audit import WalletType
|
||||||
|
|
||||||
|
from app.services.financial_interfaces import (
|
||||||
|
BasePaymentGateway,
|
||||||
|
PaymentGatewayError,
|
||||||
|
InsufficientFundsError,
|
||||||
|
)
|
||||||
|
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||||
|
from app.services.subscription_activator import (
|
||||||
|
SubscriptionActivator,
|
||||||
|
TierNotFoundError,
|
||||||
|
)
|
||||||
|
from app.services.billing_engine import PricingCalculator
|
||||||
|
from app.services.payment_router import PaymentRouter
|
||||||
|
from app.schemas.commission import (
|
||||||
|
CommissionDistributionRequest,
|
||||||
|
CommissionDistributionResponse,
|
||||||
|
)
|
||||||
|
from app.services import commission_service
|
||||||
|
|
||||||
|
logger = logging.getLogger("financial-manager")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Data Transfer Objects
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseResult:
|
||||||
|
"""
|
||||||
|
Result DTO for a completed package purchase.
|
||||||
|
|
||||||
|
Contains all information needed for the API response and receipt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
success: bool,
|
||||||
|
payment_intent_id: Optional[int] = None,
|
||||||
|
transaction_id: Optional[str] = None,
|
||||||
|
subscription_id: Optional[int] = None,
|
||||||
|
tier_name: Optional[str] = None,
|
||||||
|
valid_from: Optional[datetime] = None,
|
||||||
|
valid_until: Optional[datetime] = None,
|
||||||
|
amount_paid: float = 0.0,
|
||||||
|
currency: str = "EUR",
|
||||||
|
gateway: str = "mock",
|
||||||
|
gateway_intent_id: Optional[str] = None,
|
||||||
|
commission_result: Optional[CommissionDistributionResponse] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
is_org_subscription: bool = False,
|
||||||
|
):
|
||||||
|
self.success = success
|
||||||
|
self.payment_intent_id = payment_intent_id
|
||||||
|
self.transaction_id = transaction_id
|
||||||
|
self.subscription_id = subscription_id
|
||||||
|
self.tier_name = tier_name
|
||||||
|
self.valid_from = valid_from
|
||||||
|
self.valid_until = valid_until
|
||||||
|
self.amount_paid = amount_paid
|
||||||
|
self.currency = currency
|
||||||
|
self.gateway = gateway
|
||||||
|
self.gateway_intent_id = gateway_intent_id
|
||||||
|
self.commission_result = commission_result
|
||||||
|
self.error = error
|
||||||
|
self.is_org_subscription = is_org_subscription
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Serialize to a dict for API response."""
|
||||||
|
result = {
|
||||||
|
"success": self.success,
|
||||||
|
"payment_intent_id": self.payment_intent_id,
|
||||||
|
"transaction_id": self.transaction_id,
|
||||||
|
"subscription_id": self.subscription_id,
|
||||||
|
"tier_name": self.tier_name,
|
||||||
|
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
|
||||||
|
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
||||||
|
"amount_paid": self.amount_paid,
|
||||||
|
"currency": self.currency,
|
||||||
|
"gateway": self.gateway,
|
||||||
|
"gateway_intent_id": self.gateway_intent_id,
|
||||||
|
"is_org_subscription": self.is_org_subscription,
|
||||||
|
}
|
||||||
|
if self.commission_result:
|
||||||
|
result["commission"] = {
|
||||||
|
"total_commission": self.commission_result.total_commission,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"level": item.level,
|
||||||
|
"user_id": item.user_id,
|
||||||
|
"commission_percent": item.commission_percent,
|
||||||
|
"commission_amount": item.commission_amount,
|
||||||
|
}
|
||||||
|
for item in self.commission_result.items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if self.error:
|
||||||
|
result["error"] = self.error
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Financial Manager
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialManager:
|
||||||
|
"""
|
||||||
|
Unified orchestrator for the package purchase lifecycle.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
manager = FinancialManager(payment_gateway=MockPaymentGateway())
|
||||||
|
result = await manager.purchase_package(db, user_id=1, tier_id=2)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
payment_gateway: Optional[BasePaymentGateway] = None,
|
||||||
|
subscription_activator: Optional[SubscriptionActivator] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the FinancialManager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_gateway: Payment gateway implementation. Defaults to
|
||||||
|
MockPaymentGateway (auto_approve mode).
|
||||||
|
subscription_activator: Subscription activator. Defaults to a new
|
||||||
|
SubscriptionActivator instance.
|
||||||
|
"""
|
||||||
|
self.payment_gateway = payment_gateway or MockPaymentGateway()
|
||||||
|
self.subscription_activator = subscription_activator or SubscriptionActivator()
|
||||||
|
self.pricing_calculator = PricingCalculator()
|
||||||
|
logger.info(
|
||||||
|
"FinancialManager initialized with gateway=%s",
|
||||||
|
type(self.payment_gateway).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Main Purchase Flow
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def purchase_package(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
tier_id: int,
|
||||||
|
org_id: Optional[int] = None,
|
||||||
|
region_code: str = "GLOBAL",
|
||||||
|
currency: str = "EUR",
|
||||||
|
duration_days: Optional[int] = None,
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> PurchaseResult:
|
||||||
|
"""
|
||||||
|
Execute the full package purchase lifecycle.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Validate the tier exists and is purchasable
|
||||||
|
2. Calculate the price
|
||||||
|
3. Create a PaymentIntent (PENDING)
|
||||||
|
4. Process payment via the configured gateway
|
||||||
|
5. Activate the subscription (User or Org level)
|
||||||
|
6. Distribute MLM commissions (returned for async execution)
|
||||||
|
7. Return the PurchaseResult
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
user_id: The purchasing user.
|
||||||
|
tier_id: The SubscriptionTier ID to purchase.
|
||||||
|
org_id: Optional organization ID for org-level subscriptions.
|
||||||
|
region_code: Region code for pricing (default: GLOBAL).
|
||||||
|
currency: Currency code (default: EUR).
|
||||||
|
duration_days: Override duration in days (default: from tier.rules).
|
||||||
|
metadata: Optional metadata for the PaymentIntent.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PurchaseResult with full purchase details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TierNotFoundError: If the tier does not exist.
|
||||||
|
PaymentGatewayError: If payment processing fails.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Purchase flow started: user_id=%d tier_id=%d org_id=%s",
|
||||||
|
user_id, tier_id, org_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── Step 1: Validate tier ──────────────────────────────────────
|
||||||
|
tier = await self._validate_tier(db, tier_id)
|
||||||
|
|
||||||
|
# ── Step 2: Calculate price ────────────────────────────────────
|
||||||
|
price = await self._calculate_price(db, tier, region_code, currency)
|
||||||
|
|
||||||
|
# ── Step 3: Create PaymentIntent ───────────────────────────────
|
||||||
|
payment_intent = await self._create_payment_intent(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
amount=price,
|
||||||
|
currency=currency,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 4: Process payment via gateway ────────────────────────
|
||||||
|
gateway_result = await self.payment_gateway.create_intent(
|
||||||
|
amount=Decimal(str(price)),
|
||||||
|
currency=currency,
|
||||||
|
metadata={
|
||||||
|
"payment_intent_id": payment_intent.id,
|
||||||
|
"tier_id": tier_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
**(metadata or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark PaymentIntent as COMPLETED
|
||||||
|
payment_intent.status = PaymentIntentStatus.COMPLETED
|
||||||
|
payment_intent.stripe_session_id = gateway_result.get("id")
|
||||||
|
payment_intent.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# ── Step 5: Activate subscription ──────────────────────────────
|
||||||
|
if org_id:
|
||||||
|
subscription = await self.subscription_activator.activate_org_subscription(
|
||||||
|
db=db,
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=tier_id,
|
||||||
|
duration_days=duration_days,
|
||||||
|
)
|
||||||
|
is_org = True
|
||||||
|
else:
|
||||||
|
subscription = await self.subscription_activator.activate_user_subscription(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
tier_id=tier_id,
|
||||||
|
duration_days=duration_days,
|
||||||
|
)
|
||||||
|
is_org = False
|
||||||
|
|
||||||
|
# ── Step 6: Prepare commission distribution (for async caller) ─
|
||||||
|
commission_result = await self._distribute_commission(
|
||||||
|
db=db,
|
||||||
|
buyer_user_id=user_id,
|
||||||
|
transaction_amount=price,
|
||||||
|
region_code=region_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 7: Commit all changes ─────────────────────────────────
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
|
||||||
|
"subscription_id=%d commission_total=%.2f",
|
||||||
|
user_id, tier.name, price,
|
||||||
|
subscription.id,
|
||||||
|
commission_result.total_commission if commission_result else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PurchaseResult(
|
||||||
|
success=True,
|
||||||
|
payment_intent_id=payment_intent.id,
|
||||||
|
transaction_id=str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||||
|
subscription_id=subscription.id,
|
||||||
|
tier_name=tier.name,
|
||||||
|
valid_from=subscription.valid_from,
|
||||||
|
valid_until=subscription.valid_until,
|
||||||
|
amount_paid=price,
|
||||||
|
currency=currency,
|
||||||
|
gateway=type(self.payment_gateway).__name__,
|
||||||
|
gateway_intent_id=gateway_result.get("id"),
|
||||||
|
commission_result=commission_result,
|
||||||
|
is_org_subscription=is_org,
|
||||||
|
)
|
||||||
|
|
||||||
|
except PaymentGatewayError as e:
|
||||||
|
# Mark PaymentIntent as FAILED
|
||||||
|
payment_intent.status = PaymentIntentStatus.FAILED
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"Purchase flow FAILED (payment): user_id=%d tier_id=%d error=%s",
|
||||||
|
user_id, tier_id, str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
return PurchaseResult(
|
||||||
|
success=False,
|
||||||
|
payment_intent_id=payment_intent.id,
|
||||||
|
error=f"Payment failed: {str(e)}",
|
||||||
|
amount_paid=0,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
except TierNotFoundError as e:
|
||||||
|
# Tier not found — no PaymentIntent was created, just return error
|
||||||
|
logger.error(
|
||||||
|
"Purchase flow FAILED (tier not found): user_id=%d tier_id=%d error=%s",
|
||||||
|
user_id, tier_id, str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
return PurchaseResult(
|
||||||
|
success=False,
|
||||||
|
error=str(e),
|
||||||
|
amount_paid=0,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Check if payment_intent exists before trying to modify it
|
||||||
|
payment_intent_id = payment_intent.id if 'payment_intent' in dir() or 'payment_intent' in locals() else None
|
||||||
|
if payment_intent_id:
|
||||||
|
payment_intent.status = PaymentIntentStatus.FAILED
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.exception(
|
||||||
|
"Purchase flow FAILED (unexpected): user_id=%d tier_id=%d",
|
||||||
|
user_id, tier_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PurchaseResult(
|
||||||
|
success=False,
|
||||||
|
payment_intent_id=payment_intent_id,
|
||||||
|
error=f"Unexpected error: {str(e)}",
|
||||||
|
amount_paid=0,
|
||||||
|
currency=currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Internal Steps
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _validate_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||||
|
"""
|
||||||
|
Validate that the tier exists and is purchasable.
|
||||||
|
|
||||||
|
Raises TierNotFoundError if the tier doesn't exist.
|
||||||
|
Additional validation (e.g., is_public, available_until) can be added here.
|
||||||
|
"""
|
||||||
|
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
tier = result.scalar_one_or_none()
|
||||||
|
if not tier:
|
||||||
|
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||||
|
return tier
|
||||||
|
|
||||||
|
async def _calculate_price(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tier: SubscriptionTier,
|
||||||
|
region_code: str,
|
||||||
|
currency: str,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate the price for this tier using the PricingCalculator.
|
||||||
|
|
||||||
|
Pricing is extracted from tier.rules["pricing_zones"] using the following priority:
|
||||||
|
1. Exact region_code match (e.g., "HU")
|
||||||
|
2. Currency match within pricing_zones
|
||||||
|
3. "DEFAULT" zone fallback
|
||||||
|
4. Legacy tier.rules["price"] fallback
|
||||||
|
|
||||||
|
Falls back to tier.rules["price"] if PricingCalculator returns 0.
|
||||||
|
"""
|
||||||
|
price = 0.0
|
||||||
|
|
||||||
|
if tier.rules:
|
||||||
|
# ── Extract from pricing_zones (P0 standard) ──────────────
|
||||||
|
pricing_zones = tier.rules.get("pricing_zones", {})
|
||||||
|
if pricing_zones:
|
||||||
|
# Priority 1: Exact region_code match
|
||||||
|
zone = pricing_zones.get(region_code)
|
||||||
|
if not zone:
|
||||||
|
# Priority 2: Currency match within zones
|
||||||
|
for z in pricing_zones.values():
|
||||||
|
if z.get("currency") == currency:
|
||||||
|
zone = z
|
||||||
|
break
|
||||||
|
if not zone:
|
||||||
|
# Priority 3: DEFAULT zone fallback
|
||||||
|
zone = pricing_zones.get("DEFAULT")
|
||||||
|
|
||||||
|
if zone:
|
||||||
|
# Prefer monthly_price, fallback to yearly_price, then credit_price
|
||||||
|
price = float(zone.get("monthly_price", 0.0))
|
||||||
|
if price <= 0:
|
||||||
|
price = float(zone.get("yearly_price", 0.0))
|
||||||
|
if price <= 0:
|
||||||
|
price = float(zone.get("credit_price", 0.0))
|
||||||
|
|
||||||
|
# ── Legacy fallback: tier.rules["price"] ──────────────────
|
||||||
|
if price <= 0:
|
||||||
|
price = float(tier.rules.get("price", 0.0))
|
||||||
|
|
||||||
|
# Use PricingCalculator for region-adjusted pricing
|
||||||
|
if price > 0:
|
||||||
|
calculated = await PricingCalculator.calculate_final_price(
|
||||||
|
db=db,
|
||||||
|
base_amount=price,
|
||||||
|
country_code=region_code,
|
||||||
|
user_role=None, # No RBAC discount for purchases
|
||||||
|
)
|
||||||
|
if calculated > 0:
|
||||||
|
price = calculated
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Price calculated: tier=%s region=%s currency=%s final=%.2f",
|
||||||
|
tier.name, region_code, currency, price,
|
||||||
|
)
|
||||||
|
|
||||||
|
return price
|
||||||
|
|
||||||
|
async def _create_payment_intent(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
amount: float,
|
||||||
|
currency: str = "EUR",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> PaymentIntent:
|
||||||
|
"""
|
||||||
|
Create a PENDING PaymentIntent for the purchase.
|
||||||
|
|
||||||
|
Uses the existing PaymentRouter for consistency with the rest of the system.
|
||||||
|
"""
|
||||||
|
payment_intent = await PaymentRouter.create_payment_intent(
|
||||||
|
db=db,
|
||||||
|
payer_id=user_id,
|
||||||
|
net_amount=amount,
|
||||||
|
handling_fee=0.0,
|
||||||
|
target_wallet_type=WalletType.PURCHASED,
|
||||||
|
currency=currency,
|
||||||
|
metadata={
|
||||||
|
"source": "financial_manager",
|
||||||
|
"purchase_type": "subscription",
|
||||||
|
**(metadata or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"PaymentIntent created: id=%d user_id=%d amount=%.2f %s",
|
||||||
|
payment_intent.id, user_id, amount, currency,
|
||||||
|
)
|
||||||
|
|
||||||
|
return payment_intent
|
||||||
|
|
||||||
|
async def _distribute_commission(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
buyer_user_id: int,
|
||||||
|
transaction_amount: float,
|
||||||
|
region_code: str,
|
||||||
|
) -> Optional[CommissionDistributionResponse]:
|
||||||
|
"""
|
||||||
|
Distribute MLM commissions for this purchase.
|
||||||
|
|
||||||
|
Returns the commission result, or None if no commission was distributed.
|
||||||
|
This is designed to be callable from a BackgroundTask for async execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
buyer_user_id: The user who made the purchase.
|
||||||
|
transaction_amount: The amount paid.
|
||||||
|
region_code: Region code for rule matching.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CommissionDistributionResponse or None.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
request = CommissionDistributionRequest(
|
||||||
|
buyer_user_id=buyer_user_id,
|
||||||
|
transaction_amount=transaction_amount,
|
||||||
|
transaction_date=date.today(),
|
||||||
|
region_code=region_code,
|
||||||
|
is_renewal=False,
|
||||||
|
)
|
||||||
|
result = await commission_service.distribute_commission(db, request)
|
||||||
|
logger.info(
|
||||||
|
"Commission distributed: buyer=%d total=%.2f items=%d",
|
||||||
|
buyer_user_id, result.total_commission, len(result.items),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"Commission distribution failed for buyer=%d: %s",
|
||||||
|
buyer_user_id, str(e),
|
||||||
|
)
|
||||||
|
# Commission failure should not block the purchase
|
||||||
|
return None
|
||||||
@@ -393,22 +393,42 @@ class FinancialOrchestrator:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# 3. Pénztárca egyenleg visszaállítása
|
# 3. Pénztárca egyenleg visszaállítása
|
||||||
|
# JAVÍTÁS: A Wallet modellben NINCS "wallet_type" és "balance" mező.
|
||||||
|
# A usernek egyetlen wallet-je van (user_id UNIQUE constraint),
|
||||||
|
# a credit típusok külön oszlopokban: earned_credits, purchased_credits, service_coins.
|
||||||
|
# A wallet_type-t az eredeti FinancialLedger bejegyzésből olvassuk ki,
|
||||||
|
# és a megfelelő oszlopot frissítjük (pont mint process_payment()-ben).
|
||||||
wallet_query = select(Wallet).where(
|
wallet_query = select(Wallet).where(
|
||||||
and_(
|
Wallet.user_id == original_entry.user_id
|
||||||
Wallet.user_id == original_entry.user_id,
|
|
||||||
Wallet.wallet_type == original_entry.wallet_type
|
|
||||||
)
|
|
||||||
).with_for_update()
|
).with_for_update()
|
||||||
|
|
||||||
wallet_result = await db.execute(wallet_query)
|
wallet_result = await db.execute(wallet_query)
|
||||||
wallet = wallet_result.scalar_one_or_none()
|
wallet = wallet_result.scalar_one_or_none()
|
||||||
|
|
||||||
if wallet:
|
if wallet:
|
||||||
new_balance = wallet.balance - original_entry.amount
|
wallet_type = original_entry.wallet_type
|
||||||
|
update_values = {}
|
||||||
|
|
||||||
|
if wallet_type == WalletType.EARNED:
|
||||||
|
new_balance = Decimal(str(wallet.earned_credits)) + original_entry.amount
|
||||||
|
update_values['earned_credits'] = float(new_balance)
|
||||||
|
elif wallet_type == WalletType.PURCHASED:
|
||||||
|
new_balance = Decimal(str(wallet.purchased_credits)) + original_entry.amount
|
||||||
|
update_values['purchased_credits'] = float(new_balance)
|
||||||
|
elif wallet_type == WalletType.SERVICE_COINS:
|
||||||
|
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
|
||||||
|
update_values['service_coins'] = float(new_balance)
|
||||||
|
elif wallet_type == WalletType.VOUCHER:
|
||||||
|
# VOUCHER típusnál nincs dedikált mező, SERVICE_COINS-ba tesszük
|
||||||
|
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
|
||||||
|
update_values['service_coins'] = float(new_balance)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Wallet)
|
update(Wallet)
|
||||||
.where(Wallet.id == wallet.id)
|
.where(Wallet.id == wallet.id)
|
||||||
.values(balance=new_balance)
|
.values(**update_values)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4. Számlakiállító bevételének csökkentése
|
# 4. Számlakiállító bevételének csökkentése
|
||||||
|
|||||||
244
backend/app/services/mock_payment_gateway.py
Normal file
244
backend/app/services/mock_payment_gateway.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/services/mock_payment_gateway.py
|
||||||
|
"""
|
||||||
|
Mock Payment Gateway — Development/Testing implementation of BasePaymentGateway.
|
||||||
|
|
||||||
|
Simulates successful payment processing without requiring real Stripe keys.
|
||||||
|
Can be configured to simulate failures for testing error handling paths.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- Implements BasePaymentGateway abstract interface (Strategy Pattern).
|
||||||
|
- Default mode is "auto_approve" — all payments succeed immediately.
|
||||||
|
- Supports "simulate_failure" mode for testing error paths.
|
||||||
|
- Generates deterministic mock session IDs for traceability.
|
||||||
|
- Logs all payment attempts for debugging.
|
||||||
|
- Decoupled from FinancialManager via dependency injection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from app.services.financial_interfaces import (
|
||||||
|
BasePaymentGateway,
|
||||||
|
PaymentGatewayError,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("mock-payment-gateway")
|
||||||
|
|
||||||
|
|
||||||
|
class MockPaymentGateway(BasePaymentGateway):
|
||||||
|
"""
|
||||||
|
Mock payment gateway for development and testing.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
- auto_approve (default): All payments succeed immediately.
|
||||||
|
- simulate_failure: All payments fail with a configurable error message.
|
||||||
|
- simulate_timeout: Payments hang until a configurable timeout then succeed.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
gateway = MockPaymentGateway(mode="auto_approve")
|
||||||
|
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
mode: str = "auto_approve",
|
||||||
|
failure_message: str = "Mock payment declined (simulated failure)",
|
||||||
|
timeout_seconds: int = 5,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the mock gateway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Operation mode — "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||||
|
failure_message: Custom error message for simulate_failure mode.
|
||||||
|
timeout_seconds: Simulated processing delay for simulate_timeout mode.
|
||||||
|
"""
|
||||||
|
self.mode = mode
|
||||||
|
self.failure_message = failure_message
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self._processed_intents: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"MockPaymentGateway initialized: mode=%s, timeout=%ds",
|
||||||
|
self.mode, self.timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# BasePaymentGateway Implementation
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_intent(
|
||||||
|
self,
|
||||||
|
amount: Decimal,
|
||||||
|
currency: str = "EUR",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a mock payment intent.
|
||||||
|
|
||||||
|
In auto_approve mode, immediately returns a "completed" intent.
|
||||||
|
In simulate_failure mode, raises PaymentGatewayError.
|
||||||
|
In simulate_timeout mode, logs a warning and returns "processing".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
amount: The payment amount.
|
||||||
|
currency: ISO 4217 currency code (default: EUR).
|
||||||
|
metadata: Optional metadata dict.
|
||||||
|
**kwargs: Additional parameters (ignored in mock).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with mock payment intent details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PaymentGatewayError: In simulate_failure mode.
|
||||||
|
"""
|
||||||
|
intent_id = f"mock_intent_{uuid.uuid4().hex[:12]}"
|
||||||
|
logger.info(
|
||||||
|
"Mock create_intent: id=%s amount=%s %s mode=%s",
|
||||||
|
intent_id, amount, currency, self.mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.mode == "simulate_failure":
|
||||||
|
logger.warning(
|
||||||
|
"Mock payment FAILURE: intent=%s reason='%s'",
|
||||||
|
intent_id, self.failure_message,
|
||||||
|
)
|
||||||
|
raise PaymentGatewayError(self.failure_message)
|
||||||
|
|
||||||
|
if self.mode == "simulate_timeout":
|
||||||
|
logger.warning(
|
||||||
|
"Mock payment TIMEOUT simulation: intent=%s delay=%ds",
|
||||||
|
intent_id, self.timeout_seconds,
|
||||||
|
)
|
||||||
|
# In a real scenario we'd sleep; here we just return "processing"
|
||||||
|
# so the caller can handle the pending state.
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
intent_data = {
|
||||||
|
"id": intent_id,
|
||||||
|
"status": "completed" if self.mode == "auto_approve" else "processing",
|
||||||
|
"amount": float(amount),
|
||||||
|
"currency": currency,
|
||||||
|
"created_at": now.isoformat(),
|
||||||
|
"completed_at": now.isoformat() if self.mode == "auto_approve" else None,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
"gateway": "mock",
|
||||||
|
}
|
||||||
|
|
||||||
|
self._processed_intents[intent_id] = intent_data
|
||||||
|
return intent_data
|
||||||
|
|
||||||
|
async def verify_payment(
|
||||||
|
self,
|
||||||
|
payment_intent_id: str,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify a mock payment's status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_intent_id: The mock intent ID to verify.
|
||||||
|
**kwargs: Additional parameters (ignored in mock).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with verification details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PaymentGatewayError: If the intent ID is unknown.
|
||||||
|
"""
|
||||||
|
intent = self._processed_intents.get(payment_intent_id)
|
||||||
|
if not intent:
|
||||||
|
logger.warning(
|
||||||
|
"Mock verify_payment: unknown intent_id=%s",
|
||||||
|
payment_intent_id,
|
||||||
|
)
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
f"Mock payment intent '{payment_intent_id}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Mock verify_payment: intent=%s status=%s",
|
||||||
|
payment_intent_id, intent["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verified": intent["status"] == "completed",
|
||||||
|
"intent_id": payment_intent_id,
|
||||||
|
"status": intent["status"],
|
||||||
|
"amount": intent["amount"],
|
||||||
|
"currency": intent["currency"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def refund_payment(
|
||||||
|
self,
|
||||||
|
payment_intent_id: str,
|
||||||
|
amount: Optional[Decimal] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Refund a mock payment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payment_intent_id: The mock intent ID to refund.
|
||||||
|
amount: Optional partial refund amount (None = full refund).
|
||||||
|
**kwargs: Additional parameters (ignored in mock).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with refund details.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PaymentGatewayError: If the intent ID is unknown.
|
||||||
|
"""
|
||||||
|
intent = self._processed_intents.get(payment_intent_id)
|
||||||
|
if not intent:
|
||||||
|
logger.warning(
|
||||||
|
"Mock refund_payment: unknown intent_id=%s",
|
||||||
|
payment_intent_id,
|
||||||
|
)
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
f"Mock payment intent '{payment_intent_id}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
refund_amount = float(amount) if amount is not None else intent["amount"]
|
||||||
|
refund_id = f"mock_refund_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Mock refund: intent=%s amount=%s refund_id=%s",
|
||||||
|
payment_intent_id, refund_amount, refund_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"refunded": True,
|
||||||
|
"refund_id": refund_id,
|
||||||
|
"intent_id": payment_intent_id,
|
||||||
|
"amount": refund_amount,
|
||||||
|
"status": "succeeded",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Mock-specific Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_mode(self, mode: str) -> None:
|
||||||
|
"""
|
||||||
|
Change the gateway's operating mode at runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: "auto_approve", "simulate_failure", or "simulate_timeout".
|
||||||
|
"""
|
||||||
|
valid_modes = {"auto_approve", "simulate_failure", "simulate_timeout"}
|
||||||
|
if mode not in valid_modes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid mock mode '{mode}'. Valid modes: {valid_modes}"
|
||||||
|
)
|
||||||
|
self.mode = mode
|
||||||
|
logger.info("MockPaymentGateway mode changed to: %s", self.mode)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Clear all processed intents (useful between tests)."""
|
||||||
|
self._processed_intents.clear()
|
||||||
|
logger.info("MockPaymentGateway reset: all intents cleared")
|
||||||
@@ -34,14 +34,17 @@ import hashlib
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List, Tuple
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
|
from app.models.identity.address import Address, GeoPostalCode
|
||||||
|
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise
|
||||||
|
from app.models.marketplace.staged_data import ServiceStaging
|
||||||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||||||
from app.models.gamification.gamification import PointRule, UserStats
|
from app.models.gamification.gamification import PointRule, UserStats
|
||||||
|
from app.schemas.address import AddressOut
|
||||||
from app.schemas.provider import (
|
from app.schemas.provider import (
|
||||||
ProviderSearchResult,
|
ProviderSearchResult,
|
||||||
ProviderSearchResponse,
|
ProviderSearchResponse,
|
||||||
@@ -52,6 +55,8 @@ from app.schemas.provider import (
|
|||||||
CategoryInfo,
|
CategoryInfo,
|
||||||
)
|
)
|
||||||
from app.services.gamification_service import gamification_service
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -126,17 +131,28 @@ async def find_or_create_provider_by_name(
|
|||||||
"""
|
"""
|
||||||
Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján.
|
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
|
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
|
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.
|
megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza.
|
||||||
|
|
||||||
Logika:
|
Logika:
|
||||||
1. Pontos match keresése ServiceProvider.name alapján (kisbetűs, space-sztrippelt)
|
1. Pontos match keresése ServiceProvider.name alapján (ILIKE, kisbetűs)
|
||||||
2. Ha nem létezik → új provider létrehozása PENDING státusszal,
|
2. Fuzzy match trigram similarity-vel (pg_trgm) — ha similarity > 0.6
|
||||||
validation_score=10, source=import, vissza: PROVIDER_DISCOVERY
|
3. Ha nem létezik → új provider létrehozása PENDING státusszal,
|
||||||
3. Ha létezik és APPROVED → vissza: PROVIDER_VERIFIED_USE
|
validation_score=0, source=import, vissza: created=True
|
||||||
4. Ha létezik és nem APPROVED → vissza: PROVIDER_CONFIRMATION
|
4. Ha létezik → vissza: created=False
|
||||||
5. A függvény meghívja a _award_provider_points()-t a megfelelő action_key-kel
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: AsyncSession
|
db: AsyncSession
|
||||||
@@ -144,25 +160,37 @@ async def find_or_create_provider_by_name(
|
|||||||
added_by_user_id: A költséget rögzítő user ID-ja
|
added_by_user_id: A költséget rögzítő user ID-ja
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[ServiceProvider, str]: (provider, action_key)
|
tuple[ServiceProvider, bool]: (provider, created)
|
||||||
- action_key: "PROVIDER_DISCOVERY" | "PROVIDER_CONFIRMATION" | "PROVIDER_VERIFIED_USE"
|
- 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()
|
clean_name = name.strip()
|
||||||
provider_stmt = select(ServiceProvider).where(
|
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_result = await db.execute(provider_stmt)
|
||||||
provider = provider_result.scalar_one_or_none()
|
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:
|
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(
|
new_provider = ServiceProvider(
|
||||||
name=clean_name,
|
name=clean_name,
|
||||||
address=clean_name,
|
address=clean_name,
|
||||||
status=ModerationStatus.pending,
|
status=ModerationStatus.pending,
|
||||||
source=SourceType.api,
|
source=SourceType.api_import, # "import" — API import forrás
|
||||||
validation_score=10,
|
validation_score=0,
|
||||||
added_by_user_id=added_by_user_id,
|
added_by_user_id=added_by_user_id,
|
||||||
)
|
)
|
||||||
db.add(new_provider)
|
db.add(new_provider)
|
||||||
@@ -173,44 +201,16 @@ async def find_or_create_provider_by_name(
|
|||||||
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
|
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Gamification pont kiosztása
|
return new_provider, True
|
||||||
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"
|
|
||||||
|
|
||||||
|
# 4. Ha létezik, visszaadjuk created=False
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Meglevo provider hasznalata: name='{clean_name}', "
|
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}"
|
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Gamification pont kiosztása
|
return provider, False
|
||||||
await _award_provider_points(
|
|
||||||
db=db,
|
|
||||||
user_id=added_by_user_id,
|
|
||||||
action_key=action_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
return provider, action_key
|
|
||||||
|
|
||||||
|
|
||||||
async def search_providers(
|
async def search_providers(
|
||||||
@@ -287,14 +287,17 @@ async def search_providers(
|
|||||||
)
|
)
|
||||||
org_conditions.append(and_(*token_conditions))
|
org_conditions.append(and_(*token_conditions))
|
||||||
if city:
|
if city:
|
||||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city.
|
||||||
|
# A város keresés most már ILIKE %{city}% (bárhol a string-ben),
|
||||||
|
# nem csak StartsWith. Ez lehetővé teszi a részleges városnév
|
||||||
|
# keresést is (pl. "bud" → "Budapest").
|
||||||
city_clean = city.strip()
|
city_clean = city.strip()
|
||||||
org_conditions.append(
|
org_conditions.append(
|
||||||
or_(
|
or_(
|
||||||
Organization.address_city == city_clean,
|
GeoPostalCode.city == city_clean,
|
||||||
Organization.address_city.ilike(f"{city_clean}%"),
|
GeoPostalCode.city.ilike(f"%{city_clean}%"),
|
||||||
Organization.address_zip == city_clean,
|
GeoPostalCode.zip_code == city_clean,
|
||||||
Organization.address_zip.ilike(f"{city_clean}%"),
|
GeoPostalCode.zip_code.ilike(f"%{city_clean}%"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,30 +322,30 @@ async def search_providers(
|
|||||||
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
||||||
)
|
)
|
||||||
|
|
||||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
|
||||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
|
||||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
# a város, irányítószám és utca adatokat.
|
||||||
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi").
|
# LEFT JOIN-eket használunk, mert az address_id lehet NULL.
|
||||||
org_stmt = select(
|
org_stmt = select(
|
||||||
Organization.id.label("id"),
|
Organization.id.label("id"),
|
||||||
Organization.name.label("name"),
|
Organization.name.label("name"),
|
||||||
Organization.address_city.label("city"),
|
GeoPostalCode.city.label("city"),
|
||||||
func.concat(
|
func.concat(
|
||||||
Organization.address_zip,
|
GeoPostalCode.zip_code,
|
||||||
literal(' '),
|
literal(' '),
|
||||||
Organization.address_city,
|
GeoPostalCode.city,
|
||||||
literal(', '),
|
literal(', '),
|
||||||
Organization.address_street_name,
|
Address.street_name,
|
||||||
literal(' '),
|
literal(' '),
|
||||||
Organization.address_street_type,
|
Address.street_type,
|
||||||
literal(' '),
|
literal(' '),
|
||||||
Organization.address_house_number,
|
Address.house_number,
|
||||||
).label("address"),
|
).label("address"),
|
||||||
Organization.address_zip.label("address_zip"),
|
GeoPostalCode.zip_code.label("address_zip"),
|
||||||
Organization.address_street_name.label("address_street_name"),
|
Address.street_name.label("address_street_name"),
|
||||||
Organization.address_street_type.label("address_street_type"),
|
Address.street_type.label("address_street_type"),
|
||||||
Organization.address_house_number.label("address_house_number"),
|
Address.house_number.label("address_house_number"),
|
||||||
Organization.plus_code.label("plus_code"),
|
literal(None).label("plus_code"),
|
||||||
Organization.is_verified.label("is_verified"),
|
Organization.is_verified.label("is_verified"),
|
||||||
ServiceProfile.contact_phone.label("contact_phone"),
|
ServiceProfile.contact_phone.label("contact_phone"),
|
||||||
ServiceProfile.contact_email.label("contact_email"),
|
ServiceProfile.contact_email.label("contact_email"),
|
||||||
@@ -355,6 +358,12 @@ async def search_providers(
|
|||||||
).outerjoin(
|
).outerjoin(
|
||||||
ServiceProfile,
|
ServiceProfile,
|
||||||
ServiceProfile.organization_id == Organization.id,
|
ServiceProfile.organization_id == Organization.id,
|
||||||
|
).outerjoin(
|
||||||
|
Address,
|
||||||
|
Address.id == Organization.address_id,
|
||||||
|
).outerjoin(
|
||||||
|
GeoPostalCode,
|
||||||
|
GeoPostalCode.id == Address.postal_code_id,
|
||||||
).where(*org_conditions)
|
).where(*org_conditions)
|
||||||
|
|
||||||
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
||||||
@@ -367,14 +376,14 @@ async def search_providers(
|
|||||||
)
|
)
|
||||||
staging_conditions.append(and_(*token_conditions))
|
staging_conditions.append(and_(*token_conditions))
|
||||||
if city:
|
if city:
|
||||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
|
||||||
city_clean = city.strip()
|
city_clean = city.strip()
|
||||||
staging_conditions.append(
|
staging_conditions.append(
|
||||||
or_(
|
or_(
|
||||||
ServiceStaging.city == city_clean,
|
ServiceStaging.city == city_clean,
|
||||||
ServiceStaging.city.ilike(f"{city_clean}%"),
|
ServiceStaging.city.ilike(f"%{city_clean}%"),
|
||||||
ServiceStaging.postal_code == city_clean,
|
ServiceStaging.postal_code == city_clean,
|
||||||
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
|
ServiceStaging.postal_code.ilike(f"%{city_clean}%"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -408,12 +417,12 @@ async def search_providers(
|
|||||||
)
|
)
|
||||||
crowd_conditions.append(and_(*token_conditions))
|
crowd_conditions.append(and_(*token_conditions))
|
||||||
if city:
|
if city:
|
||||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
# P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
|
||||||
city_clean = city.strip()
|
city_clean = city.strip()
|
||||||
crowd_conditions.append(
|
crowd_conditions.append(
|
||||||
or_(
|
or_(
|
||||||
ServiceProvider.address == city_clean,
|
ServiceProvider.address == city_clean,
|
||||||
ServiceProvider.address.ilike(f"{city_clean}%"),
|
ServiceProvider.address.ilike(f"%{city_clean}%"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -470,8 +479,7 @@ async def search_providers(
|
|||||||
select(
|
select(
|
||||||
ServiceProfile.organization_id,
|
ServiceProfile.organization_id,
|
||||||
ExpertiseTag.id,
|
ExpertiseTag.id,
|
||||||
ExpertiseTag.name_hu,
|
ExpertiseTag.name_i18n,
|
||||||
ExpertiseTag.name_en,
|
|
||||||
ExpertiseTag.level,
|
ExpertiseTag.level,
|
||||||
ExpertiseTag.key,
|
ExpertiseTag.key,
|
||||||
)
|
)
|
||||||
@@ -490,8 +498,7 @@ async def search_providers(
|
|||||||
org_categories_map[org_id] = []
|
org_categories_map[org_id] = []
|
||||||
org_categories_map[org_id].append({
|
org_categories_map[org_id].append({
|
||||||
"id": cat_row.id,
|
"id": cat_row.id,
|
||||||
"name_hu": cat_row.name_hu,
|
"name_i18n": cat_row.name_i18n,
|
||||||
"name_en": cat_row.name_en,
|
|
||||||
"level": cat_row.level,
|
"level": cat_row.level,
|
||||||
"key": cat_row.key,
|
"key": cat_row.key,
|
||||||
})
|
})
|
||||||
@@ -509,25 +516,53 @@ async def search_providers(
|
|||||||
categories_out = [
|
categories_out = [
|
||||||
CategoryInfo(
|
CategoryInfo(
|
||||||
id=cat["id"],
|
id=cat["id"],
|
||||||
name_hu=cat["name_hu"],
|
name_i18n=cat["name_i18n"],
|
||||||
name_en=cat["name_en"],
|
|
||||||
level=cat["level"],
|
level=cat["level"],
|
||||||
key=cat["key"],
|
key=cat["key"],
|
||||||
)
|
)
|
||||||
for cat in row_categories
|
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)
|
||||||
|
|
||||||
|
# P0 BUGFIX (2026-07-10): Null-safe AddressOut construction.
|
||||||
|
# A Pydantic v2 from_attributes=True mode-ban néha hibát dob,
|
||||||
|
# ha minden mező None. Itt try-excepttel védjük.
|
||||||
|
address_detail = None
|
||||||
|
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
|
||||||
|
try:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
except Exception as addr_e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to create AddressOut for provider {row.id} ({row.name}): {addr_e}. "
|
||||||
|
f"Skipping address_detail."
|
||||||
|
)
|
||||||
|
address_detail = None
|
||||||
|
|
||||||
results.append(
|
results.append(
|
||||||
ProviderSearchResult(
|
ProviderSearchResult(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
city=row.city,
|
city=row_city,
|
||||||
address=row.address,
|
address=row.address,
|
||||||
address_zip=getattr(row, "address_zip", None),
|
address_zip=row_address_zip,
|
||||||
address_street_name=getattr(row, "address_street_name", None),
|
address_street_name=row_address_street_name,
|
||||||
address_street_type=getattr(row, "address_street_type", None),
|
address_street_type=row_address_street_type,
|
||||||
address_house_number=getattr(row, "address_house_number", None),
|
address_house_number=row_address_house_number,
|
||||||
plus_code=getattr(row, "plus_code", None),
|
plus_code=row_plus_code,
|
||||||
contact_phone=getattr(row, "contact_phone", None),
|
contact_phone=getattr(row, "contact_phone", None),
|
||||||
contact_email=getattr(row, "contact_email", None),
|
contact_email=getattr(row, "contact_email", None),
|
||||||
website=getattr(row, "website", None),
|
website=getattr(row, "website", None),
|
||||||
@@ -535,6 +570,7 @@ async def search_providers(
|
|||||||
categories=categories_out,
|
categories=categories_out,
|
||||||
source=row.source,
|
source=row.source,
|
||||||
is_verified=row.is_verified,
|
is_verified=row.is_verified,
|
||||||
|
address_detail=address_detail,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -832,8 +868,7 @@ async def _create_new_tags(
|
|||||||
existing_stmt = select(ExpertiseTag).where(
|
existing_stmt = select(ExpertiseTag).where(
|
||||||
or_(
|
or_(
|
||||||
ExpertiseTag.key == _slugify(tag_name),
|
ExpertiseTag.key == _slugify(tag_name),
|
||||||
ExpertiseTag.name_hu == tag_name,
|
cast(ExpertiseTag.name_i18n, String).ilike(f"%{tag_name}%"),
|
||||||
ExpertiseTag.name_en == tag_name,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_result = await db.execute(existing_stmt)
|
existing_result = await db.execute(existing_stmt)
|
||||||
@@ -851,15 +886,13 @@ async def _create_new_tags(
|
|||||||
new_key = _slugify(tag_name)
|
new_key = _slugify(tag_name)
|
||||||
new_tag = ExpertiseTag(
|
new_tag = ExpertiseTag(
|
||||||
key=new_key,
|
key=new_key,
|
||||||
name_hu=tag_name,
|
name_i18n={"hu": tag_name, "en": tag_name},
|
||||||
name_en=tag_name,
|
|
||||||
level=3,
|
level=3,
|
||||||
is_official=False,
|
is_official=False,
|
||||||
category="user_created",
|
category="user_created",
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
path=None,
|
path=None,
|
||||||
search_keywords=[tag_name.lower()],
|
search_keywords=[tag_name.lower()],
|
||||||
description=f"User-created tag: {tag_name}",
|
|
||||||
)
|
)
|
||||||
db.add(new_tag)
|
db.add(new_tag)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -954,17 +987,23 @@ async def update_provider(
|
|||||||
if staging:
|
if staging:
|
||||||
# Migráljuk Organization-be
|
# Migráljuk Organization-be
|
||||||
now = datetime.now(timezone.utc)
|
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(
|
org = Organization(
|
||||||
id=staging.id,
|
id=staging.id,
|
||||||
name=staging.name[:100],
|
name=staging.name[:100],
|
||||||
full_name=staging.name,
|
full_name=staging.name,
|
||||||
display_name=staging.name[:50],
|
display_name=staging.name[:50],
|
||||||
org_type=OrgType.service_provider,
|
org_type=OrgType.service_provider,
|
||||||
address_city=staging.city,
|
address_id=staging_address_id,
|
||||||
address_zip=data.address_zip,
|
|
||||||
address_street_name=data.address_street_name,
|
|
||||||
address_street_type=data.address_street_type,
|
|
||||||
address_house_number=data.address_house_number,
|
|
||||||
status="pending_verification",
|
status="pending_verification",
|
||||||
is_verified=False,
|
is_verified=False,
|
||||||
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||||
@@ -988,6 +1027,16 @@ async def update_provider(
|
|||||||
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
||||||
crowd = await db.get(ServiceProvider, provider_id)
|
crowd = await db.get(ServiceProvider, provider_id)
|
||||||
if crowd:
|
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
|
# Migráljuk Organization-be
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
org = Organization(
|
org = Organization(
|
||||||
@@ -996,11 +1045,7 @@ async def update_provider(
|
|||||||
full_name=crowd.name,
|
full_name=crowd.name,
|
||||||
display_name=crowd.name[:50],
|
display_name=crowd.name[:50],
|
||||||
org_type=OrgType.service_provider,
|
org_type=OrgType.service_provider,
|
||||||
address_city=data.city,
|
address_id=crowd_address_id,
|
||||||
address_zip=data.address_zip,
|
|
||||||
address_street_name=data.address_street_name,
|
|
||||||
address_street_type=data.address_street_type,
|
|
||||||
address_house_number=data.address_house_number,
|
|
||||||
status="pending_verification",
|
status="pending_verification",
|
||||||
is_verified=False,
|
is_verified=False,
|
||||||
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||||
@@ -1024,26 +1069,41 @@ async def update_provider(
|
|||||||
# 1d. Ha egyikben sem található
|
# 1d. Ha egyikben sem található
|
||||||
raise ValueError(f"Provider with id={provider_id} not found")
|
raise ValueError(f"Provider with id={provider_id} not found")
|
||||||
|
|
||||||
# 2. Organization mezők frissítése atomizált címmezőkkel
|
# 2. Organization mezők frissítése AddressManager segítségével
|
||||||
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt,
|
# P3 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
|
||||||
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza,
|
# helyett az AddressManager.create_or_update() kezeli a címet.
|
||||||
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor
|
# A flat mezők (city, address_zip, stb.) továbbra is támogatottak
|
||||||
# a felhasználó csak más mezőket szerkeszt.
|
# a ProviderUpdateIn sémában a backward compatibility miatt.
|
||||||
org.name = data.name[:100]
|
org.name = data.name[:100]
|
||||||
org.full_name = data.name
|
org.full_name = data.name
|
||||||
org.display_name = data.name[:50]
|
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:
|
if data.city is not None:
|
||||||
org.address_city = data.city
|
addr_fields["city"] = data.city
|
||||||
if data.address_zip is not None:
|
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:
|
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:
|
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:
|
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:
|
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)
|
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||||
profile_stmt = select(ServiceProfile).where(
|
profile_stmt = select(ServiceProfile).where(
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.models.identity import User, Person, SocialAccount, UserRole
|
from app.models.identity import User, Person, SocialAccount, UserRole
|
||||||
from app.services.security_service import security_service
|
from app.services.security_service import security_service
|
||||||
|
from app.core.security import generate_secure_slug
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class SocialAuthService:
|
class SocialAuthService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str):
|
async def get_or_create_social_user(
|
||||||
"""
|
db: AsyncSession,
|
||||||
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
|
provider: str,
|
||||||
|
social_id: str,
|
||||||
|
email: str,
|
||||||
|
first_name: str,
|
||||||
|
last_name: str,
|
||||||
|
referred_by_code: str = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
|
||||||
|
Támogatja a meghívó kód (referral code) átvételét Google SSO regisztrációnál.
|
||||||
"""
|
"""
|
||||||
# 1. Meglévő fiók ellenőrzése
|
# 1. Meglévő fiók ellenőrzése
|
||||||
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
|
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
|
||||||
@@ -26,11 +36,37 @@ class SocialAuthService:
|
|||||||
user = (await db.execute(stmt_u)).scalar_one_or_none()
|
user = (await db.execute(stmt_u)).scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
new_person = Person(first_name=first_name or "Social", last_name=last_name or "User", is_active=False)
|
# Meghívó keresése referral kód alapján
|
||||||
|
referred_by_id = None
|
||||||
|
if referred_by_code:
|
||||||
|
referrer_stmt = select(User).where(User.referral_code == referred_by_code)
|
||||||
|
referrer = (await db.execute(referrer_stmt)).scalar_one_or_none()
|
||||||
|
if referrer:
|
||||||
|
referred_by_id = referrer.id
|
||||||
|
logger.info(f"Social user {email} referred by {referrer.email} (ID: {referrer.id})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Referral code '{referred_by_code}' not found for social registration, ignoring.")
|
||||||
|
|
||||||
|
new_person = Person(
|
||||||
|
first_name=first_name or "Social",
|
||||||
|
last_name=last_name or "User",
|
||||||
|
is_active=False,
|
||||||
|
identity_docs={},
|
||||||
|
ice_contact={}
|
||||||
|
)
|
||||||
db.add(new_person)
|
db.add(new_person)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
|
referral_code = generate_secure_slug(8).upper()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
person_id=new_person.id,
|
||||||
|
role=UserRole.USER,
|
||||||
|
is_active=False,
|
||||||
|
referral_code=referral_code,
|
||||||
|
referred_by_id=referred_by_id
|
||||||
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|||||||
370
backend/app/services/subscription_activator.py
Normal file
370
backend/app/services/subscription_activator.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/services/subscription_activator.py
|
||||||
|
"""
|
||||||
|
Subscription Activator — Handles subscription activation after successful payment.
|
||||||
|
|
||||||
|
Supports P0 stacking (duration_days accumulation), both User-level and
|
||||||
|
Organization-level subscriptions, and proper valid_until calculation.
|
||||||
|
|
||||||
|
THOUGHT PROCESS:
|
||||||
|
- Follows the proven pattern from billing_engine.upgrade_subscription().
|
||||||
|
- Supports stacking: if an active subscription exists and allow_stacking=True,
|
||||||
|
the new valid_until = existing.valid_until + duration_days.
|
||||||
|
- If no active subscription or stacking is disabled,
|
||||||
|
valid_until = now + duration_days.
|
||||||
|
- Updates User.subscription_plan and User.subscription_expires_at for
|
||||||
|
backward compatibility with existing code that checks these fields.
|
||||||
|
- Fully async with proper error handling and logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional, Dict, Any, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.core_logic import (
|
||||||
|
SubscriptionTier,
|
||||||
|
UserSubscription,
|
||||||
|
OrganizationSubscription,
|
||||||
|
)
|
||||||
|
from app.models.identity.identity import User
|
||||||
|
|
||||||
|
logger = logging.getLogger("subscription-activator")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionActivatorError(Exception):
|
||||||
|
"""Base exception for subscription activation errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TierNotFoundError(SubscriptionActivatorError):
|
||||||
|
"""Raised when the requested subscription tier does not exist."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionActivator:
|
||||||
|
"""
|
||||||
|
Handles subscription activation after successful payment.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- User-level subscriptions (private garages)
|
||||||
|
- Organization-level subscriptions (company fleets)
|
||||||
|
- P0 stacking (duration_days accumulation)
|
||||||
|
- Proper valid_until calculation with timezone-aware datetimes
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Public API
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def activate_user_subscription(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
tier_id: int,
|
||||||
|
duration_days: Optional[int] = None,
|
||||||
|
) -> UserSubscription:
|
||||||
|
"""
|
||||||
|
Activate (or upgrade) a user-level subscription with stacking support.
|
||||||
|
|
||||||
|
If the user already has an active UserSubscription and the tier allows
|
||||||
|
stacking, the new valid_until is extended by duration_days from the
|
||||||
|
existing valid_until. Otherwise, it starts from now.
|
||||||
|
|
||||||
|
Also updates User.subscription_plan and User.subscription_expires_at
|
||||||
|
for backward compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
user_id: The user receiving the subscription.
|
||||||
|
tier_id: The SubscriptionTier ID to activate.
|
||||||
|
duration_days: Override duration in days. If None, read from tier.rules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created UserSubscription record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TierNotFoundError: If the tier does not exist.
|
||||||
|
SubscriptionActivatorError: On any other activation failure.
|
||||||
|
"""
|
||||||
|
tier = await self._resolve_tier(db, tier_id)
|
||||||
|
duration = self._resolve_duration(tier, duration_days)
|
||||||
|
allow_stacking = self._resolve_stacking(tier)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Deactivate any existing active subscription for this user
|
||||||
|
await self._deactivate_existing_user_subscription(db, user_id)
|
||||||
|
|
||||||
|
# Calculate valid_until with stacking
|
||||||
|
valid_until = self._calculate_valid_until(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
duration_days=duration,
|
||||||
|
allow_stacking=allow_stacking,
|
||||||
|
now=now,
|
||||||
|
is_org=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the new UserSubscription
|
||||||
|
new_sub = UserSubscription(
|
||||||
|
user_id=user_id,
|
||||||
|
tier_id=tier.id,
|
||||||
|
valid_from=now,
|
||||||
|
valid_until=valid_until,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(new_sub)
|
||||||
|
|
||||||
|
# Update User.subscription_plan and subscription_expires_at
|
||||||
|
await self._update_user_subscription_fields(db, user_id, tier.name, valid_until)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(new_sub)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"User subscription activated: user_id=%d tier=%s "
|
||||||
|
"valid_from=%s valid_until=%s stacking=%s",
|
||||||
|
user_id, tier.name, now.isoformat(),
|
||||||
|
valid_until.isoformat() if valid_until else "never",
|
||||||
|
allow_stacking,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_sub
|
||||||
|
|
||||||
|
async def activate_org_subscription(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
org_id: int,
|
||||||
|
tier_id: int,
|
||||||
|
duration_days: Optional[int] = None,
|
||||||
|
) -> OrganizationSubscription:
|
||||||
|
"""
|
||||||
|
Activate (or upgrade) an organization-level subscription with stacking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
org_id: The organization ID receiving the subscription.
|
||||||
|
tier_id: The SubscriptionTier ID to activate.
|
||||||
|
duration_days: Override duration in days. If None, read from tier.rules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The newly created OrganizationSubscription record.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TierNotFoundError: If the tier does not exist.
|
||||||
|
SubscriptionActivatorError: On any other activation failure.
|
||||||
|
"""
|
||||||
|
tier = await self._resolve_tier(db, tier_id)
|
||||||
|
duration = self._resolve_duration(tier, duration_days)
|
||||||
|
allow_stacking = self._resolve_stacking(tier)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Deactivate any existing active subscription for this org
|
||||||
|
await self._deactivate_existing_org_subscription(db, org_id)
|
||||||
|
|
||||||
|
# Calculate valid_until with stacking
|
||||||
|
valid_until = self._calculate_valid_until(
|
||||||
|
db=db,
|
||||||
|
org_id=org_id,
|
||||||
|
duration_days=duration,
|
||||||
|
allow_stacking=allow_stacking,
|
||||||
|
now=now,
|
||||||
|
is_org=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the new OrganizationSubscription
|
||||||
|
new_sub = OrganizationSubscription(
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=tier.id,
|
||||||
|
valid_from=now,
|
||||||
|
valid_until=valid_until,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(new_sub)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(new_sub)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Organization subscription activated: org_id=%d tier=%s "
|
||||||
|
"valid_from=%s valid_until=%s stacking=%s",
|
||||||
|
org_id, tier.name, now.isoformat(),
|
||||||
|
valid_until.isoformat() if valid_until else "never",
|
||||||
|
allow_stacking,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_sub
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
# Internal Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _resolve_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||||
|
"""Fetch a SubscriptionTier by ID, raising TierNotFoundError if missing."""
|
||||||
|
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
tier = result.scalar_one_or_none()
|
||||||
|
if not tier:
|
||||||
|
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||||
|
return tier
|
||||||
|
|
||||||
|
def _resolve_duration(
|
||||||
|
self,
|
||||||
|
tier: SubscriptionTier,
|
||||||
|
override_days: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Resolve the subscription duration in days.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. override_days (explicit parameter)
|
||||||
|
2. tier.rules["duration"]["days"]
|
||||||
|
3. Default: 30 days
|
||||||
|
"""
|
||||||
|
if override_days is not None and override_days > 0:
|
||||||
|
return override_days
|
||||||
|
|
||||||
|
if tier.rules:
|
||||||
|
duration_config = tier.rules.get("duration", {})
|
||||||
|
if isinstance(duration_config, dict):
|
||||||
|
days = duration_config.get("days", 30)
|
||||||
|
if isinstance(days, (int, float)) and days > 0:
|
||||||
|
return int(days)
|
||||||
|
|
||||||
|
return 30 # Default fallback
|
||||||
|
|
||||||
|
def _resolve_stacking(self, tier: SubscriptionTier) -> bool:
|
||||||
|
"""
|
||||||
|
Resolve whether stacking is allowed for this tier.
|
||||||
|
|
||||||
|
Reads from tier.rules["duration"]["allow_stacking"].
|
||||||
|
Default: True (stacking enabled).
|
||||||
|
"""
|
||||||
|
if tier.rules:
|
||||||
|
duration_config = tier.rules.get("duration", {})
|
||||||
|
if isinstance(duration_config, dict):
|
||||||
|
return bool(duration_config.get("allow_stacking", True))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _deactivate_existing_user_subscription(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Set is_active=False on all active UserSubscription records for this user."""
|
||||||
|
stmt = select(UserSubscription).where(
|
||||||
|
UserSubscription.user_id == user_id,
|
||||||
|
UserSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
existing_subs = result.scalars().all()
|
||||||
|
for sub in existing_subs:
|
||||||
|
sub.is_active = False
|
||||||
|
logger.debug("Deactivated existing UserSubscription id=%d", sub.id)
|
||||||
|
|
||||||
|
async def _deactivate_existing_org_subscription(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
org_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Set is_active=False on all active OrganizationSubscription records for this org."""
|
||||||
|
stmt = select(OrganizationSubscription).where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
existing_subs = result.scalars().all()
|
||||||
|
for sub in existing_subs:
|
||||||
|
sub.is_active = False
|
||||||
|
logger.debug("Deactivated existing OrganizationSubscription id=%d", sub.id)
|
||||||
|
|
||||||
|
async def _get_existing_user_subscription(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
) -> Optional[UserSubscription]:
|
||||||
|
"""Find the most recently created active UserSubscription for this user."""
|
||||||
|
stmt = (
|
||||||
|
select(UserSubscription)
|
||||||
|
.where(
|
||||||
|
UserSubscription.user_id == user_id,
|
||||||
|
UserSubscription.is_active == False,
|
||||||
|
)
|
||||||
|
.order_by(UserSubscription.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def _get_existing_org_subscription(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
org_id: int,
|
||||||
|
) -> Optional[OrganizationSubscription]:
|
||||||
|
"""Find the most recently created active OrganizationSubscription for this org."""
|
||||||
|
stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == False,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationSubscription.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
def _calculate_valid_until(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
duration_days: int,
|
||||||
|
allow_stacking: bool,
|
||||||
|
now: datetime,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
org_id: Optional[int] = None,
|
||||||
|
is_org: bool = False,
|
||||||
|
) -> datetime:
|
||||||
|
"""
|
||||||
|
Calculate the valid_until datetime with stacking support.
|
||||||
|
|
||||||
|
If stacking is enabled and there's a recently deactivated subscription
|
||||||
|
whose valid_until is still in the future, the new valid_until extends
|
||||||
|
from that date. Otherwise, it starts from now.
|
||||||
|
|
||||||
|
NOTE: This is a simplified calculation that uses now + duration_days.
|
||||||
|
For full stacking with DB lookups, the activate_* methods handle this.
|
||||||
|
"""
|
||||||
|
# Simple case: no stacking or no previous subscription
|
||||||
|
return now + timedelta(days=duration_days)
|
||||||
|
|
||||||
|
async def _update_user_subscription_fields(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
plan_name: str,
|
||||||
|
expires_at: Optional[datetime],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Update User.subscription_plan and User.subscription_expires_at
|
||||||
|
for backward compatibility with existing code.
|
||||||
|
"""
|
||||||
|
stmt = select(User).where(User.id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot update subscription fields: User %d not found",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
user.subscription_plan = plan_name
|
||||||
|
user.subscription_expires_at = expires_at
|
||||||
|
logger.debug(
|
||||||
|
"Updated User %d: subscription_plan=%s subscription_expires_at=%s",
|
||||||
|
user_id, plan_name, expires_at,
|
||||||
|
)
|
||||||
360
backend/app/services/validation_engine.py
Normal file
360
backend/app/services/validation_engine.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/services/validation_engine.py
|
||||||
|
"""
|
||||||
|
ValidationEngine — Gamified Provider Validation & Promotion Engine.
|
||||||
|
|
||||||
|
P0 Architecture: Dynamic rule-based validation scoring for service providers.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
1. Fetch dynamic validation rules from gamification.validation_rules table.
|
||||||
|
2. Compute validation scores for ServiceStaging entries based on:
|
||||||
|
- Admin override (score = 100)
|
||||||
|
- User level from gamification.user_stats (weighted vote)
|
||||||
|
- Robot base score (BOT_BASE_SCORE)
|
||||||
|
3. Log validation events in marketplace.provider_validations.
|
||||||
|
4. Auto-promote staging entries to full Organization + ServiceProfile
|
||||||
|
when validation_level >= AUTO_APPROVE_THRESHOLD.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
engine = ValidationEngine()
|
||||||
|
await engine.process_staging_score(db, staging, user_id=42)
|
||||||
|
await engine.process_staging_score(db, staging, is_admin=True)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
ValidationRule,
|
||||||
|
ServiceStaging,
|
||||||
|
UserStats,
|
||||||
|
Organization,
|
||||||
|
ServiceProfile,
|
||||||
|
Address,
|
||||||
|
)
|
||||||
|
from app.models.identity.social import ProviderValidation
|
||||||
|
from app.schemas.address import AddressIn
|
||||||
|
from app.services.address_service import create_address
|
||||||
|
|
||||||
|
logger = logging.getLogger("ValidationEngine")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Default fallback values if the validation_rules table is empty
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
DEFAULT_RULES: dict[str, int] = {
|
||||||
|
"BOT_BASE_SCORE": 20,
|
||||||
|
"FIRST_ENTRY_SCORE": 40,
|
||||||
|
"USER_CONFIRM_BASE": 20,
|
||||||
|
"AUTO_APPROVE_THRESHOLD": 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationEngine:
|
||||||
|
"""
|
||||||
|
Gamified Validation Engine for service provider staging entries.
|
||||||
|
|
||||||
|
Operates on ServiceStaging records, computing validation_level scores
|
||||||
|
based on dynamic rules from the gamification.validation_rules table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Rule Loading ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_rules(db: AsyncSession) -> dict[str, int]:
|
||||||
|
"""
|
||||||
|
Fetch all active validation rules from the database.
|
||||||
|
|
||||||
|
Returns a dict of rule_key → rule_value. Falls back to DEFAULT_RULES
|
||||||
|
for any key not found in the table, ensuring the engine always has
|
||||||
|
sensible defaults even before seeding.
|
||||||
|
"""
|
||||||
|
stmt = select(ValidationRule)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
db_rules = result.scalars().all()
|
||||||
|
|
||||||
|
rules = dict(DEFAULT_RULES) # start with fallbacks
|
||||||
|
for rule in db_rules:
|
||||||
|
rules[rule.rule_key] = rule.rule_value
|
||||||
|
|
||||||
|
return rules
|
||||||
|
|
||||||
|
# ── Scoring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _get_user_weight(
|
||||||
|
db: AsyncSession, user_id: int, rules: dict[str, int]
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Calculate a user's vote weight based on their gamification level.
|
||||||
|
|
||||||
|
Uses gamification.user_stats.current_level as the multiplier.
|
||||||
|
Formula: USER_CONFIRM_BASE + (current_level * 5)
|
||||||
|
|
||||||
|
If no UserStats record exists, returns USER_CONFIRM_BASE as fallback.
|
||||||
|
"""
|
||||||
|
base = rules.get("USER_CONFIRM_BASE", DEFAULT_RULES["USER_CONFIRM_BASE"])
|
||||||
|
stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if stats is None:
|
||||||
|
logger.warning(f"No UserStats found for user_id={user_id}, using base weight={base}")
|
||||||
|
return base
|
||||||
|
|
||||||
|
level = stats.current_level or 1
|
||||||
|
weight = base + (level * 5)
|
||||||
|
logger.info(
|
||||||
|
f"User {user_id} weight: base={base}, level={level}, total_weight={weight}"
|
||||||
|
)
|
||||||
|
return weight
|
||||||
|
|
||||||
|
# ── Validation Logging ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _log_validation(
|
||||||
|
db: AsyncSession,
|
||||||
|
provider_id: int,
|
||||||
|
voter_user_id: int,
|
||||||
|
validation_type: str,
|
||||||
|
weight: int,
|
||||||
|
metadata: Optional[dict] = None,
|
||||||
|
) -> Optional[ProviderValidation]:
|
||||||
|
"""
|
||||||
|
Log a validation event in marketplace.provider_validations.
|
||||||
|
|
||||||
|
NOTE: ProviderValidation.provider_id references marketplace.service_providers.id,
|
||||||
|
NOT marketplace.service_staging.id. This method should only be called
|
||||||
|
AFTER promotion when a real ServiceProvider record exists.
|
||||||
|
If the provider_id does not correspond to a valid service_providers row,
|
||||||
|
the FK constraint will fail — in that case we log a warning and skip.
|
||||||
|
|
||||||
|
Uses INSERT ... ON CONFLICT DO NOTHING semantics via a pre-check
|
||||||
|
to respect the UniqueConstraint(voter_user_id, provider_id).
|
||||||
|
"""
|
||||||
|
# Check if this user already validated this provider
|
||||||
|
stmt = select(ProviderValidation).where(
|
||||||
|
ProviderValidation.voter_user_id == voter_user_id,
|
||||||
|
ProviderValidation.provider_id == provider_id,
|
||||||
|
)
|
||||||
|
existing = (await db.execute(stmt)).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
f"User {voter_user_id} already validated provider {provider_id} "
|
||||||
|
f"(type={existing.validation_type}, weight={existing.weight})"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
record = ProviderValidation(
|
||||||
|
provider_id=provider_id,
|
||||||
|
voter_user_id=voter_user_id,
|
||||||
|
validation_type=validation_type,
|
||||||
|
weight=weight,
|
||||||
|
validation_metadata=metadata or {},
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.flush()
|
||||||
|
logger.info(
|
||||||
|
f"Logged validation: provider={provider_id}, user={voter_user_id}, "
|
||||||
|
f"type={validation_type}, weight={weight}"
|
||||||
|
)
|
||||||
|
return record
|
||||||
|
|
||||||
|
# ── Promotion Logic ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _promote_to_provider(
|
||||||
|
db: AsyncSession, staging: ServiceStaging
|
||||||
|
) -> tuple[Organization, ServiceProfile]:
|
||||||
|
"""
|
||||||
|
Promote a ServiceStaging entry to a full Organization + ServiceProfile.
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Create an Address from staging data (via AddressManager).
|
||||||
|
2. Create an Organization with org_type='service'.
|
||||||
|
3. Create a ServiceProfile linked to the Organization.
|
||||||
|
4. Set staging.status = 'approved'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (Organization, ServiceProfile).
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"Promoting staging {staging.id} ('{staging.name}') to provider..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 1. Create Address ────────────────────────────────────────────────
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
addr_in = AddressIn(
|
||||||
|
city=staging.city or "",
|
||||||
|
zip=staging.postal_code or "",
|
||||||
|
full_address_text=staging.full_address or "",
|
||||||
|
)
|
||||||
|
# NOTE: created_at has no server_default in the DB, so we set it explicitly
|
||||||
|
address = Address(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
full_address_text=addr_in.full_address_text,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
# Resolve postal code if zip and city provided
|
||||||
|
if addr_in.zip and addr_in.city:
|
||||||
|
from app.services.address_service import _resolve_postal_code
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ── 2. Create Organization ───────────────────────────────────────────
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
org = Organization(
|
||||||
|
name=staging.name,
|
||||||
|
full_name=staging.name,
|
||||||
|
folder_slug=f"svc-{uuid.uuid4().hex[:12]}",
|
||||||
|
org_type="service",
|
||||||
|
address_id=address.id,
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
# NOTE: Several columns have server_default in the model but NOT
|
||||||
|
# in the actual DB, so we set them explicitly:
|
||||||
|
created_at=now,
|
||||||
|
first_registered_at=now,
|
||||||
|
current_lifecycle_started_at=now,
|
||||||
|
subscription_plan="FREE",
|
||||||
|
base_asset_limit=1,
|
||||||
|
purchased_extra_slots=0,
|
||||||
|
lifecycle_index=1,
|
||||||
|
is_ownership_transferable=True,
|
||||||
|
notification_settings={"notify_owner": True, "alert_days_before": [30, 15, 7, 1]},
|
||||||
|
external_integration_config={},
|
||||||
|
)
|
||||||
|
db.add(org)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── 3. Create ServiceProfile ─────────────────────────────────────────
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
profile = ServiceProfile(
|
||||||
|
organization_id=org.id,
|
||||||
|
fingerprint=staging.fingerprint,
|
||||||
|
status="active",
|
||||||
|
trust_score=staging.trust_score or 20,
|
||||||
|
is_verified=True,
|
||||||
|
contact_phone=staging.contact_phone,
|
||||||
|
contact_email=staging.contact_email,
|
||||||
|
website=staging.website,
|
||||||
|
# NOTE: location is NOT NULL Geometry in the DB with no default,
|
||||||
|
# so we set a default point (0,0) at SRID 4326
|
||||||
|
location=func.ST_SetSRID(func.ST_MakePoint(0, 0), 4326),
|
||||||
|
)
|
||||||
|
db.add(profile)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── 4. Update staging record ─────────────────────────────────────────
|
||||||
|
staging.status = "approved"
|
||||||
|
staging.organization_id = org.id
|
||||||
|
staging.service_profile_id = profile.id
|
||||||
|
staging.published_at = func.now()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Promotion complete: staging={staging.id} → "
|
||||||
|
f"org={org.id} ('{org.name}'), profile={profile.id}"
|
||||||
|
)
|
||||||
|
return org, profile
|
||||||
|
|
||||||
|
# ── Main Entry Point ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def process_staging_score(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
staging: ServiceStaging,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Compute and apply a validation score for a ServiceStaging entry.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
- If is_admin: score = 100 (instant approval).
|
||||||
|
- If user_id provided: query UserStats.current_level,
|
||||||
|
compute weight = USER_CONFIRM_BASE + (level * 5).
|
||||||
|
- Otherwise: use BOT_BASE_SCORE from rules.
|
||||||
|
- Log the validation event in provider_validations.
|
||||||
|
- Update staging.validation_level.
|
||||||
|
- If validation_level >= AUTO_APPROVE_THRESHOLD, auto-promote.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session.
|
||||||
|
staging: The ServiceStaging record to score.
|
||||||
|
user_id: Optional user who triggered the validation.
|
||||||
|
is_admin: If True, score = 100 (admin override).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new validation_level after applying the score.
|
||||||
|
"""
|
||||||
|
rules = await self.get_rules(db)
|
||||||
|
auto_approve = rules.get(
|
||||||
|
"AUTO_APPROVE_THRESHOLD", DEFAULT_RULES["AUTO_APPROVE_THRESHOLD"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Compute score ────────────────────────────────────────────────────
|
||||||
|
if is_admin:
|
||||||
|
score = 100
|
||||||
|
effective_user_id = 0 # system user placeholder
|
||||||
|
logger.info(f"Admin override: score=100 for staging {staging.id}")
|
||||||
|
elif user_id is not None:
|
||||||
|
weight = await self._get_user_weight(db, user_id, rules)
|
||||||
|
score = weight
|
||||||
|
effective_user_id = user_id
|
||||||
|
logger.info(
|
||||||
|
f"User {user_id} validation: weight={weight} for staging {staging.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
score = rules.get("BOT_BASE_SCORE", DEFAULT_RULES["BOT_BASE_SCORE"])
|
||||||
|
effective_user_id = 0
|
||||||
|
logger.info(
|
||||||
|
f"Robot base score: {score} for staging {staging.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Update validation_level ──────────────────────────────────────────
|
||||||
|
current_level = staging.validation_level or 0
|
||||||
|
new_level = min(current_level + score, 100) # cap at 100
|
||||||
|
staging.validation_level = new_level
|
||||||
|
|
||||||
|
# ── Log validation event ─────────────────────────────────────────────
|
||||||
|
# NOTE: ProviderValidation.provider_id references marketplace.service_providers.id.
|
||||||
|
# For staging-level validations (before promotion), we skip logging to
|
||||||
|
# provider_validations since the staging entry is not a service_provider yet.
|
||||||
|
# After promotion, the _promote_to_provider method can log validations.
|
||||||
|
if effective_user_id > 0 and staging.organization_id is not None:
|
||||||
|
# Only log if the staging has been promoted (has an org)
|
||||||
|
await self._log_validation(
|
||||||
|
db,
|
||||||
|
provider_id=staging.id,
|
||||||
|
voter_user_id=effective_user_id,
|
||||||
|
validation_type="approve" if score >= 0 else "reject",
|
||||||
|
weight=score,
|
||||||
|
metadata={
|
||||||
|
"staging_id": staging.id,
|
||||||
|
"score": score,
|
||||||
|
"validation_level_before": current_level,
|
||||||
|
"validation_level_after": new_level,
|
||||||
|
"is_admin": is_admin,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
logger.info(
|
||||||
|
f"Staging {staging.id} validation_level: {current_level} → {new_level} "
|
||||||
|
f"(score={score})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Auto-promote if threshold reached ────────────────────────────────
|
||||||
|
if new_level >= auto_approve and staging.status != "approved":
|
||||||
|
logger.info(
|
||||||
|
f"Auto-approve threshold reached ({new_level} >= {auto_approve}) "
|
||||||
|
f"for staging {staging.id}"
|
||||||
|
)
|
||||||
|
await self._promote_to_provider(db, staging)
|
||||||
|
|
||||||
|
return new_level
|
||||||
199
backend/app/tests/test_validation_engine.py
Normal file
199
backend/app/tests/test_validation_engine.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/tests/test_validation_engine.py
|
||||||
|
"""
|
||||||
|
Test script for ValidationEngine.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. get_rules() — fetches dynamic rules from DB
|
||||||
|
2. process_staging_score() with admin override
|
||||||
|
3. process_staging_score() with user weight
|
||||||
|
4. process_staging_score() with robot base score
|
||||||
|
5. Auto-promotion when threshold reached
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker exec -i sf_api python -m app.tests.test_validation_engine
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models import (
|
||||||
|
ServiceStaging,
|
||||||
|
ValidationRule,
|
||||||
|
UserStats,
|
||||||
|
)
|
||||||
|
from app.models.identity.social import ProviderValidation
|
||||||
|
from app.services.validation_engine import ValidationEngine
|
||||||
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
|
||||||
|
logger = logging.getLogger("TestValidationEngine")
|
||||||
|
|
||||||
|
PASS = 0
|
||||||
|
FAIL = 0
|
||||||
|
|
||||||
|
|
||||||
|
def assert_eq(actual, expected, label: str):
|
||||||
|
global PASS, FAIL
|
||||||
|
if actual == expected:
|
||||||
|
PASS += 1
|
||||||
|
logger.info(f" ✅ {label}: {actual} == {expected}")
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
logger.error(f" ❌ {label}: {actual} != {expected}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_rules():
|
||||||
|
"""Test that get_rules() fetches all 4 base rules from the DB."""
|
||||||
|
logger.info("\n📋 Test: get_rules()")
|
||||||
|
engine = ValidationEngine()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
rules = await engine.get_rules(db)
|
||||||
|
assert_eq(rules.get("BOT_BASE_SCORE"), 20, "BOT_BASE_SCORE")
|
||||||
|
assert_eq(rules.get("FIRST_ENTRY_SCORE"), 40, "FIRST_ENTRY_SCORE")
|
||||||
|
assert_eq(rules.get("USER_CONFIRM_BASE"), 20, "USER_CONFIRM_BASE")
|
||||||
|
assert_eq(rules.get("AUTO_APPROVE_THRESHOLD"), 80, "AUTO_APPROVE_THRESHOLD")
|
||||||
|
assert_eq(len(rules), 4, "rule count")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_override():
|
||||||
|
"""Test that admin override sets score=100."""
|
||||||
|
logger.info("\n📋 Test: admin override (score=100)")
|
||||||
|
engine = ValidationEngine()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
staging = ServiceStaging(
|
||||||
|
name="Test Admin Garage",
|
||||||
|
city="Budapest",
|
||||||
|
fingerprint="test_admin_fp_001",
|
||||||
|
status="pending",
|
||||||
|
validation_level=0,
|
||||||
|
)
|
||||||
|
db.add(staging)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
new_level = await engine.process_staging_score(
|
||||||
|
db, staging, is_admin=True
|
||||||
|
)
|
||||||
|
assert_eq(new_level, 100, "admin validation_level")
|
||||||
|
assert_eq(staging.validation_level, 100, "staging.validation_level")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_user_weight():
|
||||||
|
"""Test that user weight is calculated from UserStats.current_level."""
|
||||||
|
logger.info("\n📋 Test: user weight calculation")
|
||||||
|
engine = ValidationEngine()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Create a staging entry
|
||||||
|
staging = ServiceStaging(
|
||||||
|
name="Test User Garage",
|
||||||
|
city="Debrecen",
|
||||||
|
fingerprint="test_user_fp_001",
|
||||||
|
status="pending",
|
||||||
|
validation_level=0,
|
||||||
|
)
|
||||||
|
db.add(staging)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Test with user_id that doesn't exist (should use base weight)
|
||||||
|
new_level = await engine.process_staging_score(
|
||||||
|
db, staging, user_id=999999
|
||||||
|
)
|
||||||
|
# USER_CONFIRM_BASE=20, no UserStats -> base=20
|
||||||
|
assert_eq(new_level, 20, "user weight without stats (base=20)")
|
||||||
|
|
||||||
|
staging.validation_level = 0 # reset
|
||||||
|
|
||||||
|
# Create UserStats for user 1 (if exists)
|
||||||
|
stmt = select(UserStats).where(UserStats.user_id == 1)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
stats = result.scalar_one_or_none()
|
||||||
|
if stats:
|
||||||
|
level = stats.current_level or 1
|
||||||
|
expected = 20 + (level * 5)
|
||||||
|
new_level = await engine.process_staging_score(
|
||||||
|
db, staging, user_id=1
|
||||||
|
)
|
||||||
|
assert_eq(new_level, expected, f"user weight with level={level}")
|
||||||
|
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_robot_base_score():
|
||||||
|
"""Test that robot (no user_id) uses BOT_BASE_SCORE=20."""
|
||||||
|
logger.info("\n📋 Test: robot base score (BOT_BASE_SCORE=20)")
|
||||||
|
engine = ValidationEngine()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
staging = ServiceStaging(
|
||||||
|
name="Test Robot Garage",
|
||||||
|
city="Szeged",
|
||||||
|
fingerprint="test_robot_fp_001",
|
||||||
|
status="pending",
|
||||||
|
validation_level=0,
|
||||||
|
)
|
||||||
|
db.add(staging)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
new_level = await engine.process_staging_score(db, staging)
|
||||||
|
assert_eq(new_level, 20, "robot base score")
|
||||||
|
assert_eq(staging.validation_level, 20, "staging.validation_level")
|
||||||
|
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_promote():
|
||||||
|
"""Test that staging is auto-promoted when validation_level >= 80."""
|
||||||
|
logger.info("\n📋 Test: auto-promotion at threshold >= 80")
|
||||||
|
engine = ValidationEngine()
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
staging = ServiceStaging(
|
||||||
|
name="Test Promote Garage",
|
||||||
|
city="Miskolc",
|
||||||
|
postal_code="3500",
|
||||||
|
full_address="3500 Miskolc, Test Street 1",
|
||||||
|
fingerprint="test_promote_fp_001",
|
||||||
|
status="pending",
|
||||||
|
validation_level=40,
|
||||||
|
trust_score=20,
|
||||||
|
)
|
||||||
|
db.add(staging)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# First pass: admin adds 40 -> 80 (threshold reached)
|
||||||
|
new_level = await engine.process_staging_score(
|
||||||
|
db, staging, is_admin=True
|
||||||
|
)
|
||||||
|
# Admin gives 100, but capped at 100
|
||||||
|
assert_eq(new_level, 100, "auto-promote validation_level")
|
||||||
|
# Status should be 'approved' now
|
||||||
|
assert_eq(staging.status, "approved", "staging.status after promotion")
|
||||||
|
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🔍 ValidationEngine Test Suite")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
await test_get_rules()
|
||||||
|
await test_admin_override()
|
||||||
|
await test_user_weight()
|
||||||
|
await test_robot_base_score()
|
||||||
|
await test_auto_promote()
|
||||||
|
|
||||||
|
logger.info("\n" + "=" * 60)
|
||||||
|
logger.info(f"📊 Results: {PASS} passed, {FAIL} failed")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
if FAIL > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -6,7 +6,7 @@ import httpx
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models.marketplace.service import ServiceStaging # JAVÍTOTT IMPORT ÚTVONAL!
|
from app.models.marketplace.staged_data import ServiceStaging # JAVÍTVA: helyes import a trust_score oszloppal rendelkező modellhez
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Logolás MB 2.0 szabvány szerint
|
# Logolás MB 2.0 szabvány szerint
|
||||||
@@ -91,11 +91,10 @@ class OSMScout:
|
|||||||
if existing is None:
|
if existing is None:
|
||||||
full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,")
|
full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,")
|
||||||
|
|
||||||
# Bővített JSON a nyers adatokhoz, mert a modelled nem tartalmazza a source és trust oszlopokat
|
# P0 FIX: trust_score most már az ORM objektum mezője, nem csak JSON-ben
|
||||||
raw_payload = {
|
raw_payload = {
|
||||||
"osm_tags": tags,
|
"osm_tags": tags,
|
||||||
"source": "osm_scout_v2",
|
"source": "osm_scout_v2",
|
||||||
"trust_score": 20
|
|
||||||
}
|
}
|
||||||
|
|
||||||
new_entry = ServiceStaging(
|
new_entry = ServiceStaging(
|
||||||
@@ -105,6 +104,7 @@ class OSMScout:
|
|||||||
full_address=full_addr,
|
full_address=full_addr,
|
||||||
fingerprint=f_print,
|
fingerprint=f_print,
|
||||||
status="pending",
|
status="pending",
|
||||||
|
trust_score=20, # P0 FIX: BOT_BASE_SCORE az ORM objektumon
|
||||||
raw_data=raw_payload
|
raw_data=raw_payload
|
||||||
)
|
)
|
||||||
db.add(new_entry)
|
db.add(new_entry)
|
||||||
|
|||||||
282
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
282
backend/app/workers/system/inactivity_monitor_worker.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
🤖 Inactivity Monitor Worker (Robot-21)
|
||||||
|
Detects users who have been inactive for N+ days (configured via system_parameters)
|
||||||
|
and flags them so the MLM Commission Engine can bypass them for Gen1/Gen2 rewards.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Read inactivity_threshold_days from system.system_parameters (fallback: 180)
|
||||||
|
2. Find users where last_activity_at < NOW() - INTERVAL 'N days'
|
||||||
|
AND is_active = True AND is_deleted = False
|
||||||
|
3. Set is_active = False
|
||||||
|
4. Set custom_permissions['inactivity_suspended'] = True
|
||||||
|
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||||
|
5. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||||
|
6. Send notification
|
||||||
|
|
||||||
|
MLM Integration:
|
||||||
|
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
||||||
|
Commission Engine when calculating Gen1/Gen2 rewards. If the upline user
|
||||||
|
is flagged as inactive, their downline's Gen2 rewards are forfeited.
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||||
|
- Only processes users who have a last_activity_at value (never-logged-in
|
||||||
|
users are handled separately by their created_at timestamp)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
docker exec sf_api python -m app.workers.system.inactivity_monitor_worker
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.identity import User
|
||||||
|
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||||
|
from app.models.system import SystemParameter
|
||||||
|
from app.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
logger = logging.getLogger("inactivity-monitor-worker")
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
PROCESS_NAME = "Inactivity-Monitor"
|
||||||
|
DEFAULT_INACTIVITY_DAYS = 180
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_inactivity_threshold(db: AsyncSession) -> int:
|
||||||
|
"""
|
||||||
|
Read the inactivity_threshold_days from system.system_parameters.
|
||||||
|
Falls back to DEFAULT_INACTIVITY_DAYS (180) if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stmt = select(SystemParameter).where(
|
||||||
|
SystemParameter.key == "inactivity_threshold_days",
|
||||||
|
SystemParameter.scope_level == "global",
|
||||||
|
SystemParameter.is_active == True,
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
param = result.scalar_one_or_none()
|
||||||
|
if param is not None:
|
||||||
|
val = param.value
|
||||||
|
if isinstance(val, dict):
|
||||||
|
# Could be stored as {"value": 180} or just 180
|
||||||
|
return int(val.get("value", val.get("days", DEFAULT_INACTIVITY_DAYS)))
|
||||||
|
return int(val)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to read inactivity_threshold_days from DB: {e}. "
|
||||||
|
f"Falling back to {DEFAULT_INACTIVITY_DAYS}."
|
||||||
|
)
|
||||||
|
return DEFAULT_INACTIVITY_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
async def process_inactive_users(db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Finds and flags users inactive for N+ days (configurable via system_parameters).
|
||||||
|
|
||||||
|
Two-phase detection:
|
||||||
|
A. Users with last_activity_at: last_activity_at < NOW() - N days
|
||||||
|
B. Users without last_activity_at (never logged in):
|
||||||
|
created_at < NOW() - N days AND last_activity_at IS NULL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics (processed_count, suspended_users, errors)
|
||||||
|
"""
|
||||||
|
inactivity_days = await _get_inactivity_threshold(db)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = now - timedelta(days=inactivity_days)
|
||||||
|
stats = {"processed": 0, "suspended": [], "errors": [], "threshold_days": inactivity_days}
|
||||||
|
|
||||||
|
logger.info(f"Inactivity threshold: {inactivity_days} days (cutoff={cutoff.isoformat()})")
|
||||||
|
|
||||||
|
# ── Phase A: Users with last_activity_at ──
|
||||||
|
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||||
|
# "FOR UPDATE cannot be applied to the nullable side of an outer join" error.
|
||||||
|
# The User model has relationships (role_id) that generate LEFT OUTER JOINs.
|
||||||
|
subquery_a = (
|
||||||
|
select(User.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.last_activity_at.isnot(None),
|
||||||
|
User.last_activity_at < cutoff,
|
||||||
|
User.is_active == True,
|
||||||
|
User.is_deleted == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt_a = select(User).where(User.id.in_(select(subquery_a.c.id)))
|
||||||
|
result_a = await db.execute(stmt_a)
|
||||||
|
inactive_users_a: List[User] = result_a.scalars().all()
|
||||||
|
|
||||||
|
# ── Phase B: Users without last_activity_at (never logged in) ──
|
||||||
|
subquery_b = (
|
||||||
|
select(User.id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
User.last_activity_at.is_(None),
|
||||||
|
User.created_at < cutoff,
|
||||||
|
User.is_active == True,
|
||||||
|
User.is_deleted == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt_b = select(User).where(User.id.in_(select(subquery_b.c.id)))
|
||||||
|
result_b = await db.execute(stmt_b)
|
||||||
|
inactive_users_b: List[User] = result_b.scalars().all()
|
||||||
|
|
||||||
|
# Combine both phases
|
||||||
|
all_inactive = inactive_users_a + inactive_users_b
|
||||||
|
stats["processed"] = len(all_inactive)
|
||||||
|
|
||||||
|
if not all_inactive:
|
||||||
|
logger.info("No inactive users found.")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
for user in all_inactive:
|
||||||
|
try:
|
||||||
|
# Determine the reference timestamp for this user
|
||||||
|
ref_ts = user.last_activity_at or user.created_at
|
||||||
|
days_since = (now - ref_ts).days if ref_ts else inactivity_days
|
||||||
|
|
||||||
|
# 1. Deactivate the user
|
||||||
|
user.is_active = False
|
||||||
|
|
||||||
|
# 2. Set inactivity flag in custom_permissions JSONB
|
||||||
|
# This is the key flag the MLM Commission Engine checks.
|
||||||
|
perms = dict(user.custom_permissions or {})
|
||||||
|
perms["inactivity_suspended"] = True
|
||||||
|
perms["inactivity_suspended_at"] = now.isoformat()
|
||||||
|
perms["inactivity_days_since_last_activity"] = days_since
|
||||||
|
user.custom_permissions = perms
|
||||||
|
|
||||||
|
# 3. Record ledger entry (informational, amount=0)
|
||||||
|
ledger_entry = FinancialLedger(
|
||||||
|
user_id=user.id,
|
||||||
|
amount=0.0,
|
||||||
|
entry_type=LedgerEntryType.DEBIT,
|
||||||
|
wallet_type=WalletType.EARNED,
|
||||||
|
transaction_type="ACCOUNT_SUSPENDED_INACTIVITY",
|
||||||
|
details={
|
||||||
|
"description": (
|
||||||
|
f"Account suspended after {days_since} days of inactivity. "
|
||||||
|
f"Last activity: {ref_ts.isoformat() if ref_ts else 'never'}"
|
||||||
|
),
|
||||||
|
"inactivity_days": days_since,
|
||||||
|
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||||
|
"cutoff_days": inactivity_days,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(ledger_entry)
|
||||||
|
|
||||||
|
# 4. Send notification
|
||||||
|
try:
|
||||||
|
await NotificationService.send_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=user.id,
|
||||||
|
title="Fiók felfüggesztve inaktivitás miatt",
|
||||||
|
message=(
|
||||||
|
f"Fiókod {days_since} napos inaktivitás miatt "
|
||||||
|
f"felfüggesztésre került. A jutalékrendszer a továbbiakban "
|
||||||
|
f"nem számol Gen1/Gen2 jutalékot a nevedben. "
|
||||||
|
f"Lépj be újra a fiókod aktiválásához."
|
||||||
|
),
|
||||||
|
category="system",
|
||||||
|
priority="high",
|
||||||
|
data={
|
||||||
|
"user_id": user.id,
|
||||||
|
"inactivity_days": days_since,
|
||||||
|
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||||
|
"suspended_at": now.isoformat(),
|
||||||
|
},
|
||||||
|
send_email=True,
|
||||||
|
email_template="account_suspended_inactivity",
|
||||||
|
email_vars={
|
||||||
|
"recipient": user.email,
|
||||||
|
"first_name": user.person.first_name
|
||||||
|
if user.person
|
||||||
|
else "Partnerünk",
|
||||||
|
"inactivity_days": str(days_since),
|
||||||
|
"lang": user.preferred_language,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as notify_err:
|
||||||
|
logger.warning(
|
||||||
|
f"Notification failed for user {user.id}: {notify_err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["suspended"].append(
|
||||||
|
{
|
||||||
|
"user_id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"inactivity_days": days_since,
|
||||||
|
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"User {user.id} ({user.email}) suspended after "
|
||||||
|
f"{days_since} days of inactivity"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing user {user.id}: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
stats["errors"].append({"user_id": user.id, "error": str(e)})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Inactive users processed: {stats['processed']} total, "
|
||||||
|
f"{len(stats['suspended'])} suspended, {len(stats['errors'])} errors"
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def run_full_monitor() -> dict:
|
||||||
|
"""Runs the complete inactivity monitor cycle."""
|
||||||
|
logger.info("=== Inactivity Monitor Worker started ===")
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
stats = await process_inactive_users(db)
|
||||||
|
logger.info(
|
||||||
|
f"=== Inactivity Monitor Worker completed: "
|
||||||
|
f"{stats['processed']} processed =="
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Inactivity Monitor Worker failed: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Entry point for standalone execution (cron / manual)."""
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger.info("Starting Inactivity Monitor Worker (standalone)...")
|
||||||
|
try:
|
||||||
|
stats = await run_full_monitor()
|
||||||
|
print(
|
||||||
|
f"✅ Inactivity Monitor completed. "
|
||||||
|
f"Total processed: {stats['processed']}, "
|
||||||
|
f"Suspended: {len(stats['suspended'])}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Inactivity Monitor failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
408
backend/app/workers/system/subscription_monitor_worker.py
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
🤖 Subscription Monitor Worker (Robot-20 Enhanced)
|
||||||
|
Comprehensive subscription lifecycle management for both UserSubscription
|
||||||
|
and OrganizationSubscription tables.
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Scan finance.user_subscriptions where valid_until < NOW() AND is_active = True
|
||||||
|
→ Set is_active = False, downgrade user to default fallback tier
|
||||||
|
2. Scan finance.org_subscriptions where valid_until < NOW() AND is_active = True
|
||||||
|
→ Set is_active = False, downgrade org to default fallback tier
|
||||||
|
3. Sync denormalized fields on identity.users and fleet.organizations
|
||||||
|
4. Record ledger entries (SUBSCRIPTION_EXPIRED)
|
||||||
|
5. Send notifications via NotificationService
|
||||||
|
|
||||||
|
Design:
|
||||||
|
- Uses FOR UPDATE SKIP LOCKED for atomic locking
|
||||||
|
- Falls back to SubscriptionTier.is_default_fallback == True tier
|
||||||
|
- Does NOT overwrite the existing subscription_worker.py (backward compatible)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
docker exec sf_api python -m app.workers.system.subscription_monitor_worker
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.identity import User
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
from app.models.core_logic import (
|
||||||
|
SubscriptionTier,
|
||||||
|
UserSubscription,
|
||||||
|
OrganizationSubscription,
|
||||||
|
)
|
||||||
|
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||||
|
from app.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
logger = logging.getLogger("subscription-monitor-worker")
|
||||||
|
|
||||||
|
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||||
|
PROCESS_NAME = "Subscription-Monitor"
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_default_fallback_tier(db: AsyncSession) -> Optional[SubscriptionTier]:
|
||||||
|
"""
|
||||||
|
Lekérdezi az egyetlen csomagot, ahol is_default_fallback == True.
|
||||||
|
Ha nincs ilyen, None-t ad vissza.
|
||||||
|
"""
|
||||||
|
stmt = select(SubscriptionTier).where(
|
||||||
|
SubscriptionTier.is_default_fallback == True
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_expired_ledger(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
old_plan: str,
|
||||||
|
subscription_type: str,
|
||||||
|
reference_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Record a SUBSCRIPTION_EXPIRED ledger entry (amount=0, informational only)."""
|
||||||
|
entry = FinancialLedger(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=0.0,
|
||||||
|
entry_type=LedgerEntryType.DEBIT,
|
||||||
|
wallet_type=WalletType.EARNED,
|
||||||
|
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||||
|
details={
|
||||||
|
"description": f"Subscription expired: {old_plan} → FREE ({subscription_type})",
|
||||||
|
"reference_type": subscription_type,
|
||||||
|
"reference_id": reference_id,
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": "FREE",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_expired_user_subscriptions(db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Processes expired UserSubscription records.
|
||||||
|
- Finds active subscriptions where valid_until < NOW()
|
||||||
|
- Sets is_active = False
|
||||||
|
- Updates User.subscription_plan to the fallback tier name
|
||||||
|
- Records ledger entry and sends notification
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||||
|
|
||||||
|
# 1. Find expired UserSubscriptions with atomic lock
|
||||||
|
stmt = (
|
||||||
|
select(UserSubscription)
|
||||||
|
.options(selectinload(UserSubscription.tier))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
UserSubscription.valid_until < now,
|
||||||
|
UserSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
expired_subs: List[UserSubscription] = result.scalars().all()
|
||||||
|
|
||||||
|
if not expired_subs:
|
||||||
|
logger.info("No expired user subscriptions found.")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# 2. Get the default fallback tier
|
||||||
|
fallback_tier = await _get_default_fallback_tier(db)
|
||||||
|
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||||
|
|
||||||
|
for sub in expired_subs:
|
||||||
|
try:
|
||||||
|
# Mark subscription as inactive
|
||||||
|
sub.is_active = False
|
||||||
|
|
||||||
|
# Update the denormalized User fields
|
||||||
|
user_stmt = select(User).where(User.id == sub.user_id)
|
||||||
|
user_result = await db.execute(user_stmt)
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user:
|
||||||
|
old_plan = user.subscription_plan
|
||||||
|
user.subscription_plan = fallback_plan.upper()
|
||||||
|
user.subscription_expires_at = None
|
||||||
|
user.is_vip = False
|
||||||
|
|
||||||
|
# Record ledger entry
|
||||||
|
await _record_expired_ledger(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
old_plan=old_plan,
|
||||||
|
subscription_type="user_subscription",
|
||||||
|
reference_id=sub.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
try:
|
||||||
|
await NotificationService.send_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=user.id,
|
||||||
|
title="Előfizetésed lejárt",
|
||||||
|
message=(
|
||||||
|
f"A(z) {old_plan} előfizetésed lejárt. "
|
||||||
|
f"Fiókod a(z) {fallback_plan.upper()} csomagra váltott. "
|
||||||
|
"További előnyökért frissíts előfizetést!"
|
||||||
|
),
|
||||||
|
category="billing",
|
||||||
|
priority="medium",
|
||||||
|
data={
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": fallback_plan.upper(),
|
||||||
|
"user_id": user.id,
|
||||||
|
"subscription_id": sub.id,
|
||||||
|
"expired_at": sub.valid_until.isoformat()
|
||||||
|
if sub.valid_until
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
send_email=True,
|
||||||
|
email_template="subscription_expired",
|
||||||
|
email_vars={
|
||||||
|
"recipient": user.email,
|
||||||
|
"first_name": user.person.first_name
|
||||||
|
if user.person
|
||||||
|
else "Partnerünk",
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": fallback_plan.upper(),
|
||||||
|
"lang": user.preferred_language,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as notify_err:
|
||||||
|
logger.warning(
|
||||||
|
f"Notification failed for user {user.id}: {notify_err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["downgraded"].append(
|
||||||
|
{
|
||||||
|
"user_id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": fallback_plan.upper(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"User {user.id} ({user.email}) subscription expired: "
|
||||||
|
f"{old_plan} → {fallback_plan.upper()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["processed"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing UserSubscription {sub.id}: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Expired user subscriptions processed: {stats['processed']} total, "
|
||||||
|
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def process_expired_org_subscriptions(db: AsyncSession) -> dict:
|
||||||
|
"""
|
||||||
|
Processes expired OrganizationSubscription records.
|
||||||
|
- Finds active subscriptions where valid_until < NOW()
|
||||||
|
- Sets is_active = False
|
||||||
|
- Updates Organization.subscription_plan to the fallback tier name
|
||||||
|
- Records ledger entry for the org owner
|
||||||
|
- Sends notification to org owner
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stats = {"processed": 0, "downgraded": [], "errors": []}
|
||||||
|
|
||||||
|
# 1. Find expired OrganizationSubscriptions with atomic lock
|
||||||
|
stmt = (
|
||||||
|
select(OrganizationSubscription)
|
||||||
|
.options(selectinload(OrganizationSubscription.tier))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
OrganizationSubscription.valid_until < now,
|
||||||
|
OrganizationSubscription.is_active == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
expired_subs: List[OrganizationSubscription] = result.scalars().all()
|
||||||
|
|
||||||
|
if not expired_subs:
|
||||||
|
logger.info("No expired organization subscriptions found.")
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# 2. Get the default fallback tier
|
||||||
|
fallback_tier = await _get_default_fallback_tier(db)
|
||||||
|
fallback_plan = fallback_tier.name.lower() if fallback_tier else "free"
|
||||||
|
|
||||||
|
for sub in expired_subs:
|
||||||
|
try:
|
||||||
|
# Mark subscription as inactive
|
||||||
|
sub.is_active = False
|
||||||
|
|
||||||
|
# Update the denormalized Organization fields
|
||||||
|
org_stmt = select(Organization).where(Organization.id == sub.org_id)
|
||||||
|
org_result = await db.execute(org_stmt)
|
||||||
|
org = org_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if org:
|
||||||
|
old_plan = org.subscription_plan
|
||||||
|
org.subscription_plan = fallback_plan.upper()
|
||||||
|
org.subscription_expires_at = None
|
||||||
|
|
||||||
|
# Record ledger entry for the org owner (if exists)
|
||||||
|
if org.owner_id:
|
||||||
|
await _record_expired_ledger(
|
||||||
|
db,
|
||||||
|
user_id=org.owner_id,
|
||||||
|
old_plan=old_plan,
|
||||||
|
subscription_type="org_subscription",
|
||||||
|
reference_id=sub.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send notification to org owner
|
||||||
|
try:
|
||||||
|
owner_stmt = select(User).where(User.id == org.owner_id)
|
||||||
|
owner_result = await db.execute(owner_stmt)
|
||||||
|
owner = owner_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if owner:
|
||||||
|
await NotificationService.send_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=owner.id,
|
||||||
|
title="Szervezeti előfizetésed lejárt",
|
||||||
|
message=(
|
||||||
|
f"A(z) '{org.name}' szervezet {old_plan} "
|
||||||
|
f"előfizetése lejárt. A szervezet a(z) "
|
||||||
|
f"{fallback_plan.upper()} csomagra váltott."
|
||||||
|
),
|
||||||
|
category="billing",
|
||||||
|
priority="medium",
|
||||||
|
data={
|
||||||
|
"org_id": org.id,
|
||||||
|
"org_name": org.name,
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": fallback_plan.upper(),
|
||||||
|
"subscription_id": sub.id,
|
||||||
|
"expired_at": sub.valid_until.isoformat()
|
||||||
|
if sub.valid_until
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
send_email=True,
|
||||||
|
email_template="subscription_expired",
|
||||||
|
email_vars={
|
||||||
|
"recipient": owner.email,
|
||||||
|
"first_name": owner.person.first_name
|
||||||
|
if owner.person
|
||||||
|
else "Partnerünk",
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": fallback_plan.upper(),
|
||||||
|
"lang": owner.preferred_language,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as notify_err:
|
||||||
|
logger.warning(
|
||||||
|
f"Notification failed for org owner {org.owner_id}: "
|
||||||
|
f"{notify_err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["downgraded"].append(
|
||||||
|
{
|
||||||
|
"org_id": org.id,
|
||||||
|
"org_name": org.name,
|
||||||
|
"old_plan": old_plan,
|
||||||
|
"new_plan": fallback_plan.upper(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Organization {org.id} ({org.name}) subscription expired: "
|
||||||
|
f"{old_plan} → {fallback_plan.upper()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
stats["processed"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing OrganizationSubscription {sub.id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
stats["errors"].append({"subscription_id": sub.id, "error": str(e)})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"Expired org subscriptions processed: {stats['processed']} total, "
|
||||||
|
f"{len(stats['downgraded'])} downgraded, {len(stats['errors'])} errors"
|
||||||
|
)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def run_full_monitor() -> dict:
|
||||||
|
"""
|
||||||
|
Runs the complete subscription monitor cycle.
|
||||||
|
Returns aggregated statistics.
|
||||||
|
"""
|
||||||
|
logger.info("=== Subscription Monitor Worker started ===")
|
||||||
|
aggregated = {
|
||||||
|
"user_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||||
|
"org_subscriptions": {"processed": 0, "downgraded": [], "errors": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
# Phase 1: Expired UserSubscriptions
|
||||||
|
user_stats = await process_expired_user_subscriptions(db)
|
||||||
|
aggregated["user_subscriptions"] = user_stats
|
||||||
|
|
||||||
|
# Phase 2: Expired OrganizationSubscriptions
|
||||||
|
org_stats = await process_expired_org_subscriptions(db)
|
||||||
|
aggregated["org_subscriptions"] = org_stats
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"=== Subscription Monitor Worker completed: "
|
||||||
|
f"{user_stats['processed'] + org_stats['processed']} total =="
|
||||||
|
)
|
||||||
|
return aggregated
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Subscription Monitor Worker failed: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Entry point for standalone execution (cron / manual)."""
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger.info("Starting Subscription Monitor Worker (standalone)...")
|
||||||
|
try:
|
||||||
|
stats = await run_full_monitor()
|
||||||
|
total = (
|
||||||
|
stats["user_subscriptions"]["processed"]
|
||||||
|
+ stats["org_subscriptions"]["processed"]
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"✅ Subscription Monitor completed. "
|
||||||
|
f"Total processed: {total}, "
|
||||||
|
f"User downgrades: {len(stats['user_subscriptions']['downgraded'])}, "
|
||||||
|
f"Org downgrades: {len(stats['org_subscriptions']['downgraded'])}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Subscription Monitor failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -72,7 +72,11 @@ async def process_expired_subscriptions(db: AsyncSession) -> dict:
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
amount=0.0,
|
amount=0.0,
|
||||||
entry_type=LedgerEntryType.DEBIT,
|
entry_type=LedgerEntryType.DEBIT,
|
||||||
wallet_type=WalletType.SYSTEM,
|
# JAVÍTÁS: WalletType.SYSTEM nem létező enum érték.
|
||||||
|
# A WalletType csak EARNED, PURCHASED, SERVICE_COINS, VOUCHER értékeket támogat.
|
||||||
|
# Mivel ez csak egy subscription lejárati naplóbejegyzés (amount=0),
|
||||||
|
# a SERVICE_COINS a legmegfelelőbb alapértelmezett választás.
|
||||||
|
wallet_type=WalletType.SERVICE_COINS,
|
||||||
transaction_type="SUBSCRIPTION_EXPIRED",
|
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||||
description=f"Előfizetés lejárt: {old_plan} → FREE",
|
description=f"Előfizetés lejárt: {old_plan} → FREE",
|
||||||
reference_type="subscription",
|
reference_type="subscription",
|
||||||
|
|||||||
180
backend/backend/tests/active/seed_commission_test_data.py
Normal file
180
backend/backend/tests/active/seed_commission_test_data.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
Seed test data for Commission Distribution verification.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
1. Three users with a referral chain: UserC (buyer) -> UserB (Gen1) -> UserA (Gen2)
|
||||||
|
2. An active L2_COMMISSION rule with commission_percent and upline_commission_percent
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose exec sf_api python /app/backend/tests/active/seed_commission_test_data.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/backend")
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy import text
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
database_url = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder",
|
||||||
|
)
|
||||||
|
engine = create_async_engine(database_url, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
# Check if test users already exist
|
||||||
|
existing = await db.execute(
|
||||||
|
text("SELECT id, email FROM identity.users WHERE email LIKE 'commission_test_%' ORDER BY id")
|
||||||
|
)
|
||||||
|
existing_users = existing.fetchall()
|
||||||
|
if existing_users:
|
||||||
|
print(f"⚠️ Test users already exist: {[u.email for u in existing_users]}")
|
||||||
|
print(" Skipping user creation.")
|
||||||
|
|
||||||
|
# Find the chain
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT u1.id AS buyer_id, u1.email AS buyer_email,
|
||||||
|
u2.id AS gen1_id, u2.email AS gen1_email,
|
||||||
|
u3.id AS gen2_id, u3.email AS gen2_email
|
||||||
|
FROM identity.users u1
|
||||||
|
JOIN identity.users u2 ON u1.referred_by_id = u2.id
|
||||||
|
JOIN identity.users u3 ON u2.referred_by_id = u3.id
|
||||||
|
WHERE u1.email = 'commission_test_buyer@test.com'
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if row:
|
||||||
|
print(f"\n✅ Referral chain intact:")
|
||||||
|
print(f" Buyer(Gen0): ID={row.buyer_id} < {row.buyer_email}")
|
||||||
|
print(f" Gen1: ID={row.gen1_id} < {row.gen1_email}")
|
||||||
|
print(f" Gen2: ID={row.gen2_id} < {row.gen2_email}")
|
||||||
|
else:
|
||||||
|
print("Creating test users with referral chain...")
|
||||||
|
|
||||||
|
# Create Gen2 (top-level referrer) - UserA
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO identity.users
|
||||||
|
(email, hashed_password, role, commission_tier, is_active, ui_mode,
|
||||||
|
subscription_plan, is_vip, is_deleted, preferred_language,
|
||||||
|
region_code, preferred_currency, scope_level,
|
||||||
|
custom_permissions, alternative_emails, email_history,
|
||||||
|
visual_settings, created_at)
|
||||||
|
VALUES
|
||||||
|
('commission_test_gen2@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
|
||||||
|
'free', FALSE, FALSE, 'hu',
|
||||||
|
'HU', 'HUF', 'user',
|
||||||
|
'{}'::json, '[]'::json, '[]'::json,
|
||||||
|
'{}'::jsonb, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
gen2_id = result.scalar()
|
||||||
|
print(f" Created Gen2 (Upline): ID={gen2_id}")
|
||||||
|
|
||||||
|
# Create Gen1 (referrer) - UserB, referred by Gen2
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO identity.users
|
||||||
|
(email, hashed_password, role, commission_tier, is_active, ui_mode,
|
||||||
|
referred_by_id,
|
||||||
|
subscription_plan, is_vip, is_deleted, preferred_language,
|
||||||
|
region_code, preferred_currency, scope_level,
|
||||||
|
custom_permissions, alternative_emails, email_history,
|
||||||
|
visual_settings, created_at)
|
||||||
|
VALUES
|
||||||
|
('commission_test_gen1@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
|
||||||
|
:gen2_id,
|
||||||
|
'free', FALSE, FALSE, 'hu',
|
||||||
|
'HU', 'HUF', 'user',
|
||||||
|
'{}'::json, '[]'::json, '[]'::json,
|
||||||
|
'{}'::jsonb, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""").bindparams(gen2_id=gen2_id)
|
||||||
|
)
|
||||||
|
gen1_id = result.scalar()
|
||||||
|
print(f" Created Gen1 (Referrer): ID={gen1_id}")
|
||||||
|
|
||||||
|
# Create Buyer (Gen0) - UserC, referred by Gen1
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO identity.users
|
||||||
|
(email, hashed_password, role, commission_tier, is_active, ui_mode,
|
||||||
|
referred_by_id,
|
||||||
|
subscription_plan, is_vip, is_deleted, preferred_language,
|
||||||
|
region_code, preferred_currency, scope_level,
|
||||||
|
custom_permissions, alternative_emails, email_history,
|
||||||
|
visual_settings, created_at)
|
||||||
|
VALUES
|
||||||
|
('commission_test_buyer@test.com', 'abc123', 'USER', 'STANDARD', TRUE, 'personal',
|
||||||
|
:gen1_id,
|
||||||
|
'free', FALSE, FALSE, 'hu',
|
||||||
|
'HU', 'HUF', 'user',
|
||||||
|
'{}'::json, '[]'::json, '[]'::json,
|
||||||
|
'{}'::jsonb, NOW())
|
||||||
|
RETURNING id
|
||||||
|
""").bindparams(gen1_id=gen1_id)
|
||||||
|
)
|
||||||
|
buyer_id = result.scalar()
|
||||||
|
print(f" Created Buyer (Gen0): ID={buyer_id}")
|
||||||
|
|
||||||
|
print(f"\n✅ Referral chain created:")
|
||||||
|
print(f" Buyer(Gen0): ID={buyer_id} -> referred_by={gen1_id}")
|
||||||
|
print(f" Gen1: ID={gen1_id} -> referred_by={gen2_id}")
|
||||||
|
print(f" Gen2: ID={gen2_id}")
|
||||||
|
|
||||||
|
# Check for existing L2_COMMISSION rules
|
||||||
|
rule_result = await db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id, name, commission_percent, upline_commission_percent,
|
||||||
|
commission_max_amount, is_active
|
||||||
|
FROM marketplace.commission_rules
|
||||||
|
WHERE rule_type = 'L2_COMMISSION'
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
existing_rules = rule_result.fetchall()
|
||||||
|
if existing_rules:
|
||||||
|
print(f"\n⚠️ L2_COMMISSION rules already exist:")
|
||||||
|
for r in existing_rules:
|
||||||
|
print(f" ID={r.id}, Name={r.name}, Active={r.is_active}, "
|
||||||
|
f"commission={r.commission_percent}%, upline={r.upline_commission_percent}%, "
|
||||||
|
f"max={r.commission_max_amount}")
|
||||||
|
else:
|
||||||
|
print("\nCreating L2_COMMISSION rule...")
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO marketplace.commission_rules
|
||||||
|
(rule_type, name, description, tier, region_code,
|
||||||
|
commission_percent, upline_commission_percent,
|
||||||
|
commission_max_amount, is_campaign, is_active,
|
||||||
|
start_date, end_date, created_by)
|
||||||
|
VALUES
|
||||||
|
('L2_COMMISSION', 'Standard 2-Level Commission', 'Default 2-level MLM commission rule',
|
||||||
|
'STANDARD', 'HU',
|
||||||
|
5.00, 2.00,
|
||||||
|
50000.00, FALSE, TRUE,
|
||||||
|
:start_date, NULL, 1)
|
||||||
|
RETURNING id
|
||||||
|
""").bindparams(start_date=date(2026, 1, 1))
|
||||||
|
)
|
||||||
|
rule_id = result.scalar()
|
||||||
|
print(f" Created L2_COMMISSION rule: ID={rule_id}")
|
||||||
|
print(f" commission_percent=5.00%, upline_commission_percent=2.00%, max=50000.00")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print("\n✅ Seed data created successfully!")
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
194
backend/backend/tests/active/test_commission_distribution.py
Normal file
194
backend/backend/tests/active/test_commission_distribution.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Commission Distribution Verification Test
|
||||||
|
Tests the 2-Level MLM commission distribution logic end-to-end.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Seed data created via seed_commission_test_data.py
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose exec sf_api python /app/backend/tests/active/test_commission_distribution.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_URL = "http://sf_api:8000/api/v1"
|
||||||
|
ADMIN_EMAIL = "admin@profibot.hu"
|
||||||
|
ADMIN_PASSWORD = "Admin123!"
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(base_url=BASE_URL) as client:
|
||||||
|
# ── Step 1: Login ──
|
||||||
|
print("=" * 60)
|
||||||
|
print("STEP 1: Login with admin user")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
login_data = {
|
||||||
|
"username": ADMIN_EMAIL,
|
||||||
|
"password": ADMIN_PASSWORD,
|
||||||
|
"grant_type": "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
login_resp = await client.post(
|
||||||
|
"/auth/login",
|
||||||
|
data=login_data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if login_resp.status_code != 200:
|
||||||
|
print(f"❌ Login failed: {login_resp.status_code} {login_resp.text}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
token_data = login_resp.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
print(f"❌ No access_token in response: {token_data}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"✅ Login successful, token obtained")
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
# ── Step 2: Check referral chain via DB ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 2: Verify referral chain exists")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# We'll check via the users list endpoint
|
||||||
|
resp = await client.get(
|
||||||
|
"/admin/users?limit=50",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"❌ Failed to list users: {resp.status_code} {resp.text}")
|
||||||
|
failed += 1
|
||||||
|
else:
|
||||||
|
users = resp.json()
|
||||||
|
# Find our test users
|
||||||
|
test_users = [u for u in (users.get("users") or users.get("data") or users if isinstance(users, list) else [])
|
||||||
|
if isinstance(u, dict) and "commission_test" in str(u.get("email", ""))]
|
||||||
|
# Try to find from response
|
||||||
|
print(f"✅ Users endpoint responded (status={resp.status_code})")
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
# ── Step 3: Test commission distribution ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 3: Test commission distribution API")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Use the known test user IDs from seed data
|
||||||
|
# Buyer (Gen0) = ID 155, Gen1 = 154, Gen2 = 153
|
||||||
|
# Rule ID 4: STANDARD, HU, 5% Gen1, 2% Gen2, max 50000
|
||||||
|
|
||||||
|
distribute_payload = {
|
||||||
|
"buyer_user_id": 155,
|
||||||
|
"transaction_amount": 100000.00,
|
||||||
|
"transaction_date": "2026-07-24",
|
||||||
|
"region_code": "HU",
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"Request: POST /admin/commission-rules/distribute")
|
||||||
|
print(f"Payload: {distribute_payload}")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/commission-rules/distribute",
|
||||||
|
json=distribute_payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Response status: {resp.status_code}")
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
print(f"Response body: {data}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Validate response structure
|
||||||
|
assert "transaction_amount" in data, "Missing transaction_amount"
|
||||||
|
assert "items" in data, "Missing items"
|
||||||
|
assert "total_commission" in data, "Missing total_commission"
|
||||||
|
assert len(data["items"]) > 0, "No commission items returned"
|
||||||
|
|
||||||
|
# Check Gen1 commission (5% of 100000 = 5000)
|
||||||
|
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
|
||||||
|
assert gen1_item is not None, "Missing Gen1 commission item"
|
||||||
|
assert gen1_item["user_id"] == 154, f"Expected Gen1 user_id=154, got {gen1_item['user_id']}"
|
||||||
|
assert gen1_item["commission_amount"] == 5000.00, \
|
||||||
|
f"Expected Gen1 commission=5000.00, got {gen1_item['commission_amount']}"
|
||||||
|
|
||||||
|
# Check Gen2 commission (2% of 100000 = 2000)
|
||||||
|
gen2_item = next((i for i in data["items"] if i["level"] == 2), None)
|
||||||
|
assert gen2_item is not None, "Missing Gen2 commission item"
|
||||||
|
assert gen2_item["user_id"] == 153, f"Expected Gen2 user_id=153, got {gen2_item['user_id']}"
|
||||||
|
assert gen2_item["commission_amount"] == 2000.00, \
|
||||||
|
f"Expected Gen2 commission=2000.00, got {gen2_item['commission_amount']}"
|
||||||
|
|
||||||
|
# Check total
|
||||||
|
assert data["total_commission"] == 7000.00, \
|
||||||
|
f"Expected total_commission=7000.00, got {data['total_commission']}"
|
||||||
|
|
||||||
|
print("✅ Commission distribution test PASSED!")
|
||||||
|
print(f" Gen1 (User 154): 5% of 100000 = 5000.00 ✅")
|
||||||
|
print(f" Gen2 (User 153): 2% of 100000 = 2000.00 ✅")
|
||||||
|
print(f" Total: 7000.00 ✅")
|
||||||
|
passed += 1
|
||||||
|
elif resp.status_code == 404:
|
||||||
|
print(f"⚠️ No matching rule found: {resp.text}")
|
||||||
|
print(" This means the rule resolution didn't match. Check tier/region.")
|
||||||
|
else:
|
||||||
|
print(f"❌ Distribution failed: {resp.text}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# ── Step 4: Test with max_amount cap ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("STEP 4: Test commission with max_amount cap")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# With a very large transaction, Gen1 should be capped at 50000
|
||||||
|
# 5% of 2000000 = 100000, but max is 50000
|
||||||
|
cap_payload = {
|
||||||
|
"buyer_user_id": 155,
|
||||||
|
"transaction_amount": 2000000.00,
|
||||||
|
"transaction_date": "2026-07-24",
|
||||||
|
"region_code": "HU",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/admin/commission-rules/distribute",
|
||||||
|
json=cap_payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
gen1_item = next((i for i in data["items"] if i["level"] == 1), None)
|
||||||
|
if gen1_item:
|
||||||
|
# 5% of 2M = 100000, capped at 50000
|
||||||
|
assert gen1_item["commission_amount"] <= 50000.00, \
|
||||||
|
f"Gen1 commission {gen1_item['commission_amount']} exceeds max 50000"
|
||||||
|
print(f"✅ Max amount cap verified: Gen1 commission={gen1_item['commission_amount']} (capped at 50000)")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print("⚠️ No Gen1 item in capped test")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Cap test skipped (status={resp.status_code})")
|
||||||
|
|
||||||
|
# ── Summary ──
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"RESULTS: {passed} passed, {failed} failed")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if failed > 0:
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("\n✅ All commission distribution tests PASSED!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
210
backend/backend/tests/active/test_financial_manager.py
Normal file
210
backend/backend/tests/active/test_financial_manager.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
End-to-end test for the FinancialManager package purchase flow.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. MockPaymentGateway - auto_approve, simulate_failure, simulate_timeout modes
|
||||||
|
2. SubscriptionActivator - User-level and Organization-level activation with stacking
|
||||||
|
3. FinancialManager - Full purchase lifecycle (payment -> subscription -> commission)
|
||||||
|
4. API endpoints - POST /financial-manager/purchase-package
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker compose exec sf_api python3 /app/backend/tests/active/test_financial_manager.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
sys.path.insert(0, "/app/backend")
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:8000/api/v1")
|
||||||
|
TEST_EMAIL = os.getenv("TEST_EMAIL", "admin@profibot.hu")
|
||||||
|
TEST_PASSWORD = os.getenv("TEST_PASSWORD", "Admin123!")
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
|
logger = logging.getLogger("test-financial-manager")
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
def report_test(name, success, detail=""):
|
||||||
|
global passed, failed
|
||||||
|
if success:
|
||||||
|
passed += 1
|
||||||
|
logger.info(" ✅ PASS: %s", name)
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
logger.error(" ❌ FAIL: %s - %s", name, detail)
|
||||||
|
|
||||||
|
async def get_token(client):
|
||||||
|
"""Authenticate using form-encoded OAuth2 password flow."""
|
||||||
|
resp = await client.post(
|
||||||
|
f"{BASE_URL}/auth/login",
|
||||||
|
data={"username": TEST_EMAIL, "password": TEST_PASSWORD},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, f"Login failed (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||||
|
data = resp.json()
|
||||||
|
token = data.get("access_token")
|
||||||
|
assert token, f"No access_token in response: {data}"
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def test_mock_gateway_unit():
|
||||||
|
logger.info("-" * 60)
|
||||||
|
logger.info("TEST: MockPaymentGateway Unit Tests")
|
||||||
|
logger.info("-" * 60)
|
||||||
|
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||||
|
from app.services.financial_interfaces import PaymentGatewayError
|
||||||
|
|
||||||
|
gateway = MockPaymentGateway(mode="auto_approve")
|
||||||
|
result = await gateway.create_intent(Decimal("100.00"), "EUR")
|
||||||
|
report_test("auto_approve: returns completed status", result["status"] == "completed", f"Got status: {result['status']}")
|
||||||
|
report_test("auto_approve: has intent ID", bool(result.get("id")), f"ID: {result.get('id')}")
|
||||||
|
report_test("auto_approve: correct amount", result["amount"] == 100.0, f"Amount: {result['amount']}")
|
||||||
|
|
||||||
|
verify = await gateway.verify_payment(result["id"])
|
||||||
|
report_test("verify_payment: returns verified=True", verify["verified"] is True, f"Verified: {verify['verified']}")
|
||||||
|
|
||||||
|
gateway.set_mode("simulate_failure")
|
||||||
|
try:
|
||||||
|
await gateway.create_intent(Decimal("50.00"), "EUR")
|
||||||
|
report_test("simulate_failure: raises PaymentGatewayError", False, "No exception raised")
|
||||||
|
except PaymentGatewayError:
|
||||||
|
report_test("simulate_failure: raises PaymentGatewayError", True, "")
|
||||||
|
|
||||||
|
gateway.set_mode("auto_approve")
|
||||||
|
result2 = await gateway.create_intent(Decimal("75.00"), "EUR")
|
||||||
|
refund = await gateway.refund_payment(result2["id"])
|
||||||
|
report_test("refund_payment: returns refunded=True", refund["refunded"] is True, f"Refunded: {refund['refunded']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await gateway.verify_payment("nonexistent_intent")
|
||||||
|
report_test("verify_payment unknown: raises error", False, "No exception")
|
||||||
|
except PaymentGatewayError:
|
||||||
|
report_test("verify_payment unknown: raises error", True, "")
|
||||||
|
|
||||||
|
gateway.reset()
|
||||||
|
report_test("reset: clears processed intents", len(gateway._processed_intents) == 0, f"Intents left: {len(gateway._processed_intents)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
gateway.set_mode("invalid_mode")
|
||||||
|
report_test("set_mode invalid: raises ValueError", False, "No exception")
|
||||||
|
except ValueError:
|
||||||
|
report_test("set_mode invalid: raises ValueError", True, "")
|
||||||
|
|
||||||
|
async def test_subscription_activator_unit():
|
||||||
|
logger.info("-" * 60)
|
||||||
|
logger.info("TEST: SubscriptionActivator Unit Tests")
|
||||||
|
logger.info("-" * 60)
|
||||||
|
from app.services.subscription_activator import SubscriptionActivator
|
||||||
|
from app.models.core_logic import SubscriptionTier
|
||||||
|
|
||||||
|
activator = SubscriptionActivator()
|
||||||
|
tier = SubscriptionTier(id=999, name="Test Tier", rules={"duration": {"days": 30, "allow_stacking": True}})
|
||||||
|
|
||||||
|
duration = activator._resolve_duration(tier, override_days=60)
|
||||||
|
report_test("_resolve_duration: override takes priority", duration == 60, f"Got: {duration}")
|
||||||
|
|
||||||
|
duration2 = activator._resolve_duration(tier, override_days=None)
|
||||||
|
report_test("_resolve_duration: reads from tier.rules", duration2 == 30, f"Got: {duration2}")
|
||||||
|
|
||||||
|
tier_no_rules = SubscriptionTier(id=998, name="No Rules Tier", rules={})
|
||||||
|
duration3 = activator._resolve_duration(tier_no_rules, override_days=None)
|
||||||
|
report_test("_resolve_duration: default fallback 30", duration3 == 30, f"Got: {duration3}")
|
||||||
|
|
||||||
|
stacking = activator._resolve_stacking(tier)
|
||||||
|
report_test("_resolve_stacking: enabled", stacking is True, f"Got: {stacking}")
|
||||||
|
|
||||||
|
tier_no_stacking = SubscriptionTier(id=997, name="No Stacking Tier", rules={"duration": {"days": 30, "allow_stacking": False}})
|
||||||
|
stacking2 = activator._resolve_stacking(tier_no_stacking)
|
||||||
|
report_test("_resolve_stacking: disabled", stacking2 is False, f"Got: {stacking2}")
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
valid_until = activator._calculate_valid_until(db=None, duration_days=30, allow_stacking=True, now=now)
|
||||||
|
expected = now + timedelta(days=30)
|
||||||
|
report_test("_calculate_valid_until: correct delta", abs((valid_until - expected).total_seconds()) < 1, f"Expected: {expected}, Got: {valid_until}")
|
||||||
|
|
||||||
|
async def test_financial_manager_integration():
|
||||||
|
logger.info("-" * 60)
|
||||||
|
logger.info("TEST: FinancialManager API Integration Test")
|
||||||
|
logger.info("-" * 60)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
token = await get_token(client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
logger.info(" ✅ Authenticated successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(" ❌ Authentication failed: %s", e)
|
||||||
|
report_test("Authentication", False, str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Step 1: Check FinancialManager status
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"{BASE_URL}/financial-manager/financial-manager/status", headers=headers)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
report_test("GET /financial-manager/status", data.get("status") == "operational", json.dumps(data, indent=2))
|
||||||
|
else:
|
||||||
|
report_test("GET /financial-manager/status", False, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
report_test("GET /financial-manager/status", False, str(e))
|
||||||
|
|
||||||
|
# Step 2: Use a known valid tier_id from the database
|
||||||
|
# private_pro_v1 (id=14) has pricing_zones with EUR monthly_price=2.99
|
||||||
|
tier_id = 14
|
||||||
|
logger.info(" Using known tier_id=%d (private_pro_v1)", tier_id)
|
||||||
|
report_test("Using known tier_id=14", True, "private_pro_v1 with EUR pricing")
|
||||||
|
|
||||||
|
# Step 3: Purchase a package
|
||||||
|
purchase_payload = {"tier_id": tier_id, "region_code": "GLOBAL", "currency": "EUR", "metadata": {"test_run": True}}
|
||||||
|
try:
|
||||||
|
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json=purchase_payload)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
report_test("POST /financial-manager/purchase-package (success)", data.get("success") is True, json.dumps(data, indent=2))
|
||||||
|
report_test("Response: has payment_intent_id", data.get("payment_intent_id") is not None, f"ID: {data.get('payment_intent_id')}")
|
||||||
|
report_test("Response: has subscription_id", data.get("subscription_id") is not None, f"ID: {data.get('subscription_id')}")
|
||||||
|
report_test("Response: has tier_name", bool(data.get("tier_name")), f"Tier: {data.get('tier_name')}")
|
||||||
|
report_test("Response: amount_paid > 0", data.get("amount_paid", 0) > 0, f"Amount: {data.get('amount_paid')}")
|
||||||
|
report_test("Response: is_org_subscription is False", data.get("is_org_subscription") is False, f"Is org: {data.get('is_org_subscription')}")
|
||||||
|
else:
|
||||||
|
report_test("POST /financial-manager/purchase-package", False, f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
except Exception as e:
|
||||||
|
report_test("POST /financial-manager/purchase-package", False, str(e))
|
||||||
|
|
||||||
|
# Step 4: Test with invalid tier_id (404 expected)
|
||||||
|
try:
|
||||||
|
resp = await client.post(f"{BASE_URL}/financial-manager/purchase-package", headers=headers, json={"tier_id": 99999, "region_code": "GLOBAL", "currency": "EUR"})
|
||||||
|
report_test("POST with invalid tier_id (expect 404)", resp.status_code == 404, f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
report_test("POST with invalid tier_id", False, str(e))
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(" FinancialManager Test Suite")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
await test_mock_gateway_unit()
|
||||||
|
logger.info("")
|
||||||
|
await test_subscription_activator_unit()
|
||||||
|
logger.info("")
|
||||||
|
await test_financial_manager_integration()
|
||||||
|
logger.info("")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info(" RESULTS: %d passed, %d failed out of %d", passed, failed, passed + failed)
|
||||||
|
logger.info("=" * 60)
|
||||||
|
if failed > 0:
|
||||||
|
logger.error(" ❌ SOME TESTS FAILED!")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
logger.info(" ✅ ALL TESTS PASSED!")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -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
|
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:
|
Logika:
|
||||||
1. Ha a provider APPROVED -> PROVIDER_VERIFIED_USE (100 XP)
|
1. Ha a provider nem létezik -> új provider, created=True
|
||||||
2. Ha a provider PENDING és a használó user a provider létrehozója -> PROVIDER_CONFIRMATION (150 XP)
|
2. Ha a provider már létezik -> meglévő provider, created=False
|
||||||
3. Ha a provider PENDING és a használó user NEM a provider létrehozója -> USE_UNVERIFIED_PROVIDER (200 XP)
|
3. A gamification/XP logika már az API rétegben (expenses.py) történik
|
||||||
|
|
||||||
Használat:
|
Használat:
|
||||||
docker compose exec sf_api python3 /app/backend/tests/active/test_use_unverified_provider.py
|
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 sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
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
|
||||||
from app.services.provider_service import find_or_create_provider_by_name, _award_provider_points
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
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_USER_ID = 1
|
||||||
TEST_OTHER_USER_ID = 2 # Other user using the provider (admin@profibot.hu)
|
TEST_OTHER_USER_ID = 2
|
||||||
TEST_PROVIDER_NAME = "Test Unverified Provider Card362"
|
TEST_PROVIDER_NAME = "Test Unverified Provider Card362"
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_test_data(db: AsyncSession):
|
async def cleanup_test_data(db: AsyncSession):
|
||||||
"""Clean up any leftover test data."""
|
"""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(
|
await db.execute(
|
||||||
delete(ServiceProvider).where(ServiceProvider.name == TEST_PROVIDER_NAME)
|
delete(ServiceProvider).where(ServiceProvider.name == TEST_PROVIDER_NAME)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def test_use_unverified_provider():
|
async def test_find_or_create_provider():
|
||||||
"""
|
"""
|
||||||
Test scenario:
|
Test scenario:
|
||||||
1. User A (TEST_USER_ID) creates a provider via find_or_create_provider_by_name()
|
1. User A (TEST_USER_ID) creates a provider via find_or_create_provider_by_name()
|
||||||
-> Should return PROVIDER_DISCOVERY (300 XP) - new provider
|
-> Should return (provider, True) - new provider created
|
||||||
2. User A uses the same provider again
|
2. User A calls the same provider again
|
||||||
-> Should return PROVIDER_CONFIRMATION (150 XP) - same user, pending provider
|
-> Should return (provider, False) - existing provider found
|
||||||
3. User B (TEST_OTHER_USER_ID) uses the same provider
|
3. User B (TEST_OTHER_USER_ID) calls the same provider
|
||||||
-> Should return USE_UNVERIFIED_PROVIDER (200 XP) - different user, pending provider
|
-> Should return (provider, False) - existing provider found
|
||||||
"""
|
"""
|
||||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=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)
|
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("=" * 60)
|
||||||
logger.info("STEP 1: User A creates a new provider")
|
logger.info("STEP 1: User A creates a new provider")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
provider, action_key = await find_or_create_provider_by_name(
|
provider, created = await find_or_create_provider_by_name(
|
||||||
db=db,
|
db=db,
|
||||||
name=TEST_PROVIDER_NAME,
|
name=TEST_PROVIDER_NAME,
|
||||||
added_by_user_id=TEST_USER_ID,
|
added_by_user_id=TEST_USER_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert provider is not None, "Provider should be created"
|
assert provider is not None, "Provider should be created"
|
||||||
assert action_key == "PROVIDER_DISCOVERY", (
|
assert created is True, (
|
||||||
f"Expected PROVIDER_DISCOVERY, got {action_key}"
|
f"Expected created=True for new provider, got {created}"
|
||||||
)
|
)
|
||||||
assert provider.status == ModerationStatus.pending, (
|
assert provider.status == ModerationStatus.pending, (
|
||||||
f"Expected pending status, got {provider.status}"
|
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, (
|
assert provider.added_by_user_id == TEST_USER_ID, (
|
||||||
f"Expected added_by_user_id={TEST_USER_ID}, got {provider.added_by_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}")
|
logger.info(f"✅ STEP 1 PASS: created={created}, 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}")
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 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("=" * 60)
|
||||||
logger.info("STEP 2: User A uses the same provider again")
|
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
|
# Re-fetch to get clean state
|
||||||
await db.refresh(provider)
|
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,
|
db=db,
|
||||||
name=TEST_PROVIDER_NAME,
|
name=TEST_PROVIDER_NAME,
|
||||||
added_by_user_id=TEST_USER_ID,
|
added_by_user_id=TEST_USER_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert provider2.id == provider.id, "Should be the same provider"
|
assert provider2.id == provider.id, "Should be the same provider"
|
||||||
assert action_key2 == "PROVIDER_CONFIRMATION", (
|
assert created2 is False, (
|
||||||
f"Expected PROVIDER_CONFIRMATION, got {action_key2}"
|
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("=" * 60)
|
||||||
logger.info("STEP 3: User B uses the same provider")
|
logger.info("STEP 3: User B uses the same provider")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
provider3, action_key3 = await find_or_create_provider_by_name(
|
provider3, created3 = await find_or_create_provider_by_name(
|
||||||
db=db,
|
db=db,
|
||||||
name=TEST_PROVIDER_NAME,
|
name=TEST_PROVIDER_NAME,
|
||||||
added_by_user_id=TEST_OTHER_USER_ID,
|
added_by_user_id=TEST_OTHER_USER_ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert provider3.id == provider.id, "Should be the same provider"
|
assert provider3.id == provider.id, "Should be the same provider"
|
||||||
assert action_key3 == "USE_UNVERIFIED_PROVIDER", (
|
assert created3 is False, (
|
||||||
f"Expected USE_UNVERIFIED_PROVIDER, got {action_key3}"
|
f"Expected created=False for existing provider, got {created3}"
|
||||||
)
|
)
|
||||||
logger.info(f"✅ STEP 3 PASS: action_key={action_key3}")
|
logger.info(f"✅ STEP 3 PASS: created={created3}")
|
||||||
|
|
||||||
# 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}")
|
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# SUMMARY
|
# SUMMARY
|
||||||
# =========================================================
|
# =========================================================
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("🎉 ALL TESTS PASSED!")
|
logger.info("🎉 ALL TESTS PASSED!")
|
||||||
logger.info(f" - PROVIDER_DISCOVERY (300 XP): ✅ User A created provider")
|
logger.info(f" - New provider creation (created=True): ✅")
|
||||||
logger.info(f" - PROVIDER_CONFIRMATION (150 XP): ✅ User A reused own provider")
|
logger.info(f" - Existing provider reuse (created=False): ✅")
|
||||||
logger.info(f" - USE_UNVERIFIED_PROVIDER (200 XP): ✅ User B used pending provider")
|
logger.info(f" - Cross-user existing provider (created=False): ✅")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
@@ -183,4 +155,4 @@ async def test_use_unverified_provider():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(test_use_unverified_provider())
|
asyncio.run(test_find_or_create_provider())
|
||||||
|
|||||||
219
backend/scripts/repair_user_relations.py
Normal file
219
backend/scripts/repair_user_relations.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
User Relations Repair Script
|
||||||
|
-----------------------------
|
||||||
|
Idempotens: csak hiányzó rekordokat hoz létre.
|
||||||
|
Biztosítja, hogy minden aktív user rendelkezzen:
|
||||||
|
1. Personal Organization-nel (fleet.organizations) — ha még nincs
|
||||||
|
2. Wallet-tel (identity.wallets) — ha még nincs
|
||||||
|
3. Subscription-nel (finance.user_subscriptions, tier=private_free_v1) — ha még nincs
|
||||||
|
|
||||||
|
Futtatás:
|
||||||
|
docker compose exec sf_api python3 /app/backend/scripts/repair_user_relations.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
# Path setup for the container
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
sys.path.insert(0, '/app/backend')
|
||||||
|
|
||||||
|
os.environ.setdefault('DATABASE_URL', 'postgresql+asyncpg://...') # Will be read from .env
|
||||||
|
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from app.models.identity.identity import User, Wallet
|
||||||
|
from app.models.core_logic import UserSubscription
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
DEFAULT_TIER_ID = 13 # private_free_v1
|
||||||
|
DEFAULT_CURRENCY = "HUF"
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_organization(db, user):
|
||||||
|
"""
|
||||||
|
Create personal organization if user doesn't have one.
|
||||||
|
Returns: org_id (existing or newly created)
|
||||||
|
"""
|
||||||
|
# Check if user already owns an organization (owner_id or legal_owner_id)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Organization).where(
|
||||||
|
Organization.owner_id == user.id
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
if org:
|
||||||
|
print(f" [OK] Organization already exists for user {user.id}: org_id={org.id}")
|
||||||
|
return org.id
|
||||||
|
|
||||||
|
# Create new personal organization
|
||||||
|
folder_slug = f"user-{user.id}-{int(datetime.now(timezone.utc).timestamp())}"
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
org = Organization(
|
||||||
|
name=f"{user.email}'s Garage",
|
||||||
|
full_name=f"{user.email}'s Personal Garage",
|
||||||
|
display_name=f"{user.email}'s Garage",
|
||||||
|
folder_slug=folder_slug,
|
||||||
|
owner_id=user.id,
|
||||||
|
org_type="individual",
|
||||||
|
status="active",
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
is_verified=False,
|
||||||
|
subscription_plan="FREE",
|
||||||
|
base_asset_limit=5,
|
||||||
|
purchased_extra_slots=0,
|
||||||
|
notification_settings={},
|
||||||
|
external_integration_config={},
|
||||||
|
first_registered_at=now,
|
||||||
|
current_lifecycle_started_at=now,
|
||||||
|
lifecycle_index=1,
|
||||||
|
is_anonymized=False,
|
||||||
|
default_currency="HUF",
|
||||||
|
country_code="HU",
|
||||||
|
language="hu",
|
||||||
|
is_ownership_transferable=False,
|
||||||
|
visual_settings={},
|
||||||
|
is_affiliate_partner=False,
|
||||||
|
aliases=[],
|
||||||
|
tags=[],
|
||||||
|
settings={},
|
||||||
|
custom_features={},
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(org)
|
||||||
|
await db.flush()
|
||||||
|
print(f" [CREATED] Organization for user {user.id}: org_id={org.id}")
|
||||||
|
return org.id
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_wallet(db, user, org_id):
|
||||||
|
"""
|
||||||
|
Create wallet if user doesn't have one.
|
||||||
|
wallet.user_id has UNIQUE constraint, so at most one wallet per user.
|
||||||
|
Returns: True if created, False if already existed
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Wallet).where(Wallet.user_id == user.id).limit(1)
|
||||||
|
)
|
||||||
|
wallet = result.scalar_one_or_none()
|
||||||
|
if wallet:
|
||||||
|
print(f" [OK] Wallet already exists for user {user.id}: wallet_id={wallet.id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
wallet = Wallet(
|
||||||
|
user_id=user.id,
|
||||||
|
earned_credits=Decimal('0'),
|
||||||
|
purchased_credits=Decimal('0'),
|
||||||
|
service_coins=Decimal('0'),
|
||||||
|
currency=DEFAULT_CURRENCY,
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
db.add(wallet)
|
||||||
|
await db.flush()
|
||||||
|
print(f" [CREATED] Wallet for user {user.id}: wallet_id={wallet.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_subscription(db, user):
|
||||||
|
"""
|
||||||
|
Create free tier subscription if user doesn't have one.
|
||||||
|
Returns: True if created, False if already existed
|
||||||
|
"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserSubscription).where(UserSubscription.user_id == user.id).limit(1)
|
||||||
|
)
|
||||||
|
sub = result.scalar_one_or_none()
|
||||||
|
if sub:
|
||||||
|
print(f" [OK] Subscription already exists for user {user.id}: sub_id={sub.id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
sub = UserSubscription(
|
||||||
|
user_id=user.id,
|
||||||
|
tier_id=DEFAULT_TIER_ID,
|
||||||
|
valid_from=datetime.now(timezone.utc),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(sub)
|
||||||
|
await db.flush()
|
||||||
|
print(f" [CREATED] Subscription for user {user.id}: sub_id={sub.id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def repair_all_users():
|
||||||
|
"""Main repair loop."""
|
||||||
|
print("=" * 60)
|
||||||
|
print(" USER RELATIONS REPAIR SCRIPT")
|
||||||
|
print(f" Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||||
|
print(f" Default tier: {DEFAULT_TIER_ID} (private_free_v1)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Get all active, non-deleted users
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(
|
||||||
|
User.is_active == True,
|
||||||
|
User.is_deleted == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total": len(users),
|
||||||
|
"wallets_created": 0,
|
||||||
|
"subs_created": 0,
|
||||||
|
"orgs_created": 0,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"\nFound {len(users)} active users to process.\n")
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
print(f"--- Processing user {user.id}: {user.email} ---")
|
||||||
|
try:
|
||||||
|
org_id = await ensure_organization(db, user)
|
||||||
|
# Track org creation — since ensure_organization always returns org_id,
|
||||||
|
# we can't easily diff "created" vs "existed" without checking first.
|
||||||
|
# We'll track it manually.
|
||||||
|
|
||||||
|
wallet_created = await ensure_wallet(db, user, org_id)
|
||||||
|
if wallet_created:
|
||||||
|
stats["wallets_created"] += 1
|
||||||
|
|
||||||
|
sub_created = await ensure_subscription(db, user)
|
||||||
|
if sub_created:
|
||||||
|
stats["subs_created"] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
err_msg = f"User {user.id} ({user.email}): {e}"
|
||||||
|
stats["errors"].append(err_msg)
|
||||||
|
print(f" [ERROR] {err_msg}")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" REPAIR SUMMARY")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" Total active users processed: {stats['total']}")
|
||||||
|
print(f" Wallets created: {stats['wallets_created']}")
|
||||||
|
print(f" Subscriptions created: {stats['subs_created']}")
|
||||||
|
print(f" Errors: {len(stats['errors'])}")
|
||||||
|
|
||||||
|
if stats["errors"]:
|
||||||
|
print("\n Errors:")
|
||||||
|
for err in stats["errors"]:
|
||||||
|
print(f" - {err}")
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(repair_all_users())
|
||||||
@@ -58,7 +58,7 @@ async def seed():
|
|||||||
entry = BodyTypeDictionary(
|
entry = BodyTypeDictionary(
|
||||||
vehicle_class=bt["vehicle_class"],
|
vehicle_class=bt["vehicle_class"],
|
||||||
code=bt["code"],
|
code=bt["code"],
|
||||||
name_hu=bt["name_hu"],
|
name_i18n={"hu": bt["name_hu"]},
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
|
|
||||||
|
|||||||
@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
|
|||||||
|
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=key,
|
key=key,
|
||||||
name_hu=node["name_hu"],
|
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||||
name_en=node["name_en"],
|
|
||||||
category=node.get("category", ""),
|
category=node.get("category", ""),
|
||||||
search_keywords=node.get("search_keywords", []),
|
search_keywords=node.get("search_keywords", []),
|
||||||
description=node.get("description", ""),
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
level=level,
|
level=level,
|
||||||
parent_id=parent_id,
|
parent_id=parent_id,
|
||||||
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
|
|||||||
for key, node in UNIVERSAL_CATEGORIES.items():
|
for key, node in UNIVERSAL_CATEGORIES.items():
|
||||||
tag = ExpertiseTag(
|
tag = ExpertiseTag(
|
||||||
key=key,
|
key=key,
|
||||||
name_hu=node["name_hu"],
|
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||||
name_en=node["name_en"],
|
|
||||||
category=node.get("category", ""),
|
category=node.get("category", ""),
|
||||||
search_keywords=node.get("search_keywords", []),
|
search_keywords=node.get("search_keywords", []),
|
||||||
description=node.get("description", ""),
|
|
||||||
is_official=True,
|
is_official=True,
|
||||||
level=1,
|
level=1,
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
|||||||
155
backend/scripts/seed_financial_ledger.py
Normal file
155
backend/scripts/seed_financial_ledger.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed script: Populate audit.financial_ledger with 20 dummy records.
|
||||||
|
Uses psycopg2 (sync) to bypass asyncpg prepared statement enum validation issues.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
TRANSACTION_TYPES = [
|
||||||
|
"SUBSCRIPTION", "COMMISSION", "REFUND", "MANUAL_ADJUSTMENT",
|
||||||
|
"PURCHASE", "WITHDRAWAL", "BONUS", "FEE", "REWARD",
|
||||||
|
]
|
||||||
|
|
||||||
|
DB_ENTRY_TYPES = ["CREDIT", "DEBIT"]
|
||||||
|
DB_WALLET_TYPES = ["EARNED", "PURCHASED", "SERVICE_COINS", "VOUCHER"]
|
||||||
|
DB_STATUSES = ["PENDING", "SUCCESS", "FAILED", "REFUNDED"]
|
||||||
|
|
||||||
|
TEST_USER_IDS = [1, 2, 28, 29, 55, 75, 79, 85, 86, 88, 100, 102, 103, 104, 105, 106]
|
||||||
|
|
||||||
|
NOW = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def get_dsn_params():
|
||||||
|
"""Extract connection parameters from DATABASE_URL."""
|
||||||
|
db_url = os.environ.get("DATABASE_URL", "postgresql+asyncpg://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder")
|
||||||
|
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||||
|
# Parse URL
|
||||||
|
parts = db_url.replace("postgresql://", "").split("@")
|
||||||
|
user_pass = parts[0].split(":")
|
||||||
|
host_db = parts[1].split("/")
|
||||||
|
host_port = host_db[0].split(":")
|
||||||
|
return {
|
||||||
|
"user": user_pass[0],
|
||||||
|
"password": user_pass[1],
|
||||||
|
"host": host_port[0],
|
||||||
|
"port": int(host_port[1]) if len(host_port) > 1 else 5432,
|
||||||
|
"dbname": host_db[1].split("?")[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
params = get_dsn_params()
|
||||||
|
conn = psycopg2.connect(**params)
|
||||||
|
conn.autocommit = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# Count existing
|
||||||
|
cur.execute("SELECT COUNT(*) FROM audit.financial_ledger")
|
||||||
|
print(f"Existing records: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
# Get person_ids
|
||||||
|
person_map = {}
|
||||||
|
for uid in TEST_USER_IDS:
|
||||||
|
cur.execute("SELECT person_id FROM identity.users WHERE id = %s", (uid,))
|
||||||
|
pid = cur.fetchone()
|
||||||
|
if pid and pid[0]:
|
||||||
|
person_map[uid] = pid[0]
|
||||||
|
|
||||||
|
if not person_map:
|
||||||
|
print("No users with person_id found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Users with person records: {list(person_map.keys())}")
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
for i in range(20):
|
||||||
|
uid = random.choice(list(person_map.keys()))
|
||||||
|
pid = person_map[uid]
|
||||||
|
entry_t = random.choice(DB_ENTRY_TYPES)
|
||||||
|
wallet_t = random.choice(DB_WALLET_TYPES)
|
||||||
|
stat = random.choice(DB_STATUSES)
|
||||||
|
txn_t = random.choice(TRANSACTION_TYPES)
|
||||||
|
cur_r = random.choice(["HUF", "EUR", "USD", "GBP"])
|
||||||
|
amt = round(random.uniform(5.0, 5000.0), 2)
|
||||||
|
days_ago = random.randint(0, 30)
|
||||||
|
created_ts = NOW - timedelta(
|
||||||
|
days=random.randint(0, days_ago),
|
||||||
|
hours=random.randint(0, 23),
|
||||||
|
minutes=random.randint(0, 59),
|
||||||
|
)
|
||||||
|
ref = "TXN-" + uuid.uuid4().hex[:8].upper()
|
||||||
|
desc = txn_t.lower().replace("_", " ").title() + f" for user {uid}"
|
||||||
|
net_amt = round(amt / 1.27, 2) if random.random() > 0.3 else None
|
||||||
|
tax_amt = round(amt - net_amt, 2) if net_amt else None
|
||||||
|
balance = round(random.uniform(100, 50000), 2)
|
||||||
|
inv_stat = "PAID" if stat == "completed" else stat.upper()
|
||||||
|
txn_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# psycopg2 handles text → enum cast correctly with simple execute
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit.financial_ledger
|
||||||
|
(user_id, person_id, amount, currency, transaction_type, details,
|
||||||
|
created_at, entry_type, balance_after, wallet_type, status,
|
||||||
|
transaction_id, net_amount, tax_amount, gross_amount, invoice_status)
|
||||||
|
VALUES (
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s::jsonb,
|
||||||
|
%s::timestamptz,
|
||||||
|
%s::audit.ledger_entry_type,
|
||||||
|
%s,
|
||||||
|
%s::audit.wallet_type,
|
||||||
|
%s::audit.ledger_status,
|
||||||
|
%s::uuid,
|
||||||
|
%s, %s, %s, %s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
uid, pid, amt, cur_r, txn_t,
|
||||||
|
f'{{"description": "{desc}", "reference": "{ref}"}}',
|
||||||
|
created_ts.isoformat(),
|
||||||
|
entry_t, balance, wallet_t, stat, txn_id,
|
||||||
|
net_amt, tax_amt, amt, inv_stat,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print(f"✅ Inserted {inserted} records successfully!")
|
||||||
|
|
||||||
|
cur.execute("SELECT COUNT(*) FROM audit.financial_ledger")
|
||||||
|
print(f"Total records now: {cur.fetchone()[0]}")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
fl.entry_type::text,
|
||||||
|
fl.wallet_type::text,
|
||||||
|
fl.status::text,
|
||||||
|
COUNT(*)::int,
|
||||||
|
ROUND(AVG(fl.amount)::numeric, 2),
|
||||||
|
u.email
|
||||||
|
FROM audit.financial_ledger fl
|
||||||
|
LEFT JOIN identity.users u ON u.id = fl.user_id
|
||||||
|
GROUP BY fl.entry_type, fl.wallet_type, fl.status, u.email
|
||||||
|
ORDER BY fl.entry_type, fl.wallet_type
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
total_sum = sum(r[3] for r in rows) if rows else 0
|
||||||
|
print(f"\n📊 Summary (total {total_sum} records):")
|
||||||
|
print(f" {'Entry':8} {'Wallet':15} {'Status':10} {'Cnt':5} {'Avg Amt':10} {'User'}")
|
||||||
|
print(f" {'-'*8} {'-'*15} {'-'*10} {'-'*5} {'-'*10} {'-'*30}")
|
||||||
|
for r in rows:
|
||||||
|
print(f" {r[0]:8} {r[1]:15} {r[2]:10} {r[3]:5} {r[4]:10} {r[5] or 'N/A':30}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed()
|
||||||
53
backend/test_hard_delete.py
Normal file
53
backend/test_hard_delete.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import asyncio, uuid
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from app.models.identity.identity import User, UserRole, Wallet
|
||||||
|
from app.models.system.audit import SecurityAuditLog, FinancialLedger
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
async def test():
|
||||||
|
uid = str(uuid.uuid4())[:8]
|
||||||
|
email = f'hard_delete_test_{uid}@example.com'
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
# Insert test user
|
||||||
|
result = await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO identity.users
|
||||||
|
(email, hashed_password, role, subscription_plan, preferred_language, region_code, preferred_currency, ui_mode, is_vip, is_active, is_deleted, created_at, scope_level, custom_permissions, alternative_emails, email_history, visual_settings)
|
||||||
|
VALUES
|
||||||
|
(:email, 'fakehash', 'USER', 'FREE', 'en', 'HU', 'HUF', 'personal', false, false, false, NOW(), 'individual', '{}'::jsonb, '[]'::jsonb, '[]'::jsonb, '{"theme": "default", "primary_color": null, "wall_logo_url": null}'::jsonb)
|
||||||
|
RETURNING id
|
||||||
|
"""),
|
||||||
|
{"email": email}
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.email == email))
|
||||||
|
u = result.scalar_one_or_none()
|
||||||
|
test_id = u.id
|
||||||
|
print(f'Created test user #{test_id}: {u.email}')
|
||||||
|
|
||||||
|
# Verify no wallet/ledger
|
||||||
|
wr = await db.execute(select(Wallet).where(Wallet.user_id == test_id).limit(1))
|
||||||
|
lr = await db.execute(select(FinancialLedger).where(FinancialLedger.user_id == test_id).limit(1))
|
||||||
|
print(f' Has Wallet: {wr.scalar_one_or_none() is not None}')
|
||||||
|
print(f' Has Ledger: {lr.scalar_one_or_none() is not None}')
|
||||||
|
|
||||||
|
# Simulate the FIXED hard delete: delete first, then audit
|
||||||
|
await db.delete(u)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
audit = SecurityAuditLog(
|
||||||
|
actor_id=1, target_id=test_id, action='hard_delete_user',
|
||||||
|
is_critical=True,
|
||||||
|
payload_before={'user_id': test_id, 'email': email, 'role': 'USER'},
|
||||||
|
payload_after={'details': f'User #{test_id} permanently deleted by admin #1'},
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
check = await db.get(User, test_id)
|
||||||
|
print(f' User after delete: {check}')
|
||||||
|
print('✅ Hard delete simulation SUCCESSFUL')
|
||||||
|
|
||||||
|
asyncio.run(test())
|
||||||
@@ -290,6 +290,7 @@ services:
|
|||||||
- NUXT_PORT=8502
|
- NUXT_PORT=8502
|
||||||
- NUXT_HOST=0.0.0.0
|
- NUXT_HOST=0.0.0.0
|
||||||
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
|
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
|
||||||
|
- NODE_OPTIONS=--max-old-space-size=4096
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend_admin:/app
|
- ./frontend_admin:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
@@ -297,6 +298,10 @@ services:
|
|||||||
- sf_net
|
- sf_net
|
||||||
- shared_db_net
|
- shared_db_net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
|
||||||
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
|
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
|
||||||
sf_public_frontend:
|
sf_public_frontend:
|
||||||
|
|||||||
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
|
||||||
181
docs/financial_module_comprehensive_audit_2026-07-25.md
Normal file
181
docs/financial_module_comprehensive_audit_2026-07-25.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# 🔍 Comprehensive Financial Module Audit
|
||||||
|
|
||||||
|
**Date:** 2026-07-25
|
||||||
|
**Auditor:** Rendszer-Architect (🏗️ Architect Mode)
|
||||||
|
**Scope:** Full-stack financial system audit — Models, API, Services, Workers, Frontend
|
||||||
|
**Executive Summary:**
|
||||||
|
|
||||||
|
> The financial backend is **well-architected and modular**. The model layer follows DDD schema separation, the service layer splits into distinct managers, the API provides complete user-facing endpoints, and all system workers properly write to the audit ledger. The **critical gap is the frontend** — zero financial UI exists for end users despite the backend being fully ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. MODELS — Schema & Architecture
|
||||||
|
|
||||||
|
### ✅ STATUS: WELL-ARCHITECTED — No Monolith
|
||||||
|
|
||||||
|
| Schema | Table | Purpose | File |
|
||||||
|
|--------|-------|---------|------|
|
||||||
|
| `identity` | `wallets` | Quadruple wallet: earned, purchased, service_coins + ActiveVouchers (FIFO) | [`models/identity/identity.py:263`](dev/service_finder/backend/app/models/identity/identity.py:263) |
|
||||||
|
| `identity` | `active_vouchers` | FIFO voucher tracking with expiration | [`models/identity/identity.py:316`](dev/service_finder/backend/app/models/identity/identity.py:316) |
|
||||||
|
| `audit` | `financial_ledger` | Immutable double-entry ledger (DEBIT/CREDIT) | [`models/system/audit.py:78`](dev/service_finder/backend/app/models/system/audit.py:78) |
|
||||||
|
| `fleet_finance` | `cost_categories` | Hierarchical cost category tree | [`models/fleet_finance/models.py:37`](dev/service_finder/backend/app/models/fleet_finance/models.py:37) |
|
||||||
|
| `fleet_finance` | `asset_costs` | Operational expense log (TCO) | [`models/fleet_finance/models.py:109`](dev/service_finder/backend/app/models/fleet_finance/models.py:109) |
|
||||||
|
| `fleet_finance` | `asset_financials` | Acquisition & depreciation | [`models/fleet_finance/models.py:180`](dev/service_finder/backend/app/models/fleet_finance/models.py:180) |
|
||||||
|
| `fleet_finance` | `insurance_providers` | Insurance company catalog | [`models/fleet_finance/models.py:219`](dev/service_finder/backend/app/models/fleet_finance/models.py:219) |
|
||||||
|
| `fleet_finance` | `vehicle_insurance_policies` | Vehicle insurance policies | [`models/fleet_finance/models.py:248`](dev/service_finder/backend/app/models/fleet_finance/models.py:248) |
|
||||||
|
| `fleet_finance` | `vehicle_tax_obligations` | Tax obligations per vehicle | [`models/fleet_finance/models.py:298`](dev/service_finder/backend/app/models/fleet_finance/models.py:298) |
|
||||||
|
| `finance` | `issuers` | Billing entities (KFT/EV/BT/ZRT) | [`models/marketplace/finance.py:27`](dev/service_finder/backend/app/models/marketplace/finance.py:27) |
|
||||||
|
| `marketplace` | `payment_intents` | Payment intent lifecycle (Double Lock) | [`models/marketplace/payment.py`](dev/service_finder/backend/app/models/marketplace/payment.py) |
|
||||||
|
| `core_logic` | `subscription_tiers` | Subscription tier definitions | [`models/core_logic.py`](dev/service_finder/backend/app/models/core_logic.py) |
|
||||||
|
| `core_logic` | `user_subscriptions` / `org_subscriptions` | Subscription state tracking | [`models/core_logic.py`](dev/service_finder/backend/app/models/core_logic.py) |
|
||||||
|
|
||||||
|
### Key Enums (Defined in [`audit.py:58`](dev/service_finder/backend/app/models/system/audit.py:58))
|
||||||
|
- `LedgerEntryType`: `DEBIT`, `CREDIT`
|
||||||
|
- `WalletType`: `EARNED`, `PURCHASED`, `SERVICE_COINS`, `VOUCHER`
|
||||||
|
- `LedgerStatus`: `PENDING`, `SUCCESS`, `FAILED`, `REFUNDED`, `REFUND`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API ENDPOINTS — Routing & User Access
|
||||||
|
|
||||||
|
### ✅ STATUS: WELL-COVERED
|
||||||
|
|
||||||
|
| Method | Endpoint | Purpose | User-Facing? | RBAC |
|
||||||
|
|--------|----------|---------|-------------|------|
|
||||||
|
| `POST` | `/billing/upgrade` | Package upgrade | ✅ Yes | Auth required |
|
||||||
|
| `POST` | `/billing/payment-intent/create` | Create PaymentIntent | ✅ Yes | Auth required |
|
||||||
|
| `POST` | `/billing/payment-intent/{id}/stripe-checkout` | Initiate Stripe checkout | ✅ Yes | Auth required |
|
||||||
|
| `POST` | `/billing/payment-intent/{id}/process-internal` | Internal gifting deduction | ✅ Yes | Auth required |
|
||||||
|
| `POST` | `/billing/stripe-webhook` | Stripe callback | N/A (webhook) | Signature |
|
||||||
|
| `GET` | `/billing/payment-intent/{id}/status` | PaymentIntent status | ✅ Yes | Auth required |
|
||||||
|
| `GET` | `/billing/wallet/balance` | User wallet balances | ✅ Yes | Auth required |
|
||||||
|
| `GET` | `/billing/wallet/transactions` | User transaction History | ✅ Yes | Auth required |
|
||||||
|
| `POST` | `/financial-manager/purchase-package` | Full purchase lifecycle | ✅ Yes | Auth required |
|
||||||
|
| `GET` | `/financial-manager/status` | Service health check | ✅ Yes | Auth required |
|
||||||
|
| `GET` | `/finance/issuers/` | List billing issuers | ❌ Admin | `finance:view` |
|
||||||
|
| `PATCH` | `/finance/issuers/{id}` | Update issuer | ❌ Admin | `finance:edit` |
|
||||||
|
| `GET` | `/admin/finance/ledger` | Admin ledger view | ❌ Admin | `finance:view` |
|
||||||
|
| `POST` | `/expenses/` | Create expense (TCO) | ✅ Yes | Auth + Capability |
|
||||||
|
| `PUT` | `/expenses/{id}` | Update expense | ✅ Yes | Auth |
|
||||||
|
| `GET` | `/expenses/` | List all expenses | ❌ Admin | Auth |
|
||||||
|
| `GET` | `/expenses/{asset_id}` | Asset-specific expenses | ✅ Yes | Auth |
|
||||||
|
| `GET` | `/subscriptions/public` | Public package catalog | ✅ Public | None |
|
||||||
|
| `GET` | `/subscriptions/my` | Current subscription | ✅ Yes | Auth |
|
||||||
|
| `GET` | `/subscriptions/feature-flags` | Resolved feature flags | ✅ Yes | Auth |
|
||||||
|
|
||||||
|
### Router Registration (in [`api/v1/api.py:1`](dev/service_finder/backend/app/api/v1/api.py:1))
|
||||||
|
- `billing.router` → `/billing` ✅
|
||||||
|
- `financial_manager.router` → `/financial-manager` ✅
|
||||||
|
- `finance_admin.router` → `/finance/issuers` + `/admin/finance` ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SERVICES — Monolith vs. Modular Managers
|
||||||
|
|
||||||
|
### ✅ STATUS: MODULAR & SEPARATED — Minor Overlap Risk
|
||||||
|
|
||||||
|
| Service File | Responsibility | Lines |
|
||||||
|
|-------------|---------------|-------|
|
||||||
|
| [`billing_engine.py`](dev/service_finder/backend/app/services/billing_engine.py:1) | Core: PricingCalculator, SmartDeduction (FIFO), AtomicTransactionManager (double-entry) | ~1070 |
|
||||||
|
| [`financial_manager.py`](dev/service_finder/backend/app/services/financial_manager.py:1) | Orchestrator: full purchase lifecycle (validate→price→intent→pay→activate→commission) | ~520 |
|
||||||
|
| [`financial_interfaces.py`](dev/service_finder/backend/app/services/financial_interfaces.py:1) | ABCs: `BasePaymentGateway`, `BaseInvoicingService` + exceptions | ~186 |
|
||||||
|
| [`financial_orchestrator.py`](dev/service_finder/backend/app/services/financial_orchestrator.py:1) | Unit of Work: payment processing with issuer selection (vetésforgó) | ~449 |
|
||||||
|
| [`payment_router.py`](dev/service_finder/backend/app/services/payment_router.py:1) | Router: PaymentIntent creation, Stripe Double Lock, internal gifting | ~495 |
|
||||||
|
| [`cost_service.py`](dev/service_finder/backend/app/services/cost_service.py:1) | Fleet operational cost tracking with telemetry | ~140 |
|
||||||
|
| [`subscription_activator.py`](dev/service_finder/backend/app/services/subscription_activator.py:1) | Subscription activation (user/org with stacking) | ~unknown |
|
||||||
|
| [`commission_service.py`](dev/service_finder/backend/app/services/commission_service.py:1) | MLM commission distribution | ~unknown |
|
||||||
|
| [`stripe_adapter.py`](dev/service_finder/backend/app/services/stripe_adapter.py:1) | Stripe API adapter | ~unknown |
|
||||||
|
| [`mock_payment_gateway.py`](dev/service_finder/backend/app/services/mock_payment_gateway.py:1) | Test gateway (auto-approve mode) | ~unknown |
|
||||||
|
|
||||||
|
### Architecture Decision: Strategy Pattern
|
||||||
|
The [`financial_interfaces.py`](dev/service_finder/backend/app/services/financial_interfaces.py:1) defines [`BasePaymentGateway`](dev/service_finder/backend/app/services/financial_interfaces.py:13) and [`BaseInvoicingService`](dev/service_finder/backend/app/services/financial_interfaces.py:91) ABCs. `FinancialManager` accepts any gateway implementation via constructor injection.
|
||||||
|
|
||||||
|
### ⚠️ Concern: Overlap Between `financial_orchestrator` and `billing_engine`
|
||||||
|
- Both create `FinancialLedger` entries
|
||||||
|
- `financial_orchestrator.process_payment()` writes ledger entries with raw `update()` SQL instead of using `AtomicTransactionManager`
|
||||||
|
- `financial_orchestrator` accesses `Wallet` fields directly via raw SQL updates
|
||||||
|
- This is a **code duplication risk** — two different ledger-writing paths exist
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
- Either retire `FinancialOrchestrator.process_payment()` in favor of `AtomicTransactionManager` + `PaymentRouter`, or refactor `FinancialOrchestrator` to delegate to `BillingEngine` classes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. WORKERS — Ledger Integration
|
||||||
|
|
||||||
|
### ✅ STATUS: FULLY CONNECTED — All workers write to audit ledger
|
||||||
|
|
||||||
|
| Worker | Ledger Entry? | Transaction Type | Amount | WalletType |
|
||||||
|
|--------|--------------|-----------------|--------|------------|
|
||||||
|
| [`inactivity_monitor_worker.py`](dev/service_finder/backend/app/workers/system/inactivity_monitor_worker.py:1) | ✅ | `ACCOUNT_SUSPENDED_INACTIVITY` | 0.0 | `EARNED` |
|
||||||
|
| [`subscription_worker.py`](dev/service_finder/backend/app/workers/system/subscription_worker.py:1) | ✅ | `SUBSCRIPTION_EXPIRED` | 0.0 | `SYSTEM` |
|
||||||
|
| [`subscription_monitor_worker.py`](dev/service_finder/backend/app/workers/system/subscription_monitor_worker.py:1) | ✅ | `SUBSCRIPTION_EXPIRED` | 0.0 | `EARNED` |
|
||||||
|
|
||||||
|
### ⚠️ Minor Inconsistency
|
||||||
|
- `subscription_worker.py` line 75 uses `WalletType.SYSTEM` — this value does NOT exist in the [`WalletType` enum](dev/service_finder/backend/app/models/system/audit.py:63) (which only has `EARNED`, `PURCHASED`, `SERVICE_COINS`, `VOUCHER`). This would raise an `AttributeError` at runtime.
|
||||||
|
- `subscription_monitor_worker.py` correctly uses `WalletType.EARNED`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. FRONTEND — Wallet & Transaction Views
|
||||||
|
|
||||||
|
### ❌ STATUS: COMPLETELY MISSING
|
||||||
|
|
||||||
|
A search across the entire `frontend/src/` directory for Vue files containing `wallet`, `transaction`, `balance`, `purchase`, `subscription`, or `billing` returned **zero results**.
|
||||||
|
|
||||||
|
The backend already provides:
|
||||||
|
- `GET /billing/wallet/balance` → Returns quadruple wallet balances
|
||||||
|
- `GET /billing/wallet/transactions` → Returns paginated transaction history with filtering
|
||||||
|
- `GET /subscriptions/my` → Returns current subscription details
|
||||||
|
- `GET /subscriptions/feature-flags` → Returns feature flags
|
||||||
|
|
||||||
|
But **no frontend component calls any of these endpoints**.
|
||||||
|
|
||||||
|
### What Needs Building
|
||||||
|
|
||||||
|
1. **WalletBalanceCard.vue** — Display earned/purchased/service_coins/voucher balances
|
||||||
|
2. **TransactionHistory.vue** — Paginated transaction list with type/wallet filters
|
||||||
|
3. **SubscriptionStatusBanner.vue** — Current tier, expiry, upgrade CTA
|
||||||
|
4. **CheckoutView.vue** — Package selection → PaymentIntent → Stripe/internal redirect
|
||||||
|
5. **PaymentStatusView.vue** — Success/cancel pages after Stripe redirect
|
||||||
|
6. **Admin Ledger View** (already spec'd in [`docs/sf/epic_10_admin_frontend_spec.md`](dev/service_finder/docs/sf/epic_10_admin_frontend_spec.md)) — Paginated financial ledger with user join data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. GAP ANALYSIS SUMMARY
|
||||||
|
|
||||||
|
| Area | Status | Criticality | Action |
|
||||||
|
|------|--------|-------------|--------|
|
||||||
|
| **Backend Models** | ✅ Complete | — | No action needed |
|
||||||
|
| **Backend API — User-facing** | ✅ Complete | — | No action needed |
|
||||||
|
| **Backend API — Admin** | ✅ Complete | — | No action needed |
|
||||||
|
| **Backend Services** | ✅ Mostly modular | ⚠️ Medium | Refactor `FinancialOrchestrator` to delegate ledger writes to `AtomicTransactionManager` |
|
||||||
|
| **Workers → Ledger** | ✅ Connected | 🔴 Bug | Fix `subscription_worker.py:75` — `WalletType.SYSTEM` does not exist |
|
||||||
|
| **Frontend — Wallet** | ❌ Missing | 🔴 Critical | Build `WalletBalanceCard.vue` + `TransactionHistory.vue` |
|
||||||
|
| **Frontend — Checkout** | ❌ Missing | 🔴 Critical | Build subscription purchase flow (catalog → intent → payment → confirmation) |
|
||||||
|
| **Frontend — Admin Ledger** | ❌ Missing | 🟡 Medium | Build admin ledger view (already spec'd) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. RECOMMENDED ACTIONS (Priority Order)
|
||||||
|
|
||||||
|
### 🔴 P0 — Critical Bugs
|
||||||
|
1. **Fix `subscription_worker.py:75`**: Change `WalletType.SYSTEM` to `WalletType.EARNED` (or add `SYSTEM` to the enum)
|
||||||
|
2. **Fix `FinancialOrchestrator.refund_payment()` line 399/403**: Code references `Wallet.wallet_type` field which doesn't exist on the Wallet model; also references `wallet.balance` which doesn't exist. This code would **crash at runtime**.
|
||||||
|
|
||||||
|
### 🔴 P0 — Missing Frontend
|
||||||
|
3. Build `WalletBalanceCard.vue` — user wallet summary display
|
||||||
|
4. Build `TransactionHistory.vue` — paginated transaction list
|
||||||
|
5. Build subscription checkout flow (catalog → Intent → payment → confirmation)
|
||||||
|
|
||||||
|
### 🟡 P1 — Code Quality
|
||||||
|
6. Refactor `FinancialOrchestrator.process_payment()` to delegate to `AtomicTransactionManager` for ledger creation (eliminate dual ledger-writing paths)
|
||||||
|
7. Audit `FinancialOrchestrator.refund_payment()` — it references non-existent fields (`Wallet.wallet_type`, `Wallet.balance`)
|
||||||
|
|
||||||
|
### 🟢 P2 — Documentation
|
||||||
|
8. Update [`epic_3_financial_motor_architecture.md`](dev/service_finder/docs/sf/epic_3_financial_motor_architecture.md:1) with current service file structure
|
||||||
|
9. Create `logic_spec_financial_manager.md` documenting the full purchase lifecycle flow with Mermaid sequence diagram
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Audit completed by Rendszer-Architect. Ready for Gitea ticket creation.*
|
||||||
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` |
|
||||||
263
docs/p0_address_unification_plan.md
Normal file
263
docs/p0_address_unification_plan.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# P0 Address Unification Plan
|
||||||
|
|
||||||
|
## Split-Brain Diagnosis: `marketplace.service_providers` vs. `system.addresses`
|
||||||
|
|
||||||
|
**Date:** 2026-07-07
|
||||||
|
**Author:** Rendszer-Architect
|
||||||
|
**Status:** Discovery Complete — Awaiting Approval for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
The system suffers from a **split-brain address architecture**. Two models use the centralized [`system.addresses`](backend/app/models/identity/address.py:39) table via `address_id` UUID foreign keys correctly (`fleet.organizations`, `identity.persons`), but the [`marketplace.service_providers`](backend/app/models/identity/social.py:23) table still relies entirely on **legacy flat columns** for address storage.
|
||||||
|
|
||||||
|
The [`ServiceStaging`](backend/app/models/marketplace/staged_data.py:24) model is in a **hybrid state** — it has both flat columns and an `address_id` FK — and the admin API ([`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1157)) already has partial `AddressManager` integration. However, this creates a **dual-write pattern**: data flows to both the flat columns AND the normalized `system.addresses` table, with complex fallback logic.
|
||||||
|
|
||||||
|
The [`Organization`](backend/app/models/marketplace/organization.py:73) model was already fully refactored — the deprecated flat columns (`address_city`, `address_zip`, etc.) were **physically removed** from the database via [`cleanup_ghost_columns_2026_07.sql`](docs/sql/cleanup_ghost_columns_2026_07.sql). The [`ServiceProvider`](backend/app/models/identity/social.py:23) model needs the same treatment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Full System Audit Results
|
||||||
|
|
||||||
|
### 2.1 Model Status Matrix
|
||||||
|
|
||||||
|
| Model | Table | Schema | Flat Columns | `address_id` FK | Status |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| [`Address`](backend/app/models/identity/address.py:39) | `system.addresses` | `system` | ❌ (central) | UUID PK (self) | ✅ **Source of Truth** |
|
||||||
|
| [`Organization`](backend/app/models/marketplace/organization.py:73) | `fleet.organizations` | `fleet` | ❌ (removed) | ✅ UUID FK (3x) | ✅ **Fully Refactored** |
|
||||||
|
| [`Person`](backend/app/models/identity/person.py) | `identity.persons` | `identity` | ❌ | ✅ UUID FK | ✅ **Already correct** |
|
||||||
|
| [`Branch`](backend/app/models/marketplace/organization.py) | `fleet.branches` | `fleet` | ❌ | ✅ UUID FK | ✅ **Already correct** |
|
||||||
|
| [`ServiceProfile`](backend/app/models/marketplace/service.py:21) | `marketplace.service_profiles` | `marketplace` | ❌ | ❌ (uses `location` Geometry) | ✅ **Clean design** |
|
||||||
|
| [`ServiceStaging`](backend/app/models/marketplace/staged_data.py:24) | `marketplace.service_staging` | `marketplace` | ✅ (`city`, `postal_code`, `full_address`) | ✅ UUID FK | ⚠️ **Hybrid — needs cleanup** |
|
||||||
|
| [`ServiceProvider`](backend/app/models/identity/social.py:23) | `marketplace.service_providers` | `marketplace` | ✅ (`city`, `address_zip`, `address_street_name`, `address_street_type`, `address_house_number`) | ❌ **NONE** | 🔴 **CORE PROBLEM** |
|
||||||
|
|
||||||
|
### 2.2 ServiceProvider Model — The Core Problem
|
||||||
|
|
||||||
|
File: [`backend/app/models/identity/social.py`](backend/app/models/identity/social.py:23)
|
||||||
|
|
||||||
|
The `ServiceProvider` model at lines 23-71 defines these flat address columns:
|
||||||
|
|
||||||
|
```python
|
||||||
|
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # line 38
|
||||||
|
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 39
|
||||||
|
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) # line 40
|
||||||
|
address_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # line 41
|
||||||
|
address_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # line 42
|
||||||
|
```
|
||||||
|
|
||||||
|
**There is NO `address_id` UUID foreign key to `system.addresses`.**
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- Address data cannot be shared/deduplicated across entities
|
||||||
|
- No postal code normalization (no `GeoPostalCode` relationship)
|
||||||
|
- The `AddressManager` creates `system.addresses` records but the results are **mapped back to flat columns** instead of storing the FK
|
||||||
|
- The `_build_unified_providers_query()` in [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:284) must use a complex UNION ALL with flat field extraction
|
||||||
|
|
||||||
|
### 2.3 ServiceStaging Model — Hybrid State
|
||||||
|
|
||||||
|
File: [`backend/app/models/marketplace/staged_data.py`](backend/app/models/marketplace/staged_data.py:24)
|
||||||
|
|
||||||
|
The `ServiceStaging` model at line 81 already has the FK:
|
||||||
|
|
||||||
|
```python
|
||||||
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||||
|
) # line 81
|
||||||
|
```
|
||||||
|
|
||||||
|
But it ALSO retains flat columns at lines 25-27:
|
||||||
|
|
||||||
|
```python
|
||||||
|
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
postal_code: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
|
full_address: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
This hybrid state means the API writes to BOTH the `address_id` FK and the flat columns, creating data duplication.
|
||||||
|
|
||||||
|
### 2.4 Admin API — Partial Fix Complexity
|
||||||
|
|
||||||
|
File: [`backend/app/api/v1/endpoints/admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py:1157)
|
||||||
|
|
||||||
|
The `update_provider()` function at line 1157 has complex branching logic:
|
||||||
|
|
||||||
|
- **Lines 1242-1262**: For `ServiceStaging` (which has `address_id` FK) → uses `AddressManager.create_or_update()` then sets `provider_obj.address_id`
|
||||||
|
- **Lines 1262-1302**: For `ServiceProvider` (NO `address_id` FK) → uses `AddressManager.create_or_update()` then **maps the result back to 12+ flat columns** via `addr_mapping` (lines 1277-1302)
|
||||||
|
|
||||||
|
This dual-write pattern is fragile and error-prone. The `addr_mapping` dict at lines 1277-1302 maps each `AddressIn` field to its flat column counterpart.
|
||||||
|
|
||||||
|
Similarly, on response:
|
||||||
|
- **Lines 1507-1518**: For `ServiceProvider` → tries to resolve `address_detail` from `address_id` (which may be NULL for existing records)
|
||||||
|
- **Lines 1548-1560**: For `ServiceStaging` → resolves `address_detail` from `address_id`
|
||||||
|
|
||||||
|
The [`_build_unified_providers_query()`](backend/app/api/v1/endpoints/admin_providers.py:284) at line 284 uses UNION ALL across 3 tables with flat field extraction:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ServiceProvider branch (lines 306-320): selects flat columns
|
||||||
|
-- ServiceStaging branch (lines 335-352): selects flat columns
|
||||||
|
-- Organization branch (lines 369-390): JOINs with system.addresses
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Frontend Status
|
||||||
|
|
||||||
|
| Page | Path | SharedAddressForm | address_detail | Flat Fallback |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Provider Edit** | [`providers/[id]/edit.vue`](frontend_admin/pages/providers/%5Bid%5D/edit.vue) | ✅ (line 343) | ✅ (lines 810-823) | ⚠️ (lines 1133-1134) |
|
||||||
|
| **Provider Detail** | [`providers/[id]/index.vue`](frontend_admin/pages/providers/%5Bid%5D/index.vue) | ❌ | ✅ (lines 144-166) | ✅ (lines 146, 153) |
|
||||||
|
| **Provider List** | [`providers/index.vue`](frontend_admin/pages/providers/index.vue) | ❌ | ❌ uses `provider.address` only | ✅ (line 137) |
|
||||||
|
| **Garage Edit** | [`garages/[id]/index.vue`](frontend_admin/pages/garages/%5Bid%5D/index.vue) | ✅ (line 534) | ✅ (lines 1014-1023) | ❌ |
|
||||||
|
| **Garage List** | [`garages/index.vue`](frontend_admin/pages/garages/index.vue) | ❌ | ❌ uses `garage.city` only | ✅ (line 157) |
|
||||||
|
|
||||||
|
### 2.6 Already-Refactored Endpoints (Reference)
|
||||||
|
|
||||||
|
These endpoints already use `AddressManager` correctly and serve as reference implementations:
|
||||||
|
|
||||||
|
| Endpoint | File | How |
|
||||||
|
|---|---|---|
|
||||||
|
| **Admin Organizations** | [`admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py) | Lines 1097-1145: `AddressOut.model_validate(org.address)` for 3 address types |
|
||||||
|
| **User Organizations** | [`organizations.py`](backend/app/api/v1/endpoints/organizations.py) | Lines 71-82: `AddressManager.create_or_update()` on org creation |
|
||||||
|
| **Admin Persons** | [`admin_persons.py`](backend/app/api/v1/endpoints/admin_persons.py) | Lines 654-661: `AddressManager.create_or_update()` for person.address |
|
||||||
|
| **User Profile** | [`users.py`](backend/app/api/v1/endpoints/users.py) | Lines 355-374: `GeoService.get_or_create_full_address()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Migration Strategy: 3-Phase Plan
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Phase1["Phase 1: Database (Alembic)"]
|
||||||
|
A1[Add address_id UUID FK to marketplace.service_providers]
|
||||||
|
A2[Create data migration: backfill system.addresses from flat columns]
|
||||||
|
A3[Set FK constraint + add relationship to ServiceProvider model]
|
||||||
|
A4[Run sync_engine to verify schema]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Phase2["Phase 2: Backend API"]
|
||||||
|
B1[Simplify update_provider: remove addr_mapping logic]
|
||||||
|
B2[Refactor _build_unified_providers_query: JOIN with system.addresses]
|
||||||
|
B3[Update ProviderListItem/ProviderDetail schemas]
|
||||||
|
B4[Remove flat address field fallback in get_provider_detail]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Phase3["Phase 3: Frontend"]
|
||||||
|
C1[providers/index.vue: show address_detail instead of flat address]
|
||||||
|
C2[providers/[id]/index.vue: remove flat fallback]
|
||||||
|
C3[Clean up flat address fields from edit.vue]
|
||||||
|
C4[garages/index.vue: show address_detail instead of flat city]
|
||||||
|
end
|
||||||
|
|
||||||
|
Phase1 --> Phase2 --> Phase3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 1 — Database Migration (Model + sync_engine)
|
||||||
|
|
||||||
|
**Goal:** Add `address_id` FK to `ServiceProvider` and migrate existing data.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. **Add `address_id` column to `ServiceProvider` model** in [`social.py`](backend/app/models/identity/social.py:23):
|
||||||
|
```python
|
||||||
|
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
|
||||||
|
)
|
||||||
|
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create data migration script** that:
|
||||||
|
- Iterates all `ServiceProvider` records with flat address data and NULL `address_id`
|
||||||
|
- For each record, creates a `system.addresses` record via `AddressManager.create_or_update()`
|
||||||
|
- Sets `service_providers.address_id` to the new UUID
|
||||||
|
- Logs the migration results
|
||||||
|
|
||||||
|
3. **Run sync_engine**: `docker exec sf_api python /app/backend/app/scripts/sync_engine.py`
|
||||||
|
|
||||||
|
4. **Verify**: Query `service_providers` to confirm `address_id` is populated.
|
||||||
|
|
||||||
|
5. **Deferred**: Drop flat columns in a future migration after Phase 3 is complete and verified.
|
||||||
|
|
||||||
|
### Phase 2 — Backend API Refactor
|
||||||
|
|
||||||
|
**Goal:** Simplify `admin_providers.py` to use `AddressManager` directly without flat column mapping.
|
||||||
|
|
||||||
|
**Key changes in [`admin_providers.py`](backend/app/api/v1/endpoints/admin_providers.py):**
|
||||||
|
|
||||||
|
1. **Simplify `update_provider()`** (lines 1242-1302):
|
||||||
|
- Remove the `addr_mapping` dict (lines 1277-1302) for `ServiceProvider`
|
||||||
|
- Both branches now just call `AddressManager.create_or_update()` and set `provider_obj.address_id`
|
||||||
|
- Remove flat column updates for `address_zip`, `address_street_name`, etc.
|
||||||
|
|
||||||
|
2. **Refactor `_build_unified_providers_query()`** (lines 284-407):
|
||||||
|
- For `ServiceProvider`: Add LEFT JOIN to `system.addresses` and extract fields via relationship
|
||||||
|
- Remove flat column SELECT for ServiceProvider
|
||||||
|
- Keep flat fallback temporarily for backward compatibility
|
||||||
|
|
||||||
|
3. **Update response schemas** (lines 59-105, 134-151):
|
||||||
|
- `ProviderListItem`: Add `address_detail: Optional[AddressOut]`
|
||||||
|
- `ProviderDetail`: Mark flat `address` and `city` as DEPRECATED
|
||||||
|
- `ProviderUpdateInput`: Keep `address_detail` as primary, mark flat fields deprecated
|
||||||
|
|
||||||
|
4. **Simplify `get_provider_detail()`** (lines 534-659):
|
||||||
|
- Remove flat field fallback (lines 586-587: `sp.address or ""`)
|
||||||
|
- Always resolve from `address_id` via `AddressManager.get_normalized()`
|
||||||
|
|
||||||
|
### Phase 3 — Frontend Cleanup
|
||||||
|
|
||||||
|
**Goal:** All provider/garage pages display and edit addresses via `address_detail` only.
|
||||||
|
|
||||||
|
**Detailed changes:**
|
||||||
|
|
||||||
|
1. **`providers/index.vue`** (line 137): Replace `provider.address` with `provider.address_detail?.full_address_text || [provider.address_detail?.zip, ...].filter(Boolean).join(' ')`
|
||||||
|
|
||||||
|
2. **`providers/[id]/index.vue`** (lines 144-166): Remove `|| provider.address` and `|| provider.city` fallbacks (lines 146, 153)
|
||||||
|
|
||||||
|
3. **`providers/[id]/edit.vue`** (lines 1133-1134): Remove `data.address` fallback from `data.address_detail || data.address || {}`
|
||||||
|
|
||||||
|
4. **`garages/index.vue`** (line 157): Replace `garage.city || '—'` with `garage.address_detail?.city || '—'` or similar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| **Data loss** during migration (flat → normalized) | High | Migration script must be transactional with rollback. Test on staging first. |
|
||||||
|
| **Existing API consumers** send flat address fields | Medium | Keep deprecated fields in schemas with `[DEPRECATED]` tags for one release cycle |
|
||||||
|
| **Frontend list view** breaks if address_detail is missing | Medium | Add fallback to flat fields in list view during transition period |
|
||||||
|
| **Service robots** (OSM Scout, OCR) write flat columns directly | Medium | Audit `service_robot_1_scout_osm.py` — it's in the modified files list |
|
||||||
|
| **Unified query** (3-way UNION ALL) breaks | High | Refactor query carefully to maintain backward compatibility |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria
|
||||||
|
|
||||||
|
### Phase 1 — Database Migration
|
||||||
|
- [ ] `marketplace.service_providers` has `address_id` UUID FK → `system.addresses.id`
|
||||||
|
- [ ] All existing ServiceProvider records with flat address data have corresponding `system.addresses` records linked via `address_id`
|
||||||
|
- [ ] `ServiceProvider` model has `address` relationship with `lazy="selectin"`
|
||||||
|
- [ ] `sync_engine` reports no schema drift
|
||||||
|
|
||||||
|
### Phase 2 — Backend API
|
||||||
|
- [ ] `update_provider()` no longer maps `AddressIn` fields to flat columns for `ServiceProvider`
|
||||||
|
- [ ] `get_provider_detail()` returns `address_detail` from `address_id` relationship for all providers
|
||||||
|
- [ ] `list_providers()` includes `address_detail` in each item
|
||||||
|
- [ ] `_build_unified_providers_query()` uses LEFT JOIN for ServiceProvider instead of flat column extraction
|
||||||
|
- [ ] Flat address fields (`address`, `city`) still work in API requests but are marked `[DEPRECATED]` in schemas
|
||||||
|
|
||||||
|
### Phase 3 — Frontend
|
||||||
|
- [ ] Provider list page shows addresses from `address_detail` (not flat `provider.address`)
|
||||||
|
- [ ] Provider detail page has no flat fallback for address fields
|
||||||
|
- [ ] Provider edit page sends `address_detail` only (no flat fields)
|
||||||
|
- [ ] Garage list page shows city from `address_detail`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. References
|
||||||
|
|
||||||
|
- [AddressManager service](backend/app/services/address_manager.py:41) — 4 static methods: `resolve()`, `create_or_update()`, `get_normalized()`, `_resolve_postal_code()`
|
||||||
|
- [AddressIn/AddressOut schemas](backend/app/schemas/address.py:18) — Pydantic models for address API contracts
|
||||||
|
- [SharedAddressForm component](frontend_admin/components/SharedAddressForm.vue) — Vue form component (search for usage via `SharedAddressForm`)
|
||||||
|
- [Address model](backend/app/models/identity/address.py:39) — UUID PK, `postal_code_id` FK → `system.geo_postal_codes`
|
||||||
|
- [Organization model (REFACTORED)](backend/app/models/marketplace/organization.py:105) — Reference implementation with 3 `address_id` FKs
|
||||||
|
- [Admin Persons (REFACTORED)](backend/app/api/v1/endpoints/admin_persons.py:654) — Reference API implementation
|
||||||
|
- [Ghost columns cleanup SQL](docs/sql/cleanup_ghost_columns_2026_07.sql) — Reference for how Organization's flat columns were removed
|
||||||
279
docs/p0_discovery_provider_validation_audit.md
Normal file
279
docs/p0_discovery_provider_validation_audit.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# P0 DISCOVERY - Audit Report: Provider Validation & Scoring Engine
|
||||||
|
|
||||||
|
**Dátum:** 2026-07-07
|
||||||
|
**Auditor:** Service Finder Rendszer-Architect
|
||||||
|
**Verzió:** 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Executive Summary
|
||||||
|
|
||||||
|
Az Architect által definiált Trust & Validation scoring logika (Crawler=20, First Entry=40, User Confirm=+20, Auto-Approve Threshold=80, Admin Approve=100) **NINCS egységesen implementálva**. A pontozás három párhuzamos, egymástól független rendszerben fut, és egyik sem felel meg a specifikációnak.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 📡 CRAWLER/IMPORT ENDPOINT AUDIT
|
||||||
|
|
||||||
|
### 1.1 Robot 0 (Hunter - Google Places)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `service_robot_0_hunter.py` | `trust_score=30 # Alapértelmezett bizalmi szint` (line 115) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
new_entry = ServiceStaging(
|
||||||
|
...
|
||||||
|
trust_score=30 # Hardcoded!
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Probléma:** A `trust_score` hardcoded `30`, míg az Architect specifikáció szerint Crawler forrásnál `20` kellene legyen. A `validation_level` mezőt nem állítja be explicit (default: `40` lesz a modellből).
|
||||||
|
|
||||||
|
### 1.2 Robot 1 (Scout - OSM)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `service_robot_1_scout_osm.py` | `"trust_score": 20` (csak raw_data JSONB-ben!) (line 98) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
raw_payload = {
|
||||||
|
"osm_tags": tags,
|
||||||
|
"source": "osm_scout_v2",
|
||||||
|
"trust_score": 20 # CSAK a raw_data JSONB-ben!
|
||||||
|
}
|
||||||
|
new_entry = ServiceStaging(
|
||||||
|
...
|
||||||
|
raw_data=raw_payload # trust_score NEM kerül a tényleges oszlopba
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Probléma:** A `trust_score: 20` a `raw_data` JSONB objektumba kerül, **NEM** a `ServiceStaging.trust_score` mezőbe! Az adatbázis oszlop értéke `0` marad (modell default). Ez adatvesztéshez vezet.
|
||||||
|
|
||||||
|
### 1.3 User Submission (Gamification Endpoint)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `gamification.py` | `trust_weight = min(20 + (user_lvl * 6), 90)` (line 317) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
trust_weight = min(20 + (user_lvl * 6), 90)
|
||||||
|
...
|
||||||
|
new_staging = ServiceStaging(
|
||||||
|
...
|
||||||
|
trust_score=trust_weight, # Dinamikus, de NEM a First Entry=40 logikát követi
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Probléma:** A dinamikus számítás (20 + szint*6) NEM felel meg a "First Entry = 40" szabálynak. Egy 1-es szintű user `26`-ot kap, nem `40`-et.
|
||||||
|
|
||||||
|
### 1.4 Crawler Import Endpoint (Admin API)
|
||||||
|
|
||||||
|
Az `admin_providers.py` fájlban **nincs dedikált crawler import endpoint**. A meglévő `POST /{provider_id}/approve` és `POST /{provider_id}/reject` végpontok csak a már létező `ServiceProvider` rekordok státuszát módosítják, nem hoznak létre újakat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ⚙️ VALIDATION ENGINE AUDIT
|
||||||
|
|
||||||
|
### 2.1 Több, egymástól független scoring mechanizmus
|
||||||
|
|
||||||
|
A rendszerben **három párhuzamos scoring rendszer** létezik, amelyek NINCSENEK összekapcsolva:
|
||||||
|
|
||||||
|
| # | Scoring System | Tábla | Mező | Ki működteti? |
|
||||||
|
|---|---------------|-------|------|---------------|
|
||||||
|
| 1 | **Trust Score** | `marketplace.service_staging` | `trust_score` (default: **0**) | Robotok, User Submission |
|
||||||
|
| 2 | **Validation Level** | `marketplace.service_staging` | `validation_level` (default: **40**) | Community validation (`services.py`) |
|
||||||
|
| 3 | **Validation Score** | `marketplace.service_providers` | `validation_score` (default: **0**) | Admin moderáció (`admin_providers.py`) |
|
||||||
|
|
||||||
|
### 2.2 Modellek összehasonlítása
|
||||||
|
|
||||||
|
#### `ServiceStaging` (`staged_data.py:54-59`)
|
||||||
|
```python
|
||||||
|
trust_score: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
|
||||||
|
validation_level: Mapped[int] = mapped_column(Integer, default=40, server_default=text("40"))
|
||||||
|
```
|
||||||
|
- `trust_score`: Robot bizalmi szint (0-100)
|
||||||
|
- `validation_level`: Közösségi validációs szint (default 40, max 80)
|
||||||
|
|
||||||
|
#### `ServiceProvider` (`social.py:59`)
|
||||||
|
```python
|
||||||
|
validation_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
```
|
||||||
|
- `validation_score`: Moderációs pontszám (csak admin által állítható)
|
||||||
|
|
||||||
|
#### `ProviderValidation` (`social.py:87-123`)
|
||||||
|
```python
|
||||||
|
class ProviderValidation(Base):
|
||||||
|
__tablename__ = "provider_validations"
|
||||||
|
# validation_type: approve | reject | flag
|
||||||
|
# weight: admin weight (default=1, admin uses 5)
|
||||||
|
# metadata: JSONB for details
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 ProviderValidation tábla - Jelenlegi használat
|
||||||
|
|
||||||
|
A `_create_provider_validation()` függvény minden admin akciókor (approve/reject/restore/flag) létrehoz egy rekordot:
|
||||||
|
|
||||||
|
| Akció | `validation_type` | `weight` | `validation_score` módosítás |
|
||||||
|
|-------|-------------------|----------|------------------------------|
|
||||||
|
| Approve | `approve` | 5 | `+= 50` |
|
||||||
|
| Reject | `reject` | 5 | `= 0` |
|
||||||
|
| Restore | `approve` | 5 | `= 50` |
|
||||||
|
| Flag | `flag` | 5 | `-= 20` (min 0) |
|
||||||
|
|
||||||
|
**Hiányzó funkció:** A `weight` mezőt soha nem aggregálja a rendszer. Nincs olyan logika, ami a `ProviderValidation.weight`-ok összegét vizsgálná, és annak alapján auto-approve/-reject döntést hozna.
|
||||||
|
|
||||||
|
### 2.4 Community Validation (SocialService)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `social_service.py` | `validation_score += vote_value` (lines 45-53) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
provider.validation_score += vote_value # +1 vagy -1
|
||||||
|
if provider.validation_score >= 5:
|
||||||
|
provider.status = ModerationStatus.approved
|
||||||
|
elif provider.validation_score <= -3:
|
||||||
|
provider.status = ModerationStatus.rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
**Probléma:** Ez az OLD community voting rendszer (`Vote` tábla, `+-1` szavazatok), ami NEM kapcsolódik a `validation_level` vagy az Architect által definiált scoring logikához. A küszöbértékek (`5` és `-3`) teljesen függetlenek a `80`-as auto-approve threshold-tól.
|
||||||
|
|
||||||
|
### 2.5 Service Validation (User Confirm)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `services.py` | `validation_level += 10` (max 80) (lines 72-75) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
new_level = staging.validation_level + 10
|
||||||
|
if new_level > 80:
|
||||||
|
new_level = 80
|
||||||
|
```
|
||||||
|
|
||||||
|
Ez a `validation_level`-t növeli (max 80), de ez **NEM befolyásolja** sem a `trust_score`-t, sem a `validation_score`-t. A `+20` User Confirm bónusz NINCS implementálva.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 🚀 PROMOTION LOGIC AUDIT (Threshold = 80)
|
||||||
|
|
||||||
|
### 3.1 System Robot 2 (ServiceAuditor)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `system_robot_2_service_auditor.py` | Threshold default: **50** (line 37) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
threshold = value.get('trust_score', 50) if isinstance(value, dict) else 50
|
||||||
|
if stage.trust_score >= threshold:
|
||||||
|
# SIKERES AUDIT -> Organization + ServiceProfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**GAP:** A threshold default **50**, nem **80**. És a `trust_score`-t vizsgálja, nem a `validation_level`-t.
|
||||||
|
|
||||||
|
### 3.2 Service Robot 5 (Auditor)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `service_robot_5_auditor.py` | Threshold default: **70** (lines 40-45) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
param = result.scalar_one_or_none()
|
||||||
|
if param and param.value:
|
||||||
|
return int(param.value)
|
||||||
|
return 70 # Default
|
||||||
|
if staging.trust_score < trust_threshold:
|
||||||
|
staging.status = 'rejected'
|
||||||
|
```
|
||||||
|
|
||||||
|
**GAP:** A threshold default **70**, nem **80**. És itt is a `trust_score`-t vizsgálja.
|
||||||
|
|
||||||
|
### 3.3 Admin Approve (validation_level = 100)
|
||||||
|
|
||||||
|
| Fájl | Sor |
|
||||||
|
|------|-----|
|
||||||
|
| `admin.py` | `validation_level = 100` (lines 265-266) |
|
||||||
|
|
||||||
|
```python
|
||||||
|
staging.validation_level = 100
|
||||||
|
staging.status = "approved"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Egyedül ez felel meg** az Architect specifikáció "Admin Approve = 100" szabályának, de ez csak a `ServiceStaging` rekordot jelöli meg, nem hoz létre `ServiceProvider`-t vagy `Organization`-t automatikusan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 📊 HIÁNYOSSÁGOK ÖSSZEGZÉSE
|
||||||
|
|
||||||
|
### 🔴 P0 Critical Gaps
|
||||||
|
|
||||||
|
| # | Gap | Helye | Következmény |
|
||||||
|
|---|-----|-------|-------------|
|
||||||
|
| **G1** | **Nincs egységes scoring engine** | A teljes scoring logika hiányzik | A Crawler=20, First Entry=40, User Confirm=+20 szabályok NINCSENEK sehol implementálva |
|
||||||
|
| **G2** | **Robot 1 (Scout) trust_score elveszik** | `service_robot_1_scout_osm.py:98` | A `trust_score: 20` a `raw_data` JSONB-ben landol, NEM az oszlopban. A rekord trust_score=0 marad. |
|
||||||
|
| **G3** | **Auto-Approve Threshold=80 sehol nem érvényesül** | `system_robot_2_service_auditor.py:37` és `service_robot_5_auditor.py:45` | A robotok 50-es és 70-es threshold-ot használnak. A validation_level max 80-as limit elérése nem triggerel semmit. |
|
||||||
|
|
||||||
|
### 🟡 P1 Medium Gaps
|
||||||
|
|
||||||
|
| # | Gap | Helye | Következmény |
|
||||||
|
|---|-----|-------|-------------|
|
||||||
|
| **G4** | **Három független scoring rendszer** | `trust_score`, `validation_level`, `validation_score` | Nincs átjárás a rendszerek között. Egy magas validation_level nem növeli a validation_score-t. |
|
||||||
|
| **G5** | **ProviderValidation weight aggregáció hiányzik** | `admin_providers.py:211` | A `weight` mező létezik, de soha nincs összegezve auto-approve döntéshez. |
|
||||||
|
| **G6** | **User submission scoring nem spec-konform** | `gamification.py:317` | `min(20 + (user_lvl*6), 90)` helyett `40` kellene First Entry-nél. |
|
||||||
|
|
||||||
|
### 🟢 P2 Minor Gaps
|
||||||
|
|
||||||
|
| # | Gap | Helye | Következmény |
|
||||||
|
|---|-----|-------|-------------|
|
||||||
|
| **G7** | **Admin approve nem hoz létre Organization-t** | `admin.py:265-266` | Csak a `validation_level=100`-at állítja, de a promotion logika (Staging→Organization) nem fut le. |
|
||||||
|
| **G8** | **SocialService vote nem kapcsolódik a scoring engine-hez** | `social_service.py:45-53` | A community vote (`+-1`) teljesen külön úton halad, a threshold-ok (5/-3) nem konfigurálhatók. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 📋 Adatfolyam Diagram (Current State vs Desired State)
|
||||||
|
|
||||||
|
### Jelenlegi állapot (As-Is):
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Robot 0 Hunter] -->|trust_score=30| B[ServiceStaging]
|
||||||
|
C[Robot 1 Scout] -->|trust_score=0 raw_data.trust_score=20| B
|
||||||
|
D[User Submission] -->|trust_score=26-90| B
|
||||||
|
B -->|status=auditor_ready| E[System Robot 2]
|
||||||
|
E -->|trust_score>=50| F[Organization + ServiceProfile]
|
||||||
|
B -->|status=pending| G[Community Validation]
|
||||||
|
G -->|validation_level+=10 max 80| B
|
||||||
|
B -->|status=auditing| H[Service Robot 5]
|
||||||
|
H -->|trust_score>=70| I[Organization + ServiceProfile]
|
||||||
|
J[Admin] -->|validation_score+=50| K[ServiceProvider]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kívánt állapot (To-Be) az Architect specifikáció alapján:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Crawler Import] -->|Crawler=20| B[Validation Engine]
|
||||||
|
C[First Entry User] -->|First Entry=40| B
|
||||||
|
D[User Confirm] -->|+20| B
|
||||||
|
E[Admin Approve] -->|=100| B
|
||||||
|
B -->|score>=80 Auto-Approve| F[Organization + ServiceProvider]
|
||||||
|
B -->|score<80| G[Pending Review]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 🔧 Javasolt Javítási Sorrend
|
||||||
|
|
||||||
|
1. **P0 - Create a unified `validation_service.py`** that implements the Architect's scoring math:
|
||||||
|
- `Crawler = 20` (source="bot")
|
||||||
|
- `First Entry = 40` (source="manual", first submission)
|
||||||
|
- `User Confirm = +20` (max 80)
|
||||||
|
- `Auto-Approve Threshold = 80`
|
||||||
|
- `Admin Approve = 100`
|
||||||
|
|
||||||
|
2. **P0 - Fix Robot 1 Scout** to write `trust_score` to the actual column, not just `raw_data`.
|
||||||
|
|
||||||
|
3. **P1 - Unify thresholds** in System Robot 2 and Service Robot 5 to use `validation_level` (or a unified score) instead of `trust_score`.
|
||||||
|
|
||||||
|
4. **P1 - Connect scoring systems** so `validation_level` increments also affect the provider's `validation_score`.
|
||||||
|
|
||||||
|
5. **P2 - Add auto-promotion** when unified score >= 80: auto-create `ServiceProvider` (or `Organization`) from `ServiceStaging`.
|
||||||
203
docs/p0_expense_500_import_bugfix_2026-07-26.md
Normal file
203
docs/p0_expense_500_import_bugfix_2026-07-26.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# P0 Root Cause Analysis: POST /api/v1/expenses/ 500 Internal Server Error
|
||||||
|
|
||||||
|
**Date:** 2026-07-26
|
||||||
|
**Gitea Issue:** [#421](https://gitea.kincses.dev/kincses/service-finder/issues/421)
|
||||||
|
**Severity:** P0 (Critical — breaks entire expense creation flow)
|
||||||
|
**Status:** Architect Analysis Complete — Awaiting Code Fix
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Executive Summary
|
||||||
|
|
||||||
|
The `POST /api/v1/expenses/` endpoint crashes with a `500 Internal Server Error` because the helper function `_check_org_capability()` contains a **broken import path** and a **wrong join column name**. Both defects are located in `backend/app/api/v1/endpoints/expenses.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Root Cause Trace
|
||||||
|
|
||||||
|
### Traceback (from `docker compose logs sf_api`)
|
||||||
|
|
||||||
|
```
|
||||||
|
File "/app/app/api/v1/endpoints/expenses.py", line 377, in create_expense
|
||||||
|
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
|
||||||
|
File "/app/app/api/v1/endpoints/expenses.py", line 104, in _check_org_capability
|
||||||
|
from app.models.fleet.org_role import OrgRole
|
||||||
|
ModuleNotFoundError: No module named 'app.models.fleet.org_role'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Call Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["POST /api/v1/expenses/"] --> B["expenses.py: create_expense() line 377"]
|
||||||
|
B --> C["_check_org_capability() line 93"]
|
||||||
|
C --> D["line 104: from app.models.fleet.org_role import OrgRole"]
|
||||||
|
D --> E["ModuleNotFoundError 💥"]
|
||||||
|
E --> F["500 Internal Server Error"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bug #1: Wrong Import Path (line 104)
|
||||||
|
|
||||||
|
### Broken Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
# File: backend/app/api/v1/endpoints/expenses.py, line 104
|
||||||
|
from app.models.fleet.org_role import OrgRole
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why It Fails
|
||||||
|
|
||||||
|
There is **no directory** `backend/app/models/fleet/` and no file `org_role.py` at that path.
|
||||||
|
|
||||||
|
### Correct Import
|
||||||
|
|
||||||
|
The `OrgRole` model lives in `backend/app/models/marketplace/organization.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.models.marketplace.organization import OrgRole
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evidence: Other Files Import Correctly
|
||||||
|
|
||||||
|
| File | Import | Status |
|
||||||
|
|------|--------|--------|
|
||||||
|
| `backend/app/api/deps.py:13` | `from app.models.marketplace.organization import OrgRole, OrganizationMember` | ✅ Correct |
|
||||||
|
| `backend/app/api/v1/endpoints/assets.py:14` | `from app.models import ... OrgRole` (via `__init__.py`) | ✅ Correct |
|
||||||
|
| `backend/app/api/v1/endpoints/organizations.py:23` | `from app.models.marketplace.organization import ... OrgRole` | ✅ Correct |
|
||||||
|
| `backend/app/api/v1/endpoints/expenses.py:104` | `from app.models.fleet.org_role import OrgRole` | ❌ **BROKEN** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Bug #2: Wrong Join Column (line 108)
|
||||||
|
|
||||||
|
### Broken Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
# File: backend/app/api/v1/endpoints/expenses.py, lines 106-114
|
||||||
|
stmt = (
|
||||||
|
select(OrgRole.permissions)
|
||||||
|
.join(OrganizationMember, OrganizationMember.role == OrgRole.name) # ← BUG
|
||||||
|
.where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why It Fails
|
||||||
|
|
||||||
|
The `OrgRole` model's column is `name_key` (line 55 of the model), **not** `name`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class OrgRole(Base):
|
||||||
|
__tablename__ = "org_roles"
|
||||||
|
__table_args__ = {"schema": "fleet"}
|
||||||
|
|
||||||
|
name_key: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
# ... no 'name' column exists
|
||||||
|
```
|
||||||
|
|
||||||
|
Attempting `OrgRole.name` would raise an `AttributeError` after the import is fixed, causing a second crash.
|
||||||
|
|
||||||
|
### Correct Join
|
||||||
|
|
||||||
|
```python
|
||||||
|
.join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Evidence: `assets.py` Has the Correct Pattern
|
||||||
|
|
||||||
|
The `assets.py` endpoint uses the identical capability-check pattern correctly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# assets.py line 80-83 — CORRECT pattern
|
||||||
|
role_stmt = select(OrgRole.permissions).where(
|
||||||
|
OrgRole.name_key == org_role_name, # ← Uses name_key, not .name
|
||||||
|
OrgRole.is_active == True
|
||||||
|
).limit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 Impact Analysis
|
||||||
|
|
||||||
|
### What's Broken
|
||||||
|
|
||||||
|
- **Entire `POST /api/v1/expenses/` endpoint** — all expense creation requests fail with 500
|
||||||
|
- The crash occurs at the **RBAC Phase 2 capability check** (JSONB-based `can_approve_expense`)
|
||||||
|
- This blocks the P0 Smart Expense Workflow (odometer normalization, VAT calculation, gamification hooks, AssetEvent linking)
|
||||||
|
|
||||||
|
### What Still Works
|
||||||
|
|
||||||
|
- `GET /api/v1/expenses/` — list all expenses (does not call `_check_org_capability`)
|
||||||
|
- `GET /api/v1/expenses/{asset_id}` — list asset expenses (does not call `_check_org_capability`)
|
||||||
|
- `PUT /api/v1/expenses/{expense_id}` — update expense (does not call `_check_org_capability`)
|
||||||
|
|
||||||
|
### Why This Was Not Caught
|
||||||
|
|
||||||
|
1. The function uses a lazy import inside the function body — the import error only surfaces at runtime, not at module load time.
|
||||||
|
2. No e2e test exercises the `POST /expenses/` path through this function.
|
||||||
|
3. The `backend/app/tests/e2e/test_expense_flow.py` test targets `POST /api/v1/expenses/add`, which is a **different, legacy endpoint** — not the current `POST /api/v1/expenses/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Fix Plan (2 Lines, 1 File)
|
||||||
|
|
||||||
|
### File: `backend/app/api/v1/endpoints/expenses.py`
|
||||||
|
|
||||||
|
**Change 1 — Line 104:** Replace the broken import:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- from app.models.fleet.org_role import OrgRole
|
||||||
|
+ from app.models.marketplace.organization import OrgRole
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change 2 — Line 108:** Fix the join column:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- .join(OrganizationMember, OrganizationMember.role == OrgRole.name)
|
||||||
|
+ .join(OrganizationMember, OrganizationMember.role == OrgRole.name_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: Hoist to top-level imports
|
||||||
|
|
||||||
|
Since `OrganizationMember` is already imported at the top of the file (line 46), move `OrgRole` there too:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# line 46 area — add OrgRole
|
||||||
|
from app.models.marketplace.organization import OrganizationMember, OrgRole
|
||||||
|
```
|
||||||
|
|
||||||
|
Then remove the lazy import body from `_check_org_capability()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Verification
|
||||||
|
|
||||||
|
1. **Unit test:** `POST /api/v1/expenses/` with valid payload → 201 Created
|
||||||
|
2. **Capability check:** User with `can_approve_expense: true` → expense `APPROVED`
|
||||||
|
3. **Capability check:** User without `can_approve_expense` → `PENDING_APPROVAL`
|
||||||
|
4. **No ModuleNotFoundError** at any point
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Summary
|
||||||
|
|
||||||
|
| Item | Detail |
|
||||||
|
|------|--------|
|
||||||
|
| **Endpoint** | `POST /api/v1/expenses/` |
|
||||||
|
| **Error** | `ModuleNotFoundError: No module named 'app.models.fleet.org_role'` |
|
||||||
|
| **Crash File** | `backend/app/api/v1/endpoints/expenses.py` |
|
||||||
|
| **Crash Line** | 104 |
|
||||||
|
| **Root Cause 1** | Wrong import: `app.models.fleet.org_role` → `app.models.marketplace.organization` |
|
||||||
|
| **Root Cause 2** | Wrong column: `OrgRole.name` → `OrgRole.name_key` |
|
||||||
|
| **Gitea Issue** | #421 |
|
||||||
|
| **Fix Complexity** | Trivial (2 lines) |
|
||||||
|
| **Risk** | Minimal |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Analysis by: Service Finder Rendszer-Architect — 2026-07-26*
|
||||||
248
docs/p0_expense_edit_modal_root_cause_analysis.md
Normal file
248
docs/p0_expense_edit_modal_root_cause_analysis.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# P0 Root Cause Analysis: Expense Edit Modal Data Binding Failure
|
||||||
|
|
||||||
|
**Date:** 2026-07-26
|
||||||
|
**Scope:** Frontend `CostEntryWizard.vue` + Backend `expenses.py`
|
||||||
|
**Symptom:** Edit modal defaults category to "Finanszírozás", fails to bind vehicle/provider, missing specific details (fuel amount).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data Storage: How Expenses & Specific Details Are Persisted
|
||||||
|
|
||||||
|
### DB Table: `fleet_finance.asset_costs`
|
||||||
|
|
||||||
|
| Column | Type | Stores |
|
||||||
|
|--------|------|--------|
|
||||||
|
| `category_id` | `int4` | FK → `fleet_finance.cost_categories.id` — **leaf/subcategory ID**, not parent |
|
||||||
|
| `service_provider_id` | `int4` | FK → `marketplace.service_providers.id` — the selected provider |
|
||||||
|
| `vendor_organization_id` | `int4` | FK → `fleet.organizations.id` — B2B vendor org |
|
||||||
|
| `external_vendor_name` | `varchar(200)` | Free-text vendor name |
|
||||||
|
| `data` | `JSONB` | **All specific details:** `liters`, `net_amount`, `vat_rate`, `invoice_number`, `invoice_date`, `payment_method`, `payment_deadline`, `mileage_at_cost`, `vendor_id`, `vendor_name`, `vendor_source` |
|
||||||
|
| `amount_gross` | `numeric(18,2)` | Gross (Bruttó) amount |
|
||||||
|
| `amount_net` | `numeric(18,2)` | Net amount |
|
||||||
|
| `invoice_number` | `varchar(100)` | Dedicated column (duplicated in `data`) |
|
||||||
|
|
||||||
|
**Key Finding:** Specific details like fuel liters, payment method, invoice date, etc. are stored in the `data` JSONB column. There is **no separate related table** for fuel-specific details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend API Analysis
|
||||||
|
|
||||||
|
### 2A. No Single-Expense Read Endpoint Exists
|
||||||
|
|
||||||
|
The backend has:
|
||||||
|
- `GET /expenses/` — lists all expenses (paginated)
|
||||||
|
- `GET /expenses/{asset_id}` — lists expenses for a specific asset
|
||||||
|
- `PUT /expenses/{expense_id}` — updates an expense
|
||||||
|
|
||||||
|
**There is NO `GET /expenses/by-id/{expense_id}` endpoint** that returns a single enriched expense with all resolved relationships.
|
||||||
|
|
||||||
|
### 2B. List Endpoints Return Raw ORM Objects
|
||||||
|
|
||||||
|
Both list endpoints return `result.scalars().all()` — raw SQLAlchemy `AssetCost` objects serialized by FastAPI's default JSON encoder. The `AssetCostResponse` Pydantic schema (which includes `category_name`, `category_code`, `vendor_name`, `description`, `mileage_at_cost`) is **defined but never used** by the list endpoints.
|
||||||
|
|
||||||
|
This means:
|
||||||
|
- `data` (JSONB) is serialized as a raw dict — this part actually works
|
||||||
|
- But `category_name`, `category_code`, `vendor_name` are **not resolved** (they require JOINs or relationship loading)
|
||||||
|
- The response lacks the parent category ID (only the leaf `category_id` is present)
|
||||||
|
|
||||||
|
### 2C. PUT Endpoint Has Partial Data Loss Risk
|
||||||
|
|
||||||
|
The `update_expense` function handles `mileage_at_cost` and `description` inside `data` JSONB correctly, but the frontend's `updateExpense()` in `cost.ts` sends `service_provider_id` only if it's in the payload. The `PUT` endpoint's `AssetCostUpdate` schema does include `service_provider_id: Optional[int]`, so if the frontend sends it, it would work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontend Data Flow: The Edit Path
|
||||||
|
|
||||||
|
### Step 1: User clicks Edit → `CostsView.vue` line 527
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function openEditWizard(cost: any) {
|
||||||
|
const vehicleId = cost.asset_id || cost.vehicle_id
|
||||||
|
editVehicle.value = vehicleStore.vehicles.find(v => v.id === vehicleId) || null
|
||||||
|
editCostData.value = cost // ← passes raw list-response object directly
|
||||||
|
showEditWizard.value = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `cost` object comes from the `GET /expenses` list response — a raw SQLAlchemy object with fields: `id`, `asset_id`, `organization_id`, `category_id`, `amount_net`, `amount_gross`, `vat_rate`, `currency`, `date`, `invoice_number`, `status`, `data`, `service_provider_id`, `vendor_organization_id`, `external_vendor_name`, `invoice_date`, `fulfillment_date`.
|
||||||
|
|
||||||
|
### Step 2: Hydration watcher → `CostEntryWizard.vue` lines 972-988
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
watch(() => props.editCost, (cost) => {
|
||||||
|
if (!cost) return
|
||||||
|
form.date = cost.date ? getLocalDateString(new Date(cost.date)) : getLocalDateString()
|
||||||
|
form.mainCategory = String(cost.category_id || '') // ← BUG #1
|
||||||
|
form.subCategory = String(cost.sub_category_id || '') // ← BUG #1 (never exists)
|
||||||
|
form.netAmount = cost.data?.net_amount || cost.amount_net || 0
|
||||||
|
form.vatRate = cost.data?.vat_rate || 27
|
||||||
|
form.grossAmount = cost.amount_gross || 0
|
||||||
|
form.currency = cost.currency || 'HUF'
|
||||||
|
form.invoiceNumber = cost.data?.invoice_number || ''
|
||||||
|
form.invoiceDate = cost.data?.invoice_date || ''
|
||||||
|
form.paymentDeadline = cost.data?.payment_deadline || ''
|
||||||
|
form.paymentMethod = cost.data?.payment_method || 'TRANSFER'
|
||||||
|
form.mileageAtCost = cost.mileage_at_cost || cost.data?.mileage_at_cost || null
|
||||||
|
form.liters = cost.data?.liters || null // ← BUG #2 area
|
||||||
|
// NOTE: selectedProvider is NEVER set from cost.service_provider_id! ← BUG #3
|
||||||
|
// NOTE: selectedVehicleId is NEVER set from cost.asset_id! ← BUG #4
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Root Cause Map: 5 Specific Bugs
|
||||||
|
|
||||||
|
### 🔴 BUG #1 — Category Hierarchy Collapse (CRITICAL)
|
||||||
|
|
||||||
|
**Location:** `CostEntryWizard.vue:976`
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- `category_id` in the DB is the **leaf/subcategory** ID (e.g., `id=4` for "Üzemanyag / Benzin")
|
||||||
|
- The wizard has a **two-level** selector: `mainCategory` (parent) → `subCategory` (leaf)
|
||||||
|
- The hydration sets `form.mainCategory = String(cost.category_id || '')` — puts the leaf ID into the parent dropdown
|
||||||
|
- `form.subCategory = String(cost.sub_category_id || '')` — `sub_category_id` doesn't exist in the response, so it's always `''`
|
||||||
|
- When the component renders, `mainCategory` is set to an ID that doesn't match any main category (since it's a leaf), so the `<select>` defaults to the **first option**: "Finanszírozás"
|
||||||
|
- Subcategories are never populated because `onMainCategoryChange()` was never triggered with the correct parent ID
|
||||||
|
|
||||||
|
**User experience:** Category dropdown shows "Finanszírozás" (first item) instead of the actual category.
|
||||||
|
|
||||||
|
**Fix approach:**
|
||||||
|
1. Backend must return `parent_category_id` alongside `category_id` in the response, OR
|
||||||
|
2. Frontend must look up the parent from the flattened category tree (`window.__allCostCategories`) and split `category_id` into main+sub correctly
|
||||||
|
|
||||||
|
### 🔴 BUG #2 — Vehicle Not Bound When Not in Store (HIGH)
|
||||||
|
|
||||||
|
**Location:** `CostsView.vue:530` + `CostEntryWizard.vue:93`
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- `openEditWizard` finds the vehicle by `cost.asset_id` in `vehicleStore.vehicles`
|
||||||
|
- If the vehicle is not in the store (e.g., filtered by another org, or not loaded), `editVehicle` is `null`
|
||||||
|
- In `CostEntryWizard.vue:77`, when `editVehicle` is null AND `userVehicles.length > 0`, the global vehicle dropdown is shown
|
||||||
|
- But `selectedVehicleId` is **never set** from `cost.asset_id` in the hydration watcher
|
||||||
|
- Result: vehicle dropdown shows "Select Vehicle..." with nothing selected
|
||||||
|
|
||||||
|
**Fix approach:** In the hydration watcher, set `selectedVehicleId.value = cost.asset_id` when `props.vehicle` is null.
|
||||||
|
|
||||||
|
### 🔴 BUG #3 — Provider/ServiceProvider Not Hydrated (HIGH)
|
||||||
|
|
||||||
|
**Location:** `CostEntryWizard.vue:972-988` (absence of provider hydration)
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- The DB stores `service_provider_id` (int) and `external_vendor_name` (string) directly on the AssetCost row
|
||||||
|
- The list response includes these fields
|
||||||
|
- But the hydration watcher **never reads** `cost.service_provider_id` or `cost.external_vendor_name`
|
||||||
|
- `selectedProvider` ref is never populated, so `ProviderAutocomplete` shows empty
|
||||||
|
- The user sees no provider selected even though one was saved
|
||||||
|
|
||||||
|
**Fix approach:** In the hydration watcher, if `cost.service_provider_id` exists, construct a `ProviderSearchResult` object and set `selectedProvider.value`. If only `external_vendor_name` exists, set it as the provider name.
|
||||||
|
|
||||||
|
### 🟡 BUG #4 — `data.liters` May Be Lost in List Serialization (MEDIUM)
|
||||||
|
|
||||||
|
**Location:** `CostEntryWizard.vue:987`
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- The hydration reads `cost.data?.liters`
|
||||||
|
- The list endpoint returns raw SQLAlchemy objects; the `data` JSONB column **should** serialize correctly as a dict via FastAPI's `jsonable_encoder`
|
||||||
|
- However, if the `data` column is `None` or `{}` in the DB, `cost.data?.liters` returns `undefined`, and `|| null` gives `null`
|
||||||
|
- This should work for most cases, but there's a subtle issue: the `data` field on the SQLAlchemy model has `server_default="'{}'::jsonb"`, but if a row was inserted without going through SQLAlchemy (e.g., raw SQL), `data` could be `None`
|
||||||
|
|
||||||
|
**Fix approach:** Add defensive null-check: `(cost.data && cost.data.liters) ? cost.data.liters : null`
|
||||||
|
|
||||||
|
### 🟡 BUG #5 — `mainCategory` Watcher Clears `subCategory` on Hydration (MEDIUM)
|
||||||
|
|
||||||
|
**Location:** `CostEntryWizard.vue:818-819`
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- The hydration watcher runs and sets `form.mainCategory = String(cost.category_id || '')`
|
||||||
|
- Vue's reactivity triggers the `v-model` change on the `<select>` element
|
||||||
|
- This calls `@change="onMainCategoryChange"` which does:
|
||||||
|
```typescript
|
||||||
|
form.subCategory = '' // ← CLEARS whatever the hydration may try to set
|
||||||
|
subCategories.value = []
|
||||||
|
```
|
||||||
|
- Even if we fix BUG #1 to correctly populate subCategory, the `@change` handler would clear it
|
||||||
|
|
||||||
|
**Fix approach:** In the hydration watcher, after setting `mainCategory`, manually call `onMainCategoryChange()` (which populates `subCategories`), and THEN set `form.subCategory` to the correct leaf ID. The order must be: set mainCategory → populate subCategories → set subCategory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BACKEND │
|
||||||
|
│ │
|
||||||
|
│ GET /expenses?organization_id=X │
|
||||||
|
│ └→ returns: [AssetCost ORM objects] │
|
||||||
|
│ fields: id, asset_id, category_id(leaf!), service_provider_id, │
|
||||||
|
│ amount_gross, date, data{liters, net_amount, ...}, │
|
||||||
|
│ external_vendor_name, invoice_date, ... │
|
||||||
|
│ │
|
||||||
|
│ MISSING: parent_category_id, category_name, vendor_name, │
|
||||||
|
│ vehicle_name, license_plate │
|
||||||
|
│ │
|
||||||
|
│ There is NO GET /expenses/by-id/{id} endpoint! │
|
||||||
|
└──────────────────────┬──────────────────────────────────────────────┘
|
||||||
|
│ raw ORM objects (FastAPI jsonable_encoder)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONTEND │
|
||||||
|
│ │
|
||||||
|
│ CostsView.vue │
|
||||||
|
│ └→ openEditWizard(cost) │
|
||||||
|
│ editCostData.value = cost ← raw list item │
|
||||||
|
│ editVehicle.value = store.find(v => v.id === cost.asset_id) │
|
||||||
|
│ │
|
||||||
|
│ CostEntryWizard.vue │
|
||||||
|
│ └→ watch(editCost): │
|
||||||
|
│ form.mainCategory = String(cost.category_id) ← BUG: leaf ID │
|
||||||
|
│ form.subCategory = String(cost.sub_category_id) ← BUG: undef │
|
||||||
|
│ form.liters = cost.data?.liters │
|
||||||
|
│ selectedProvider = ??? ← BUG: never │
|
||||||
|
│ selectedVehicleId = ??? ← BUG: never │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Recommended Fix Plan
|
||||||
|
|
||||||
|
### Phase 1: Backend (Minimal — No Schema Changes)
|
||||||
|
|
||||||
|
1. **Create `GET /expenses/by-id/{expense_id}` endpoint** in `expenses.py` that:
|
||||||
|
- Fetches a single `AssetCost` by ID
|
||||||
|
- JOINs `CostCategory` to resolve `category_name`, `category_code`, `parent_id`
|
||||||
|
- JOINs `ServiceProvider` to resolve provider name
|
||||||
|
- Returns the data using `AssetCostResponse` schema (already defined!)
|
||||||
|
- Includes `parent_category_id` in the response for the two-level dropdown
|
||||||
|
|
||||||
|
2. **Optionally, enrich the list endpoint** to return `parent_category_id` by joining `CostCategory.parent_id`, but this is less critical if the single-expense endpoint is used for edit hydration.
|
||||||
|
|
||||||
|
### Phase 2: Frontend (Edit Path)
|
||||||
|
|
||||||
|
1. **In `CostsView.vue` `openEditWizard()`:** fetch the full expense detail from the new `GET /expenses/by-id/{id}` endpoint instead of passing the raw list object.
|
||||||
|
|
||||||
|
2. **In `CostEntryWizard.vue` hydration watcher:**
|
||||||
|
- Split `category_id` into `mainCategory` (parent) and `subCategory` (leaf) using `window.__allCostCategories`
|
||||||
|
- Call `onMainCategoryChange()` after setting `mainCategory` to populate subcategories, then set `subCategory`
|
||||||
|
- Set `selectedProvider.value` from `cost.service_provider_id` + resolved provider name
|
||||||
|
- Set `selectedVehicleId.value` from `cost.asset_id` when vehicle prop is null
|
||||||
|
- Defensive null-check on `cost.data?.liters`
|
||||||
|
|
||||||
|
### Phase 3: Edge Cases
|
||||||
|
|
||||||
|
1. When `service_provider_id` is null but `external_vendor_name` is set, populate the provider autocomplete with the free-text name
|
||||||
|
2. Handle the case where `vendor_organization_id` points to a B2B org (different from `service_provider_id`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Files That Need Changes
|
||||||
|
|
||||||
|
| File | Change Type | Priority |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| `backend/app/api/v1/endpoints/expenses.py` | Add `GET /expenses/by-id/{id}` endpoint | P0 |
|
||||||
|
| `backend/app/schemas/asset_cost.py` | Add `parent_category_id` to `AssetCostResponse` | P0 |
|
||||||
|
| `frontend_app/src/views/costs/CostsView.vue` | Fetch full expense detail on edit | P0 |
|
||||||
|
| `frontend_app/src/components/cost/CostEntryWizard.vue` | Fix category split, provider hydration, vehicle selection | P0 |
|
||||||
|
| `frontend_app/src/stores/cost.ts` | Add `fetchExpenseById()` action (optional, if store-based) | P1 |
|
||||||
193
docs/p0_provider_ingestion_gamification_audit_report.md
Normal file
193
docs/p0_provider_ingestion_gamification_audit_report.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# 🔍 P0 DISCOVERY — Provider Ingestion & Gamification Workflow Audit Report
|
||||||
|
|
||||||
|
**Dátum:** 2026-07-09
|
||||||
|
**Auditor:** Service Finder Rendszer-Architect
|
||||||
|
**Verzió:** 1.0
|
||||||
|
**Mód:** READ-ONLY — NO FILES MODIFIED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Executive Summary
|
||||||
|
|
||||||
|
Ez az audit a teljes Provider Ingestion & Gamification Workflow-t vizsgálja. A vizsgálat 4 audit területet fed le.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 🟢 AUDIT: Deduplication & Matching — STÁTUSZ: READY
|
||||||
|
|
||||||
|
### READY ✅
|
||||||
|
|
||||||
|
| Komponens | Fájl | Státusz |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Provider name exact match (ILIKE) | `backend/app/services/provider_service.py` (line 125) | ✅ READY |
|
||||||
|
| Provider name fuzzy match (pg_trgm, threshold 0.6) | `backend/app/services/provider_service.py` (line 163) | ✅ READY |
|
||||||
|
| find_or_create_provider_by_name() returns (provider, created) tuple | `backend/app/services/provider_service.py` (line 125) | ✅ READY |
|
||||||
|
| Address dedup (postal_code_id + street_name + house_number) | `backend/app/services/address_manager.py` (line 145) | ✅ READY |
|
||||||
|
| Union ALL search (3 sources) | `backend/app/services/provider_service.py` (line 215) | ✅ READY |
|
||||||
|
|
||||||
|
### PARTIAL 🟡
|
||||||
|
|
||||||
|
| Komponens | Státusz |
|
||||||
|
|-----------|---------|
|
||||||
|
| Tax number dedup for providers | 🟡 PARTIAL — Only exists for Organizations, not ServiceProviders |
|
||||||
|
| Combined matching (name + address + tax_number) | 🟡 PARTIAL — Name-only dedup exists |
|
||||||
|
|
||||||
|
### MISSING ❌
|
||||||
|
|
||||||
|
| Komponens | Státusz |
|
||||||
|
|-----------|---------|
|
||||||
|
| Dedup by address for providers | ❌ MISSING — Name-only matching, no geo/address dedup for ServiceProvider |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 🟢 AUDIT: Gamification & Rules Engine — STÁTUSZ: PARTIAL
|
||||||
|
|
||||||
|
### READY ✅
|
||||||
|
|
||||||
|
| Komponens | Fájl | Státusz |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Dynamic point rules (26 rules in DB) | `gamification.point_rules` table | ✅ READY |
|
||||||
|
| GamificationService.process_activity() | `backend/app/services/gamification_service.py` (line 74) | ✅ READY |
|
||||||
|
| Penalty system (4 levels L0-L3) | `backend/app/services/gamification_service.py` (line 226) | ✅ READY |
|
||||||
|
| Level-up logic | `backend/app/services/gamification_service.py` (line 258) | ✅ READY |
|
||||||
|
| Social points → credits conversion | `backend/app/services/gamification_service.py` (line 265) | ✅ READY |
|
||||||
|
| ADD_NEW_PROVIDER (500 XP) awarded | `backend/app/services/provider_service.py` (line 63) | ✅ READY |
|
||||||
|
| PROVIDER_DISCOVERY (300 XP) awarded in expense hook | `backend/app/api/v1/endpoints/expenses.py` (line 568) | ✅ READY |
|
||||||
|
| Admin approve → ADD_NEW_PROVIDER XP | `backend/app/api/v1/endpoints/admin_providers.py` (line 729) | ✅ READY |
|
||||||
|
| Admin gamification API endpoints (full CRUD) | `backend/app/api/v1/endpoints/admin_gamification.py` | ✅ READY |
|
||||||
|
| User-facing gamification endpoints | `backend/app/api/v1/endpoints/gamification.py` | ✅ READY |
|
||||||
|
|
||||||
|
### PARTIAL 🟡
|
||||||
|
|
||||||
|
| Komponens | Státusz |
|
||||||
|
|-----------|---------|
|
||||||
|
| PROVIDER_CONFIRMATION (150 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||||
|
| PROVIDER_VERIFIED_USE (100 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||||
|
| USE_UNVERIFIED_PROVIDER (200 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||||
|
| RATE_PROVIDER (250 XP) | 🟡 Defined in DB but NEVER awarded |
|
||||||
|
| Subscription-level gamification multiplier | 🟡 No field exists in subscription_tiers |
|
||||||
|
|
||||||
|
### MISSING ❌
|
||||||
|
|
||||||
|
| Komponens | Státusz |
|
||||||
|
|-----------|---------|
|
||||||
|
| `social_service.py` hardcoded XP (50) — `process_activity(db, user_id, 50, 10, ...)` should be dynamic | ❌ MISSING |
|
||||||
|
| `provider_validations` table — Masterbook spec, NEVER created | ❌ MISSING |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 🟢 AUDIT: AI / Invoice OCR — STÁTUSZ: PARTIAL
|
||||||
|
|
||||||
|
### READY ✅
|
||||||
|
|
||||||
|
| Komponens | Fájl | Státusz |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| OCR Robot (OCRRobot class) | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 19) | ✅ READY |
|
||||||
|
| AIService.process_ocr_document() | `backend/app/services/ai_service.py` (line 174) | ✅ READY |
|
||||||
|
| Vision model support (Llama Vision) | `backend/app/services/ai_service.py` (line 48) | ✅ READY |
|
||||||
|
| Fallback AI (Gemini/Groq) | `backend/app/services/ai_service.py` (line 81) | ✅ READY |
|
||||||
|
| Trust Matching by tax_number | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 101) | ✅ READY |
|
||||||
|
| OCR save to Document.ocr_data (JSONB) | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 122) | ✅ READY |
|
||||||
|
| PREMIUM/VIP user filtering | `backend/app/workers/ocr/robot_1_ocr_processor.py` (line 42) | ✅ READY |
|
||||||
|
|
||||||
|
### MISSING ❌
|
||||||
|
|
||||||
|
| Komponens | Státusz |
|
||||||
|
|-----------|---------|
|
||||||
|
| AI prompt templates for invoice extraction (ai_prompt_ocr_*) | ❌ KRITIKUS — 0 rows in system_parameters |
|
||||||
|
| OCR → service_staging pipeline | ❌ KRITIKUS — OCR saves to Document.ocr_data only, NEVER feeds into staging |
|
||||||
|
| OCR → gamification hook | ❌ MISSING — No XP awarded for OCR-based provider discovery |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🟢 AUDIT: Admin Configuration UI — STÁTUSZ: READY
|
||||||
|
|
||||||
|
### READY ✅
|
||||||
|
|
||||||
|
| Komponens | Fájl | Státusz |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Providers list page (stats, filters, search, pagination) | `frontend_admin/pages/providers/index.vue` | ✅ READY |
|
||||||
|
| Providers pending queue (approve/reject) | `frontend_admin/pages/providers/pending.vue` | ✅ READY |
|
||||||
|
| Providers rejected queue (restore) | `frontend_admin/pages/providers/rejected.vue` | ✅ READY |
|
||||||
|
| Point rules CRUD (inline editing) | `frontend_admin/pages/gamification/point-rules.vue` | ✅ READY |
|
||||||
|
| Master config editor (XP, penalty, conversion) | `frontend_admin/pages/gamification/config.vue` | ✅ READY |
|
||||||
|
| Validation rules editor | `frontend_admin/pages/gamification/validation-rules.vue` | ✅ READY |
|
||||||
|
| System parameters editor | `frontend_admin/pages/gamification/parameters.vue` | ✅ READY |
|
||||||
|
| Badges, Competitions, Leaderboard, Levels, Seasons, Users | `frontend_admin/pages/gamification/` | ✅ READY |
|
||||||
|
| Admin Provider API (full endpoint suite) | `backend/app/api/v1/endpoints/admin_providers.py` | ✅ READY |
|
||||||
|
| Promotion Engine (staging → live) | `backend/app/api/v1/endpoints/admin_providers.py` (line 1399) | ✅ READY |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 📊 END-TO-END WORKFLOW STATUS MATRIX
|
||||||
|
|
||||||
|
```
|
||||||
|
User uploads invoice
|
||||||
|
│
|
||||||
|
├── ✅ Step 1: OCR document created (pending_ocr)
|
||||||
|
│
|
||||||
|
├── ❌ Step 2: AI prompt templates exist? → NEM (0 rows in system_parameters)
|
||||||
|
│ └── ⚠️ OCR Robot uses fallback prompt
|
||||||
|
│
|
||||||
|
├── 🟡 Step 3: AIService extracts provider data
|
||||||
|
│ ├── ✅ Trust Matching by tax_number
|
||||||
|
│ └── ❌ NO mapping to service_staging or service_providers
|
||||||
|
│
|
||||||
|
├── ❌ Step 4: OCR output → service_staging → NINCS KAPCSOLAT
|
||||||
|
│
|
||||||
|
├── ✅ Step 5: User creates expense with external_vendor_name
|
||||||
|
│ └── ✅ PROVIDER_DISCOVERY hook fires (300 XP)
|
||||||
|
│
|
||||||
|
├── 🟡 Step 6: Provider status-based XP
|
||||||
|
│ ├── ✅ ADD_NEW_PROVIDER (500 XP)
|
||||||
|
│ ├── 🟡 PROVIDER_DISCOVERY (300 XP) — only for new providers
|
||||||
|
│ ├── ❌ PROVIDER_CONFIRMATION (150 XP) — NEVER
|
||||||
|
│ ├── ❌ PROVIDER_VERIFIED_USE (100 XP) — NEVER
|
||||||
|
│ ├── ❌ USE_UNVERIFIED_PROVIDER (200 XP) — NEVER
|
||||||
|
│ └── ❌ RATE_PROVIDER (250 XP) — NEVER
|
||||||
|
│
|
||||||
|
├── ✅ Step 7: Admin moderation (approve/reject/flag/restore)
|
||||||
|
│ └── ✅ Promotion Engine (staging → live)
|
||||||
|
│
|
||||||
|
├── ✅ Step 8: Admin UI for all gamification config
|
||||||
|
│
|
||||||
|
└── ❌ Step 9: Subscription-tier multiplier → NINCS
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 🔴 KRITIKUS HIÁNYOSSÁGOK (P0 Priority)
|
||||||
|
|
||||||
|
| # | Hiányosság | Hatás |
|
||||||
|
|---|-----------|-------|
|
||||||
|
| **P0-1** | AI prompt sablonok hiányoznak (`ai_prompt_ocr_*`) | OCR Robot nem tud pontos adatot kinyerni |
|
||||||
|
| **P0-2** | OCR → service_staging pipeline hiányzik | OCR-ből kinyert provider adatok NEM kerülnek moderációs várólistába |
|
||||||
|
| **P0-3** | 5 pontszabály sosem kerül kiosztásra | PROVIDER_CONFIRMATION, PROVIDER_VERIFIED_USE, USE_UNVERIFIED_PROVIDER, RATE_PROVIDER |
|
||||||
|
| **P0-4** | Subscription tiers-ben nincs gamification multiplier | Előfizetési csomag nem befolyásolja a pontszerzést |
|
||||||
|
| **P0-5** | `social_service.py` hardcoded 50 XP | Nem a dinamikus pontszabályt használja |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 🟡 JAVASLATOK (P1 Priority)
|
||||||
|
|
||||||
|
| # | Javaslat |
|
||||||
|
|---|----------|
|
||||||
|
| P1-1 | AI prompt sablonok seedelése (`ai_prompt_ocr_invoice`, `ai_prompt_ocr_provider`) |
|
||||||
|
| P1-2 | OCR → staging pipeline megépítése |
|
||||||
|
| P1-3 | Pontszabályok bekötése a `find_or_create_provider_by_name()`-be |
|
||||||
|
| P1-4 | RATE_PROVIDER bekötése a `vote_for_provider()` metódusba |
|
||||||
|
| P1-5 | Subscription-tier multiplier hozzáadása a `rules` JSONB-hez |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 📂 Korábbi Auditok Állapota
|
||||||
|
|
||||||
|
| Audit | Dátum | Státusz most |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| `p0_discovery_provider_validation_audit.md` | 2026-07-07 | Több GAP (3 párhuzamos scoring rendszer) továbbra is fennáll |
|
||||||
|
| `p0_service_provider_gamification_audit_report.md` | 2026-06-30 | CRITICAL GAP (admin_providers.py hiányzott) → **MEGOLDVA** (implementálva 2026-07-07) |
|
||||||
|
| `gamification_provider_connection_analysis.md` | 2026-06-30 | 5 pontszabály nem volt bekötve → PROVIDER_DISCOVERY **MEGOLDVA**, a többi 4 továbbra is hiányzik |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Report generated by Service Finder Rendszer-Architect (DeepSeek Reasoner)*
|
||||||
149
docs/p0_wallet_balance_500_bugfix_2026-07-26.md
Normal file
149
docs/p0_wallet_balance_500_bugfix_2026-07-26.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# P0 Bug #420: `GET /api/v1/billing/wallet/balance` - 500 Internal Server Error
|
||||||
|
|
||||||
|
**Dátum:** 2026-07-26
|
||||||
|
**Súlyosság:** P0 - Blokkoló (minden wallet/balance hívást érint)
|
||||||
|
**Státusz:** Megoldva (pycache purge + restart)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hiba Leírása
|
||||||
|
|
||||||
|
A `GET /api/v1/billing/wallet/balance` végpont `500 Internal Server Error` hibával tért vissza bizonyos meglévő felhasználók (pl. user_id=86, 28) esetén.
|
||||||
|
|
||||||
|
### Hibaüzenet a logból:
|
||||||
|
```
|
||||||
|
2026-07-26 12:37:49,388 [ERROR] app.api.v1.endpoints.billing: Pénztárca egyenleg lekérdezési hiba: 'FinancialLedger' object has no attribute 'description'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Érintett fájlok:
|
||||||
|
- [`backend/app/models/system/audit.py:78`](backend/app/models/system/audit.py:78) — `FinancialLedger` SQLAlchemy modell
|
||||||
|
- [`backend/app/services/billing_engine.py:1078`](backend/app/services/billing_engine.py:1078) — `get_user_balance()`
|
||||||
|
- [`backend/app/services/billing_engine.py:598`](backend/app/services/billing_engine.py:598) — `get_wallet_summary()`
|
||||||
|
- [`backend/app/api/v1/endpoints/billing.py:298`](backend/app/api/v1/endpoints/billing.py:298) — `get_wallet_balance()` endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Root Cause Analysis
|
||||||
|
|
||||||
|
### 2.1 A FinancialLedger modell
|
||||||
|
|
||||||
|
A [`FinancialLedger`](backend/app/models/system/audit.py:78) modell **NEM rendelkezik `description` oszloppal**. Az egyetlen "leíró" mező a `details` (JSONB). A modell oszlopai:
|
||||||
|
|
||||||
|
```
|
||||||
|
id, user_id, person_id, amount, currency, transaction_type,
|
||||||
|
related_agent_id, details, created_at, entry_type, balance_after,
|
||||||
|
wallet_type, issuer_id, invoice_status, tax_amount, gross_amount,
|
||||||
|
net_amount, transaction_id, status
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 A jelenlegi forráskód helyes
|
||||||
|
|
||||||
|
A [`billing_engine.py`](backend/app/services/billing_engine.py:542) `get_transaction_history()` metódusa helyesen használja a `details` JSONB mezőt a leírás kinyerésére:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Line 577-579
|
||||||
|
desc = None
|
||||||
|
if entry.details and isinstance(entry.details, dict):
|
||||||
|
desc = entry.details.get("description") or entry.details.get("desc") or None
|
||||||
|
```
|
||||||
|
|
||||||
|
A [`billing.py`](backend/app/api/v1/endpoints/billing.py:373) router szintén helyesen:
|
||||||
|
```python
|
||||||
|
# Line 376-378
|
||||||
|
description = ""
|
||||||
|
if entry.details and isinstance(entry.details, dict):
|
||||||
|
description = entry.details.get("description", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 A tényleges ok: Stale Python Bytecode Cache
|
||||||
|
|
||||||
|
Az MD5 ellenőrzés igazolta, hogy a konténerben futó forráskód és a host-on lévő forráskód **azonos**:
|
||||||
|
|
||||||
|
| Fájl | MD5 |
|
||||||
|
|------|-----|
|
||||||
|
| `billing.py` (host & container) | `6b00968c129376e3db630e266835261b` |
|
||||||
|
| `billing_engine.py` (host & container) | `54d86868aa077bebfeddaf32c69642fc` |
|
||||||
|
|
||||||
|
A konténerben azonban régi `.pyc` bytecode fájlok voltak cache-elve:
|
||||||
|
```
|
||||||
|
/app/app/api/v1/endpoints/__pycache__/billing.cpython-312.pyc
|
||||||
|
/app/app/services/__pycache__/billing_engine.cpython-312.pyc
|
||||||
|
```
|
||||||
|
|
||||||
|
Ezek a `.pyc` fájlok egy **korábbi forráskód-verzióhoz** tartoztak, ahol még létezett `entry.description` hivatkozás a `FinancialLedger` objektumokon (vagy egy másik séma érvényben volt).
|
||||||
|
|
||||||
|
### 2.4 Miért nem frissültek a .pyc fájlok?
|
||||||
|
|
||||||
|
A `docker-compose.yml`-ben a `sf_api` konténer forráskódja volume mount-tal van csatolva (nem COPY). Amikor a forráskód változik a host-on, a Python értelmező a konténerben azonnal látja az új `.py` fájlokat, de a már legenerált `.pyc` cache fájlok megmaradnak. Ha a `.py` fájl timestamp-je régebbi, mint a `.pyc`-é (vagy a Python nem érzékeli a változást), a régi bytecode fut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Megoldás
|
||||||
|
|
||||||
|
### Végrehajtott lépések:
|
||||||
|
|
||||||
|
1. **Pycache törlése a konténerben:**
|
||||||
|
```bash
|
||||||
|
docker compose exec sf_api find /app -type d -name "__pycache__" -exec rm -rf {} +
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Konténer újraindítása:**
|
||||||
|
```bash
|
||||||
|
docker compose restart sf_api
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verifikáció:** A restart után az endpoint már NEM dob 500-as hibát. A logokban nem jelenik meg több `description` hiba.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Code Mode Blueprint (Preventív Intézkedés)
|
||||||
|
|
||||||
|
Bár a jelenlegi forráskód helyes, az alábbi preventív intézkedést javaslom a `Code Mode`-ban:
|
||||||
|
|
||||||
|
### 4.1 Docker Compose: pycache purge a startup előtt
|
||||||
|
|
||||||
|
A `docker-compose.yml` `sf_api` szolgáltatás `command` sorában futtassunk egy automatikus pycache purge-öt a Uvicorn indítása előtt:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
command: >
|
||||||
|
/bin/sh -c "
|
||||||
|
find /app -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null;
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Opcionális: `.pyc` fájlok teljes letiltása fejlesztői környezetben
|
||||||
|
|
||||||
|
A `docker-compose.yml` environment változói közé:
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- PYTHONDONTWRITEBYTECODE=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ez megakadályozza, hogy a Python egyáltalán `.pyc` fájlokat generáljon, így mindig a forráskódból fog futni.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Függelék: Mermaid Folyamatábra
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Client
|
||||||
|
participant FastAPI as billing.py router
|
||||||
|
participant BillingEngine as billing_engine.py
|
||||||
|
participant Model as FinancialLedger
|
||||||
|
participant DB as PostgreSQL
|
||||||
|
|
||||||
|
Client->>FastAPI: GET /api/v1/billing/wallet/balance
|
||||||
|
FastAPI->>BillingEngine: get_user_balance(db, user_id)
|
||||||
|
BillingEngine->>BillingEngine: get_wallet_summary(db, user_id)
|
||||||
|
BillingEngine->>DB: SELECT * FROM identity.wallets WHERE user_id=?
|
||||||
|
BillingEngine->>DB: SELECT * FROM audit.financial_ledger LIMIT 10
|
||||||
|
BillingEngine->>Model: entry.details.get("description")
|
||||||
|
Note over Model: Helyes: details JSONB mezőből olvas
|
||||||
|
Model-->>BillingEngine: description string
|
||||||
|
BillingEngine-->>FastAPI: {"earned": X, "purchased": Y, ...}
|
||||||
|
FastAPI-->>Client: 200 OK + JSON balances
|
||||||
|
```
|
||||||
|
|
||||||
|
**A hiba idején** a `.pyc` bytecode `entry.description`-t próbált elérni, ami nem létező attribútum a `FinancialLedger` modellen. Ez `AttributeError`-t okozott, amit a `billing.py` router `except Exception` ága elkapott és 500-ra fordított.
|
||||||
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*
|
||||||
6
frontend_admin/.nuxt/components.d.ts
vendored
6
frontend_admin/.nuxt/components.d.ts
vendored
@@ -15,6 +15,9 @@ type LazyComponent<T> = DefineComponent<HydrationStrategies, {}, {}, {}, {}, {},
|
|||||||
|
|
||||||
|
|
||||||
export const LanguageSwitcher: typeof import("../components/LanguageSwitcher.vue")['default']
|
export const LanguageSwitcher: typeof import("../components/LanguageSwitcher.vue")['default']
|
||||||
|
export const RawDataViewer: typeof import("../components/RawDataViewer.vue")['default']
|
||||||
|
export const SharedAddressForm: typeof import("../components/SharedAddressForm.vue")['default']
|
||||||
|
export const SmartLocationInput: typeof import("../components/SmartLocationInput.vue")['default']
|
||||||
export const TreeNode: typeof import("../components/TreeNode.vue")['default']
|
export const TreeNode: typeof import("../components/TreeNode.vue")['default']
|
||||||
export const ChartsUserTrendChart: typeof import("../components/charts/UserTrendChart.vue")['default']
|
export const ChartsUserTrendChart: typeof import("../components/charts/UserTrendChart.vue")['default']
|
||||||
export const GaragesGarageFleetTab: typeof import("../components/garages/GarageFleetTab.vue")['default']
|
export const GaragesGarageFleetTab: typeof import("../components/garages/GarageFleetTab.vue")['default']
|
||||||
@@ -46,6 +49,9 @@ export const Html: typeof import("../node_modules/nuxt/dist/head/runtime/compone
|
|||||||
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
|
export const Body: typeof import("../node_modules/nuxt/dist/head/runtime/components")['Body']
|
||||||
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
|
export const NuxtIsland: typeof import("../node_modules/nuxt/dist/app/components/nuxt-island")['default']
|
||||||
export const LazyLanguageSwitcher: LazyComponent<typeof import("../components/LanguageSwitcher.vue")['default']>
|
export const LazyLanguageSwitcher: LazyComponent<typeof import("../components/LanguageSwitcher.vue")['default']>
|
||||||
|
export const LazyRawDataViewer: LazyComponent<typeof import("../components/RawDataViewer.vue")['default']>
|
||||||
|
export const LazySharedAddressForm: LazyComponent<typeof import("../components/SharedAddressForm.vue")['default']>
|
||||||
|
export const LazySmartLocationInput: LazyComponent<typeof import("../components/SmartLocationInput.vue")['default']>
|
||||||
export const LazyTreeNode: LazyComponent<typeof import("../components/TreeNode.vue")['default']>
|
export const LazyTreeNode: LazyComponent<typeof import("../components/TreeNode.vue")['default']>
|
||||||
export const LazyChartsUserTrendChart: LazyComponent<typeof import("../components/charts/UserTrendChart.vue")['default']>
|
export const LazyChartsUserTrendChart: LazyComponent<typeof import("../components/charts/UserTrendChart.vue")['default']>
|
||||||
export const LazyGaragesGarageFleetTab: LazyComponent<typeof import("../components/garages/GarageFleetTab.vue")['default']>
|
export const LazyGaragesGarageFleetTab: LazyComponent<typeof import("../components/garages/GarageFleetTab.vue")['default']>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 +0,0 @@
|
|||||||
const t={common:{loading:{t:0,b:{t:2,i:[{t:3}],s:"Načítavanie..."}},saving:{t:0,b:{t:2,i:[{t:3}],s:"Ukladanie..."}},error:{t:0,b:{t:2,i:[{t:3}],s:"Došlo k chybe"}},retry:{t:0,b:{t:2,i:[{t:3}],s:"Opakovať"}},save:{t:0,b:{t:2,i:[{t:3}],s:"Uložiť"}},saving_data:{t:0,b:{t:2,i:[{t:3}],s:"Ukladanie údajov..."}},cancel:{t:0,b:{t:2,i:[{t:3}],s:"Zrušiť"}},discard:{t:0,b:{t:2,i:[{t:3}],s:"Zahodiť"}},delete:{t:0,b:{t:2,i:[{t:3}],s:"Zmazať"}},delete_confirm:{t:0,b:{t:2,i:[{t:3}],s:"Naozaj chcete zmazať túto položku?"}},delete_btn:{t:0,b:{t:2,i:[{t:3}],s:"Zmazať"}},delete_title:{t:0,b:{t:2,i:[{t:3}],s:"Potvrdiť Zmazanie"}},deleted:{t:0,b:{t:2,i:[{t:3}],s:"Úspešne zmazané"}},edit:{t:0,b:{t:2,i:[{t:3}],s:"Upraviť"}},edit_title:{t:0,b:{t:2,i:[{t:3}],s:"Upraviť Položku"}},create:{t:0,b:{t:2,i:[{t:3}],s:"Vytvoriť"}},create_btn:{t:0,b:{t:2,i:[{t:3}],s:"Vytvoriť"}},create_title:{t:0,b:{t:2,i:[{t:3}],s:"Vytvoriť Nové"}},create_first:{t:0,b:{t:2,i:[{t:3}],s:"Vytvorte svoju prvú položku pre začiatok"}},search:{t:0,b:{t:2,i:[{t:3}],s:"Hľadať"}},search_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Hľadať..."}},filter:{t:0,b:{t:2,i:[{t:3}],s:"Filter"}},no_results:{t:0,b:{t:2,i:[{t:3}],s:"Nenašli sa žiadne výsledky"}},no_results_hint:{t:0,b:{t:2,i:[{t:3}],s:"Skúste upraviť kritériá vyhľadávania alebo filtra"}},no_items:{t:0,b:{t:2,i:[{t:3}],s:"Žiadne položky k dispozícii"}},actions:{t:0,b:{t:2,i:[{t:3}],s:"Akcie"}},status:{t:0,b:{t:2,i:[{t:3}],s:"Stav"}},active:{t:0,b:{t:2,i:[{t:3}],s:"Aktívny"}},inactive:{t:0,b:{t:2,i:[{t:3}],s:"Neaktívny"}},activate:{t:0,b:{t:2,i:[{t:3}],s:"Aktivovať"}},deactivate:{t:0,b:{t:2,i:[{t:3}],s:"Deaktivovať"}},name:{t:0,b:{t:2,i:[{t:3}],s:"Názov"}},name_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Zadajte názov"}},description:{t:0,b:{t:2,i:[{t:3}],s:"Popis"}},desc_placeholder:{t:0,b:{t:2,i:[{t:3}],s:"Zadajte popis"}},details:{t:0,b:{t:2,i:[{t:3}],s:"Detaily"}},email:{t:0,b:{t:2,i:[{t:3}],s:"Email"}},id:{t:0,b:{t:2,i:[{t:3}],s:"ID"}},type:{t:0,b:{t:2,i:[{t:3}],s:"Typ"}},type_corporate:{t:0,b:{t:2,i:[{t:3}],s:"Firemný"}},view:{t:0,b:{t:2,i:[{t:3}],s:"Zobraziť"}},updated:{t:0,b:{t:2,i:[{t:3}],s:"Úspešne aktualizované"}},save_error:{t:0,b:{t:2,i:[{t:3}],s:"Uloženie zlyhalo"}},invalid_json:{t:0,b:{t:2,i:[{t:3}],s:"Neplatný formát JSON"}},prev:{t:0,b:{t:2,i:[{t:3}],s:"Predchádzajúci"}},next:{t:0,b:{t:2,i:[{t:3}],s:"Ďalší"}},none:{t:0,b:{t:2,i:[{t:3}],s:"Žiadne"}},reset:{t:0,b:{t:2,i:[{t:3}],s:"Reset"}},confirm:{t:0,b:{t:2,i:[{t:3}],s:"Potvrdiť"}},close:{t:0,b:{t:2,i:[{t:3}],s:"Zavrieť"}},back:{t:0,b:{t:2,i:[{t:3}],s:"Späť"}},continue:{t:0,b:{t:2,i:[{t:3}],s:"Pokračovať"}},exit:{t:0,b:{t:2,i:[{t:3}],s:"Ukončiť"}},all:{t:0,b:{t:2,i:[{t:3}],s:"Všetky"}},select_all:{t:0,b:{t:2,i:[{t:3}],s:"Vybrať Všetky"}},deselect_all:{t:0,b:{t:2,i:[{t:3}],s:"Odznačiť Všetky"}},expires:{t:0,b:{t:2,i:[{t:3}],s:"Vyprší"}},created:{t:0,b:{t:2,i:[{t:3}],s:"Vytvorené"}},start_date:{t:0,b:{t:2,i:[{t:3}],s:"Dátum Začiatku"}},end_date:{t:0,b:{t:2,i:[{t:3}],s:"Dátum Ukončenia"}},points:{t:0,b:{t:2,i:[{t:3}],s:"Body"}},level:{t:0,b:{t:2,i:[{t:3}],s:"Úroveň"}},user:{t:0,b:{t:2,i:[{t:3}],s:"Používateľ"}},user_id:{t:0,b:{t:2,i:[{t:3}],s:"ID Používateľa"}},services:{t:0,b:{t:2,i:[{t:3}],s:"Služby"}},discoveries:{t:0,b:{t:2,i:[{t:3}],s:"Objavy"}},restriction:{t:0,b:{t:2,i:[{t:3}],s:"Obmedzenie"}},restriction_mild:{t:0,b:{t:2,i:[{t:3}],s:"Mierne"}},restriction_moderate:{t:0,b:{t:2,i:[{t:3}],s:"Stredné"}},restriction_severe:{t:0,b:{t:2,i:[{t:3}],s:"Prísne"}},min_xp:{t:0,b:{t:2,i:[{t:3}],s:"Min XP"}},xp:{t:0,b:{t:2,i:[{t:3}],s:"XP"}},penalty:{t:0,b:{t:2,i:[{t:3}],s:"Trest"}},feature_flags:{t:0,b:{t:2,i:[{t:3}],s:"Funkcie"}},permission:{t:0,b:{t:2,i:[{t:3}],s:"Oprávnenie"}},role:{t:0,b:{t:2,i:[{t:3}],s:"Rola"}},domain:{t:0,b:{t:2,i:[{t:3}],s:"Doména"}},action:{t:0,b:{t:2,i:[{t:3}],s:"Akcia"}},value:{t:0,b:{t:2,i:[{t:3}],s:"Hodnota"}},key:{t:0,b:{t:2,i:[{t:3}],s:"Kľúč"}},enabled:{t:0,b:{t:2,i:[{t:3}],s:"Povolené"}},disabled:{t:0,b:{t:2,i:[{t:3}],s:"Zakázané"}},yes:{t:0,b:{t:2,i:[{t:3}],s:"Áno"}},no:{t:0,b:{t:2,i:[{t:3}],s:"Nie"}},on:{t:0,b:{t:2,i:[{t:3}],s:"Zapnuté"}},off:{t:0,b:{t:2,i:[{t:3}],s:"Vypnuté"}}}};export{t as default};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user