Compare commits
9 Commits
cbfb955580
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f28d3e70d | ||
|
|
c9118bf52f | ||
|
|
33c4d793db | ||
|
|
4594de6c11 | ||
|
|
2e0abc62a7 | ||
|
|
07b59032ce | ||
|
|
7654913d21 | ||
|
|
6e627d0ebe | ||
|
|
189cbfd7ca |
540
.roo/history.md
540
.roo/history.md
@@ -1,523 +1,49 @@
|
||||
# Service Finder Fejlesztési Történet
|
||||
### ✅ Verifikáció
|
||||
1. **Build ellenőrzés** → `npx vite build` sikeres (7.08s, 0 error)
|
||||
2. **Chunk generálva**: `CostEntryWizard-C_-R4OzC.js` (29.95 kB) - refaktorált komponens helyesen fordul
|
||||
3. **ProviderAutocomplete** újrafelhasználva mindhárom komponensben - nincs duplikáció
|
||||
|
||||
## 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.
|
||||
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
|
||||
|
||||
### 🔧 Eredmények
|
||||
**Dátum:** 2026-07-25
|
||||
|
||||
**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.
|
||||
## Fix: POST /api/v1/expenses/ — 500 Internal Server Error (PG ENUM vs varchar JOIN)
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/static/locales/en.json` - GAMIFICATION szekció hozzáadva (16 kulcs: SUBMIT_SERVICE, QUIZ, BADGE, SEASON, ACHIEVEMENTS)
|
||||
- `backend/static/locales/hu.json` - GAMIFICATION szekció hozzáadva (16 kulcs, magyar fordításokkal)
|
||||
**Dátum:** 2026-07-26
|
||||
|
||||
### ✅ Ellenőrzés
|
||||
- JSON szintaxis: ✅ Érvényes mindkét fájlban
|
||||
- i18n helper `t()` függvény: ✅ Minden kulcs lekérdezhető angolul és magyarul
|
||||
- Interpoláció: ✅ `{error}` helyőrzők működnek
|
||||
- Leaf key count: 16 = 16 (en/hu egyezés)
|
||||
### Probléma
|
||||
A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás) költség létrehozásakor.
|
||||
|
||||
## 2026-06-30 - [#344] Fázis 5/7: Backend API endpointok átalakítása - hardcoded stringek eltávolítása
|
||||
### 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.
|
||||
|
||||
### 🎯 Cél
|
||||
A `backend/app/api/v1/endpoints/gamification.py` végpontjaiban található összes hardcoded magyar és angol szöveg cseréje az i18n `t()` helper segítségével.
|
||||
### 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
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/app/api/v1/endpoints/gamification.py` - 14 helyen hardcoded string → `t("GAMIFICATION.xxx")` hívás; `from app.core.i18n import t` import hozzáadva
|
||||
- `backend/static/locales/hu.json` - +10 új GAMIFICATION kulcs (QUIZ.ALREADY_COMPLETED, QUIZ.COMPLETED_SUCCESS, QUIZ.QUESTION_NOT_FOUND, QUIZ.POINTS_FAILED, BADGE.AWARDED_SUCCESS, BADGE.POINTS_FAILED, SEASON.NO_ACTIVE, XP_*_DESC, QUIZ_*_DESC)
|
||||
- `backend/static/locales/en.json` - +10 új GAMIFICATION kulcs angol fordítással
|
||||
### Verifikáció
|
||||
- `POST /api/v1/expenses/` → **HTTP 201** ✅
|
||||
- Biztosítási költség sikeresen létrejött
|
||||
|
||||
### ✅ Ellenőrzés
|
||||
- Python import: ✅ `from app.api.v1.endpoints.gamification import router` sikeres
|
||||
- Szintaxis: ✅ Nincs hiba
|
||||
- i18n lefedettség: ✅ Minden korábbi hardcoded string i18n kulcsra cserélve
|
||||
- Interpoláció: ✅ `{error}`, `{badge_name}` helyőrzők működnek
|
||||
---
|
||||
|
||||
## 2026-06-23 - Cost Entry Wizard (CostEntryWizard.vue) - 4-lépéses Számla Űrlap
|
||||
## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
|
||||
|
||||
### 🎯 Cél
|
||||
4-lépéses stepper wizard építése részletes számla/invoice költségek rögzítésére a CostsActionsCard-ból indíthatóan.
|
||||
**Dátum:** 2026-07-25
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
### Probléma
|
||||
...
|
||||
|
||||
## 2026-06-24 - P0 Fix & Enhance: Permissions Page Data Flow, Editing & i18n
|
||||
|
||||
### 🎯 Cél
|
||||
Permissions oldal adatáramlás javítása, szerkesztés/mentés funkció bevezetése, és @nuxtjs/i18n telepítése/konfigurálása.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `frontend_admin/pages/permissions/index.vue` - Teljes átírás: client-side data fetch onMounted-ben, szerkeszthető toggle-ok (modifiedPermissions Map), PATCH mentés, toast notification, $t() hívások
|
||||
- `frontend_admin/nuxt.config.ts` - @nuxtjs/i18n modul hozzáadása, locales konfiguráció (hu/en)
|
||||
- `frontend_admin/i18n/locales/hu.json` - Magyar fordítási fájl (permissions oldal összes szövege)
|
||||
- `frontend_admin/i18n/locales/en.json` - Angol fordítási fájl
|
||||
|
||||
### ✅ Eredmények
|
||||
- Build: ✅ Sikeres (hu-DbjfOUfH.mjs, en-DnJ23n0t.mjs locale chunk-ok)
|
||||
- i18n modul: @nuxtjs/i18n v10.4.0 telepítve, no_prefix stratégiával
|
||||
- Szerkesztés: modified/original Map-ek, handleToggle, savePermissions (PATCH /admin/permissions/override/{org_id})
|
||||
- Adatmentés: success/error toast notification auto-clear-el
|
||||
|
||||
## 2026-06-24 - P0 Execution: Wire Packages UI to Real Database
|
||||
|
||||
### 🎯 Cél
|
||||
A frontend_admin Packages oldal (packages/index.vue) mock adatok helyett valós API adatbázisból töltse a SubscriptionTier csomagokat. "Új Csomag Létrehozása" gomb és modal hozzáadása.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/app/schemas/subscription.py` - `tier_level` és `feature_capabilities` mezők hozzáadva a SubscriptionTierResponse, SubscriptionTierCreate, SubscriptionTierUpdate modellekhez
|
||||
- `backend/app/api/v1/endpoints/admin_packages.py` - create/update végpontok frissítve az új mezők kezelésére
|
||||
- `frontend_admin/pages/packages/index.vue` - Teljes átírás: mock adatok eltávolítva, fetchPackages() API hívás, create/edit/delete modal-ok, loading/error/empty állapotok, i18n támogatás
|
||||
- `frontend_admin/locales/hu.json` - packages i18n kulcsok (magyar)
|
||||
- `frontend_admin/locales/en.json` - packages i18n kulcsok (angol)
|
||||
|
||||
## 2026-06-29 - #327 Gitea Kártya: Backend - Meglévő PATCH /admin/users/{id}/penalty endpoint javítása
|
||||
|
||||
### 🎯 Cél
|
||||
A #327-es Gitea kártya (Epic 8 Gamification 2.0, 4. implementációs lépés) feladata a meglévő `PATCH /admin/users/{user_id}/penalty` végpont hibáinak kijavítása. Az endpoint már refaktorálva volt GamificationService `process_activity()` használatára, de 3 hibát tartalmazott.
|
||||
|
||||
### 🔧 Javított hibák
|
||||
|
||||
**1. Hiányzó `import logging` az [`admin.py`](backend/app/api/v1/endpoints/admin.py)-ban**
|
||||
- `logger.error()` hívás volt a fájlban (483. sor), de `import logging` nem szerepelt
|
||||
- Javítás: `import logging` és `logger = logging.getLogger("admin-endpoints")` hozzáadva
|
||||
|
||||
**2. Undefined variable `admin` a `approve_action` endpointban**
|
||||
- 82. sor: `admin.id` helyett `current_user.id` (az `admin` változó sosem volt definiálva)
|
||||
|
||||
**3. Rossz [`SecurityAuditLog`](backend/app/models/system/audit.py:12) mezőnevek a penalty endpointban (464-471. sor)**
|
||||
- `user_id=current_user.id` → `actor_id=current_user.id`
|
||||
- `target_user_id=user_id` → `target_id=user_id`
|
||||
- `details="..."` → `payload_after={"penalty_points_added": ..., "reason": ...}` (JSON dict, nem string)
|
||||
- `ip_address="admin_api"` → eltávolítva (nem létező mező)
|
||||
- `payload_before={}` hozzáadva (kötelező mező)
|
||||
|
||||
### 🐛 Hibakeresési tapasztalat
|
||||
- A kezdeti 500-as hibaüzenet félrevezető volt: `'UserStats' object has no attribute 'level'` - valójában a `current_level` mező helyesen létezik
|
||||
- A valódi hiba a `SecurityAuditLog` létrehozásánál volt, de a `raise e` minta a [`process_activity()`](backend/app/services/gamification_service.py:174)-ben elnyelte a belső traceback-et
|
||||
- Stratégiai debug logging segítségével sikerült lokalizálni a valódi hibát
|
||||
- `.pyc` cache törlése és konténer újraindítás szükséges volt a friss kód betöltéséhez
|
||||
|
||||
### ✅ Eredmények
|
||||
- HTTP 200: `{"status":"success","message":"Gamification penalty applied to user 1","user_id":1,"penalty_points_added":100,"total_penalty_points":300,"restriction_level":1}`
|
||||
- Adatbázis: user 1 `penalty_points=300`, `restriction_level=1`
|
||||
- Audit log: `audit.security_audit_logs` bejegyzés `action="apply_gamification_penalty"`, `actor_id=2`, `target_id=1`
|
||||
- Sync Engine: ✅ 1284 OK, 0 Fixed, 0 Extra
|
||||
- Debug logging eltávolítva a [`gamification_service.py`](backend/app/services/gamification_service.py)-ból
|
||||
- Gitea #327: ✅ Lezárva
|
||||
|
||||
## 2026-06-29 - Gitea #328: Gamification Admin Integration - Bugfixes & Endpoint Verification
|
||||
|
||||
### 🎯 Cél
|
||||
Gitea #328 ("Backend: gamification.py endpointok Gami") kártya feladatainak befejezése: admin gamification végpontok hibáinak javítása és teljes körű tesztelése.
|
||||
|
||||
### 🔧 Javítások
|
||||
|
||||
**1. [`gamification.py`](backend/app/api/v1/endpoints/gamification.py) - submit-service végpont hibák:**
|
||||
- Hiányzó `logger` import hozzáadva (`import logging`, `logger = logging.getLogger("gamification-endpoints")`)
|
||||
- `metadata={...}` → `provided_fields={...}` javítás (a `UserContribution` modellben `provided_fields` a JSONB mező neve)
|
||||
- Hiányzó kötelező mezők hozzáadva: `action_type=1`, `earned_xp=submission_rewards.get("xp", 100)`
|
||||
|
||||
**2. [`admin_gamification.py`](backend/app/api/v1/endpoints/admin_gamification.py) - master-config végpont javítás:**
|
||||
- `system_service.get_setting()` → `system_service.get_scoped_parameter()` (a `SystemService`-ben nincs `get_setting` metódus)
|
||||
- `system_service.set_setting()` → `system_service.set_scoped_parameter()` ugyanezen okból
|
||||
|
||||
**3. [`system_service.py`](backend/app/services/system_service.py) - set_scoped_parameter upsert logika javítás:**
|
||||
- Az eredeti `ON CONFLICT ON CONSTRAINT uix_param_scope` nem működött `scope_id IS NULL` esetén, mert PostgreSQL-ben a UNIQUE constraint nem blokkolja a NULL értékű duplikátumokat (NULL != NULL)
|
||||
- Kétlépéses megközelítésre váltva: először megkeresi a meglévő rekordot, ha van frissíti, ha nincs beszúrja
|
||||
- Adatbázis: `system.system_parameters.scope_level` oszlop típusa átállítva custom PostgreSQL enum-ról `VARCHAR(50)`-re a SQLAlchemy String(50) modellel való kompatibilitás miatt
|
||||
|
||||
### ✅ Teszt Eredmények
|
||||
- **Admin Gamification végpontok:** 18/18 ✅ (GET/POST/PUT/DELETE point-rules, level-configs, badges, seasons, competitions, user-stats, master-config, system-params, points-ledger, contributions)
|
||||
- **Penalty/Reward végpontok:** ✅ POST `/admin/gamification/user-stats/{id}/penalty` és `/reward`
|
||||
- **Legacy PATCH `/admin/users/{id}/penalty`:** ✅ Továbbra is működik a GamificationService-en keresztül
|
||||
- **Sync Engine:** ✅ 1284 OK, 0 Fixed, 0 Extra
|
||||
- **Gitea #328:** ✅ Lezárva
|
||||
|
||||
## 2026-06-29 - #329 Frontend: Gamification menüpont + routing
|
||||
|
||||
### 🎯 Cél
|
||||
Gamification menüpont és routing létrehozása a Nuxt 3 admin frontendben.
|
||||
|
||||
### 📝 Megvalósítás
|
||||
- **`frontend_admin/layouts/default.vue`:** Gamification menücsoport beszúrása a sidebar `menuGroups` tömbjébe 6 menüelemmel (Játékmenet Beállítások, Események & Szezonok, Felhasználói Adatok, Gamification HQ, Rendszer Konfig, Rendszerparaméterek)
|
||||
- **`frontend_admin/pages/gamification/`:** 12 oldal létrehozása:
|
||||
- `index.vue` — Gamification HQ Dashboard (stat kártyák, gyors műveletek, pontnapló, top 5 ranglista)
|
||||
- `point-rules.vue`, `levels.vue`, `badges.vue` — Játékmenet beállítások
|
||||
- `seasons.vue`, `competitions.vue` — Események & Szezonok
|
||||
- `users/index.vue`, `users/[id].vue` — Felhasználói statisztikák
|
||||
- `leaderboard.vue`, `ledger.vue` — Ranglista és Pontnapló
|
||||
- `config.vue`, `parameters.vue` — Rendszer konfiguráció
|
||||
- **Routing:** Nuxt 3 file-based routing automatikusan generálja a route-okat
|
||||
- **Build:** Sikeres Nuxt build (Vite + Nitro), konténer újraindítva
|
||||
- **Gitea #329:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #330: Gamification Dashboard (Frontend Admin)
|
||||
|
||||
### 🎯 Cél
|
||||
Gamification HQ Dashboard oldal (`/gamification/index.vue`) elkészítése az admin felülethez, stat kártyákkal, gyors műveletekkel, pontnaplóval és top 5 ranglistával.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- **`backend/app/api/v1/endpoints/admin_gamification.py`:**
|
||||
- Új `GET /admin/gamification/dashboard-summary` végpont hozzáadva, ami összesíti: összes felhasználó, aktív szezon neve, függő hozzájárulások száma, mai XP
|
||||
- `list_points_ledger` végpont javítva: `user_name` mező hozzáadva (User.email JOIN-nel)
|
||||
- `datetime.utcnow()` → `datetime.now(timezone.utc)` deprecation fix
|
||||
- **`frontend_admin/pages/gamification/index.vue`:**
|
||||
- API hívás átirányítva a dedikált `/api/v1/admin/gamification/dashboard-summary` végpontra
|
||||
- Leaderboard adatok mapping-je kiegészítve: `username`/`current_level`/`total_xp` mezők támogatása a backend válaszhoz igazodva
|
||||
- **Sync Engine:** ✅ 1284 elem szinkronban, 0 hiba
|
||||
- **Gitea #330:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #331: Frontend Pontszabályok + Szintek CRUD oldal (admin)
|
||||
|
||||
### 🎯 Cél
|
||||
A Gamification admin felület Pontszabályok és Szintek CRUD oldalainak implementálása a Nuxt 3 admin frontendben.
|
||||
|
||||
### 🔧 Változtatások
|
||||
|
||||
- **`frontend_admin/pages/gamification/point-rules.vue`:**
|
||||
- Teljes CRUD implementáció: listázás, létrehozás, szerkesztés, soft-delete (is_active=False)
|
||||
- Adattábla: ID, action_key, points, description, is_active oszlopok
|
||||
- Create/Edit modal: action_key, points, description, is_active mezők validációval
|
||||
- Delete confirmation modal soft-delete-hez
|
||||
- 409 Conflict kezelés (duplikált action_key)
|
||||
- Toast értesítések minden művelethez
|
||||
- API végpont: `/api/v1/admin/gamification/point-rules`
|
||||
|
||||
- **`frontend_admin/pages/gamification/levels.vue`:**
|
||||
- Teljes CRUD implementáció: listázás, létrehozás, szerkesztés
|
||||
- Adattábla: level_number, rank_name, min_points, is_penalty oszlopok
|
||||
- Create/Edit modal: level_number, rank_name, min_points, is_penalty mezők validációval
|
||||
- Vizuális szint fa diagram progress bar-okkal
|
||||
- 409 Conflict kezelés (duplikált level_number)
|
||||
- Toast értesítések minden művelethez
|
||||
- API végpont: `/api/v1/admin/gamification/level-configs`
|
||||
|
||||
- **Build:** ✅ Sikeres Nuxt build a `sf_admin_frontend` konténerben
|
||||
- **Gitea #331:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #332: Frontend: Kitüntetések (Badge) kezelő oldal
|
||||
|
||||
### 🎯 Cél
|
||||
A Gamification admin felület Kitüntetések (Badge) CRUD oldalának implementálása a Nuxt 3 admin frontendben. A backend API végpontok már léteztek a [`admin_gamification.py`](backend/app/api/v1/endpoints/admin_gamification.py)-ban (GET/POST/PUT/DELETE `/admin/gamification/badges`).
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
|
||||
- **`frontend_admin/pages/gamification/badges.vue`:**
|
||||
- Teljes CRUD implementáció: listázás, létrehozás, szerkesztés, törlés
|
||||
- Adattábla: ID, Név, Leírás, Ikon (kép előnézettel), Műveletek oszlopok
|
||||
- Create/Edit modal: name (kötelező, max 100), description (kötelező, max 500), icon_url (opcionális URL élő előnézettel)
|
||||
- Delete confirmation modal: badge név és leírás megjelenítése
|
||||
- 409 Conflict kezelés (duplikált név)
|
||||
- Toast értesítések minden művelethez (siker/hiba)
|
||||
- Loading, error és empty állapotok kezelése
|
||||
- Auth middleware (`useAuth()`)
|
||||
- API végpont: `/api/v1/admin/gamification/badges`
|
||||
|
||||
### ✅ Eredmények
|
||||
- **GET /admin/gamification/badges:** ✅ 200 OK (2 meglévő badge listázva)
|
||||
- **POST /admin/gamification/badges:** ✅ 201 Created (új badge létrehozva egyedi névvel)
|
||||
- **PUT /admin/gamification/badges/{id}:** ✅ 200 Updated (badge adatok módosítva)
|
||||
- **DELETE /admin/gamification/badges/{id}:** ✅ 204 No Content (badge törölve, GET visszaadja a maradékot)
|
||||
- **Sync Engine:** ✅ Ellenőrizve
|
||||
- **Gitea #332:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #333: Frontend Szezonok + Versenyek kezelő oldal
|
||||
|
||||
### 🎯 Cél
|
||||
A gamification admin felület Szezonok (seasons) és Versenyek (competitions) kezelő oldalainak implementálása a meglévő backend API végpontokra építve.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- **`frontend_admin/pages/gamification/seasons.vue`** - Teljes CRUD implementáció: kártya nézet, create/edit modal, aktiválás megerősítéssel, státusz badge-ek (active/inactive), dátumtartomány megjelenítés
|
||||
- **`frontend_admin/pages/gamification/competitions.vue`** - Teljes CRUD implementáció: grid kártya nézet, szezon szűrő dropdown, create/edit modal JSON rules szerkesztővel, státusz badge-ek (draft/active/completed/cancelled)
|
||||
|
||||
### ✅ Eredmény
|
||||
- Build: ✅ Sikeres (Nuxt build hiba nélkül)
|
||||
- Backend API: már implementálva (`admin_gamification.py`)
|
||||
- Menüpontok: már konfigurálva (`default.vue`)
|
||||
- **Gitea #333:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #334: Frontend: Felhasználói statisztikák + büntetés admin nézet (penalty)
|
||||
|
||||
### 🎯 Cél
|
||||
A Gamification admin felület felhasználói statisztikák oldalának (`users/index.vue`) és felhasználó részletes nézetének (`users/[id].vue`) implementálása, büntetés/jutalom funkciókkal.
|
||||
|
||||
### 🔧 Eredmények
|
||||
|
||||
**1. BACKEND ELLENŐRZÉS:** ✅ PASS
|
||||
- `admin_gamification.py` router már teljesen implementálva volt (1098 sor)
|
||||
- Minden szükséges végpont létezik: user-stats list/get/update, penalty POST, reward POST, points-ledger, contributions
|
||||
- `GamificationService.process_activity()` támogatja a penalty/reward admin műveleteket
|
||||
|
||||
**2. FRONTEND `users/index.vue`:** ✅ Implementálva
|
||||
- Felhasználói statisztikák adattábla (XP, szint, pontok, büntetés, restrikció, aktivitás)
|
||||
- Szűrők: szint, minimum/maximum XP, büntetés státusz
|
||||
- Lapozás (offset/limit alapú)
|
||||
- Linkek a részletes nézetre
|
||||
|
||||
**3. FRONTEND `users/[id].vue`:** ✅ Implementálva
|
||||
- Statisztika kártyák: XP, szint, pontok, social pont, büntetés, restrikció, kvóta, kitiltás
|
||||
- Aktivitás mutatók: szolgáltatások, felfedezések, validációk, szolgáltatók
|
||||
- **Büntetés űrlap:** POST `/admin/gamification/user-stats/{id}/penalty` (amount + reason)
|
||||
- **Jutalom űrlap:** POST `/admin/gamification/user-stats/{id}/reward` (xp_amount + social_amount + reason)
|
||||
- Pontnapló tábla a felhasználóhoz tartozó ledger bejegyzésekkel
|
||||
|
||||
**4. ADATBÁZIS SZINKRONIZÁCIÓ:** ✅ PASS
|
||||
- sync_engine: 1284 elem OK, 0 hiba, 0 extra elem
|
||||
- A rendszer tökéletesen szinkronban van
|
||||
|
||||
### ✅ Eredmény
|
||||
- Backend: ✅ Már implementálva (admin_gamification.py)
|
||||
- Frontend users/index.vue: ✅ Teljes implementáció (szűrők, tábla, lapozás)
|
||||
- Frontend users/[id].vue: ✅ Teljes implementáció (statok, büntetés, jutalom, ledger)
|
||||
- Adatbázis: ✅ Szinkronban
|
||||
- **Gitea #334:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #335: Frontend Ranglista Admin Nézet (Leaderboard)
|
||||
|
||||
### 🎯 Cél
|
||||
Admin ranglista nézet megvalósítása a gamification rendszerhez: backend API végpont + frontend admin oldal.
|
||||
|
||||
### 🔧 Eredmények
|
||||
|
||||
**1. BACKEND - Admin Leaderboard API végpont:** ✅
|
||||
- `GET /admin/gamification/leaderboard` végpont hozzáadva az [`admin_gamification.py`](backend/app/api/v1/endpoints/admin_gamification.py:1193) fájlhoz
|
||||
- Támogatja: pagination (limit/offset), filtering (level, min_xp, search), sorting (sort_by/sort_order)
|
||||
- Kétlépcsős eager loading: `joinedload(UserStats.user).joinedload(User.person)` a Person nevek eléréséhez
|
||||
- Explicit `onclause` a Person join-nál az `AmbiguousForeignKeysError` elkerülésére (User↔Person bidirekcionális FK)
|
||||
- `or_` import hozzáadva a SQLAlchemy import sorhoz
|
||||
|
||||
**2. FRONTEND - Leaderboard oldal:** ✅
|
||||
- [`leaderboard.vue`](frontend_admin/pages/gamification/leaderboard.vue) teljes implementáció:
|
||||
- Keresés név/email alapján
|
||||
- Szint filter (1-20), minimum XP filter
|
||||
- Rendezés (XP, Pontok, Social Points, Szint) + irány toggle
|
||||
- Adattábla 13 oszloppal (Rank, Név, Email, XP, Szint, Pontok, Social, Penalty, Restriction, Felfedezések, Szolgáltatások, Frissítve, Műveletek)
|
||||
- Rank badge-ek (arany/ezüst/bronz) top 3 helyezéshez
|
||||
- Lapozás first/prev/next/last + oldalméret választó (25/50/100/250)
|
||||
- Loading, error, empty állapotok
|
||||
- Link a felhasználói részletek oldalra
|
||||
|
||||
**3. TESZTELÉS:** ✅
|
||||
- Alap leaderboard lekérdezés: 12 user, XP szerint csökkenő
|
||||
- Szint filter (level=2): 7 találat
|
||||
- Min XP filter (min_xp=4000): 2 találat
|
||||
- Rendezés ascending (current_level): helyes sorrend
|
||||
- Keresés névre ("accip"): 1 találat (Tímea Accipe)
|
||||
- Keresés emailre ("profibot"): 6 találat
|
||||
- Keresés vezetéknévre ("Gyöngyössy"): 2 találat
|
||||
- Kombinált filter (level=2 & search=accip): 1 találat
|
||||
- Lapozás (offset=5, limit=3): helyes rank számozás
|
||||
- **Gitea #335:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #336: Frontend Master Config & System Parameters Pages
|
||||
|
||||
### 🎯 Cél
|
||||
Két admin frontend oldal implementálása a Gamification 2.0 modulhoz:
|
||||
1. `config.vue` — GAMIFICATION_MASTER_CONFIG JSON szerkesztő hatás kalkulátorral
|
||||
2. `parameters.vue` — Gamification kategóriájú SystemParameter-ek DataTable inline szerkesztéssel
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `frontend_admin/pages/gamification/config.vue` — Teljes implementáció (~540 sor): 4 konfigurációs szekció (XP Logic, Penalty Logic, Conversion Logic, Level Rewards) csúszkás/inputos szerkesztéssel, Impact Calculator szint/aktivitás/szociális pont csúszkákkal, valós idejű számított értékekkel (napi XP, szintlépés XP, szociális kredit, napok a szintlépésig), Raw JSON szerkesztő validációval, mentés PUT /admin/gamification/master-config végpontra
|
||||
- `frontend_admin/pages/gamification/parameters.vue` — Teljes implementáció (~280 sor): DataTable key/value/category/scope_level/scope_id oszlopokkal, inline szerkesztő modal textarea-val, smart value parsing (JSON/Number/String auto-detection), mentés PUT /admin/gamification/system-params/{key} végpontra
|
||||
|
||||
### ✅ Eredmények
|
||||
- Backend API-k auditálva: GET/PUT /admin/gamification/master-config és GET/PUT /admin/gamification/system-params végpontok teljesen implementálva
|
||||
- Mindkét oldal Nuxt HMR-rel betöltődött és fordítási hiba nélkül fut a sf_admin_frontend konténerben (port 8502)
|
||||
- Auth pattern: useCookie('access_token') + Authorization: Bearer header
|
||||
- Dark theme: slate-800/emerald/amber/rose/violet/cyan színséma
|
||||
- **Gitea #336:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #337: Frontend Pontnapló böngésző oldal (ledger.vue)
|
||||
|
||||
### 🎯 Cél
|
||||
PointsLedger admin böngésző oldal elkészítése DataTable-lel, szűrőkkel, lapozással és CSV exporttal.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/app/api/v1/endpoints/admin_gamification.py` - `reason_search` paraméter hozzáadása a `GET /admin/gamification/points-ledger` végponthoz (ILIKE keresés a reason mezőben)
|
||||
- `frontend_admin/pages/gamification/ledger.vue` - Teljes implementáció: loading/error/empty állapotok, szűrősáv (user_id, date_from, date_to, reason_search), adattábla (ID, User, Pont, Büntetés, XP, Ok, Forrás, Dátum), lapozás (Előző/Következő), CSV export, toast értesítések
|
||||
|
||||
### ✅ API Teszt Eredmények
|
||||
- `GET /api/v1/admin/gamification/points-ledger?limit=5` → 200 OK (5 entry)
|
||||
- `GET /api/v1/admin/gamification/points-ledger?reason_search=test&limit=5` → 200 OK (5 entry, ILIKE filter működik)
|
||||
- `GET /api/v1/admin/gamification/points-ledger?user_id=1&limit=5` → 200 OK (4 entry)
|
||||
|
||||
- **Gitea #337:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gamification Admin E2E Teszt Suite (#338)
|
||||
|
||||
### 🎯 Cél
|
||||
Teljes körű E2E teszt suite létrehozása a Gamification admin felülethez, 10 tesztforgatókönyvvel.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/tests/e2e/test_gamification_admin_e2e.py` - LÉTREHOZVA: 995 soros E2E teszt suite
|
||||
|
||||
### 📋 Teszt forgatókönyvek
|
||||
1. **Point Rules CRUD** - Create, List, Update, Delete (soft-delete), Duplicate 409
|
||||
2. **Level Configs CRUD** - Create, List, Update, Duplicate 409
|
||||
3. **Badges CRUD** - Create, List, Update, Delete (hard-delete), Duplicate
|
||||
4. **Seasons CRUD + Activation** - Create, List, Update, Activate, Single active verification
|
||||
5. **Competitions CRUD** - Create, List, Update, Non-existent season 404
|
||||
6. **User Stats** - List, Detail (404), Reward, Penalty, Update
|
||||
7. **Master Config** - Get, Update, Verify
|
||||
8. **System Params** - List, Update
|
||||
9. **Points Ledger** - List, Pagination, Reason search, User filter
|
||||
10. **Permission Check** - Regular user without gamification:manage gets 403
|
||||
11. **Dashboard Summary** - Verify response structure
|
||||
|
||||
### ✅ Teszt Eredmények
|
||||
- **40/40 passed, 0 failed** ✅
|
||||
- Minden admin gamification végpont lefedve
|
||||
- Permission teszt: asyncpg-vel aktivált user, email verification-en keresztül
|
||||
- Superadmin bypass token (`dev_bypass_active`) használata admin műveletekhez
|
||||
|
||||
- **Gitea #338:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Gamification Nyelvi Modul Audit (#339)
|
||||
|
||||
### 🎯 Cél
|
||||
A gamification backend és frontend admin modulok teljes körű nyelvi auditja - hardcoded stringek azonosítása és i18n javítási terv készítése.
|
||||
|
||||
### 🔧 Eredmények
|
||||
- **16 fájl** átvizsgálva (3 backend + 12 frontend Vue + 1 nuxt.config)
|
||||
- **Megállapítás:** A frontend admin 12 Vue oldalán 100%-ban hardcoded magyar nyelvű szövegek, sehol nincs `useI18n()` vagy `$t()` használat
|
||||
- **Backend:** Kevert magyar/angol hardcoded hibaüzenetek, keménykódolt kvíz (magyar), achievement címek (angol)
|
||||
- **Lokációs fájlok:** Egyetlen gamification kulcs sem található a `frontend_admin/locales/*.json` és `backend/static/locales/*.json` fájlokban
|
||||
- **Javítási javaslat:** 7 fázisú terv a teljes i18n migrációhoz (15-20 fájl módosítás)
|
||||
- **Dokumentáció:** `docs/gamification_i18n_audit_and_fix_proposal.md` - részletes audit és fix proposal
|
||||
|
||||
- **Gitea #339:** ✅ Audit elkészült
|
||||
|
||||
## 2026-06-29 - Gitea #340: Gamification i18n kulcsok felvétele (Fázis 1/7)
|
||||
|
||||
### 🎯 Cél
|
||||
Gamification i18n kulcsok felvétele a `frontend_admin/locales/en.json` és `hu.json` fájlokba a `docs/gamification_i18n_audit_and_fix_proposal.md` dokumentációban definiált kulcs struktúra alapján.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `frontend_admin/locales/en.json` — Hozzáadva a `gamification` szekció 11 al-szekcióval (dashboard, badges, competitions, config, leaderboard, ledger, levels, parameters, point_rules, seasons, users)
|
||||
- `frontend_admin/locales/hu.json` — Hozzáadva a `gamification` szekció magyar fordítással
|
||||
|
||||
### ✅ Eredmény
|
||||
Mindkét JSON fájl validálva (Python json.load), szintaktikailag helyes. A gamification szótár teljes egészében elérhető mind angol, mind magyar nyelven.
|
||||
- **Gitea #340:** ✅ Implementáció kész
|
||||
|
||||
## 2026-06-29 - Gitea #341: Fázis 2/7 - 12 Vue oldal átalakítása useI18n() és t() hívásokkal
|
||||
|
||||
### 🎯 Cél
|
||||
A 12 gamification admin Vue oldal hardcoded magyar szövegeinek lecserélése `useI18n()` + `t()` hívásokra, a #340-ben felvett i18n kulcsok használatával.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `frontend_admin/i18n/locales/en.json` — Kiegészítve hiányzó gamification kulcsokkal (icon_hint, icon_preview, create_btn, unknown_error, name_placeholder, desc_placeholder, select_season, rules_placeholder, season_id_label)
|
||||
- `frontend_admin/i18n/locales/hu.json` — Kiegészítve hiányzó gamification kulcsokkal (magyar fordítás)
|
||||
- `frontend_admin/pages/gamification/badges.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/competitions.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/config.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/index.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/leaderboard.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/ledger.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/levels.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/parameters.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/point-rules.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/seasons.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/users/index.vue` — useI18n() + t() konverzió
|
||||
- `frontend_admin/pages/gamification/users/[id].vue` — useI18n() + t() konverzió
|
||||
|
||||
### ✅ Eredmény
|
||||
- Build: ✅ Sikeres (`✓ built in 6.95s`)
|
||||
- Mind a 12 Vue oldal átalakítva: `const { t } = useI18n()` hozzáadva, minden hardcoded string `t('gamification.xxx.yyy')` hívással helyettesítve
|
||||
- Interpoláció támogatás: delete confirmációk, penalty/reward sikerüzenetek, paraméter frissítés üzenetek
|
||||
- **Gitea #341:** ✅ Elkészült
|
||||
|
||||
## 2026-06-29 - Debug: JSON Parse Error javítás (Gitea #341 utómunka)
|
||||
|
||||
### 🐛 Hiba
|
||||
Nuxt runtime error: `[plugin json] i18n/locales/hu.json: Could not parse JSON file` — a build után a böngészőben JSON parse hiba lépett fel.
|
||||
|
||||
### 🔧 Javított hibák
|
||||
1. **hu.json (line 377):** `'{"type": "service_submission", ...}'` — a JSON string értékeknél aposztróf (`'`) helyett dupla idézőjel (`"`) kell, escape-elve: `"{\"type\": \"service_submission\", ...}"`
|
||||
2. **en.json (line 377):** Ugyanez a hiba — aposztróf használata JSON stringben
|
||||
3. **hu.json (truncated):** A fájl csonkán íródott ki — hiányzott a `seasons`, `users`, `user_detail` szekció és a záró `}}}`
|
||||
|
||||
### ✅ Eredmény
|
||||
- Mindkét JSON fájl validálva Python `json.load()`-dal: ✅ Sikeres
|
||||
- Build: ✅ Sikeres (`✓ built in 6.88s`)
|
||||
- Mind a 12 gamification i18n szekció elérhető mindkét nyelvben
|
||||
|
||||
## 2026-06-30 - [#342] Backend i18n helper megírása (app/core/i18n.py)
|
||||
|
||||
### 🎯 Cél
|
||||
Backend i18n segédosztály létrehozása a `backend/static/locales/*.json` fájlok betöltésére és kulcs alapú szöveg lekérésre.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/app/core/i18n.py` - Teljes átírás: JSON fájlokból cache-elt szótár építése, dot-notation kulcsfeloldás, `{{variable}}` interpoláció, context locale integráció, EN fallback, `reload_locales()` támogatás
|
||||
|
||||
### ✅ Eredmények
|
||||
- `t(key, lang, **kwargs)` - Dot-notation kulcsfeloldás és `{{variable}}` interpoláció
|
||||
- `get_locale(lang)` - Teljes nyelvi szótár visszaadása
|
||||
- `reload_locales()` - Cache újratöltés futásidőben
|
||||
- Locale detekció: explicit `lang` param → context locale (I18nMiddleware) → `"hu"` default
|
||||
- Fallback lánc: requested lang → EN → bármely elérhető nyelv → kulcsnév
|
||||
- 10/10 teszt sikeres (EN, HU, változók, fallback, context, reload)
|
||||
|
||||
## 2026-06-30 - [#345] Fázis 6/7: Kvíz tartalom kiszervezése JSON/DB-be nyelvi kulcsokkal
|
||||
|
||||
### 🎯 Cél
|
||||
A keménykódolt magyar nyelvű kvíz kérdések, válaszok és magyarázatok kiszervezése a kódból nyelvi JSON fájlokba.
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/static/quiz/en.json` - **LÉTREHOZVA**: Angol nyelvű kvíz kérdések (3 db) JSON formátumban
|
||||
- `backend/static/quiz/hu.json` - **LÉTREHOZVA**: Magyar nyelvű kvíz kérdések (3 db) JSON formátumban
|
||||
- `backend/app/core/i18n.py` - **MÓDOSÍTVA**: `get_quiz_data()`, `reload_quiz_data()` függvények hozzáadva a kvíz JSON-ek cache-elt betöltéséhez
|
||||
- `backend/app/api/v1/endpoints/gamification.py` - **MÓDOSÍTVA**: `GET /quiz/questions` és `POST /quiz/answer` végpontok hardcoded adatainak kiváltása `get_quiz_data()` hívásokkal
|
||||
|
||||
### ✅ Eredmények
|
||||
- `get_quiz_data(lang)` - Kvíz kérdések betöltése nyelv szerinti JSON fájlokból, locale detekcióval (explicit lang → context locale → "hu" default → EN fallback)
|
||||
- `reload_quiz_data()` - Kvíz cache újratöltés futásidőben
|
||||
- 3 kvíz kérdés mindkét nyelven (HU + EN), minden opcióval és magyarázattal
|
||||
- Szintaxis ellenőrzés: ✅ Sikeres
|
||||
- Kvíz adatbetöltés teszt: ✅ Sikeres (HU: 3, EN: 3, DE fallback: 3)
|
||||
|
||||
## 2026-06-30 - [#346] Fázis 7/7: Tesztelés - magyar és angol nyelvű gamification felület
|
||||
|
||||
### 🎯 Cél
|
||||
A gamification i18n migráció teljes körű tesztelése: backend API lokalizált hibaüzenetek, kvíz rendszer nyelvfüggő működése, achievement címek nyelvfüggő megjelenítése, nyelvváltás konzisztencia, hiányzó fordítás fallback viselkedés, frontend build ellenőrzés.
|
||||
|
||||
### 🔧 Eredmények
|
||||
|
||||
**1. BACKEND E2E TESZTEK (Admin Gamification):** ✅ 40/40 PASS
|
||||
- Point Rules CRUD: ✅ 5/5
|
||||
- Level Configs CRUD: ✅ 5/5
|
||||
- Badges CRUD: ✅ 5/5
|
||||
- Seasons CRUD: ✅ 5/5
|
||||
- Competitions CRUD: ✅ 5/5
|
||||
- User Stats: ✅ 4/4
|
||||
- Master Config: ✅ 2/2
|
||||
- System Params: ✅ 2/2
|
||||
- Points Ledger: ✅ 2/2
|
||||
- Permission Check: ✅ 3/3
|
||||
- Dashboard Summary: ✅ 2/2
|
||||
|
||||
**2. BACKEND I18N API TESZTEK:** ✅ 15/15 PASS
|
||||
- Localized Error Messages (HU/EN): ✅ 4/4
|
||||
- Quiz Language-Dependent Operation: ✅ 3/3
|
||||
- Achievement Titles Language Display: ✅ 1/1
|
||||
- Language Switch Consistency: ✅ 4/4
|
||||
- Missing Translation Fallback: ✅ 3/3
|
||||
|
||||
**3. FRONTEND ADMIN BUILD:** ✅ Sikeres
|
||||
- Nuxt 3 build: 313 modules transformed, 7.96s client + 6.71s server
|
||||
|
||||
**4. FRONTEND I18N ELLENŐRZÉS:** ✅ 11/11 oldal
|
||||
- Minden gamification admin oldal használja a `useI18n().t()` függvényt
|
||||
- en.json (817 sor) és hu.json (719 sor) teljes gamification szekcióval
|
||||
|
||||
### 🔧 Módosított fájlok
|
||||
- `backend/tests/e2e/test_gamification_i18n_api.py` - Login fix: `json=` → `data=` (OAuth2PasswordRequestForm kompatibilitás)
|
||||
### 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,
|
||||
admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
|
||||
subscriptions, marketing, admin_permissions, regions,
|
||||
admin_gamification,
|
||||
admin_gamification, admin_providers, locations,
|
||||
admin_commission,
|
||||
financial_manager,
|
||||
)
|
||||
|
||||
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(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="/admin/finance", tags=["admin-finance"])
|
||||
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
|
||||
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
|
||||
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
|
||||
@@ -46,3 +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(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_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.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
|
||||
from app.models.identity.address import Address
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from app.models.system import SystemParameter, ParameterScope
|
||||
from app.services.system_service import system_service
|
||||
# JAVÍTVA: Security audit modellek
|
||||
@@ -623,23 +623,12 @@ async def list_users(
|
||||
seen_ids.add(row.id)
|
||||
unique_users.append(row)
|
||||
|
||||
from app.schemas.address import AddressOut
|
||||
|
||||
user_list = []
|
||||
for u in unique_users:
|
||||
person = u.person
|
||||
address = person.address if person else None
|
||||
# Cím összefűzése
|
||||
address_str = None
|
||||
if address:
|
||||
parts = []
|
||||
if address.zip:
|
||||
parts.append(str(address.zip))
|
||||
if address.city:
|
||||
parts.append(str(address.city))
|
||||
if address.street_name:
|
||||
parts.append(str(address.street_name))
|
||||
if address.house_number:
|
||||
parts.append(str(address.house_number))
|
||||
address_str = ', '.join(parts) if parts else None
|
||||
|
||||
# Személy nevének összefűzése
|
||||
person_name = None
|
||||
@@ -663,12 +652,8 @@ async def list_users(
|
||||
"first_name": person.first_name if person else None,
|
||||
"last_name": person.last_name if person else None,
|
||||
"phone": person.phone if person else None,
|
||||
# Címadatok
|
||||
"postal_code": str(address.zip) if address and address.zip else None,
|
||||
"city": address.city if address and address.city else None,
|
||||
"street": address.street_name if address and address.street_name else None,
|
||||
"house_number": address.house_number if address and address.house_number else None,
|
||||
"address": address_str,
|
||||
# Címadatok — egységes AddressOut formátumban
|
||||
"address": AddressOut.model_validate(address) if address else None,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -943,6 +928,8 @@ async def search_organizations(
|
||||
"""
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
# P0 FIX: Join with system.addresses and system.geo_postal_codes
|
||||
# instead of selecting the ghost column Organization.address_city.
|
||||
stmt = (
|
||||
select(
|
||||
Organization.id,
|
||||
@@ -955,11 +942,13 @@ async def search_organizations(
|
||||
Organization.is_verified,
|
||||
Organization.is_active,
|
||||
Organization.is_deleted,
|
||||
Organization.address_city,
|
||||
GeoPostalCode.city.label("city"),
|
||||
Organization.region,
|
||||
Organization.segment,
|
||||
Organization.created_at,
|
||||
)
|
||||
.outerjoin(Address, Organization.address_id == Address.id)
|
||||
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
|
||||
.where(
|
||||
or_(
|
||||
Organization.full_name.ilike(f"%{name}%"),
|
||||
@@ -987,7 +976,7 @@ async def search_organizations(
|
||||
"is_verified": row.is_verified,
|
||||
"is_active": row.is_active,
|
||||
"is_deleted": row.is_deleted,
|
||||
"city": row.address_city,
|
||||
"city": row.city,
|
||||
"region": row.region,
|
||||
"segment": row.segment,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
|
||||
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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -28,7 +28,9 @@ from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.fleet.organization import ContactPerson
|
||||
from app.models.identity import User, Person
|
||||
from app.services.address_manager import AddressManager
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
from app.schemas.organization import (
|
||||
OrganizationMemberCreate,
|
||||
OrganizationMemberUpdate,
|
||||
@@ -207,30 +209,36 @@ class GarageDetailsResponse(BaseModel):
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
org_type: str
|
||||
# Cím adatok (elsődleges)
|
||||
# Cím adatok (elsődleges) — denormalizált mezők (backward compatible)
|
||||
address_city: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
# P0: Unified AddressOut nested object
|
||||
address_detail: Optional[AddressOut] = None
|
||||
tax_number: Optional[str] = None
|
||||
reg_number: Optional[str] = None
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
# ── Számlázási cím (Billing Address) — denormalizált mezők (backward compatible) ──
|
||||
billing_zip: Optional[str] = None
|
||||
billing_city: Optional[str] = None
|
||||
billing_street_name: Optional[str] = None
|
||||
billing_street_type: Optional[str] = None
|
||||
billing_house_number: Optional[str] = None
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
# P0: Unified AddressOut nested object for billing address
|
||||
billing_address_detail: Optional[AddressOut] = None
|
||||
# ── Értesítési cím (Notification Address) — denormalizált mezők (backward compatible) ──
|
||||
notification_zip: Optional[str] = None
|
||||
notification_city: Optional[str] = None
|
||||
notification_street_name: Optional[str] = None
|
||||
notification_street_type: Optional[str] = None
|
||||
notification_house_number: Optional[str] = None
|
||||
# P0: Unified AddressOut nested object for notification address
|
||||
notification_address_detail: Optional[AddressOut] = None
|
||||
# Előfizetés
|
||||
subscription: Optional[SubscriptionSummary] = None
|
||||
# Kapcsolattartó (ContactPerson modellből)
|
||||
@@ -421,6 +429,9 @@ async def list_organizations(
|
||||
# Resolve email: prefer contact_email, fallback to owner's email
|
||||
resolved_email = org.contact_email or (org.owner.email if org.owner else None)
|
||||
|
||||
# P1 FIX: Use relationship-based city instead of ghost column org.address_city
|
||||
resolved_city = org.address.city if org.address else None
|
||||
|
||||
garage_list.append(GarageListItem(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
@@ -432,7 +443,7 @@ async def list_organizations(
|
||||
is_verified=org.is_verified,
|
||||
is_deleted=org.is_deleted,
|
||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||
city=org.address_city,
|
||||
city=resolved_city,
|
||||
region=org.region,
|
||||
segment=org.segment,
|
||||
member_count=member_counts.get(org.id, 0),
|
||||
@@ -696,17 +707,22 @@ async def get_organization_details(
|
||||
|
||||
# 2c. P0 ADD-ON: Telephelyek (branches) lekérése
|
||||
from app.models.marketplace.organization import Branch # noqa: E402
|
||||
branches_stmt = select(Branch).where(
|
||||
branches_stmt = (
|
||||
select(Branch)
|
||||
.options(selectinload(Branch.address))
|
||||
.where(
|
||||
Branch.organization_id == org_id,
|
||||
Branch.is_deleted == False,
|
||||
).order_by(Branch.name)
|
||||
)
|
||||
.order_by(Branch.name)
|
||||
)
|
||||
branches_result = await db.execute(branches_stmt)
|
||||
branch_rows = branches_result.scalars().all()
|
||||
branches_list = [
|
||||
BranchBrief(
|
||||
id=str(b.id),
|
||||
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,
|
||||
)
|
||||
for b in branch_rows
|
||||
@@ -1078,6 +1094,19 @@ async def get_organization_details(
|
||||
f"({org.full_name}) — {len(members_list)} members"
|
||||
)
|
||||
|
||||
# ── P0 REFACTORED: Build unified AddressOut objects from relationships ──
|
||||
address_detail = None
|
||||
if org.address:
|
||||
address_detail = AddressOut.model_validate(org.address)
|
||||
|
||||
billing_address_detail = None
|
||||
if org.billing_address:
|
||||
billing_address_detail = AddressOut.model_validate(org.billing_address)
|
||||
|
||||
notification_address_detail = None
|
||||
if org.notification_address:
|
||||
notification_address_detail = AddressOut.model_validate(org.notification_address)
|
||||
|
||||
return GarageDetailsResponse(
|
||||
id=org.id,
|
||||
name=org.name,
|
||||
@@ -1087,11 +1116,13 @@ async def get_organization_details(
|
||||
is_active=org.is_active,
|
||||
is_verified=org.is_verified,
|
||||
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
|
||||
address_city=org.address_city,
|
||||
address_zip=org.address_zip,
|
||||
address_street_name=org.address_street_name,
|
||||
address_street_type=org.address_street_type,
|
||||
address_house_number=org.address_house_number,
|
||||
# ── P0 REFACTORED: Denormalized fields populated from relationship ──
|
||||
address_city=org.address.city if org.address else None,
|
||||
address_zip=org.address.zip if org.address else None,
|
||||
address_street_name=org.address.street_name if org.address else None,
|
||||
address_street_type=org.address.street_type if org.address else None,
|
||||
address_house_number=org.address.house_number if org.address else None,
|
||||
address_detail=address_detail,
|
||||
tax_number=org.tax_number,
|
||||
reg_number=org.reg_number,
|
||||
# ── P0: Zero-Duplication Kapcsolattartó (owner fallback) ──
|
||||
@@ -1099,17 +1130,19 @@ async def get_organization_details(
|
||||
contact_email=resolved_contact_email,
|
||||
contact_phone=resolved_contact_phone,
|
||||
# ── Számlázási cím ──
|
||||
billing_zip=org.billing_zip,
|
||||
billing_city=org.billing_city,
|
||||
billing_street_name=org.billing_street_name,
|
||||
billing_street_type=org.billing_street_type,
|
||||
billing_house_number=org.billing_house_number,
|
||||
billing_zip=org.billing_address.zip if org.billing_address else None,
|
||||
billing_city=org.billing_address.city if org.billing_address else None,
|
||||
billing_street_name=org.billing_address.street_name if org.billing_address else None,
|
||||
billing_street_type=org.billing_address.street_type if org.billing_address else None,
|
||||
billing_house_number=org.billing_address.house_number if org.billing_address else None,
|
||||
billing_address_detail=billing_address_detail,
|
||||
# ── Értesítési cím ──
|
||||
notification_zip=org.notification_zip,
|
||||
notification_city=org.notification_city,
|
||||
notification_street_name=org.notification_street_name,
|
||||
notification_street_type=org.notification_street_type,
|
||||
notification_house_number=org.notification_house_number,
|
||||
notification_zip=org.notification_address.zip if org.notification_address else None,
|
||||
notification_city=org.notification_address.city if org.notification_address else None,
|
||||
notification_street_name=org.notification_address.street_name if org.notification_address else None,
|
||||
notification_street_type=org.notification_address.street_type if org.notification_address else None,
|
||||
notification_house_number=org.notification_address.house_number if org.notification_address else None,
|
||||
notification_address_detail=notification_address_detail,
|
||||
subscription=subscription_summary,
|
||||
primary_contact=primary_contact,
|
||||
created_at=org.created_at.isoformat() if org.created_at else None,
|
||||
@@ -1224,28 +1257,34 @@ class OrganizationUpdate(BaseModel):
|
||||
# ── Org-level email/phone (used for individual garages to propagate to Person/User) ──
|
||||
email: Optional[str] = Field(None, max_length=255, description="Szervezeti e-mail (individual garages esetén a User rekordba propagálódik)")
|
||||
phone: Optional[str] = Field(None, max_length=50, description="Szervezeti telefon (individual garages esetén a Person rekordba propagálódik)")
|
||||
# ── Elsődleges cím (Primary Address) ──
|
||||
address_zip: Optional[str] = Field(None, max_length=10, description="Irányítószám")
|
||||
address_city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Utca típusa (utca, tér, stb.)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám")
|
||||
# ── Elsődleges cím (Primary Address) — DEPRECATED: Use address_detail instead ──
|
||||
address_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd az address_detail mezőt! Irányítószám")
|
||||
address_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd az address_detail mezőt! Város")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd az address_detail mezőt! Utca neve")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd az address_detail mezőt! Utca típusa (utca, tér, stb.)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd az address_detail mezőt! Házszám")
|
||||
# P0: Unified AddressIn nested object for primary address
|
||||
address_detail: Optional[AddressIn] = None
|
||||
# ── Kapcsolattartó (Contact Person) ──
|
||||
contact_person_name: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó neve")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Kapcsolattartó telefonszáma")
|
||||
# ── Számlázási cím (Billing Address) ──
|
||||
billing_zip: Optional[str] = Field(None, max_length=10, description="Számlázási irányítószám")
|
||||
billing_city: Optional[str] = Field(None, max_length=100, description="Számlázási város")
|
||||
billing_street_name: Optional[str] = Field(None, max_length=150, description="Számlázási utca neve")
|
||||
billing_street_type: Optional[str] = Field(None, max_length=50, description="Számlázási utca típusa")
|
||||
billing_house_number: Optional[str] = Field(None, max_length=20, description="Számlázási házszám")
|
||||
# ── Értesítési cím (Notification Address) ──
|
||||
notification_zip: Optional[str] = Field(None, max_length=10, description="Értesítési irányítószám")
|
||||
notification_city: Optional[str] = Field(None, max_length=100, description="Értesítési város")
|
||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="Értesítési utca neve")
|
||||
notification_street_type: Optional[str] = Field(None, max_length=50, description="Értesítési utca típusa")
|
||||
notification_house_number: Optional[str] = Field(None, max_length=20, description="Értesítési házszám")
|
||||
# ── Számlázási cím (Billing Address) — DEPRECATED: Use billing_address_detail instead ──
|
||||
billing_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási irányítószám")
|
||||
billing_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási város")
|
||||
billing_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca neve")
|
||||
billing_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási utca típusa")
|
||||
billing_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a billing_address_detail mezőt! Számlázási házszám")
|
||||
# P0: Unified AddressIn nested object for billing address
|
||||
billing_address_detail: Optional[AddressIn] = None
|
||||
# ── Értesítési cím (Notification Address) — DEPRECATED: Use notification_address_detail instead ──
|
||||
notification_zip: Optional[str] = Field(None, max_length=10, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési irányítószám")
|
||||
notification_city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési város")
|
||||
notification_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca neve")
|
||||
notification_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési utca típusa")
|
||||
notification_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Használd a notification_address_detail mezőt! Értesítési házszám")
|
||||
# P0: Unified AddressIn nested object for notification address
|
||||
notification_address_detail: Optional[AddressIn] = None
|
||||
|
||||
|
||||
@router.put(
|
||||
@@ -1279,11 +1318,58 @@ async def update_organization(
|
||||
detail=f"Szervezet nem található ID-vel: {org_id}",
|
||||
)
|
||||
|
||||
# 2. Összegyűjtjük a változásokat
|
||||
# 2. P0 REFACTORED: Address FK logic via AddressManager
|
||||
raw_data = payload.model_dump(exclude_none=True)
|
||||
|
||||
# ── Primary address via AddressManager ──
|
||||
address_detail: Optional[AddressIn] = raw_data.pop("address_detail", None)
|
||||
if address_detail:
|
||||
# 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 = {}
|
||||
for field_name in payload.model_dump(exclude_none=True):
|
||||
value = getattr(payload, field_name)
|
||||
for field_name in raw_data:
|
||||
value = raw_data[field_name]
|
||||
if value is not None and hasattr(org, field_name):
|
||||
# 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)
|
||||
update_fields[field_name] = value
|
||||
|
||||
@@ -1333,6 +1419,11 @@ async def update_organization(
|
||||
f"({org.full_name}): fields={list(update_fields.keys())}"
|
||||
)
|
||||
|
||||
# ── P0 REFACTORED: Build unified AddressOut from relationship ──
|
||||
resp_address_detail = None
|
||||
if org.address:
|
||||
resp_address_detail = AddressOut.model_validate(org.address)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"A(z) '{org.full_name}' garázs adatai frissítve.",
|
||||
@@ -1345,11 +1436,7 @@ async def update_organization(
|
||||
"display_name": org.display_name,
|
||||
"tax_number": org.tax_number,
|
||||
"reg_number": org.reg_number,
|
||||
"address_zip": org.address_zip,
|
||||
"address_city": org.address_city,
|
||||
"address_street_name": org.address_street_name,
|
||||
"address_street_type": org.address_street_type,
|
||||
"address_house_number": org.address_house_number,
|
||||
"address_detail": resp_address_detail,
|
||||
"status": org.status,
|
||||
"is_active": org.is_active,
|
||||
"contact_email": org.contact_email,
|
||||
|
||||
@@ -26,7 +26,8 @@ from app.models.identity import User, Person, UserRole
|
||||
from app.models.identity.address import Address
|
||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.user import AddressResponse
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
from app.services.address_manager import AddressManager
|
||||
|
||||
logger = logging.getLogger("admin-persons")
|
||||
router = APIRouter()
|
||||
@@ -59,7 +60,7 @@ class PersonListItem(BaseModel):
|
||||
merged_into_id: Optional[int] = None
|
||||
users_count: int = 0
|
||||
active_user: Optional[PersonListItemActiveUser] = None
|
||||
address: Optional[AddressResponse] = None
|
||||
address: Optional[AddressOut] = None
|
||||
|
||||
|
||||
class PersonListResponse(BaseModel):
|
||||
@@ -214,20 +215,7 @@ async def list_persons(
|
||||
# Cím adatok
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = AddressResponse(
|
||||
address_zip=person.address.zip,
|
||||
address_city=person.address.city,
|
||||
address_street_name=person.address.street_name,
|
||||
address_street_type=person.address.street_type,
|
||||
address_house_number=person.address.house_number,
|
||||
address_stairwell=person.address.stairwell,
|
||||
address_floor=person.address.floor,
|
||||
address_door=person.address.door,
|
||||
address_hrsz=person.address.parcel_id,
|
||||
full_address_text=person.address.full_address_text,
|
||||
latitude=person.address.latitude,
|
||||
longitude=person.address.longitude,
|
||||
)
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
|
||||
items.append(PersonListItem(
|
||||
id=person.id,
|
||||
@@ -338,7 +326,7 @@ class PersonDetailResponse(BaseModel):
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
# Kapcsolódó entitások
|
||||
address: Optional[AddressResponse] = None
|
||||
address: Optional[AddressOut] = None
|
||||
users: List[PersonDetailUserInfo] = []
|
||||
memberships: List[PersonDetailMembership] = []
|
||||
owned_organizations: List[PersonDetailOwnedOrganization] = []
|
||||
@@ -394,20 +382,7 @@ async def get_person_detail(
|
||||
# ── Cím adatok ──
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = AddressResponse(
|
||||
address_zip=person.address.zip,
|
||||
address_city=person.address.city,
|
||||
address_street_name=person.address.street_name,
|
||||
address_street_type=person.address.street_type,
|
||||
address_house_number=person.address.house_number,
|
||||
address_stairwell=person.address.stairwell,
|
||||
address_floor=person.address.floor,
|
||||
address_door=person.address.door,
|
||||
address_hrsz=person.address.parcel_id,
|
||||
full_address_text=person.address.full_address_text,
|
||||
latitude=person.address.latitude,
|
||||
longitude=person.address.longitude,
|
||||
)
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
|
||||
# ── Kapcsolódó User-ek ──
|
||||
users_data: List[PersonDetailUserInfo] = []
|
||||
@@ -520,22 +495,6 @@ async def get_person_detail(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PersonAddressUpdate(BaseModel):
|
||||
"""Cím adatok a Person szerkesztéshez."""
|
||||
address_zip: Optional[str] = Field(default=None, description="Irányítószám")
|
||||
address_city: Optional[str] = Field(default=None, description="Város")
|
||||
address_street_name: Optional[str] = Field(default=None, description="Utca név")
|
||||
address_street_type: Optional[str] = Field(default=None, description="Közterület jellege")
|
||||
address_house_number: Optional[str] = Field(default=None, description="Házszám")
|
||||
address_stairwell: Optional[str] = Field(default=None, description="Lépcsőház")
|
||||
address_floor: Optional[str] = Field(default=None, description="Emelet")
|
||||
address_door: Optional[str] = Field(default=None, description="Ajtó")
|
||||
address_hrsz: Optional[str] = Field(default=None, description="Helyrajzi szám")
|
||||
full_address_text: Optional[str] = Field(default=None, description="Teljes cím szöveg")
|
||||
latitude: Optional[float] = Field(default=None, description="GPS szélesség")
|
||||
longitude: Optional[float] = Field(default=None, description="GPS hosszúság")
|
||||
|
||||
|
||||
class PersonUpdateRequest(BaseModel):
|
||||
"""Admin által szerkeszthető Person mezők.
|
||||
|
||||
@@ -554,8 +513,8 @@ class PersonUpdateRequest(BaseModel):
|
||||
is_active: Optional[bool] = Field(default=None, description="Aktív státusz")
|
||||
is_ghost: Optional[bool] = Field(default=None, description="Ghost státusz")
|
||||
|
||||
# Cím adatok (opcionális, nested object)
|
||||
address: Optional[PersonAddressUpdate] = Field(default=None, description="Cím adatok")
|
||||
# Cím adatok (opcionális, nested object) — egységes AddressIn séma
|
||||
address: Optional[AddressIn] = Field(default=None, description="Cím adatok")
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
@@ -692,62 +651,14 @@ async def update_person(
|
||||
changes["is_ghost"] = {"old": person.is_ghost, "new": update_data.is_ghost}
|
||||
person.is_ghost = update_data.is_ghost
|
||||
|
||||
# ── 5. Cím adatok frissítése ──
|
||||
# ── 5. Cím adatok frissítése (P1 REFACTORED: AddressManager) ──
|
||||
if update_data.address is not None:
|
||||
addr_data = update_data.address
|
||||
|
||||
# Ha van már cím, frissítjük, különben létrehozzuk
|
||||
if person.address:
|
||||
address = person.address
|
||||
else:
|
||||
# Új cím létrehozása
|
||||
address = Address(
|
||||
created_at=datetime.utcnow()
|
||||
# P1 FIX: Use AddressManager.create_or_update() instead of
|
||||
# writing directly to Address model fields.
|
||||
person.address_id = await AddressManager.create_or_update(
|
||||
db, person.address_id, update_data.address
|
||||
)
|
||||
db.add(address)
|
||||
person.address = address
|
||||
|
||||
# Cím mezők frissítése
|
||||
addr_changes: Dict[str, Any] = {}
|
||||
if addr_data.address_zip is not None:
|
||||
addr_changes["address_zip"] = {"old": address.zip, "new": addr_data.address_zip}
|
||||
# Megjegyzés: a zip a GeoPostalCode kapcsolatból jön, itt csak a postal_code_id-t tudnánk keresni
|
||||
# Egyszerűsítés: a frontend által küldött adatokat tároljuk a full_address_text-ben is
|
||||
if addr_data.address_city is not None:
|
||||
addr_changes["address_city"] = {"old": address.city, "new": addr_data.address_city}
|
||||
if addr_data.address_street_name is not None:
|
||||
addr_changes["address_street_name"] = {"old": address.street_name, "new": addr_data.address_street_name}
|
||||
address.street_name = addr_data.address_street_name
|
||||
if addr_data.address_street_type is not None:
|
||||
addr_changes["address_street_type"] = {"old": address.street_type, "new": addr_data.address_street_type}
|
||||
address.street_type = addr_data.address_street_type
|
||||
if addr_data.address_house_number is not None:
|
||||
addr_changes["address_house_number"] = {"old": address.house_number, "new": addr_data.address_house_number}
|
||||
address.house_number = addr_data.address_house_number
|
||||
if addr_data.address_stairwell is not None:
|
||||
addr_changes["address_stairwell"] = {"old": address.stairwell, "new": addr_data.address_stairwell}
|
||||
address.stairwell = addr_data.address_stairwell
|
||||
if addr_data.address_floor is not None:
|
||||
addr_changes["address_floor"] = {"old": address.floor, "new": addr_data.address_floor}
|
||||
address.floor = addr_data.address_floor
|
||||
if addr_data.address_door is not None:
|
||||
addr_changes["address_door"] = {"old": address.door, "new": addr_data.address_door}
|
||||
address.door = addr_data.address_door
|
||||
if addr_data.address_hrsz is not None:
|
||||
addr_changes["address_hrsz"] = {"old": address.parcel_id, "new": addr_data.address_hrsz}
|
||||
address.parcel_id = addr_data.address_hrsz
|
||||
if addr_data.full_address_text is not None:
|
||||
addr_changes["full_address_text"] = {"old": address.full_address_text, "new": addr_data.full_address_text}
|
||||
address.full_address_text = addr_data.full_address_text
|
||||
if addr_data.latitude is not None:
|
||||
addr_changes["latitude"] = {"old": address.latitude, "new": addr_data.latitude}
|
||||
address.latitude = addr_data.latitude
|
||||
if addr_data.longitude is not None:
|
||||
addr_changes["longitude"] = {"old": address.longitude, "new": addr_data.longitude}
|
||||
address.longitude = addr_data.longitude
|
||||
|
||||
if addr_changes:
|
||||
changes["address"] = addr_changes
|
||||
changes["address"] = {"old": None, "new": "updated via AddressManager"}
|
||||
|
||||
# ── 6. Mentés ──
|
||||
if changes:
|
||||
@@ -768,20 +679,7 @@ async def update_person(
|
||||
# Cím adatok
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = AddressResponse(
|
||||
address_zip=person.address.zip,
|
||||
address_city=person.address.city,
|
||||
address_street_name=person.address.street_name,
|
||||
address_street_type=person.address.street_type,
|
||||
address_house_number=person.address.house_number,
|
||||
address_stairwell=person.address.stairwell,
|
||||
address_floor=person.address.floor,
|
||||
address_door=person.address.door,
|
||||
address_hrsz=person.address.parcel_id,
|
||||
full_address_text=person.address.full_address_text,
|
||||
latitude=person.address.latitude,
|
||||
longitude=person.address.longitude,
|
||||
)
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
|
||||
# Kapcsolódó User-ek
|
||||
users_data: List[PersonDetailUserInfo] = []
|
||||
|
||||
1788
backend/app/api/v1/endpoints/admin_providers.py
Normal file
1788
backend/app/api/v1/endpoints/admin_providers.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ Végpontok:
|
||||
GET /admin/users/{user_id} — Felhasználó részletes adatai (User + Person + Address)
|
||||
PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által
|
||||
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
|
||||
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.models.identity import User, Person, UserRole
|
||||
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.system.audit import SecurityAuditLog, FinancialLedger
|
||||
from app.models.vehicle.history import AuditLog, LogSeverity
|
||||
from app.schemas.user import UserResponse
|
||||
from app.schemas.organization import OrganizationMemberResponse
|
||||
@@ -644,3 +647,124 @@ async def get_user_memberships(
|
||||
))
|
||||
|
||||
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 app.db.session import get_db
|
||||
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.config import settings
|
||||
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 pydantic import BaseModel, Field, EmailStr
|
||||
from datetime import datetime, timezone
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -57,6 +62,9 @@ async def login(
|
||||
detail="AUTH.USER_INACTIVE"
|
||||
)
|
||||
|
||||
# ── Update last activity timestamp ──
|
||||
user.last_activity_at = datetime.now(timezone.utc)
|
||||
|
||||
# ── Device Fingerprint Tracking ──
|
||||
if device_fingerprint:
|
||||
# 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:
|
||||
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) ──
|
||||
if request.device_fingerprint:
|
||||
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján
|
||||
@@ -380,3 +391,155 @@ async def restore_account_verify(
|
||||
detail=result.get("message", "Érvénytelen vagy lejárt kód.")
|
||||
)
|
||||
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)
|
||||
):
|
||||
"""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:
|
||||
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
|
||||
result = await db.execute(stmt)
|
||||
@@ -97,7 +97,7 @@ async def list_body_types(
|
||||
"id": r.id,
|
||||
"vehicle_class": r.vehicle_class,
|
||||
"code": r.code,
|
||||
"name_hu": r.name_hu,
|
||||
"name_i18n": r.name_i18n if r.name_i18n else {},
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -1,70 +1,233 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
||||
"""
|
||||
Expense (AssetCost) API endpoints.
|
||||
|
||||
P0 Smart Expense Workflow (2026-06-20):
|
||||
- POST /expenses/ — Create expense with odometer normalization, provider validation,
|
||||
smart linking to AssetEvent, and gamification hooks.
|
||||
- GET /expenses/ — List all expenses (admin).
|
||||
- GET /expenses/{asset_id} — List expenses for a specific asset.
|
||||
- PUT /expenses/{expense_id} — Update an existing expense.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1):
|
||||
- amount_gross is the primary field (Bruttó).
|
||||
- amount_net is optional — calculated back from gross + vat_rate.
|
||||
- All VAT calculations happen in the service layer, not in the schema.
|
||||
|
||||
P0 HYBRID VENDOR REFACTOR (2026-07-01):
|
||||
- service_provider_id (marketplace.service_providers) is the primary vendor FK.
|
||||
- vendor_organization_id (fleet.organizations) is secondary (B2B).
|
||||
- external_vendor_name is the fallback for free-text typed names.
|
||||
|
||||
P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation.
|
||||
- A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
|
||||
ami FK violation-t okoz. Itt validáljuk, hogy a megadott ID létezik-e
|
||||
a fleet.organizations táblában. Ha nem, átirányítjuk service_provider_id-ra.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime, timezone, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, func, and_, or_, cast, Text, case, literal, union_all
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.fleet_finance.models import AssetCost, CostCategory
|
||||
from app.models.vehicle.asset import Asset
|
||||
from app.models.vehicle.asset import AssetEvent
|
||||
from app.models.vehicle.asset import OdometerReading
|
||||
from app.models.marketplace.organization import Organization
|
||||
from app.models.marketplace.organization import OrganizationMember, OrgRole
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
|
||||
from app.models.system.system import SystemParameter
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate, AssetCostResponse
|
||||
from app.services.gamification_service import GamificationService
|
||||
from app.services.provider_service import find_or_create_provider_by_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cost category IDs that are service/maintenance/repair related
|
||||
# These trigger automatic AssetEvent creation
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
|
||||
|
||||
# Fuel category IDs - auto-approved even for DRIVER role
|
||||
FUEL_CATEGORY_IDS = {1} # FUEL
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── SINGLE EXPENSE READ ENDPOINT ──
|
||||
# P0 BUGFIX (2026-07-26): Dedicated single-expense endpoint that returns
|
||||
# enriched AssetCostResponse with resolved category name, parent_category_id,
|
||||
# provider name, etc. This is used by the frontend edit modal hydration.
|
||||
#
|
||||
# NOTE: This MUST be registered BEFORE the {asset_id} wildcard route to avoid
|
||||
# FastAPI routing the "by-id" path segment as an asset_id UUID.
|
||||
|
||||
|
||||
@router.get("/by-id/{expense_id}")
|
||||
async def get_expense_by_id(
|
||||
expense_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get a single expense by its ID with enriched fields.
|
||||
|
||||
Returns a fully populated AssetCostResponse including:
|
||||
- category_name, category_code, parent_category_id (from CostCategory JOIN)
|
||||
- service_provider_name (from ServiceProvider JOIN)
|
||||
- vendor_name (from Organization JOIN via vendor_organization_id)
|
||||
- description, mileage_at_cost (extracted from data JSONB)
|
||||
|
||||
This is the preferred endpoint for frontend edit modal hydration.
|
||||
"""
|
||||
from app.models.fleet_finance.models import CostCategory
|
||||
from app.models.identity.social import ServiceProvider
|
||||
from app.models.marketplace.organization import Organization
|
||||
|
||||
stmt = (
|
||||
select(AssetCost)
|
||||
.where(AssetCost.id == expense_id)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
cost = result.scalar_one_or_none()
|
||||
|
||||
if not cost:
|
||||
raise HTTPException(status_code=404, detail="Expense not found.")
|
||||
|
||||
# Resolve enriched fields via explicit queries (no lazy loading surprises)
|
||||
# 1. Category details + parent_id
|
||||
category_name = None
|
||||
category_code = None
|
||||
parent_category_id = None
|
||||
if cost.category_id is not None:
|
||||
cat_stmt = select(CostCategory).where(CostCategory.id == cost.category_id)
|
||||
cat_result = await db.execute(cat_stmt)
|
||||
category = cat_result.scalar_one_or_none()
|
||||
if category:
|
||||
category_name = category.name
|
||||
category_code = category.code
|
||||
parent_category_id = category.parent_id
|
||||
|
||||
# 2. Service provider name
|
||||
service_provider_name = None
|
||||
if cost.service_provider_id is not None:
|
||||
sp_stmt = select(ServiceProvider).where(ServiceProvider.id == cost.service_provider_id)
|
||||
sp_result = await db.execute(sp_stmt)
|
||||
provider = sp_result.scalar_one_or_none()
|
||||
if provider:
|
||||
service_provider_name = provider.name
|
||||
|
||||
# 3. Vendor organization name
|
||||
vendor_name = None
|
||||
if cost.vendor_organization_id is not None:
|
||||
org_stmt = select(Organization).where(Organization.id == cost.vendor_organization_id)
|
||||
org_result = await db.execute(org_stmt)
|
||||
vendor_org = org_result.scalar_one_or_none()
|
||||
if vendor_org:
|
||||
vendor_name = vendor_org.name
|
||||
|
||||
# 4. Extract description and mileage from data JSONB
|
||||
data = cost.data or {}
|
||||
description = data.get("description")
|
||||
mileage_at_cost = data.get("mileage_at_cost")
|
||||
|
||||
response = AssetCostResponse(
|
||||
id=cost.id,
|
||||
asset_id=cost.asset_id,
|
||||
organization_id=cost.organization_id,
|
||||
category_id=cost.category_id,
|
||||
status=cost.status,
|
||||
linked_asset_event_id=cost.linked_asset_event_id,
|
||||
# Monetary
|
||||
amount_gross=cost.amount_gross,
|
||||
amount_net=cost.amount_net,
|
||||
vat_rate=cost.vat_rate,
|
||||
currency=cost.currency,
|
||||
# Dates
|
||||
cost_date=cost.date,
|
||||
invoice_number=cost.invoice_number,
|
||||
# Invoice dates
|
||||
invoice_date=cost.invoice_date,
|
||||
fulfillment_date=cost.fulfillment_date,
|
||||
# Vendor fields
|
||||
vendor_organization_id=cost.vendor_organization_id,
|
||||
service_provider_id=cost.service_provider_id,
|
||||
external_vendor_name=cost.external_vendor_name,
|
||||
# Raw data JSONB
|
||||
data=data,
|
||||
# Enriched fields
|
||||
category_name=category_name,
|
||||
category_code=category_code,
|
||||
parent_category_id=parent_category_id,
|
||||
description=description,
|
||||
mileage_at_cost=mileage_at_cost,
|
||||
vendor_name=vendor_name,
|
||||
service_provider_name=service_provider_name,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": response,
|
||||
}
|
||||
|
||||
# ── FUEL CATEGORY IDS ──
|
||||
# These are the category IDs that represent fuel costs.
|
||||
# Used for auto-approval logic: fuel costs are always auto-approved.
|
||||
FUEL_CATEGORY_IDS = {1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
|
||||
# ── SERVICE-RELATED CATEGORY IDS ──
|
||||
# These categories trigger automatic AssetEvent creation (Smart Linking).
|
||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18}
|
||||
|
||||
gamification_service = GamificationService()
|
||||
|
||||
|
||||
# ── HELPER FUNCTIONS ──
|
||||
|
||||
|
||||
async def _resolve_user_role_in_org(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int
|
||||
) -> str:
|
||||
"""
|
||||
Resolve the user's role within the given organization.
|
||||
organization_id: int,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the user's role in the given organization.
|
||||
|
||||
Returns:
|
||||
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
||||
Returns 'MEMBER' as default if no membership found.
|
||||
Returns the role name (e.g. 'owner', 'admin', 'driver') or None if not found.
|
||||
"""
|
||||
stmt = select(OrganizationMember.role).where(
|
||||
stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
OrganizationMember.status == "active",
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
return role or "MEMBER"
|
||||
member = result.scalar_one_or_none()
|
||||
if member:
|
||||
return member.role
|
||||
return None
|
||||
|
||||
|
||||
async def _check_org_capability(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
organization_id: int,
|
||||
capability_name: str
|
||||
capability: str,
|
||||
) -> bool:
|
||||
"""
|
||||
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
|
||||
"""Check if a user has a specific capability in an organization.
|
||||
|
||||
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
|
||||
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
||||
Uses the JSONB permissions field from fleet.org_roles.
|
||||
Returns True if the user's role has the capability, False otherwise.
|
||||
|
||||
Returns:
|
||||
bool: True ha a user rendelkezik a képességgel.
|
||||
NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
|
||||
OrganizationMember.role is PG ENUM 'fleet.orguserrole', OrgRole.name_key is varchar.
|
||||
A direct JOIN would cause: operator does not exist: fleet.orguserrole = character varying
|
||||
See assets.py:_get_user_permissions() for the same pattern.
|
||||
"""
|
||||
# 1. Get user's org role
|
||||
# Step 1: Get the user's role name (Python string — asyncpg auto-converts ENUM)
|
||||
member_stmt = select(OrganizationMember.role).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
OrganizationMember.status == "active",
|
||||
).limit(1)
|
||||
member_result = await db.execute(member_stmt)
|
||||
org_role_name = member_result.scalar_one_or_none()
|
||||
@@ -72,367 +235,188 @@ async def _check_org_capability(
|
||||
if not org_role_name:
|
||||
return False
|
||||
|
||||
# 2. Get permissions from fleet.org_roles table
|
||||
# Step 2: Look up permissions from fleet.org_roles using the role name
|
||||
role_stmt = select(OrgRole.permissions).where(
|
||||
OrgRole.name_key == org_role_name,
|
||||
OrgRole.is_active == True
|
||||
OrgRole.is_active == True,
|
||||
).limit(1)
|
||||
role_result = await db.execute(role_stmt)
|
||||
permissions = role_result.scalar_one_or_none()
|
||||
|
||||
if not permissions:
|
||||
# Fallback to OrganizationMember.permissions
|
||||
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||
OrganizationMember.user_id == user_id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
member_perm_result = await db.execute(member_perm_stmt)
|
||||
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||
|
||||
if not isinstance(permissions, dict):
|
||||
permissions = {}
|
||||
|
||||
return permissions.get(capability_name, False)
|
||||
if permissions and isinstance(permissions, dict):
|
||||
return permissions.get(capability, False)
|
||||
return False
|
||||
|
||||
|
||||
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
|
||||
"""Calculate net amount from gross amount and VAT rate.
|
||||
|
||||
Formula: net = gross / (1 + vat_rate/100)
|
||||
|
||||
Args:
|
||||
amount_gross: The gross amount (Bruttó)
|
||||
vat_rate: The VAT rate in percent (e.g., 27.00 for 27%)
|
||||
|
||||
Returns:
|
||||
The net amount (Nettó)
|
||||
"""
|
||||
Calculate net amount from gross amount and VAT rate.
|
||||
|
||||
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
|
||||
is the absolute Source of Truth. Net is calculated back from gross.
|
||||
|
||||
Formula: net = gross / (1 + vat_rate / 100)
|
||||
|
||||
If vat_rate is 0 or None, net = gross (0% VAT content).
|
||||
"""
|
||||
if vat_rate is None or vat_rate == 0:
|
||||
if vat_rate == 0:
|
||||
return amount_gross
|
||||
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
|
||||
divisor = Decimal("1") + (vat_rate / Decimal("100"))
|
||||
return (amount_gross / divisor).quantize(Decimal("0.01"))
|
||||
|
||||
|
||||
# ── ENDPOINTS ──
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_all_expenses(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user),
|
||||
page: int = Query(1, ge=1, description="Page number"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
||||
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"),
|
||||
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."),
|
||||
current_user=Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
asset_id: Optional[uuid.UUID] = Query(None),
|
||||
organization_id: Optional[int] = Query(None),
|
||||
category_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc, amount_desc, amount_asc"),
|
||||
):
|
||||
"""
|
||||
List all expenses across all assets for the current user's organization,
|
||||
ordered by date descending with pagination.
|
||||
Supports optional asset_id filter for vehicle-specific cost views.
|
||||
Supports optional organization_id filter for cross-org views (user must be a member).
|
||||
Returns enriched expense data including vehicle info, category name, and vendor.
|
||||
List all expenses (admin endpoint).
|
||||
Supports filtering by asset_id, organization_id, category_id, status.
|
||||
Supports sorting by date or amount.
|
||||
"""
|
||||
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org
|
||||
if organization_id is not None:
|
||||
# Verify user is a member of the requested organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the requested organization."
|
||||
)
|
||||
org_id = organization_id
|
||||
stmt = select(AssetCost)
|
||||
|
||||
if asset_id:
|
||||
stmt = stmt.where(AssetCost.asset_id == asset_id)
|
||||
if organization_id:
|
||||
stmt = stmt.where(AssetCost.organization_id == organization_id)
|
||||
if category_id:
|
||||
stmt = stmt.where(AssetCost.category_id == category_id)
|
||||
if status:
|
||||
stmt = stmt.where(AssetCost.status == status)
|
||||
|
||||
# Sorting
|
||||
if sort == "date_asc":
|
||||
stmt = stmt.order_by(AssetCost.date.asc())
|
||||
elif sort == "amount_desc":
|
||||
stmt = stmt.order_by(AssetCost.amount_gross.desc())
|
||||
elif sort == "amount_asc":
|
||||
stmt = stmt.order_by(AssetCost.amount_gross.asc())
|
||||
else:
|
||||
# Fallback: resolve user's first active organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="No active organization membership found.")
|
||||
org_id = membership.organization_id
|
||||
stmt = stmt.order_by(AssetCost.date.desc())
|
||||
|
||||
# Calculate offset
|
||||
offset = (page - 1) * page_size
|
||||
# Count total
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Build base query with joins
|
||||
base_select = (
|
||||
select(
|
||||
AssetCost,
|
||||
CostCategory.code,
|
||||
CostCategory.name,
|
||||
Organization.name,
|
||||
Asset.license_plate,
|
||||
Asset.brand,
|
||||
Asset.model,
|
||||
)
|
||||
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
|
||||
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
|
||||
.join(Asset, AssetCost.asset_id == Asset.id)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filters = [AssetCost.organization_id == org_id]
|
||||
if asset_id:
|
||||
filters.append(AssetCost.asset_id == asset_id)
|
||||
|
||||
expense_stmt = (
|
||||
base_select
|
||||
.where(*filters)
|
||||
.order_by(desc(AssetCost.date))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
expense_result = await db.execute(expense_stmt)
|
||||
rows = expense_result.all()
|
||||
|
||||
# Build enriched response
|
||||
expenses = []
|
||||
for row in rows:
|
||||
cost = row[0]
|
||||
cat_code = row[1]
|
||||
cat_name = row[2]
|
||||
vendor_name = row[3]
|
||||
license_plate = row[4]
|
||||
brand = row[5]
|
||||
model = row[6]
|
||||
|
||||
data = cost.data or {}
|
||||
description = data.get("description")
|
||||
mileage_at_cost = data.get("mileage_at_cost")
|
||||
|
||||
# Build vehicle display name
|
||||
vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown"
|
||||
|
||||
expenses.append({
|
||||
"id": str(cost.id),
|
||||
"asset_id": str(cost.asset_id),
|
||||
"organization_id": cost.organization_id,
|
||||
"category_id": cost.category_id,
|
||||
"category_code": cat_code,
|
||||
"category_name": cat_name,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
"status": cost.status,
|
||||
"description": description,
|
||||
"mileage_at_cost": mileage_at_cost,
|
||||
"invoice_number": cost.invoice_number,
|
||||
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
|
||||
"vendor_organization_id": cost.vendor_organization_id,
|
||||
"external_vendor_name": cost.external_vendor_name,
|
||||
"vendor_name": vendor_name,
|
||||
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
|
||||
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
|
||||
# Vehicle info
|
||||
"vehicle_name": vehicle_name,
|
||||
"license_plate": license_plate,
|
||||
})
|
||||
|
||||
# Count total for pagination (respect asset_id filter)
|
||||
count_filters = [AssetCost.organization_id == org_id]
|
||||
if asset_id:
|
||||
count_filters.append(AssetCost.asset_id == asset_id)
|
||||
count_stmt = (
|
||||
select(func.count(AssetCost.id))
|
||||
.where(*count_filters)
|
||||
)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar()
|
||||
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
# Paginate
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
expenses = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": expenses,
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"data": expenses,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.put("/{expense_id}", status_code=200)
|
||||
async def update_expense(
|
||||
expense_id: UUID,
|
||||
update: AssetCostUpdate,
|
||||
expense_id: uuid.UUID,
|
||||
expense_update: AssetCostUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update an existing expense record.
|
||||
Update an existing expense (PUT /expenses/{expense_id}).
|
||||
|
||||
Only the fields provided in the request body will be updated.
|
||||
The expense_id is the UUID of the existing AssetCost record.
|
||||
The user must be a member of the organization that owns the expense.
|
||||
All fields in AssetCostUpdate are optional — only provided fields will be updated.
|
||||
The expense_id is passed via path parameter.
|
||||
"""
|
||||
# Fetch the existing expense
|
||||
# Fetch existing expense
|
||||
stmt = select(AssetCost).where(AssetCost.id == expense_id)
|
||||
result = await db.execute(stmt)
|
||||
cost = result.scalar_one_or_none()
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if not cost:
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Expense not found.")
|
||||
|
||||
# Verify user has access to the expense's organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.organization_id == cost.organization_id,
|
||||
OrganizationMember.status == "active"
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
membership = org_result.scalar_one_or_none()
|
||||
# Update only provided fields
|
||||
update_data = expense_update.model_dump(exclude_unset=True)
|
||||
|
||||
if not membership:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You are not an active member of the organization that owns this expense."
|
||||
)
|
||||
|
||||
# Build update dict from non-None fields
|
||||
update_data = update.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields provided for update.")
|
||||
|
||||
# Map 'date' field to 'cost_date' column if present
|
||||
if 'date' in update_data:
|
||||
update_data['cost_date'] = update_data.pop('date')
|
||||
|
||||
# Handle mileage_at_cost and description → store in data JSONB
|
||||
data_update = {}
|
||||
if 'mileage_at_cost' in update_data:
|
||||
data_update['mileage_at_cost'] = update_data.pop('mileage_at_cost')
|
||||
if 'description' in update_data:
|
||||
data_update['description'] = update_data.pop('description')
|
||||
|
||||
# Merge data_update into existing data JSONB
|
||||
if data_update:
|
||||
existing_data = dict(cost.data or {})
|
||||
existing_data.update(data_update)
|
||||
update_data['data'] = existing_data
|
||||
# Handle special fields
|
||||
if "date" in update_data:
|
||||
update_data["date"] = update_data.pop("date")
|
||||
if "mileage_at_cost" in update_data:
|
||||
# mileage_at_cost is stored inside data JSONB
|
||||
if existing.data is None:
|
||||
existing.data = {}
|
||||
existing.data["mileage_at_cost"] = update_data.pop("mileage_at_cost")
|
||||
if "description" in update_data:
|
||||
if existing.data is None:
|
||||
existing.data = {}
|
||||
existing.data["description"] = update_data.pop("description")
|
||||
|
||||
# Apply updates
|
||||
for field, value in update_data.items():
|
||||
setattr(cost, field, value)
|
||||
setattr(existing, field, value)
|
||||
|
||||
try:
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
await db.refresh(cost)
|
||||
|
||||
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
|
||||
await db.refresh(existing)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"id": cost.id,
|
||||
"asset_id": cost.asset_id,
|
||||
"category_id": cost.category_id,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"expense_status": cost.status,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
"id": existing.id,
|
||||
"message": "Expense updated successfully.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Expense update error for {expense_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Hiba a költség módosításakor"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{asset_id}")
|
||||
async def list_asset_expenses(
|
||||
asset_id: UUID,
|
||||
asset_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of expenses to skip"),
|
||||
current_user=Depends(get_current_user),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
sort: Optional[str] = Query("date_desc", description="Sort order: date_desc, date_asc"),
|
||||
):
|
||||
"""
|
||||
List all expenses for a specific asset, ordered by date descending.
|
||||
Returns enriched expense data including category name, code, and vendor info.
|
||||
Used by the OverviewTab and CostManagerModal to display real expense data.
|
||||
List expenses for a specific asset.
|
||||
Supports pagination and sorting by date.
|
||||
"""
|
||||
# Validate asset exists
|
||||
stmt = select(Asset).where(Asset.id == asset_id)
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Asset not found.")
|
||||
|
||||
# Fetch expenses with category join and vendor organization join
|
||||
expense_stmt = (
|
||||
select(AssetCost, CostCategory.code, CostCategory.name, Organization.name)
|
||||
.outerjoin(CostCategory, AssetCost.category_id == CostCategory.id)
|
||||
.outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id)
|
||||
stmt = (
|
||||
select(AssetCost)
|
||||
.where(AssetCost.asset_id == asset_id)
|
||||
.order_by(desc(AssetCost.date))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
expense_result = await db.execute(expense_stmt)
|
||||
rows = expense_result.all()
|
||||
|
||||
# Build enriched response
|
||||
expenses = []
|
||||
for row in rows:
|
||||
cost = row[0] # AssetCost instance
|
||||
cat_code = row[1] # CostCategory.code
|
||||
cat_name = row[2] # CostCategory.name
|
||||
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
|
||||
# Sorting
|
||||
if sort == "date_asc":
|
||||
stmt = stmt.order_by(AssetCost.date.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(AssetCost.date.desc())
|
||||
|
||||
# Extract description and mileage from data JSONB
|
||||
data = cost.data or {}
|
||||
description = data.get("description")
|
||||
mileage_at_cost = data.get("mileage_at_cost")
|
||||
# Count total
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
expenses.append({
|
||||
"id": str(cost.id),
|
||||
"asset_id": str(cost.asset_id),
|
||||
"organization_id": cost.organization_id,
|
||||
"category_id": cost.category_id,
|
||||
"category_code": cat_code,
|
||||
"category_name": cat_name,
|
||||
"amount_gross": str(cost.amount_gross) if cost.amount_gross else None,
|
||||
"amount_net": str(cost.amount_net),
|
||||
"vat_rate": str(cost.vat_rate) if cost.vat_rate else None,
|
||||
"currency": cost.currency,
|
||||
"date": cost.date.isoformat() if cost.date else None,
|
||||
"status": cost.status,
|
||||
"description": description,
|
||||
"mileage_at_cost": mileage_at_cost,
|
||||
"invoice_number": cost.invoice_number,
|
||||
"linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None,
|
||||
# === B2B VENDOR FIELDS ===
|
||||
"vendor_organization_id": cost.vendor_organization_id,
|
||||
"external_vendor_name": cost.external_vendor_name,
|
||||
"vendor_name": vendor_name, # Enriched from fleet.organizations.name
|
||||
# === INVOICE DATES ===
|
||||
"invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None,
|
||||
"fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None,
|
||||
})
|
||||
|
||||
# Count total for pagination
|
||||
count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id)
|
||||
count_result = await db.execute(count_stmt)
|
||||
total = count_result.scalar()
|
||||
# Paginate
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
expenses = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": expenses,
|
||||
"status": "success",
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"data": expenses,
|
||||
}
|
||||
|
||||
|
||||
@@ -440,7 +424,7 @@ async def list_asset_expenses(
|
||||
async def create_expense(
|
||||
expense: AssetCostCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(get_current_user)
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||
@@ -563,8 +547,88 @@ async def create_expense(
|
||||
if expense.description:
|
||||
data["description"] = expense.description
|
||||
|
||||
# ── PROVIDER AUTO-DISCOVERY HOOK (Card #360) ──
|
||||
# Ha external_vendor_name meg van adva, de service_provider_id nincs,
|
||||
# automatikusan felfedezzük vagy létrehozzuk a providert.
|
||||
#
|
||||
# P0 ARCHITECTURE CLEANUP (2026-07-01):
|
||||
# A gamification/XP logika ELTÁVOLÍTVA a service rétegből.
|
||||
# A find_or_create_provider_by_name() már csak (provider, created) tuple-t ad vissza.
|
||||
# Ha created=True (új provider felfedezve), itt az API rétegben hívjuk meg
|
||||
# a gamification_service.award_points()-t a PROVIDER_DISCOVERY action_key-kel.
|
||||
resolved_provider_id = expense.service_provider_id
|
||||
if expense.external_vendor_name and not expense.service_provider_id:
|
||||
try:
|
||||
# Create AssetCost instance with new fields
|
||||
provider, created = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=expense.external_vendor_name,
|
||||
added_by_user_id=current_user.id,
|
||||
)
|
||||
resolved_provider_id = provider.id
|
||||
|
||||
# P0 ARCHITECTURE CLEANUP: Gamification XP kiosztása az API rétegben
|
||||
# Csak akkor adunk XP-t, ha a provider újonnan lett felfedezve (created=True)
|
||||
if created:
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
amount=0,
|
||||
reason="PROVIDER_DISCOVERY",
|
||||
commit=False, # A hívó (create_expense) kezeli a commit-ot
|
||||
action_key="PROVIDER_DISCOVERY",
|
||||
)
|
||||
logger.info(
|
||||
f"Gamification XP awarded for PROVIDER_DISCOVERY: "
|
||||
f"name='{expense.external_vendor_name}', "
|
||||
f"provider_id={provider.id}, user_id={current_user.id}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Provider auto-discovery: name='{expense.external_vendor_name}', "
|
||||
f"provider_id={provider.id}, created={created}, "
|
||||
f"user_id={current_user.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
# Ha a provider felderítés hibázik, ne blokkolja a költség rögzítését
|
||||
logger.warning(
|
||||
f"Provider auto-discovery failed for '{expense.external_vendor_name}': {e}. "
|
||||
f"Expense will be created without provider link."
|
||||
)
|
||||
|
||||
try:
|
||||
# ── P0 BUGFIX (2026-07-10): FK-safe vendor_organization_id validation ──
|
||||
# A frontend elküldheti a ServiceProvider.id-t vendor_organization_id-ként,
|
||||
# ami FK violation-t okoz, mert az FK a fleet.organizations táblára hivatkozik.
|
||||
# Itt validáljuk, hogy a megadott vendor_organization_id létezik-e.
|
||||
resolved_vendor_org_id = expense.vendor_organization_id
|
||||
if resolved_vendor_org_id is not None:
|
||||
try:
|
||||
org_check_stmt = select(Organization.id).where(
|
||||
Organization.id == resolved_vendor_org_id,
|
||||
Organization.is_deleted == False,
|
||||
)
|
||||
org_check_result = await db.execute(org_check_stmt)
|
||||
org_exists = org_check_result.scalar_one_or_none()
|
||||
if org_exists is None:
|
||||
# A megadott ID nem létezik fleet.organizations-ben.
|
||||
# Valószínűleg ServiceProvider.id-t küldött a frontend.
|
||||
logger.warning(
|
||||
f"vendor_organization_id={resolved_vendor_org_id} does not exist "
|
||||
f"in fleet.organizations. Setting to None and using service_provider_id."
|
||||
)
|
||||
resolved_vendor_org_id = None
|
||||
# Ha nincs resolved_provider_id, akkor a vendor_organization_id-t
|
||||
# használjuk service_provider_id-ként (P0 Hybrid Vendor Refactor)
|
||||
if resolved_provider_id is None:
|
||||
resolved_provider_id = expense.vendor_organization_id
|
||||
except Exception as org_check_e:
|
||||
logger.warning(
|
||||
f"vendor_organization_id validation failed for "
|
||||
f"id={resolved_vendor_org_id}: {org_check_e}. Setting to None."
|
||||
)
|
||||
resolved_vendor_org_id = None
|
||||
|
||||
# ── PHASE 1: Create AssetCost instance with new fields ──
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
@@ -578,9 +642,10 @@ async def create_expense(
|
||||
status=expense_status,
|
||||
data=data,
|
||||
# === B2B VENDOR FIELDS ===
|
||||
vendor_organization_id=expense.vendor_organization_id,
|
||||
vendor_organization_id=resolved_vendor_org_id,
|
||||
# P0 HYBRID VENDOR REFACTOR: service_provider_id az expense creation-ben
|
||||
service_provider_id=expense.service_provider_id,
|
||||
# Ha az auto-discovery hook feloldotta a providert, a resolved_provider_id-t használjuk
|
||||
service_provider_id=resolved_provider_id,
|
||||
external_vendor_name=expense.external_vendor_name,
|
||||
# === INVOICE DATES ===
|
||||
invoice_date=expense.invoice_date,
|
||||
@@ -590,7 +655,76 @@ async def create_expense(
|
||||
db.add(new_cost)
|
||||
await db.flush() # Flush to get new_cost.id
|
||||
|
||||
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
|
||||
# ── PHASE 2: ODOMETER NORMALIZATION ──
|
||||
# P0 Smart Expense Workflow: Create a dedicated OdometerReading record
|
||||
# linked to the asset_id and the newly created cost_id.
|
||||
odometer_record = None
|
||||
if expense.mileage_at_cost is not None:
|
||||
odometer_record = OdometerReading(
|
||||
asset_id=expense.asset_id,
|
||||
reading=expense.mileage_at_cost,
|
||||
source="expense",
|
||||
cost_id=new_cost.id,
|
||||
)
|
||||
db.add(odometer_record)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"Odometer normalization: created reading {odometer_record.id} "
|
||||
f"for asset {expense.asset_id}, reading={expense.mileage_at_cost}, "
|
||||
f"linked to cost {new_cost.id}"
|
||||
)
|
||||
|
||||
# ── PHASE 3: PROVIDER VALIDATION SCORE INCREMENT ──
|
||||
# P0 Smart Expense Workflow: If service_provider_id is provided,
|
||||
# increment validation_score by 10. If it reaches 100, set is_verified.
|
||||
provider_validated = False
|
||||
provider_id_for_gamification = resolved_provider_id or expense.service_provider_id
|
||||
if provider_id_for_gamification is not None:
|
||||
try:
|
||||
provider_stmt = select(ServiceProvider).where(
|
||||
ServiceProvider.id == provider_id_for_gamification
|
||||
)
|
||||
provider_result = await db.execute(provider_stmt)
|
||||
provider = provider_result.scalar_one_or_none()
|
||||
|
||||
if provider:
|
||||
old_score = provider.validation_score or 0
|
||||
if old_score < 100:
|
||||
new_score = min(old_score + 10, 100)
|
||||
provider.validation_score = new_score
|
||||
provider_validated = True
|
||||
|
||||
logger.info(
|
||||
f"Provider validation score incremented: provider_id={provider.id}, "
|
||||
f"old_score={old_score}, new_score={new_score}"
|
||||
)
|
||||
|
||||
# If validation_score reaches 100, mark the provider as verified
|
||||
if new_score >= 100:
|
||||
# Update ServiceProvider status to approved
|
||||
from app.models.identity.social import ModerationStatus
|
||||
provider.status = ModerationStatus.approved
|
||||
|
||||
# Also update the linked ServiceProfile.is_verified if exists
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
ServiceProfile.organization_id == provider.id
|
||||
)
|
||||
profile_result = await db.execute(profile_stmt)
|
||||
profile = profile_result.scalar_one_or_none()
|
||||
if profile:
|
||||
profile.is_verified = True
|
||||
logger.info(
|
||||
f"Provider fully verified: provider_id={provider.id}, "
|
||||
f"profile_id={profile.id}"
|
||||
)
|
||||
except Exception as prov_e:
|
||||
# Provider validation failure should not block expense creation
|
||||
logger.warning(
|
||||
f"Provider validation score increment failed for "
|
||||
f"provider_id={provider_id_for_gamification}: {prov_e}"
|
||||
)
|
||||
|
||||
# ── PHASE 4: SMART LINKING - Auto-create AssetEvent for service-related costs ──
|
||||
event_id = None
|
||||
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
|
||||
# Map cost category to event type
|
||||
@@ -632,6 +766,51 @@ async def create_expense(
|
||||
if expense.mileage_at_cost is not None and expense.mileage_at_cost > (asset.current_mileage or 0):
|
||||
asset.current_mileage = expense.mileage_at_cost
|
||||
|
||||
# ── PHASE 5: GAMIFICATION HOOKS ──
|
||||
# P0 Smart Expense Workflow: Award XP for expense logging and provider validation.
|
||||
# All gamification calls use commit=False to stay within the outer transaction.
|
||||
|
||||
# 5a. Always award APP_USAGE_EXPENSE for successful expense logging
|
||||
try:
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
amount=0,
|
||||
reason="APP_USAGE_EXPENSE",
|
||||
commit=False,
|
||||
action_key="APP_USAGE_EXPENSE",
|
||||
)
|
||||
logger.info(
|
||||
f"Gamification XP awarded for APP_USAGE_EXPENSE: "
|
||||
f"user_id={current_user.id}, cost_id={new_cost.id}"
|
||||
)
|
||||
except Exception as gam_e:
|
||||
logger.warning(
|
||||
f"Gamification APP_USAGE_EXPENSE failed for user {current_user.id}: {gam_e}"
|
||||
)
|
||||
|
||||
# 5b. If provider validation score was incremented, award PROVIDER_VALIDATION_HELP
|
||||
if provider_validated:
|
||||
try:
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
amount=0,
|
||||
reason="PROVIDER_VALIDATION_HELP",
|
||||
commit=False,
|
||||
action_key="PROVIDER_VALIDATION_HELP",
|
||||
)
|
||||
logger.info(
|
||||
f"Gamification XP awarded for PROVIDER_VALIDATION_HELP: "
|
||||
f"user_id={current_user.id}, provider_id={provider_id_for_gamification}"
|
||||
)
|
||||
except Exception as gam_e:
|
||||
logger.warning(
|
||||
f"Gamification PROVIDER_VALIDATION_HELP failed for "
|
||||
f"user {current_user.id}: {gam_e}"
|
||||
)
|
||||
|
||||
# ── FINAL COMMIT: Single atomic transaction ──
|
||||
await db.commit()
|
||||
await db.refresh(new_cost)
|
||||
|
||||
@@ -646,6 +825,8 @@ async def create_expense(
|
||||
"expense_status": new_cost.status,
|
||||
"date": new_cost.date.isoformat() if new_cost.date else None,
|
||||
"event_id": str(event_id) if event_id else None,
|
||||
"odometer_reading_id": str(odometer_record.id) if odometer_record else None,
|
||||
"provider_validated": provider_validated,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
# /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.
|
||||
"""
|
||||
|
||||
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 import select
|
||||
from typing import List
|
||||
from sqlalchemy import select, func, text
|
||||
from typing import List, Optional
|
||||
|
||||
from app.api import deps
|
||||
from app.models.identity import User
|
||||
from app.models.marketplace.finance import Issuer
|
||||
from app.schemas.finance import IssuerResponse, IssuerUpdate
|
||||
from app.models.system.audit import FinancialLedger
|
||||
from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Issuer Endpoints (existing)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
|
||||
async def list_issuers(
|
||||
db: AsyncSession = Depends(deps.get_db),
|
||||
@@ -27,6 +34,7 @@ async def list_issuers(
|
||||
List all Issuers (billing entities).
|
||||
Protected by RequirePermission("finance:view").
|
||||
"""
|
||||
from app.models.marketplace.finance import Issuer
|
||||
result = await db.execute(select(Issuer).order_by(Issuer.id))
|
||||
issuers = result.scalars().all()
|
||||
return issuers
|
||||
@@ -44,6 +52,7 @@ async def update_issuer(
|
||||
Update an Issuer's details (activate/deactivate, revenue limit, API config).
|
||||
Protected by RequirePermission("finance:edit").
|
||||
"""
|
||||
from app.models.marketplace.finance import Issuer
|
||||
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
|
||||
issuer = result.scalar_one_or_none()
|
||||
|
||||
@@ -62,3 +71,133 @@ async def update_issuer(
|
||||
await db.refresh(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.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
|
||||
from app.schemas.subscription import SubscriptionTierResponse
|
||||
from app.schemas.address import AddressOut
|
||||
from app.services.billing_engine import upgrade_org_subscription
|
||||
from app.services.address_manager import AddressManager
|
||||
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
|
||||
from app.models.identity import User, OneTimePassword
|
||||
from app.models.identity.address import Address
|
||||
from app.core.config import settings
|
||||
from app.services.security_service import security_service
|
||||
from app.models import LogSeverity
|
||||
@@ -65,7 +68,10 @@ async def onboard_organization(
|
||||
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása
|
||||
temp_slug = hashlib.md5(f"{org_in.tax_number}-{uuid.uuid4()}".encode()).hexdigest()[:12]
|
||||
|
||||
# 4. Mentés
|
||||
# 4. P0 REFACTORED: Create Address record via AddressManager, then link via address_id FK
|
||||
address_id = await AddressManager.create_or_update(db, None, org_in.address)
|
||||
|
||||
# 5. Mentés
|
||||
new_org = Organization(
|
||||
full_name=org_in.full_name,
|
||||
name=org_in.name,
|
||||
@@ -73,12 +79,7 @@ async def onboard_organization(
|
||||
tax_number=org_in.tax_number,
|
||||
reg_number=org_in.reg_number,
|
||||
folder_slug=temp_slug,
|
||||
address_zip=org_in.address_zip,
|
||||
address_city=org_in.address_city,
|
||||
address_street_name=org_in.address_street_name,
|
||||
address_street_type=org_in.address_street_type,
|
||||
address_house_number=org_in.address_house_number,
|
||||
address_hrsz=org_in.address_hrsz,
|
||||
address_id=address_id,
|
||||
country_code=org_in.country_code,
|
||||
org_type=OrgType.business,
|
||||
status="pending_verification",
|
||||
@@ -96,22 +97,17 @@ async def onboard_organization(
|
||||
db.add(new_org)
|
||||
await db.flush()
|
||||
|
||||
# 5. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA
|
||||
# 6. ALAPÉRTELMEZETT KÖZPONTI TELEPHELY LÉTREHOZÁSA (with address_id FK)
|
||||
main_branch = Branch(
|
||||
organization_id=new_org.id,
|
||||
name=_("ORGANIZATION.BRANCH.DEFAULT_NAME"),
|
||||
is_main=True,
|
||||
postal_code=org_in.address_zip,
|
||||
city=org_in.address_city,
|
||||
street_name=org_in.address_street_name,
|
||||
street_type=org_in.address_street_type,
|
||||
house_number=org_in.address_house_number,
|
||||
hrsz=org_in.address_hrsz,
|
||||
address_id=address_id,
|
||||
status="active"
|
||||
)
|
||||
db.add(main_branch)
|
||||
|
||||
# 6. TULAJDONOS RÖGZÍTÉSE
|
||||
# 7. TULAJDONOS RÖGZÍTÉSE
|
||||
owner_member = OrganizationMember(
|
||||
organization_id=new_org.id,
|
||||
user_id=current_user.id,
|
||||
@@ -119,7 +115,7 @@ async def onboard_organization(
|
||||
)
|
||||
db.add(owner_member)
|
||||
|
||||
# 7. NAS Mappa létrehozása
|
||||
# 8. NAS Mappa létrehozása
|
||||
try:
|
||||
base_path = getattr(settings, "NAS_STORAGE_PATH", "/mnt/nas/app_data")
|
||||
org_path = os.path.join(base_path, "organizations", str(new_org.id))
|
||||
@@ -185,6 +181,7 @@ async def get_my_organizations(
|
||||
A bejelentkezett felhasználóhoz tartozó szervezetek listázása.
|
||||
P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket
|
||||
a subscription_tier JSONB rules alapján.
|
||||
P0 REFACTORED: Address adatok az address relációból (AddressOut séma).
|
||||
"""
|
||||
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||
|
||||
@@ -205,7 +202,10 @@ async def get_my_organizations(
|
||||
stmt = (
|
||||
select(Organization)
|
||||
.where(Organization.id.in_(select(subq.c.id)))
|
||||
.options(selectinload(Organization.members))
|
||||
.options(
|
||||
selectinload(Organization.members),
|
||||
selectinload(Organization.address),
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
orgs = result.scalars().all()
|
||||
@@ -256,13 +256,8 @@ async def get_my_organizations(
|
||||
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
|
||||
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
|
||||
"user_role": _get_user_role(o, current_user.id),
|
||||
# ── Cím adatok (Address fields) ──
|
||||
"address_zip": o.address_zip,
|
||||
"address_city": o.address_city,
|
||||
"address_street_name": o.address_street_name,
|
||||
"address_street_type": o.address_street_type,
|
||||
"address_house_number": o.address_house_number,
|
||||
"address_hrsz": o.address_hrsz,
|
||||
# ── P0 REFACTORED: Address from relationship ──
|
||||
"address": AddressOut.model_validate(o.address).model_dump() if o.address else None,
|
||||
}
|
||||
for o in orgs
|
||||
]
|
||||
@@ -704,6 +699,12 @@ async def update_organization(
|
||||
"""
|
||||
Szervezet adatainak részleges frissítése (általános PATCH).
|
||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||
|
||||
P0 REFACTORED: Address kezelés a unified AddressIn/AddressOut séma alapján.
|
||||
- Ha payload.address meg van adva, akkor:
|
||||
* Ha van meglévő address_id → frissíti a system.addresses rekordot
|
||||
* Ha nincs → létrehoz egy új system.addresses rekordot és beállítja az address_id FK-t
|
||||
- A régi denormalizált mezők (address_zip, address_city, stb.) ELTÁVOLÍTVA.
|
||||
"""
|
||||
# 1. Jogosultság ellenőrzése
|
||||
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
|
||||
@@ -745,7 +746,15 @@ async def update_organization(
|
||||
org.visual_settings = current_vs
|
||||
del update_dict["visual_settings"]
|
||||
|
||||
# 4. Többi mező frissítése
|
||||
# 3. P0 REFACTORED: Address FK logic via AddressManager
|
||||
if "address" in update_dict:
|
||||
addr_in = update_dict["address"]
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, addr_in
|
||||
)
|
||||
del update_dict["address"]
|
||||
|
||||
# 4. Többi mező frissítése (kivéve a régi denormalizált címmezőket)
|
||||
for field, value in update_dict.items():
|
||||
if hasattr(org, field) and value is not None:
|
||||
setattr(org, field, value)
|
||||
|
||||
@@ -15,9 +15,10 @@ import logging
|
||||
from typing import Optional, List
|
||||
|
||||
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.orm import joinedload
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
@@ -76,8 +77,8 @@ async def get_category_tree(
|
||||
nodes.append(CategoryTreeNode(
|
||||
id=tag.id,
|
||||
key=tag.key,
|
||||
name_hu=tag.name_hu,
|
||||
name_en=tag.name_en,
|
||||
name_i18n=tag.name_i18n if tag.name_i18n else {},
|
||||
description_i18n=tag.description_i18n,
|
||||
level=tag.level,
|
||||
path=tag.path,
|
||||
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.
|
||||
|
||||
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:
|
||||
stmt = select(ExpertiseTag).where(
|
||||
ExpertiseTag.level >= 2,
|
||||
ExpertiseTag.is_official == True,
|
||||
or_(
|
||||
ExpertiseTag.name_hu.ilike(f"%{q}%"),
|
||||
ExpertiseTag.name_en.ilike(f"%{q}%"),
|
||||
cast(ExpertiseTag.name_i18n, String).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)
|
||||
tags = result.scalars().all()
|
||||
@@ -126,8 +126,7 @@ async def autocomplete_categories(
|
||||
CategoryAutocompleteItem(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||
level=t.level,
|
||||
path=t.path,
|
||||
parent_id=t.parent_id,
|
||||
@@ -164,8 +163,7 @@ async def list_expertise_categories(
|
||||
ExpertiseCategoryOut(
|
||||
id=t.id,
|
||||
key=t.key,
|
||||
name_hu=t.name_hu,
|
||||
name_en=t.name_en,
|
||||
name_i18n=t.name_i18n if t.name_i18n else {},
|
||||
category=t.category,
|
||||
level=t.level,
|
||||
parent_id=t.parent_id,
|
||||
@@ -183,7 +181,7 @@ async def list_expertise_categories(
|
||||
|
||||
@router.get("/search", response_model=ProviderSearchResponse)
|
||||
async def search_service_providers(
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"),
|
||||
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)"),
|
||||
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"),
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
from app.db.session import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.marketplace.organization import Organization, Branch
|
||||
from app.models.identity.address import Address, GeoPostalCode
|
||||
from geoalchemy2 import WKTElement
|
||||
from typing import Optional
|
||||
|
||||
@@ -23,16 +24,24 @@ async def match_service(
|
||||
"""
|
||||
Geofencing keresőmotor PostGIS segítségével.
|
||||
Ha nincs megadva lat/lng, akkor nem alkalmazunk távolságszűrést.
|
||||
|
||||
P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A Branch.city denormalizált
|
||||
mező helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
|
||||
a város adatokat. LEFT JOIN-t használunk, mert az address_id lehet NULL.
|
||||
"""
|
||||
# Alap lekérdezés: aktív szervezetek és telephelyek
|
||||
query = select(
|
||||
Organization.id,
|
||||
Organization.name,
|
||||
Branch.city,
|
||||
GeoPostalCode.city.label("city"),
|
||||
Branch.branch_rating,
|
||||
Branch.location
|
||||
).join(
|
||||
Branch, Organization.id == Branch.organization_id
|
||||
).outerjoin(
|
||||
Address, Address.id == Branch.address_id
|
||||
).outerjoin(
|
||||
GeoPostalCode, GeoPostalCode.id == Address.postal_code_id
|
||||
).where(
|
||||
Organization.is_active == True,
|
||||
Branch.is_deleted == False
|
||||
|
||||
@@ -36,8 +36,8 @@ async def register_service_hunt(
|
||||
"""), {"n": name, "f": f"{name}-{lat}-{lng}", "lat": lat, "lng": lng, "user_id": current_user.id})
|
||||
|
||||
# MB 2.0 Gamification: Dinamikus pontszám a felfedezésért
|
||||
reward_points = await ConfigService.get_int(db, "GAMIFICATION_HUNT_REWARD", 50)
|
||||
await GamificationService.award_points(db, current_user.id, reward_points, f"Service Hunt: {name}")
|
||||
# A pontérték a point_rules táblából jön (action_key='SERVICE_HUNT')
|
||||
await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Hunt: {name}", action_key="SERVICE_HUNT")
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Discovery registered and points awarded."}
|
||||
|
||||
@@ -86,8 +86,8 @@ async def validate_staged_service(
|
||||
)
|
||||
|
||||
# 5. Adományozz dinamikus XP-t a current_user-nek a GamificationService-en keresztül
|
||||
validation_reward = await ConfigService.get_int(db, "GAMIFICATION_VALIDATE_REWARD", 10)
|
||||
await GamificationService.award_points(db, current_user.id, validation_reward, f"Service Validation: staging #{staging_id}")
|
||||
# A pontérték a point_rules táblából jön (action_key='SERVICE_VALIDATION')
|
||||
await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Validation: staging #{staging_id}", action_key="SERVICE_VALIDATION")
|
||||
|
||||
# 6. Növeld a current_user places_validated értékét a UserStats-ban
|
||||
await db.execute(
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import datetime
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.schemas.user import UserResponse, UserUpdate, ActiveOrganizationUpdate, UserWithTokenResponse, PersonUpdate, ChangePasswordRequest
|
||||
from app.schemas.address import AddressOut
|
||||
from app.models.identity import User, Person
|
||||
from app.models.identity.address import Address
|
||||
from app.services.trust_engine import TrustEngine
|
||||
@@ -69,20 +70,7 @@ async def _build_user_response(user: User, active_org_id: Optional[int] = None,
|
||||
if person:
|
||||
address_data = None
|
||||
if person.address:
|
||||
address_data = {
|
||||
"address_zip": getattr(person.address, 'zip', None) or getattr(getattr(person.address, 'postal_code', None), 'zip_code', None),
|
||||
"address_city": getattr(person.address, 'city', None) or getattr(getattr(person.address, 'postal_code', None), 'city', None),
|
||||
"address_street_name": person.address.street_name,
|
||||
"address_street_type": person.address.street_type,
|
||||
"address_house_number": person.address.house_number,
|
||||
"address_stairwell": person.address.stairwell,
|
||||
"address_floor": person.address.floor,
|
||||
"address_door": person.address.door,
|
||||
"address_hrsz": person.address.parcel_id,
|
||||
"full_address_text": person.address.full_address_text,
|
||||
"latitude": person.address.latitude,
|
||||
"longitude": person.address.longitude,
|
||||
}
|
||||
address_data = AddressOut.model_validate(person.address)
|
||||
person_data = {
|
||||
"id": person.id,
|
||||
"id_uuid": str(person.id_uuid) if person.id_uuid else None,
|
||||
@@ -343,14 +331,10 @@ async def update_my_person(
|
||||
raise HTTPException(status_code=400, detail="No Person record found. Complete KYC first.")
|
||||
|
||||
# ── Update Person fields ──
|
||||
update_dict = update_data.dict(exclude_unset=True)
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
person_fields = ["first_name", "last_name", "phone", "mothers_last_name",
|
||||
"mothers_first_name", "birth_place", "birth_date"]
|
||||
address_fields = ["address_zip", "address_city", "address_street_name",
|
||||
"address_street_type", "address_house_number",
|
||||
"address_stairwell", "address_floor", "address_door",
|
||||
"address_hrsz"]
|
||||
|
||||
for field in person_fields:
|
||||
if field in update_dict and update_dict[field] is not None:
|
||||
@@ -368,21 +352,21 @@ async def update_my_person(
|
||||
person.identity_docs = update_dict["identity_docs"]
|
||||
flag_modified(person, "identity_docs")
|
||||
|
||||
# ── Update Address fields ──
|
||||
has_address_update = any(f in update_dict for f in address_fields)
|
||||
if has_address_update:
|
||||
# ── Update Address fields (via AddressIn) ──
|
||||
addr_data = update_data.address
|
||||
if addr_data is not None:
|
||||
# Use GeoService to get or create address
|
||||
addr_id = await GeoService.get_or_create_full_address(
|
||||
db,
|
||||
zip_code=update_dict.get("address_zip"),
|
||||
city=update_dict.get("address_city"),
|
||||
street_name=update_dict.get("address_street_name"),
|
||||
street_type=update_dict.get("address_street_type"),
|
||||
house_number=update_dict.get("address_house_number"),
|
||||
stairwell=update_dict.get("address_stairwell"),
|
||||
floor=update_dict.get("address_floor"),
|
||||
door=update_dict.get("address_door"),
|
||||
parcel_id=update_dict.get("address_hrsz"),
|
||||
zip_code=addr_data.zip,
|
||||
city=addr_data.city,
|
||||
street_name=addr_data.street_name,
|
||||
street_type=addr_data.street_type,
|
||||
house_number=addr_data.house_number,
|
||||
stairwell=addr_data.stairwell,
|
||||
floor=addr_data.floor,
|
||||
door=addr_data.door,
|
||||
parcel_id=addr_data.parcel_id,
|
||||
)
|
||||
if addr_id:
|
||||
person.address_id = addr_id
|
||||
|
||||
@@ -327,6 +327,23 @@ def reload_quiz_data() -> None:
|
||||
logger.info("Quiz: Quiz data reloaded successfully")
|
||||
|
||||
|
||||
# ── LocaleManager wrapper for email_manager compatibility ──────────────
|
||||
class _LocaleManager:
|
||||
"""
|
||||
Wrapper providing a ``.get()`` interface around the ``t()`` function.
|
||||
|
||||
The ``email_manager`` module imports ``locale_manager`` and calls
|
||||
``locale_manager.get(key, lang=lang, **variables)``. This class
|
||||
delegates to the existing ``t()`` function so that no import breaks.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
|
||||
return t(key, lang=lang, **kwargs)
|
||||
|
||||
|
||||
locale_manager = _LocaleManager()
|
||||
|
||||
# ── Preload on import ──────────────────────────────────────────────────
|
||||
_load_locales()
|
||||
_load_quiz_data()
|
||||
|
||||
@@ -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,
|
||||
és leálláskor megáll.
|
||||
|
||||
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel
|
||||
véletlenszerűen 0:15 és 0:30 között fog lefutni.
|
||||
Feladatok:
|
||||
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
|
||||
@@ -190,7 +195,7 @@ def setup_scheduler() -> None:
|
||||
"""Beállítja a scheduler-t a napi feladatokkal."""
|
||||
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(
|
||||
daily_financial_maintenance,
|
||||
trigger=CronTrigger(hour=0, minute=15, jitter=900),
|
||||
@@ -199,7 +204,85 @@ def setup_scheduler() -> None:
|
||||
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
|
||||
|
||||
@@ -37,11 +37,15 @@ from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise
|
||||
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
|
||||
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)
|
||||
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
|
||||
|
||||
# 9. Rendszer, Gamification és egyebek
|
||||
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.rbac import SystemRole, SystemPermission, SystemRolePermission
|
||||
@@ -75,7 +79,7 @@ __all__ = [
|
||||
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
|
||||
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
|
||||
"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",
|
||||
|
||||
@@ -99,4 +103,6 @@ __all__ = [
|
||||
"CampaignStatus", "CreativeType", "PlacementType",
|
||||
# RBAC Phase 1
|
||||
"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
|
||||
from typing import Optional, List, Any
|
||||
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.dialects.postgresql import JSONB
|
||||
from sqlalchemy.sql import func
|
||||
@@ -243,11 +243,15 @@ class ServiceCatalog(Base):
|
||||
Tábla: system.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)
|
||||
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(String)
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||
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
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
@@ -9,6 +9,7 @@ from .gamification import (
|
||||
UserContribution,
|
||||
Season,
|
||||
)
|
||||
from .validation_rule import ValidationRule
|
||||
|
||||
__all__ = [
|
||||
"PointRule",
|
||||
@@ -19,4 +20,5 @@ __all__ = [
|
||||
"UserBadge",
|
||||
"UserContribution",
|
||||
"Season",
|
||||
"ValidationRule",
|
||||
]
|
||||
@@ -48,6 +48,13 @@ class PointsLedger(Base):
|
||||
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# SNAPSHOT MECHANIZMUS (2026-06-30, #369)
|
||||
# Tárolja a kiosztáskor érvényes point_rules adatokat, hogy a pontértékek
|
||||
# megőrződjenek a ledger-ben, még akkor is, ha a point_rules táblában
|
||||
# később módosítják a pontértékeket.
|
||||
# Tartalma: {"action_key": str, "point_rule_id": int, "points_at_time": int, "multiplier": float}
|
||||
points_snapshot: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship("User")
|
||||
|
||||
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()
|
||||
)
|
||||
@@ -30,6 +30,7 @@ from .social import (
|
||||
ServiceReview,
|
||||
ModerationStatus,
|
||||
SourceType,
|
||||
ProviderValidation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -58,4 +59,5 @@ __all__ = [
|
||||
"ServiceReview",
|
||||
"ModerationStatus",
|
||||
"SourceType",
|
||||
"ProviderValidation",
|
||||
]
|
||||
@@ -57,7 +57,7 @@ class Address(Base):
|
||||
# Robot és térképes funkciók számára
|
||||
latitude: 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)
|
||||
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"))
|
||||
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_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# === SOFT DELETE ===
|
||||
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)
|
||||
|
||||
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import enum
|
||||
import uuid
|
||||
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.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID
|
||||
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.identity.address import Address
|
||||
|
||||
class ModerationStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
approved = "approved"
|
||||
@@ -17,6 +20,7 @@ class ModerationStatus(str, enum.Enum):
|
||||
class SourceType(str, enum.Enum):
|
||||
manual = "manual"
|
||||
ocr = "ocr"
|
||||
api = "api"
|
||||
api_import = "import"
|
||||
|
||||
class ServiceProvider(Base):
|
||||
@@ -26,14 +30,19 @@ class ServiceProvider(Base):
|
||||
kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz.
|
||||
"""
|
||||
__tablename__ = "service_providers"
|
||||
__table_args__ = {"schema": "marketplace"}
|
||||
__table_args__ = {"schema": "marketplace", "extend_existing": True}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
address: Mapped[str] = mapped_column(String, nullable=False)
|
||||
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)
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
@@ -58,6 +67,20 @@ class ServiceProvider(Base):
|
||||
validation_score: Mapped[int] = mapped_column(Integer, default=0)
|
||||
evidence_image_path: Mapped[Optional[str]] = mapped_column(String)
|
||||
added_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
|
||||
# === P0 3D FILTERING MATRIX: Járműosztályok és specializációk ===
|
||||
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
|
||||
)
|
||||
|
||||
# === 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())
|
||||
|
||||
class Vote(Base):
|
||||
@@ -73,6 +96,45 @@ class Vote(Base):
|
||||
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketplace.service_providers.id"), nullable=False)
|
||||
vote_value: Mapped[int] = mapped_column(Integer, nullable=False) # +1 vagy -1
|
||||
|
||||
|
||||
class ProviderValidation(Base):
|
||||
"""
|
||||
Provider validációs rekordok (XP farming prevention).
|
||||
|
||||
Minden egyes admin moderációs akció (approve/reject/flag) egy rekordot hoz létre
|
||||
ebben a táblában. A Vote táblával ellentétben itt az admin által végzett
|
||||
validációk kerülnek rögzítésre, súlyozott értékkel.
|
||||
|
||||
Features:
|
||||
- UniqueConstraint(voter_user_id, provider_id): egy user csak egyszer validálhat
|
||||
- weight: az admin szintjétől függő súly (pl. superadmin=10, admin=5)
|
||||
- metadata: JSONB a validáció részleteivel (reason, evidence, stb.)
|
||||
- Küszöbérték: ha a weight-ek összege >= VALIDATION_THRESHOLD, a provider auto-approved
|
||||
"""
|
||||
__tablename__ = "provider_validations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('voter_user_id', 'provider_id', name='uq_voter_provider_validation'),
|
||||
{"schema": "marketplace", "extend_existing": True}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
provider_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("marketplace.service_providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
voter_user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("identity.users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
validation_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="approve"
|
||||
) # approve | reject | flag
|
||||
weight: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
validation_metadata: Mapped[Optional[dict]] = mapped_column("metadata", JSONB, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
provider: Mapped["ServiceProvider"] = relationship("ServiceProvider", foreign_keys=[provider_id])
|
||||
voter: Mapped["User"] = relationship("User", foreign_keys=[voter_user_id])
|
||||
|
||||
class Competition(Base):
|
||||
""" Gamifikált versenyek (pl. Januári Feltöltő Verseny). """
|
||||
__tablename__ = "competitions"
|
||||
|
||||
@@ -30,6 +30,9 @@ from .staged_data import (
|
||||
|
||||
from .service_request import ServiceRequest
|
||||
|
||||
# Commission Rules (Phase 2)
|
||||
from .commission import CommissionRule, CommissionRuleType, CommissionTier
|
||||
|
||||
__all__ = [
|
||||
"Organization",
|
||||
"OrganizationMember",
|
||||
@@ -52,4 +55,7 @@ __all__ = [
|
||||
"LocationType",
|
||||
"StagedVehicleData",
|
||||
"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) ---
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
billing_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
notification_address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
|
||||
is_anonymized: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false"))
|
||||
anonymized_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
@@ -122,14 +124,6 @@ class Organization(Base):
|
||||
# Business segment for scope-based admin access (e.g., "Fleet", "Dealer", "Service")
|
||||
segment: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
|
||||
|
||||
address_zip: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
address_city: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
address_street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
address_street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
address_house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
address_hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
plus_code: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
|
||||
tax_number: Mapped[Optional[str]] = mapped_column(String(20), unique=True, index=True)
|
||||
reg_number: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
@@ -138,20 +132,6 @@ class Organization(Base):
|
||||
contact_email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, comment="Kapcsolattartó e-mail címe")
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, comment="Kapcsolattartó telefonszáma")
|
||||
|
||||
# ── P0 CRM: Számlázási cím (Billing Address) ──
|
||||
billing_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
billing_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
billing_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
billing_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
billing_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# ── P0 CRM: Értesítési cím (Notification Address) ──
|
||||
notification_zip: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
notification_city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
notification_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
|
||||
notification_street_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
notification_house_number: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
org_type: Mapped[OrgType] = mapped_column(
|
||||
PG_ENUM(OrgType, name="orgtype", schema="fleet"),
|
||||
default=OrgType.individual
|
||||
@@ -248,6 +228,26 @@ class Organization(Base):
|
||||
# Kapcsolat az örök személy rekordhoz
|
||||
legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities")
|
||||
|
||||
# ── P0 ADDRESS RELATIONSHIPS (Code-First Refactor) ──
|
||||
# Main/primary address
|
||||
address: Mapped[Optional["Address"]] = relationship(
|
||||
"Address",
|
||||
foreign_keys=[address_id],
|
||||
lazy="selectin"
|
||||
)
|
||||
# Billing address
|
||||
billing_address: Mapped[Optional["Address"]] = relationship(
|
||||
"Address",
|
||||
foreign_keys=[billing_address_id],
|
||||
lazy="selectin"
|
||||
)
|
||||
# Notification address
|
||||
notification_address: Mapped[Optional["Address"]] = relationship(
|
||||
"Address",
|
||||
foreign_keys=[notification_address_id],
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
# ── P0 3D CAPABILITY MATRIX: SubscriptionTier relationship ──
|
||||
subscription_tier: Mapped[Optional["SubscriptionTier"]] = relationship(
|
||||
"SubscriptionTier",
|
||||
@@ -335,18 +335,7 @@ class Branch(Base):
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
is_main: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Denormalizált adatok a gyors lekérdezéshez
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
street_name: Mapped[Optional[str]] = mapped_column(String(150))
|
||||
street_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
house_number: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
stairwell: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
floor: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
door: Mapped[Optional[str]] = mapped_column(String(20))
|
||||
hrsz: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
|
||||
# PostGIS location field for geographic queries
|
||||
# PostGIS location field for geographic queries (KEPT INTACT)
|
||||
location: Mapped[Optional[Any]] = mapped_column(
|
||||
Geometry(geometry_type='POINT', srid=4326),
|
||||
nullable=True
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
||||
from typing import Any, List, Optional
|
||||
from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, ENUM as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB, ENUM as SQLEnum, ARRAY
|
||||
from geoalchemy2 import Geometry
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -35,7 +35,7 @@ class ServiceProfile(Base):
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
|
||||
|
||||
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(
|
||||
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
|
||||
@@ -72,6 +72,14 @@ class ServiceProfile(Base):
|
||||
website: Mapped[Optional[str]] = mapped_column(String)
|
||||
bio: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
# === P0 3D FILTERING MATRIX: Járműosztályok és specializációk ===
|
||||
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
|
||||
)
|
||||
|
||||
# Kapcsolatok
|
||||
organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile")
|
||||
expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service")
|
||||
@@ -97,14 +105,15 @@ class ExpertiseTag(Base):
|
||||
__tablename__ = "expertise_tags"
|
||||
__table_args__ = (
|
||||
Index('idx_expertise_path', 'path'),
|
||||
Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "marketplace"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name_hu: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
name_en: Mapped[Optional[str]] = mapped_column(String(100))
|
||||
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
|
||||
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
|
||||
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
|
||||
|
||||
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
|
||||
@@ -169,9 +178,7 @@ class ServiceStaging(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True)
|
||||
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||
full_address: Mapped[Optional[str]] = mapped_column(String)
|
||||
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
|
||||
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||
|
||||
@@ -185,6 +192,9 @@ class ServiceStaging(Base):
|
||||
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Kapcsolat a címhez
|
||||
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
""" Robot vezérlési paraméterek adminból. """
|
||||
__tablename__ = "discovery_parameters"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
|
||||
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 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
|
||||
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
|
||||
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())
|
||||
|
||||
class DiscoveryParameter(Base):
|
||||
|
||||
@@ -444,7 +444,7 @@ class AssetEvent(Base):
|
||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
|
||||
user: Mapped[Optional["User"]] = relationship("User")
|
||||
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
|
||||
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
|
||||
|
||||
@@ -165,10 +165,12 @@ class BodyTypeDictionary(Base):
|
||||
__tablename__ = "dict_body_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
|
||||
Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin'),
|
||||
{"schema": "vehicle"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, 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)
|
||||
category_name: 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']
|
||||
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
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""Update schema for Issuer entities (PATCH)."""
|
||||
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 datetime import datetime
|
||||
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Organization Member Schemas (P0: Full CRUD for Garage Employees)
|
||||
@@ -101,16 +103,8 @@ class CorpOnboardIn(BaseModel):
|
||||
language: str = "hu"
|
||||
default_currency: str = "HUF"
|
||||
|
||||
# --- ATOMIZÁLT CÍM (Modell szinkron) ---
|
||||
address_zip: str
|
||||
address_city: str
|
||||
address_street_name: str
|
||||
address_street_type: str
|
||||
address_house_number: str
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# --- P0 REFACTORED: Unified address via AddressIn ---
|
||||
address: AddressIn = Field(..., description="Székhely címe (AddressIn struktúrában)")
|
||||
|
||||
contacts: List[ContactCreate] = []
|
||||
|
||||
@@ -132,13 +126,8 @@ class OrganizationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
tax_number: Optional[str] = None
|
||||
# ── Cím adatok (Address fields) ──
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── P0 REFACTORED: Unified address via AddressIn ──
|
||||
address: Optional[AddressIn] = None
|
||||
# ── Crowdsourced search fields ──
|
||||
aliases: Optional[List[str]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -170,12 +159,7 @@ class OrganizationResponse(BaseModel):
|
||||
external_integration_config: Optional[Any] = None
|
||||
aliases: List[str] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
# ── Cím adatok (Address fields) ──
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# ── P0 REFACTORED: Unified address via AddressOut ──
|
||||
address: Optional[AddressOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -6,6 +6,12 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
- street -> address_street_name, address_street_type, address_house_number
|
||||
- A ProviderSearchResult is tartalmazza az atomizált címmezőket.
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): Egységes AddressIn/AddressOut.
|
||||
- ProviderSearchResult: address_detail: Optional[AddressOut] a meglévő flat mezők mellett
|
||||
- ProviderQuickAddIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
|
||||
- ProviderUpdateIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK a backward compatibility miatt
|
||||
|
||||
4-Level Category Architecture (2026-06-17):
|
||||
===========================================
|
||||
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
|
||||
@@ -15,6 +21,7 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -28,8 +35,8 @@ class CategoryTreeNode(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
is_official: bool = True
|
||||
@@ -46,8 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
level: int = 0
|
||||
path: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
@@ -66,8 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
|
||||
"""
|
||||
id: int
|
||||
key: str
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
category: Optional[str] = None
|
||||
level: int = 0
|
||||
parent_id: Optional[int] = None
|
||||
@@ -84,8 +89,7 @@ class CategoryInfo(BaseModel):
|
||||
szolgáltatásokat a kártyákon.
|
||||
"""
|
||||
id: int
|
||||
name_hu: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
|
||||
level: int = 0
|
||||
key: str
|
||||
|
||||
@@ -97,6 +101,15 @@ class ProviderSearchResult(BaseModel):
|
||||
- address_street_name, address_street_type, address_house_number
|
||||
- A frontend ezeket intelligensen fűzi össze megjelenítéskor.
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
|
||||
- address_detail: Optional[AddressOut] — egységes cím objektum.
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
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ő.
|
||||
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
|
||||
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
|
||||
@@ -106,12 +119,12 @@ class ProviderSearchResult(BaseModel):
|
||||
category: Optional[str] = None
|
||||
specialization: List[str] = Field(default_factory=list)
|
||||
categories: List[CategoryInfo] = Field(default_factory=list)
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
|
||||
address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
|
||||
address_zip: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.zip instead")
|
||||
address_street_name: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_name instead")
|
||||
address_street_type: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_type instead")
|
||||
address_house_number: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.house_number instead")
|
||||
plus_code: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
contact_email: Optional[str] = None
|
||||
@@ -120,6 +133,10 @@ class ProviderSearchResult(BaseModel):
|
||||
source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added")
|
||||
is_verified: bool = False
|
||||
rating: Optional[float] = None
|
||||
supported_vehicle_classes: Optional[List[str]] = 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)
|
||||
|
||||
@@ -138,6 +155,15 @@ class ProviderQuickAddIn(BaseModel):
|
||||
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
|
||||
- address_detail: Optional[AddressIn] — egységes cím objektum.
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
P0 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.
|
||||
- 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.
|
||||
@@ -149,16 +175,24 @@ class ProviderQuickAddIn(BaseModel):
|
||||
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)")
|
||||
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")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
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="[DEPRECATED] Use address_detail.zip instead")
|
||||
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="[DEPRECATED] Use address_detail.street_type instead")
|
||||
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)")
|
||||
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
|
||||
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
|
||||
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL")
|
||||
tags: Optional[List[str]] = Field(None, description="Szolgáltatás címkék (specialization_tags)")
|
||||
supported_vehicle_classes: Optional[List[str]] = Field(
|
||||
None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])"
|
||||
)
|
||||
specializations: Optional[dict] = Field(
|
||||
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):
|
||||
@@ -177,16 +211,25 @@ class ProviderUpdateIn(BaseModel):
|
||||
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
|
||||
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
|
||||
|
||||
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
|
||||
- address_detail: Optional[AddressIn] — egységes cím objektum.
|
||||
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
|
||||
a backward compatibility miatt.
|
||||
|
||||
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):
|
||||
- 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
|
||||
"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
|
||||
city: Optional[str] = Field(None, max_length=100, description="Város")
|
||||
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám")
|
||||
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)")
|
||||
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)")
|
||||
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)")
|
||||
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="[DEPRECATED] Use address_detail.zip instead")
|
||||
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="[DEPRECATED] Use address_detail.street_type instead")
|
||||
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)")
|
||||
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")
|
||||
@@ -198,6 +241,14 @@ class ProviderUpdateIn(BaseModel):
|
||||
new_tags: Optional[List[str]] = Field(
|
||||
None, description="User által gépelt új címkenevek (is_official=False, level=3)"
|
||||
)
|
||||
supported_vehicle_classes: Optional[List[str]] = Field(
|
||||
None, description="Támogatott járműosztályok (pl. ['car', 'hgv', 'van'])"
|
||||
)
|
||||
specializations: Optional[dict] = Field(
|
||||
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):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemParameterBase(BaseModel):
|
||||
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_id: Optional[str] = None
|
||||
is_active: bool = True
|
||||
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
|
||||
|
||||
class SystemParameterUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
value: Optional[Dict[str, Any]] = None
|
||||
value: Optional[Any] = 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 datetime import date, datetime
|
||||
|
||||
|
||||
class AddressResponse(BaseModel):
|
||||
"""Beágyazott cím adatok a PersonResponse számára.
|
||||
A mezőnevek address_ előtaggal egyeznek a frontend által várt PersonUpdate formátummal."""
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
full_address_text: Optional[str] = None
|
||||
latitude: Optional[float] = None
|
||||
longitude: Optional[float] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
from app.schemas.address import AddressIn, AddressOut
|
||||
|
||||
|
||||
class PersonResponse(BaseModel):
|
||||
@@ -38,7 +21,7 @@ class PersonResponse(BaseModel):
|
||||
identity_docs: Optional[Any] = None
|
||||
ice_contact: Optional[Any] = None
|
||||
is_active: bool = True
|
||||
address: Optional[AddressResponse] = None
|
||||
address: Optional[AddressOut] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -90,16 +73,8 @@ class PersonUpdate(BaseModel):
|
||||
# Okmány adatok (identity_docs)
|
||||
identity_docs: Optional[Any] = None
|
||||
|
||||
# Cím adatok
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_street_name: Optional[str] = None
|
||||
address_street_type: Optional[str] = None
|
||||
address_house_number: Optional[str] = None
|
||||
address_stairwell: Optional[str] = None
|
||||
address_floor: Optional[str] = None
|
||||
address_door: Optional[str] = None
|
||||
address_hrsz: Optional[str] = None
|
||||
# Cím adatok — egységes AddressIn séma
|
||||
address: Optional[AddressIn] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
|
||||
53
backend/app/scripts/check_provider_validations.py
Normal file
53
backend/app/scripts/check_provider_validations.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Check if provider_validations table exists and create if not.
|
||||
"""
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
|
||||
async with engine.connect() as conn:
|
||||
# Check if table exists
|
||||
result = await conn.execute(
|
||||
text("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'marketplace' AND table_name = 'provider_validations')")
|
||||
)
|
||||
exists = result.scalar()
|
||||
print(f"provider_validations table exists: {exists}")
|
||||
|
||||
if not exists:
|
||||
# Create the table
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS marketplace.provider_validations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
provider_id INTEGER NOT NULL REFERENCES marketplace.service_providers(id) ON DELETE CASCADE,
|
||||
voter_user_id INTEGER NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE,
|
||||
validation_type VARCHAR(20) NOT NULL DEFAULT 'approve',
|
||||
weight INTEGER NOT NULL DEFAULT 1,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(voter_user_id, provider_id)
|
||||
)
|
||||
"""))
|
||||
await conn.commit()
|
||||
print("Created provider_validations table")
|
||||
|
||||
# Check columns
|
||||
result = await conn.execute(text("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'marketplace' AND table_name = 'provider_validations'
|
||||
ORDER BY ordinal_position
|
||||
"""))
|
||||
print("\nColumns:")
|
||||
for row in result:
|
||||
print(f" {row.column_name}: {row.data_type} (nullable: {row.is_nullable})")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
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 = [
|
||||
{
|
||||
"key": "auto_szerelo",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autószerelő", "en": "Car Mechanic"},
|
||||
"name_hu": "Autószerelő",
|
||||
"name_en": "Car Mechanic",
|
||||
"category": "vehicle_service",
|
||||
@@ -24,6 +26,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"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_en": "Motorcycle Mechanic",
|
||||
"category": "vehicle_service",
|
||||
@@ -32,6 +36,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "gumiszerviz",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Gumiszerviz", "en": "Tire Service"},
|
||||
"name_hu": "Gumiszerviz",
|
||||
"name_en": "Tire Service",
|
||||
"category": "vehicle_service",
|
||||
@@ -40,6 +46,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "karosszerialakatos",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Karosszérialakatos", "en": "Body Shop"},
|
||||
"name_hu": "Karosszérialakatos",
|
||||
"name_en": "Body Shop",
|
||||
"category": "body_paint",
|
||||
@@ -48,6 +56,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "fényező",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Fényező", "en": "Painter"},
|
||||
"name_hu": "Fényező",
|
||||
"name_en": "Painter",
|
||||
"category": "body_paint",
|
||||
@@ -56,6 +66,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"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_en": "Tow Truck / Roadside Assistance",
|
||||
"category": "roadside",
|
||||
@@ -64,6 +76,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "benzinkút",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Benzinkút", "en": "Gas Station"},
|
||||
"name_hu": "Benzinkút",
|
||||
"name_en": "Gas Station",
|
||||
"category": "fuel",
|
||||
@@ -72,6 +86,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"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_en": "Parts Store",
|
||||
"category": "parts",
|
||||
@@ -80,6 +96,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "vizsgaállomás",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Vizsgaállomás", "en": "Inspection Station"},
|
||||
"name_hu": "Vizsgaállomás",
|
||||
"name_en": "Inspection Station",
|
||||
"category": "inspection",
|
||||
@@ -88,6 +106,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"key": "autókozmetika",
|
||||
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
|
||||
"name_i18n": {"hu": "Autókozmetika", "en": "Car Detailing"},
|
||||
"name_hu": "Autókozmetika",
|
||||
"name_en": "Car Detailing",
|
||||
"category": "detailing",
|
||||
@@ -96,6 +116,8 @@ BASE_CATEGORIES = [
|
||||
},
|
||||
{
|
||||
"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_en": "Other Service",
|
||||
"category": "other",
|
||||
@@ -123,11 +145,9 @@ async def seed_expertise_tags():
|
||||
for cat in BASE_CATEGORIES:
|
||||
tag = ExpertiseTag(
|
||||
key=cat["key"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
|
||||
category=cat["category"],
|
||||
search_keywords=cat["search_keywords"],
|
||||
description=cat["description"],
|
||||
is_official=True,
|
||||
discovery_points=10,
|
||||
usage_count=0,
|
||||
|
||||
172
backend/app/scripts/seed_gamification_action_keys.py
Normal file
172
backend/app/scripts/seed_gamification_action_keys.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Gamification Action Keys Seed Script
|
||||
=====================================
|
||||
Cél: Hiányzó action_key-ek felvétele a gamification.point_rules táblába.
|
||||
|
||||
Az audit során az alábbi akciókhoz NEM létezik action_key a point_rules táblában:
|
||||
- KYC_VERIFICATION (auth_service KYC completion)
|
||||
- P2P_REFERRAL_SUCCESS (auth_service P2P referral)
|
||||
- EXPENSE_LOG (cost_service expense recording)
|
||||
- FLEET_EVENT (fleet_service vehicle event)
|
||||
- PROVIDER_VALIDATED (social_service provider validation)
|
||||
- PROVIDER_REJECTED (social_service provider rejection)
|
||||
- SERVICE_HUNT (services.py service hunt)
|
||||
- SERVICE_VALIDATION (services.py service validation)
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_gamification_action_keys.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.gamification.gamification import PointRule
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Gamification-Action-Keys")
|
||||
|
||||
# Hiányzó action_key-ek a point_rules táblába
|
||||
# Pontértékek a meglévő ConfigService default értékek alapján:
|
||||
# KYC_VERIFICATION = 100 (auth_service: kyc_reward)
|
||||
# P2P_REFERRAL_SUCCESS = 50 (auth_service: gamification_p2p_invite_xp default)
|
||||
# EXPENSE_LOG = 50 (cost_service: xp_per_cost_log default)
|
||||
# FLEET_EVENT = 20 (fleet_service: event_rewards["default"]["xp"])
|
||||
# PROVIDER_VALIDATED = 100 (social_service: hardcoded 100XP)
|
||||
# PROVIDER_REJECTED = 50 (social_service: hardcoded 50XP)
|
||||
# SERVICE_HUNT = 50 (services.py: GAMIFICATION_HUNT_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 = [
|
||||
{
|
||||
"action_key": "KYC_VERIFICATION",
|
||||
"points": 100,
|
||||
"description": "Sikeres KYC (Know Your Customer) folyamat teljesítése",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "P2P_REFERRAL_SUCCESS",
|
||||
"points": 50,
|
||||
"description": "Sikeres P2P meghívó (referred_by_id alapján)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "EXPENSE_LOG",
|
||||
"points": 50,
|
||||
"description": "Költség rögzítése (base_xp, OCR bónusz nélkül)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "FLEET_EVENT",
|
||||
"points": 20,
|
||||
"description": "Flotta esemény rögzítése (alapértelmezett pont)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_VALIDATED",
|
||||
"points": 100,
|
||||
"description": "Provider adatainak közösségi validálása (jóváhagyás)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_REJECTED",
|
||||
"points": 50,
|
||||
"description": "Provider adatainak közösségi elutasítása (büntetés)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "SERVICE_HUNT",
|
||||
"points": 50,
|
||||
"description": "Új szerviz felfedezése (service hunt)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "SERVICE_VALIDATION",
|
||||
"points": 10,
|
||||
"description": "Szerviz validálása (más user által beküldött staging rekord)",
|
||||
"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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_gamification_action_keys():
|
||||
"""
|
||||
Feltölti a gamification.point_rules táblát a hiányzó action_key-ekkel.
|
||||
Csak azokat a rekordokat szúrja be, amelyek még nem léteznek (action_key alapján).
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for rule_data in SEED_RULES:
|
||||
# Ellenőrizzük, hogy létezik-e már ez a szabály
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == rule_data["action_key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
|
||||
f"(pont: {existing.points}, ID: {existing.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Új szabály beszúrása
|
||||
new_rule = PointRule(
|
||||
action_key=rule_data["action_key"],
|
||||
points=rule_data["points"],
|
||||
description=rule_data["description"],
|
||||
is_active=rule_data["is_active"],
|
||||
)
|
||||
db.add(new_rule)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"✅ Szabály létrehozva: {rule_data['action_key']} "
|
||||
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
|
||||
)
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_gamification_action_keys())
|
||||
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())
|
||||
124
backend/app/scripts/seed_provider_point_rules.py
Normal file
124
backend/app/scripts/seed_provider_point_rules.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Provider Gamification Point Rules Seed Script
|
||||
=============================================
|
||||
Cél: Új gamification pontszabályok beszúrása a provider discovery rendszerhez.
|
||||
|
||||
Új szabályok:
|
||||
- PROVIDER_DISCOVERY (300 pont) — Új provider felfedezése expense rögzítéskor
|
||||
- PROVIDER_CONFIRMATION (150 pont) — Meglévő provider adatainak megerősítése
|
||||
- PROVIDER_VERIFIED_USE (100 pont) — Már validált provider használata
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/app/scripts/seed_provider_point_rules.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Projekt gyökér hozzáadása a Python path-hoz
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.gamification.gamification import PointRule
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Seed-Provider-Point-Rules")
|
||||
|
||||
# Új provider discovery pontszabályok
|
||||
# A pontértékek a kártya specifikációja szerint (#356, #362):
|
||||
# PROVIDER_DISCOVERY = 300 — Új provider felfedezése
|
||||
# PROVIDER_CONFIRMATION = 150 — Saját pending provider használata
|
||||
# PROVIDER_VERIFIED_USE = 100 — Már validált provider használata
|
||||
# USE_UNVERIFIED_PROVIDER = 200 — Más user által létrehozott, még pending provider használata
|
||||
SEED_RULES = [
|
||||
{
|
||||
"action_key": "PROVIDER_DISCOVERY",
|
||||
"points": 300,
|
||||
"description": "Új, még nem létező provider felfedezése expense rögzítéskor (external_vendor_name alapján)",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_CONFIRMATION",
|
||||
"points": 150,
|
||||
"description": "Saját magad által létrehozott, még pending státuszú provider használata",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "PROVIDER_VERIFIED_USE",
|
||||
"points": 100,
|
||||
"description": "Már jóváhagyott (approved) provider használata költség rögzítésnél",
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"action_key": "USE_UNVERIFIED_PROVIDER",
|
||||
"points": 200,
|
||||
"description": "Más user által létrehozott, még pending státuszú provider használata (XP farming prevention)",
|
||||
"is_active": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_provider_point_rules():
|
||||
"""
|
||||
Feltölti a gamification.point_rules táblát a provider discovery szabályokkal.
|
||||
Csak azokat a rekordokat szúrja be, amelyek még nem léteznek (action_key alapján).
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for rule_data in SEED_RULES:
|
||||
# Ellenőrizzük, hogy létezik-e már ez a szabály
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == rule_data["action_key"]
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
logger.info(
|
||||
f"⏭️ Szabály már létezik: {rule_data['action_key']} "
|
||||
f"(pont: {existing.points}, ID: {existing.id})"
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Új szabály beszúrása
|
||||
new_rule = PointRule(
|
||||
action_key=rule_data["action_key"],
|
||||
points=rule_data["points"],
|
||||
description=rule_data["description"],
|
||||
is_active=rule_data["is_active"],
|
||||
)
|
||||
db.add(new_rule)
|
||||
await db.flush()
|
||||
logger.info(
|
||||
f"✅ Szabály létrehozva: {rule_data['action_key']} "
|
||||
f"(pont: {rule_data['points']}, ID: {new_rule.id})"
|
||||
)
|
||||
inserted_count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"\n📊 Összegzés: {inserted_count} új szabály beszúrva, "
|
||||
f"{skipped_count} már létezett."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"❌ Hiba a seedelés során: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_provider_point_rules())
|
||||
@@ -320,6 +320,15 @@ async def seed_params():
|
||||
"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
|
||||
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)
|
||||
@@ -286,8 +286,8 @@ class AssetService:
|
||||
))
|
||||
|
||||
# Gamification
|
||||
reward = await config.get_setting(db, "xp_reward_asset_register", default=250)
|
||||
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG")
|
||||
# A pontérték a point_rules táblából jön (action_key='ASSET_REGISTER')
|
||||
await GamificationService.award_points(db, user_id, amount=0, reason="ASSET_REGISTER", action_key="ASSET_REGISTER")
|
||||
|
||||
# Check if this is user's first vehicle and award "First Car" badge
|
||||
await AssetService._award_first_car_badge(db, user_id, target_org_id)
|
||||
|
||||
@@ -295,19 +295,21 @@ class AuthService:
|
||||
user.region_code = kyc_in.region_code
|
||||
|
||||
# Gamification XP jóváírás (commit=False, mert a külső metódus kezeli a tranzakciót)
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=int(kyc_reward), reason="KYC_VERIFICATION", commit=False)
|
||||
# A pontérték a point_rules táblából jön (action_key='KYC_VERIFICATION')
|
||||
await GamificationService.award_points(db, user_id=user.id, amount=0, reason="KYC_VERIFICATION", commit=False, action_key="KYC_VERIFICATION")
|
||||
|
||||
# P2P Referral XP a meghívónak (ha van referred_by_id)
|
||||
if user.referred_by_id:
|
||||
p2p_xp = await config.get_setting(db, "gamification_p2p_invite_xp", default=50)
|
||||
# A pontérték a point_rules táblából jön (action_key='P2P_REFERRAL_SUCCESS')
|
||||
await GamificationService.award_points(
|
||||
db,
|
||||
user_id=user.referred_by_id,
|
||||
amount=int(p2p_xp),
|
||||
amount=0,
|
||||
reason="P2P_REFERRAL_SUCCESS",
|
||||
commit=False
|
||||
commit=False,
|
||||
action_key="P2P_REFERRAL_SUCCESS"
|
||||
)
|
||||
logger.info(f"P2P XP ({p2p_xp}) awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
||||
logger.info(f"P2P XP awarded to referrer ID {user.referred_by_id} for user {user.id}")
|
||||
|
||||
await db.commit()
|
||||
return user
|
||||
|
||||
@@ -573,16 +573,22 @@ class AtomicTransactionManager:
|
||||
# Convert to dictionary format
|
||||
transactions = []
|
||||
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({
|
||||
"id": entry.id,
|
||||
"user_id": entry.user_id,
|
||||
"amount": float(entry.amount),
|
||||
"entry_type": entry.entry_type.value,
|
||||
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
||||
"description": entry.description,
|
||||
"description": desc,
|
||||
"transaction_id": str(entry.transaction_id),
|
||||
"reference_type": entry.reference_type,
|
||||
"reference_id": entry.reference_id,
|
||||
"reference_type": None,
|
||||
"reference_id": 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
|
||||
})
|
||||
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
|
||||
return {
|
||||
"wallet_id": wallet.id,
|
||||
"balances": {
|
||||
"earned": float(wallet.earned_credits),
|
||||
"purchased": float(wallet.purchased_credits),
|
||||
"service_coins": float(wallet.service_coins),
|
||||
"voucher": float(voucher_balance),
|
||||
"earned": float(wallet.earned_credits or 0),
|
||||
"purchased": float(wallet.purchased_credits or 0),
|
||||
"service_coins": float(wallet.service_coins or 0),
|
||||
"voucher": float(voucher_balance or 0),
|
||||
"total": float(
|
||||
wallet.earned_credits +
|
||||
wallet.purchased_credits +
|
||||
wallet.service_coins +
|
||||
voucher_balance
|
||||
(wallet.earned_credits or 0) +
|
||||
(wallet.purchased_credits or 0) +
|
||||
(wallet.service_coins or 0) +
|
||||
(voucher_balance or 0)
|
||||
)
|
||||
},
|
||||
"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),
|
||||
)
|
||||
@@ -25,8 +25,6 @@ class CostService:
|
||||
try:
|
||||
# 1. Dinamikus konfiguráció lekérése
|
||||
base_currency = await config.get_setting(db, "finance_base_currency", default="EUR")
|
||||
base_xp = await config.get_setting(db, "xp_per_cost_log", default=50)
|
||||
ocr_multiplier = await config.get_setting(db, "xp_multiplier_ocr_cost", default=1.5)
|
||||
|
||||
# 2. Intelligens Árfolyamkezelés
|
||||
exchange_rate = Decimal("1.0")
|
||||
@@ -66,12 +64,10 @@ class CostService:
|
||||
await self._sync_telemetry(db, cost_in.asset_id, cost_in.mileage_at_cost)
|
||||
|
||||
# 5. Gamification (Értékesebb az adat, ha van róla fotó/OCR)
|
||||
final_xp = base_xp
|
||||
if new_cost.is_ai_generated:
|
||||
final_xp = int(base_xp * float(ocr_multiplier))
|
||||
|
||||
# A pontérték a point_rules táblából jön (action_key='EXPENSE_LOG')
|
||||
# Az OCR bónusz továbbra is érvényesül a process_activity szorzóin keresztül
|
||||
await GamificationService.award_points(
|
||||
db, user_id=user_id, amount=final_xp, reason=f"EXPENSE_LOG_{cost_in.cost_type}"
|
||||
db, user_id=user_id, amount=0, reason=f"EXPENSE_LOG_{cost_in.cost_type}", action_key="EXPENSE_LOG"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
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()
|
||||
|
||||
# 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(
|
||||
and_(
|
||||
Wallet.user_id == original_entry.user_id,
|
||||
Wallet.wallet_type == original_entry.wallet_type
|
||||
)
|
||||
Wallet.user_id == original_entry.user_id
|
||||
).with_for_update()
|
||||
|
||||
wallet_result = await db.execute(wallet_query)
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
update(Wallet)
|
||||
.where(Wallet.id == wallet.id)
|
||||
.values(balance=new_balance)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
# 4. Számlakiállító bevételének csökkentése
|
||||
|
||||
@@ -82,15 +82,17 @@ class FleetService:
|
||||
db.add(new_event)
|
||||
|
||||
# 6. DINAMIKUS GAMIFIKÁCIÓ
|
||||
# Kikeresjük a konkrét eseménytípushoz tartozó pontokat
|
||||
# A pontérték a point_rules táblából jön (action_key='FLEET_EVENT')
|
||||
# A social pont továbbra is az event_rewards konfigurációból jön
|
||||
rewards = event_rewards.get(event_data.event_type, event_rewards["default"])
|
||||
|
||||
await gamification_service.process_activity(
|
||||
db,
|
||||
user_id,
|
||||
xp_amount=rewards["xp"],
|
||||
xp_amount=0,
|
||||
social_amount=rewards["social"],
|
||||
reason=f"FLEET_EVENT_{event_data.event_type.upper()}"
|
||||
reason=f"FLEET_EVENT_{event_data.event_type.upper()}",
|
||||
action_key="FLEET_EVENT"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
|
||||
import logging
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
@@ -12,6 +13,25 @@ from app.services.config_service import config # 2.0 Központi konfigurátor
|
||||
|
||||
logger = logging.getLogger("Gamification-Service-2.0")
|
||||
|
||||
|
||||
def _deep_merge_config(base: dict, overlay: dict) -> dict:
|
||||
"""
|
||||
Rekurzívan egyesít két dictionary-t.
|
||||
Az overlay kulcsai felülírják a base kulcsait, de a base-ben lévő
|
||||
hiányzó kulcsok megmaradnak az overlay-ből.
|
||||
|
||||
Ez biztosítja, hogy a GAMIFICATION_MASTER_CONFIG minden szükséges
|
||||
kulcsot tartalmazzon, még akkor is, ha az adatbázisban lévő konfiguráció
|
||||
hiányos (pl. régebbi verzióból származik).
|
||||
"""
|
||||
result = deepcopy(base)
|
||||
for key, value in overlay.items():
|
||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
||||
result[key] = _deep_merge_config(result[key], value)
|
||||
elif key not in result:
|
||||
result[key] = deepcopy(value)
|
||||
return result
|
||||
|
||||
class GamificationService:
|
||||
"""
|
||||
Gamification Service 2.0 - A 'Jövevény' lelke.
|
||||
@@ -22,22 +42,24 @@ class GamificationService:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True):
|
||||
async def award_points(db: AsyncSession, user_id: int, amount: int, reason: str, social_points: int = 0, commit: bool = True, action_key: str | None = None):
|
||||
""" Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
|
||||
|
||||
Args:
|
||||
commit: If True (default), commits the transaction internally.
|
||||
Set to False when called from within a larger transaction
|
||||
(e.g., from AuthService.complete_kyc).
|
||||
action_key: Opcionális. Ha meg van adva, a point_rules táblából
|
||||
olvassa a pontértékeket a master config helyett.
|
||||
"""
|
||||
service = GamificationService()
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit)
|
||||
return await service.process_activity(db, user_id, xp_amount=amount, social_amount=social_points, reason=reason, commit=commit, action_key=action_key)
|
||||
|
||||
async def _get_point_rule(self, db: AsyncSession, action_key: str) -> dict | None:
|
||||
"""Lekér egy pontszabályt a point_rules táblából action_key alapján.
|
||||
|
||||
Returns:
|
||||
dict with 'points' and 'description' or None if not found/inactive.
|
||||
dict with 'points', 'description', 'rule_id' or None if not found/inactive.
|
||||
"""
|
||||
stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key,
|
||||
@@ -46,7 +68,7 @@ class GamificationService:
|
||||
result = await db.execute(stmt)
|
||||
rule = result.scalar_one_or_none()
|
||||
if rule:
|
||||
return {"points": rule.points, "description": rule.description}
|
||||
return {"points": rule.points, "description": rule.description, "rule_id": rule.id}
|
||||
return None
|
||||
|
||||
async def process_activity(
|
||||
@@ -80,7 +102,7 @@ class GamificationService:
|
||||
try:
|
||||
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
|
||||
# Minden paraméter az admin felületről módosítható JSON-ként
|
||||
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default={
|
||||
_DEFAULT_GAMIFICATION_CONFIG = {
|
||||
"xp_logic": {"base_xp": 500, "exponent": 1.5},
|
||||
"penalty_logic": {
|
||||
"recovery_rate": 0.5,
|
||||
@@ -89,15 +111,28 @@ class GamificationService:
|
||||
},
|
||||
"conversion_logic": {"social_to_credit_rate": 100},
|
||||
"level_rewards": {"credits_per_10_levels": 50}
|
||||
})
|
||||
}
|
||||
cfg = await config.get_setting(db, "GAMIFICATION_MASTER_CONFIG", default=_DEFAULT_GAMIFICATION_CONFIG)
|
||||
|
||||
# 🔐 HIÁNYZÓ KULCSOK VÉDELME (Deep Merge)
|
||||
# Ha a GAMIFICATION_MASTER_CONFIG már létezik az adatbázisban, de hiányoznak
|
||||
# belőle kulcsok (pl. penalty_logic egy régebbi verzióból), a default értékek
|
||||
# automatikusan kiegészítik a hiányzó részeket.
|
||||
# Ez megakadályozza a KeyError kivételeket a cfg["penalty_logic"] hívásoknál.
|
||||
if isinstance(cfg, dict):
|
||||
cfg = _deep_merge_config(cfg, _DEFAULT_GAMIFICATION_CONFIG)
|
||||
|
||||
# 1/b. POINT RULES TÁBLA LEKÉRÉSE (ha action_key meg van adva)
|
||||
# A point_rules tábla elsőbbséget élvez a master config-gal szemben!
|
||||
point_rule_id = None
|
||||
points_at_time = None
|
||||
if action_key:
|
||||
rule = await self._get_point_rule(db, action_key)
|
||||
if rule:
|
||||
# A point_rules-ból jövő pontok felülírják a paraméterként kapott értékeket
|
||||
xp_amount = rule["points"]
|
||||
point_rule_id = rule.get("rule_id")
|
||||
points_at_time = rule["points"]
|
||||
if rule.get("description"):
|
||||
reason = f"{action_key}: {rule['description']}"
|
||||
logger.debug(f"Point rule applied: {action_key} -> {xp_amount} XP")
|
||||
@@ -152,7 +187,18 @@ class GamificationService:
|
||||
stats.social_points %= rate # A maradék pont megmarad
|
||||
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
|
||||
|
||||
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id mezőkkel)
|
||||
# 7. NAPLÓZÁS (kibővítve xp, source_type, source_id, points_snapshot mezőkkel)
|
||||
# A points_snapshot JSONB tárolja a kiosztáskor érvényes point_rules adatokat,
|
||||
# hogy a pontértékek megőrződjenek a ledger-ben, még akkor is, ha a
|
||||
# point_rules táblában később módosítják a pontértékeket.
|
||||
snapshot = None
|
||||
if action_key and point_rule_id and points_at_time is not None:
|
||||
snapshot = {
|
||||
"action_key": action_key,
|
||||
"point_rule_id": point_rule_id,
|
||||
"points_at_time": points_at_time,
|
||||
"multiplier": multiplier,
|
||||
}
|
||||
db.add(PointsLedger(
|
||||
user_id=user_id,
|
||||
points=final_xp,
|
||||
@@ -160,6 +206,7 @@ class GamificationService:
|
||||
reason=reason,
|
||||
source_type=source_type,
|
||||
source_id=source_id,
|
||||
points_snapshot=snapshot,
|
||||
))
|
||||
|
||||
# Only commit if caller wants us to manage the transaction
|
||||
|
||||
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 typing import Optional, List, Tuple
|
||||
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete
|
||||
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete, join
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging
|
||||
from app.models.identity.social import ServiceProvider
|
||||
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.gamification.gamification import PointRule, UserStats
|
||||
from app.schemas.address import AddressOut
|
||||
from app.schemas.provider import (
|
||||
ProviderSearchResult,
|
||||
ProviderSearchResponse,
|
||||
@@ -52,6 +55,8 @@ from app.schemas.provider import (
|
||||
CategoryInfo,
|
||||
)
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.address_manager import AddressManager
|
||||
from app.schemas.address import AddressIn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,9 +69,12 @@ async def _award_provider_points(
|
||||
"""
|
||||
Dinamikus pontjóváírás a gamification.point_rules tábla alapján.
|
||||
|
||||
Ez a függvény az adatbázisból olvassa ki a pontértéket az action_key
|
||||
alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről
|
||||
bármikor módosíthatók a point_rules táblában.
|
||||
REFAKTOR (2026-06-30): A duplikált point_rules olvasási logika eltávolításra
|
||||
került. A pontértékeket most a GamificationService.award_points() olvassa ki
|
||||
a point_rules táblából az action_key alapján a process_activity() hívás során.
|
||||
|
||||
Ez a függvény már csak a providers_added_count statisztikát kezeli.
|
||||
A pontok kiszámítása és naplózása a GamificationService-ben történik.
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
@@ -76,39 +84,19 @@ async def _award_provider_points(
|
||||
Returns:
|
||||
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív)
|
||||
"""
|
||||
# 1. Pontszabály lekérdezése az adatbázisból (dinamikus!)
|
||||
rule_stmt = select(PointRule).where(
|
||||
PointRule.action_key == action_key,
|
||||
PointRule.is_active == True,
|
||||
)
|
||||
rule_result = await db.execute(rule_stmt)
|
||||
rule = rule_result.scalar_one_or_none()
|
||||
|
||||
if not rule:
|
||||
logger.warning(
|
||||
f"Pontszabály '{action_key}' nem található vagy inaktív. "
|
||||
f"Pontjóváírás kihagyva user_id={user_id}."
|
||||
)
|
||||
return 0
|
||||
|
||||
points_to_award = rule.points
|
||||
logger.info(
|
||||
f"Dinamikus pont lekérés: action_key='{action_key}', "
|
||||
f"points={points_to_award} (forrás: gamification.point_rules tábla)"
|
||||
)
|
||||
|
||||
# 2. Pontok jóváírása a Gamification Service-en keresztül
|
||||
# A GamificationService.award_points() kezeli a szorzókat, szintlépést,
|
||||
# büntetés ledolgozást és a naplózást (PointsLedger).
|
||||
# 1. Pontok jóváírása a Gamification Service-en keresztül (action_key alapján)
|
||||
# A GamificationService.award_points() kezeli a point_rules olvasást, szorzókat,
|
||||
# szintlépést, büntetés ledolgozást és a naplózást (PointsLedger).
|
||||
await gamification_service.award_points(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=points_to_award,
|
||||
reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért",
|
||||
commit=False, # A hívó (quick_add_provider) kezeli a commit-ot
|
||||
amount=0,
|
||||
reason=f"{action_key}",
|
||||
commit=False, # A hívó kezeli a commit-ot
|
||||
action_key=action_key,
|
||||
)
|
||||
|
||||
# 3. Statisztika növelése (providers_added_count)
|
||||
# 2. Statisztika növelése (providers_added_count)
|
||||
stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
|
||||
stats = (await db.execute(stats_stmt)).scalar_one_or_none()
|
||||
|
||||
@@ -122,7 +110,7 @@ async def _award_provider_points(
|
||||
# Ha még nincs UserStats rekordja, létrehozzuk
|
||||
stats = UserStats(
|
||||
user_id=user_id,
|
||||
total_xp=points_to_award,
|
||||
total_xp=0,
|
||||
current_level=1,
|
||||
providers_added_count=1,
|
||||
)
|
||||
@@ -132,7 +120,97 @@ async def _award_provider_points(
|
||||
f"providers_added_count=1"
|
||||
)
|
||||
|
||||
return points_to_award
|
||||
return 0 # Visszatérési érték már nem a pontszám, mert az award_points kezeli
|
||||
|
||||
|
||||
async def find_or_create_provider_by_name(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
added_by_user_id: int,
|
||||
) -> tuple:
|
||||
"""
|
||||
Keres vagy létrehoz egy ServiceProvider-t a megadott név alapján.
|
||||
|
||||
P0 ARCHITECTURE CLEANUP (2026-07-01):
|
||||
======================================
|
||||
Gamification/XP logika ELTÁVOLÍTVA ebből a service rétegből.
|
||||
A gamification hívások az API rétegbe (expenses.py) kerültek át.
|
||||
Ez a függvény immár tiszta adatbázis service — nem tud a gamification-ről.
|
||||
|
||||
Visszatérési érték módosítva: (provider, created) tuple.
|
||||
- created=True: új provider lett létrehozva (felfedezés)
|
||||
- created=False: meglévő provider lett lekérve
|
||||
|
||||
pg_trgm similarity threshold: 0.3 -> 0.6 (hamis pozitív találatok csökkentése)
|
||||
|
||||
Ez a függvény az expense auto-discovery hook része. Amikor egy user
|
||||
költséget rögzít és megad egy external_vendor_name-t, ez a függvény
|
||||
megkeresi, hogy létezik-e már a provider, és ha nem, létrehozza.
|
||||
|
||||
Logika:
|
||||
1. Pontos match keresése ServiceProvider.name alapján (ILIKE, kisbetűs)
|
||||
2. Fuzzy match trigram similarity-vel (pg_trgm) — ha similarity > 0.6
|
||||
3. Ha nem létezik → új provider létrehozása PENDING státusszal,
|
||||
validation_score=0, source=import, vissza: created=True
|
||||
4. Ha létezik → vissza: created=False
|
||||
|
||||
Args:
|
||||
db: AsyncSession
|
||||
name: A provider neve (external_vendor_name)
|
||||
added_by_user_id: A költséget rögzítő user ID-ja
|
||||
|
||||
Returns:
|
||||
tuple[ServiceProvider, bool]: (provider, created)
|
||||
- created=True: új provider lett létrehozva
|
||||
- created=False: meglévő provider lett lekérve
|
||||
"""
|
||||
# 1. Pontos match keresése (ILIKE, kisbetűsen, space-sztrippelten)
|
||||
clean_name = name.strip()
|
||||
provider_stmt = select(ServiceProvider).where(
|
||||
ServiceProvider.name.ilike(clean_name)
|
||||
)
|
||||
provider_result = await db.execute(provider_stmt)
|
||||
provider = provider_result.scalar_one_or_none()
|
||||
|
||||
# 2. Fuzzy match trigram similarity-vel (pg_trgm), ha nincs pontos találat
|
||||
# P0 ARCHITECTURE CLEANUP: threshold 0.3 -> 0.6 a hamis pozitívok csökkentésére
|
||||
if not provider:
|
||||
fuzzy_stmt = select(ServiceProvider).where(
|
||||
func.similarity(ServiceProvider.name, clean_name) > 0.6
|
||||
).order_by(
|
||||
func.similarity(ServiceProvider.name, clean_name).desc()
|
||||
).limit(1)
|
||||
fuzzy_result = await db.execute(fuzzy_stmt)
|
||||
provider = fuzzy_result.scalar_one_or_none()
|
||||
|
||||
if not provider:
|
||||
# 3. Ha nem létezik → létrehozás, created=True
|
||||
new_provider = ServiceProvider(
|
||||
name=clean_name,
|
||||
address=clean_name,
|
||||
status=ModerationStatus.pending,
|
||||
source=SourceType.api_import, # "import" — API import forrás
|
||||
validation_score=0,
|
||||
added_by_user_id=added_by_user_id,
|
||||
)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
f"Új provider felfedezve: name='{clean_name}', "
|
||||
f"provider_id={new_provider.id}, user_id={added_by_user_id}"
|
||||
)
|
||||
|
||||
return new_provider, True
|
||||
|
||||
# 4. Ha létezik, visszaadjuk created=False
|
||||
logger.info(
|
||||
f"Meglevo provider hasznalata: name='{clean_name}', "
|
||||
f"provider_id={provider.id}, "
|
||||
f"user_id={added_by_user_id}, provider_creator_id={provider.added_by_user_id}"
|
||||
)
|
||||
|
||||
return provider, False
|
||||
|
||||
|
||||
async def search_providers(
|
||||
@@ -209,14 +287,17 @@ async def search_providers(
|
||||
)
|
||||
org_conditions.append(and_(*token_conditions))
|
||||
if city:
|
||||
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith
|
||||
# P0 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()
|
||||
org_conditions.append(
|
||||
or_(
|
||||
Organization.address_city == city_clean,
|
||||
Organization.address_city.ilike(f"{city_clean}%"),
|
||||
Organization.address_zip == city_clean,
|
||||
Organization.address_zip.ilike(f"{city_clean}%"),
|
||||
GeoPostalCode.city == city_clean,
|
||||
GeoPostalCode.city.ilike(f"%{city_clean}%"),
|
||||
GeoPostalCode.zip_code == city_clean,
|
||||
GeoPostalCode.zip_code.ilike(f"%{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -241,30 +322,30 @@ async def search_providers(
|
||||
ServiceProfile.id.in_(select(profile_subq.c.service_id))
|
||||
)
|
||||
|
||||
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált
|
||||
# címmezőket (address_street_name, address_street_type, address_house_number).
|
||||
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán
|
||||
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi").
|
||||
# P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
|
||||
# helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
|
||||
# a város, irányítószám és utca adatokat.
|
||||
# LEFT JOIN-eket használunk, mert az address_id lehet NULL.
|
||||
org_stmt = select(
|
||||
Organization.id.label("id"),
|
||||
Organization.name.label("name"),
|
||||
Organization.address_city.label("city"),
|
||||
GeoPostalCode.city.label("city"),
|
||||
func.concat(
|
||||
Organization.address_zip,
|
||||
GeoPostalCode.zip_code,
|
||||
literal(' '),
|
||||
Organization.address_city,
|
||||
GeoPostalCode.city,
|
||||
literal(', '),
|
||||
Organization.address_street_name,
|
||||
Address.street_name,
|
||||
literal(' '),
|
||||
Organization.address_street_type,
|
||||
Address.street_type,
|
||||
literal(' '),
|
||||
Organization.address_house_number,
|
||||
Address.house_number,
|
||||
).label("address"),
|
||||
Organization.address_zip.label("address_zip"),
|
||||
Organization.address_street_name.label("address_street_name"),
|
||||
Organization.address_street_type.label("address_street_type"),
|
||||
Organization.address_house_number.label("address_house_number"),
|
||||
Organization.plus_code.label("plus_code"),
|
||||
GeoPostalCode.zip_code.label("address_zip"),
|
||||
Address.street_name.label("address_street_name"),
|
||||
Address.street_type.label("address_street_type"),
|
||||
Address.house_number.label("address_house_number"),
|
||||
literal(None).label("plus_code"),
|
||||
Organization.is_verified.label("is_verified"),
|
||||
ServiceProfile.contact_phone.label("contact_phone"),
|
||||
ServiceProfile.contact_email.label("contact_email"),
|
||||
@@ -277,6 +358,12 @@ async def search_providers(
|
||||
).outerjoin(
|
||||
ServiceProfile,
|
||||
ServiceProfile.organization_id == Organization.id,
|
||||
).outerjoin(
|
||||
Address,
|
||||
Address.id == Organization.address_id,
|
||||
).outerjoin(
|
||||
GeoPostalCode,
|
||||
GeoPostalCode.id == Address.postal_code_id,
|
||||
).where(*org_conditions)
|
||||
|
||||
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
|
||||
@@ -289,14 +376,14 @@ async def search_providers(
|
||||
)
|
||||
staging_conditions.append(and_(*token_conditions))
|
||||
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()
|
||||
staging_conditions.append(
|
||||
or_(
|
||||
ServiceStaging.city == city_clean,
|
||||
ServiceStaging.city.ilike(f"{city_clean}%"),
|
||||
ServiceStaging.city.ilike(f"%{city_clean}%"),
|
||||
ServiceStaging.postal_code == city_clean,
|
||||
ServiceStaging.postal_code.ilike(f"{city_clean}%"),
|
||||
ServiceStaging.postal_code.ilike(f"%{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -330,12 +417,12 @@ async def search_providers(
|
||||
)
|
||||
crowd_conditions.append(and_(*token_conditions))
|
||||
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()
|
||||
crowd_conditions.append(
|
||||
or_(
|
||||
ServiceProvider.address == city_clean,
|
||||
ServiceProvider.address.ilike(f"{city_clean}%"),
|
||||
ServiceProvider.address.ilike(f"%{city_clean}%"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -392,8 +479,7 @@ async def search_providers(
|
||||
select(
|
||||
ServiceProfile.organization_id,
|
||||
ExpertiseTag.id,
|
||||
ExpertiseTag.name_hu,
|
||||
ExpertiseTag.name_en,
|
||||
ExpertiseTag.name_i18n,
|
||||
ExpertiseTag.level,
|
||||
ExpertiseTag.key,
|
||||
)
|
||||
@@ -412,8 +498,7 @@ async def search_providers(
|
||||
org_categories_map[org_id] = []
|
||||
org_categories_map[org_id].append({
|
||||
"id": cat_row.id,
|
||||
"name_hu": cat_row.name_hu,
|
||||
"name_en": cat_row.name_en,
|
||||
"name_i18n": cat_row.name_i18n,
|
||||
"level": cat_row.level,
|
||||
"key": cat_row.key,
|
||||
})
|
||||
@@ -431,25 +516,53 @@ async def search_providers(
|
||||
categories_out = [
|
||||
CategoryInfo(
|
||||
id=cat["id"],
|
||||
name_hu=cat["name_hu"],
|
||||
name_en=cat["name_en"],
|
||||
name_i18n=cat["name_i18n"],
|
||||
level=cat["level"],
|
||||
key=cat["key"],
|
||||
)
|
||||
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(
|
||||
ProviderSearchResult(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
city=row.city,
|
||||
city=row_city,
|
||||
address=row.address,
|
||||
address_zip=getattr(row, "address_zip", None),
|
||||
address_street_name=getattr(row, "address_street_name", None),
|
||||
address_street_type=getattr(row, "address_street_type", None),
|
||||
address_house_number=getattr(row, "address_house_number", None),
|
||||
plus_code=getattr(row, "plus_code", None),
|
||||
address_zip=row_address_zip,
|
||||
address_street_name=row_address_street_name,
|
||||
address_street_type=row_address_street_type,
|
||||
address_house_number=row_address_house_number,
|
||||
plus_code=row_plus_code,
|
||||
contact_phone=getattr(row, "contact_phone", None),
|
||||
contact_email=getattr(row, "contact_email", None),
|
||||
website=getattr(row, "website", None),
|
||||
@@ -457,6 +570,7 @@ async def search_providers(
|
||||
categories=categories_out,
|
||||
source=row.source,
|
||||
is_verified=row.is_verified,
|
||||
address_detail=address_detail,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -754,8 +868,7 @@ async def _create_new_tags(
|
||||
existing_stmt = select(ExpertiseTag).where(
|
||||
or_(
|
||||
ExpertiseTag.key == _slugify(tag_name),
|
||||
ExpertiseTag.name_hu == tag_name,
|
||||
ExpertiseTag.name_en == tag_name,
|
||||
cast(ExpertiseTag.name_i18n, String).ilike(f"%{tag_name}%"),
|
||||
)
|
||||
)
|
||||
existing_result = await db.execute(existing_stmt)
|
||||
@@ -773,15 +886,13 @@ async def _create_new_tags(
|
||||
new_key = _slugify(tag_name)
|
||||
new_tag = ExpertiseTag(
|
||||
key=new_key,
|
||||
name_hu=tag_name,
|
||||
name_en=tag_name,
|
||||
name_i18n={"hu": tag_name, "en": tag_name},
|
||||
level=3,
|
||||
is_official=False,
|
||||
category="user_created",
|
||||
parent_id=None,
|
||||
path=None,
|
||||
search_keywords=[tag_name.lower()],
|
||||
description=f"User-created tag: {tag_name}",
|
||||
)
|
||||
db.add(new_tag)
|
||||
await db.flush()
|
||||
@@ -876,17 +987,23 @@ async def update_provider(
|
||||
if staging:
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
# ── AddressManager: Cím létrehozása a staging adataiból ──
|
||||
staging_addr_in = AddressIn(
|
||||
city=staging.city or "",
|
||||
zip=data.address_zip or "",
|
||||
street_name=data.address_street_name or "",
|
||||
street_type=data.address_street_type or "",
|
||||
house_number=data.address_house_number or "",
|
||||
)
|
||||
staging_address_id = await AddressManager.create_or_update(db, None, staging_addr_in)
|
||||
|
||||
org = Organization(
|
||||
id=staging.id,
|
||||
name=staging.name[:100],
|
||||
full_name=staging.name,
|
||||
display_name=staging.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=staging.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
address_id=staging_address_id,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
@@ -910,6 +1027,16 @@ async def update_provider(
|
||||
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
|
||||
crowd = await db.get(ServiceProvider, provider_id)
|
||||
if crowd:
|
||||
# ── AddressManager: Cím létrehozása a crowd adataiból ──
|
||||
crowd_addr_in = AddressIn(
|
||||
city=data.city or "",
|
||||
zip=data.address_zip or "",
|
||||
street_name=data.address_street_name or "",
|
||||
street_type=data.address_street_type or "",
|
||||
house_number=data.address_house_number or "",
|
||||
)
|
||||
crowd_address_id = await AddressManager.create_or_update(db, None, crowd_addr_in)
|
||||
|
||||
# Migráljuk Organization-be
|
||||
now = datetime.now(timezone.utc)
|
||||
org = Organization(
|
||||
@@ -918,11 +1045,7 @@ async def update_provider(
|
||||
full_name=crowd.name,
|
||||
display_name=crowd.name[:50],
|
||||
org_type=OrgType.service_provider,
|
||||
address_city=data.city,
|
||||
address_zip=data.address_zip,
|
||||
address_street_name=data.address_street_name,
|
||||
address_street_type=data.address_street_type,
|
||||
address_house_number=data.address_house_number,
|
||||
address_id=crowd_address_id,
|
||||
status="pending_verification",
|
||||
is_verified=False,
|
||||
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
|
||||
@@ -946,26 +1069,41 @@ async def update_provider(
|
||||
# 1d. Ha egyikben sem található
|
||||
raise ValueError(f"Provider with id={provider_id} not found")
|
||||
|
||||
# 2. Organization mezők frissítése atomizált címmezőkkel
|
||||
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt,
|
||||
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza,
|
||||
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor
|
||||
# a felhasználó csak más mezőket szerkeszt.
|
||||
# 2. Organization mezők frissítése AddressManager segítségével
|
||||
# P3 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
|
||||
# helyett az AddressManager.create_or_update() kezeli a címet.
|
||||
# A flat mezők (city, address_zip, stb.) továbbra is támogatottak
|
||||
# a ProviderUpdateIn sémában a backward compatibility miatt.
|
||||
org.name = data.name[:100]
|
||||
org.full_name = data.name
|
||||
org.display_name = data.name[:50]
|
||||
|
||||
# ── Cím frissítése AddressManager segítségével ──
|
||||
# Összegyűjtjük a flat mezőkből az AddressIn adatokat
|
||||
addr_fields = {}
|
||||
if data.city is not None:
|
||||
org.address_city = data.city
|
||||
addr_fields["city"] = data.city
|
||||
if data.address_zip is not None:
|
||||
org.address_zip = data.address_zip
|
||||
addr_fields["zip"] = data.address_zip
|
||||
if data.address_street_name is not None:
|
||||
org.address_street_name = data.address_street_name
|
||||
addr_fields["street_name"] = data.address_street_name
|
||||
if data.address_street_type is not None:
|
||||
org.address_street_type = data.address_street_type
|
||||
addr_fields["street_type"] = data.address_street_type
|
||||
if data.address_house_number is not None:
|
||||
org.address_house_number = data.address_house_number
|
||||
addr_fields["house_number"] = data.address_house_number
|
||||
if data.plus_code is not None:
|
||||
org.plus_code = data.plus_code
|
||||
addr_fields["plus_code"] = data.plus_code
|
||||
|
||||
# Ha van address_detail, az elsőbbséget élvez a flat mezőkkel szemben
|
||||
if data.address_detail is not None:
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, data.address_detail
|
||||
)
|
||||
elif addr_fields:
|
||||
addr_in = AddressIn(**addr_fields)
|
||||
org.address_id = await AddressManager.create_or_update(
|
||||
db, org.address_id, addr_in
|
||||
)
|
||||
|
||||
# 3. ServiceProfile lekérdezése (ha létezik)
|
||||
profile_stmt = select(ServiceProfile).where(
|
||||
|
||||
@@ -5,14 +5,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.identity import User, Person, SocialAccount, UserRole
|
||||
from app.services.security_service import security_service
|
||||
from app.core.security import generate_secure_slug
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SocialAuthService:
|
||||
@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,
|
||||
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
|
||||
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()
|
||||
|
||||
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)
|
||||
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)
|
||||
await db.flush()
|
||||
|
||||
|
||||
@@ -16,20 +16,20 @@ class SocialService:
|
||||
"""
|
||||
|
||||
async def create_service_provider(self, db: AsyncSession, obj_in: ServiceProviderCreate, user_id: int):
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.provider_service import _award_provider_points
|
||||
|
||||
new_provider = ServiceProvider(**obj_in.model_dump(), added_by_user_id=user_id)
|
||||
db.add(new_provider)
|
||||
await db.flush()
|
||||
|
||||
# Alappontszám az új beküldésért
|
||||
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}")
|
||||
# Dinamikus pontjóváírás a point_rules táblából (ADD_NEW_PROVIDER)
|
||||
await _award_provider_points(db, user_id, "ADD_NEW_PROVIDER")
|
||||
await db.commit()
|
||||
await db.refresh(new_provider)
|
||||
return new_provider
|
||||
|
||||
async def vote_for_provider(self, db: AsyncSession, voter_id: int, provider_id: int, vote_value: int):
|
||||
from app.services.gamification_service import gamification_service
|
||||
from app.services.provider_service import _award_provider_points
|
||||
|
||||
# Duplikált szavazat ellenőrzése
|
||||
exists = (await db.execute(select(Vote).where(and_(Vote.user_id == voter_id, Vote.provider_id == provider_id)))).scalar()
|
||||
@@ -53,6 +53,9 @@ class SocialService:
|
||||
provider.status = ModerationStatus.rejected
|
||||
await self._penalize_user(db, provider.added_by_user_id, provider.name)
|
||||
|
||||
# RATE_PROVIDER pontok kiosztása a sikeres szavazatért
|
||||
await _award_provider_points(db, voter_id, "RATE_PROVIDER")
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "score": provider.validation_score, "new_status": provider.status}
|
||||
|
||||
@@ -67,7 +70,8 @@ class SocialService:
|
||||
from app.services.gamification_service import gamification_service
|
||||
if not user_id: return
|
||||
|
||||
await gamification_service.process_activity(db, user_id, 100, 20, f"Validated: {provider_name}")
|
||||
# A pontérték a point_rules táblából jön (action_key='PROVIDER_VALIDATED')
|
||||
await gamification_service.process_activity(db, user_id, xp_amount=0, social_amount=20, reason=f"Validated: {provider_name}", action_key="PROVIDER_VALIDATED")
|
||||
|
||||
# Aktuális verseny keresése és pontozása
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -91,8 +95,8 @@ class SocialService:
|
||||
from app.services.gamification_service import gamification_service
|
||||
if not user_id: return
|
||||
|
||||
# JAVÍTVA: is_penalty=True hozzáadva a gamification híváshoz
|
||||
await gamification_service.process_activity(db, user_id, 50, 0, f"Rejected: {provider_name}", is_penalty=True)
|
||||
# A pontérték a point_rules táblából jön (action_key='PROVIDER_REJECTED')
|
||||
await gamification_service.process_activity(db, user_id, xp_amount=0, social_amount=0, reason=f"Rejected: {provider_name}", is_penalty=True, action_key="PROVIDER_REJECTED")
|
||||
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
if user and hasattr(user, 'reputation_score'):
|
||||
|
||||
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 sqlalchemy import select, text
|
||||
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
|
||||
|
||||
# Logolás MB 2.0 szabvány szerint
|
||||
@@ -91,11 +91,10 @@ class OSMScout:
|
||||
if existing is None:
|
||||
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 = {
|
||||
"osm_tags": tags,
|
||||
"source": "osm_scout_v2",
|
||||
"trust_score": 20
|
||||
}
|
||||
|
||||
new_entry = ServiceStaging(
|
||||
@@ -105,6 +104,7 @@ class OSMScout:
|
||||
full_address=full_addr,
|
||||
fingerprint=f_print,
|
||||
status="pending",
|
||||
trust_score=20, # P0 FIX: BOT_BASE_SCORE az ORM objektumon
|
||||
raw_data=raw_payload
|
||||
)
|
||||
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,
|
||||
amount=0.0,
|
||||
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",
|
||||
description=f"Előfizetés lejárt: {old_plan} → FREE",
|
||||
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())
|
||||
158
backend/backend/tests/active/test_use_unverified_provider.py
Normal file
158
backend/backend/tests/active/test_use_unverified_provider.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
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
|
||||
tér vissza a (provider, created) tuple-lel, ahol created bool jelzi,
|
||||
hogy új provider jött-e létre vagy meglévő került elő.
|
||||
|
||||
Logika:
|
||||
1. Ha a provider nem létezik -> új provider, created=True
|
||||
2. Ha a provider már létezik -> meglévő provider, created=False
|
||||
3. A gamification/XP logika már az API rétegben (expenses.py) történik
|
||||
|
||||
Használat:
|
||||
docker compose exec sf_api python3 /app/backend/tests/active/test_use_unverified_provider.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "backend"))
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.models.identity.social import ServiceProvider, ModerationStatus, SourceType
|
||||
from app.services.provider_service import find_or_create_provider_by_name
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("Test-FIND_OR_CREATE_PROVIDER")
|
||||
|
||||
TEST_USER_ID = 1
|
||||
TEST_OTHER_USER_ID = 2
|
||||
TEST_PROVIDER_NAME = "Test Unverified Provider Card362"
|
||||
|
||||
|
||||
async def cleanup_test_data(db: AsyncSession):
|
||||
"""Clean up any leftover test data."""
|
||||
await db.execute(
|
||||
delete(ServiceProvider).where(ServiceProvider.name == TEST_PROVIDER_NAME)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def test_find_or_create_provider():
|
||||
"""
|
||||
Test scenario:
|
||||
1. User A (TEST_USER_ID) creates a provider via find_or_create_provider_by_name()
|
||||
-> Should return (provider, True) - new provider created
|
||||
2. User A calls the same provider again
|
||||
-> Should return (provider, False) - existing provider found
|
||||
3. User B (TEST_OTHER_USER_ID) calls the same provider
|
||||
-> Should return (provider, False) - existing provider found
|
||||
"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
# Clean up first
|
||||
await cleanup_test_data(db)
|
||||
|
||||
# =========================================================
|
||||
# STEP 1: User A creates a new provider (created=True)
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP 1: User A creates a new provider")
|
||||
logger.info("=" * 60)
|
||||
|
||||
provider, created = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=TEST_PROVIDER_NAME,
|
||||
added_by_user_id=TEST_USER_ID,
|
||||
)
|
||||
|
||||
assert provider is not None, "Provider should be created"
|
||||
assert created is True, (
|
||||
f"Expected created=True for new provider, got {created}"
|
||||
)
|
||||
assert provider.status == ModerationStatus.pending, (
|
||||
f"Expected pending status, got {provider.status}"
|
||||
)
|
||||
assert provider.added_by_user_id == TEST_USER_ID, (
|
||||
f"Expected added_by_user_id={TEST_USER_ID}, got {provider.added_by_user_id}"
|
||||
)
|
||||
logger.info(f"✅ STEP 1 PASS: created={created}, provider_id={provider.id}")
|
||||
|
||||
# =========================================================
|
||||
# STEP 2: User A uses the same provider again (created=False)
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP 2: User A uses the same provider again")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Re-fetch to get clean state
|
||||
await db.refresh(provider)
|
||||
|
||||
provider2, created2 = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=TEST_PROVIDER_NAME,
|
||||
added_by_user_id=TEST_USER_ID,
|
||||
)
|
||||
|
||||
assert provider2.id == provider.id, "Should be the same provider"
|
||||
assert created2 is False, (
|
||||
f"Expected created=False for existing provider, got {created2}"
|
||||
)
|
||||
logger.info(f"✅ STEP 2 PASS: created={created2}")
|
||||
|
||||
# =========================================================
|
||||
# STEP 3: User B uses the same provider (created=False)
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("STEP 3: User B uses the same provider")
|
||||
logger.info("=" * 60)
|
||||
|
||||
provider3, created3 = await find_or_create_provider_by_name(
|
||||
db=db,
|
||||
name=TEST_PROVIDER_NAME,
|
||||
added_by_user_id=TEST_OTHER_USER_ID,
|
||||
)
|
||||
|
||||
assert provider3.id == provider.id, "Should be the same provider"
|
||||
assert created3 is False, (
|
||||
f"Expected created=False for existing provider, got {created3}"
|
||||
)
|
||||
logger.info(f"✅ STEP 3 PASS: created={created3}")
|
||||
|
||||
# =========================================================
|
||||
# SUMMARY
|
||||
# =========================================================
|
||||
logger.info("=" * 60)
|
||||
logger.info("🎉 ALL TESTS PASSED!")
|
||||
logger.info(f" - New provider creation (created=True): ✅")
|
||||
logger.info(f" - Existing provider reuse (created=False): ✅")
|
||||
logger.info(f" - Cross-user existing provider (created=False): ✅")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Clean up
|
||||
await cleanup_test_data(db)
|
||||
|
||||
except AssertionError as e:
|
||||
logger.error(f"❌ TEST FAILED: {e}")
|
||||
await db.rollback()
|
||||
await cleanup_test_data(db)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ UNEXPECTED ERROR: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
await cleanup_test_data(db)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_find_or_create_provider())
|
||||
@@ -0,0 +1,28 @@
|
||||
"""feat: add vehicle classes and specializations to providers
|
||||
|
||||
Revision ID: 2387cd18fde7
|
||||
Revises: 7cd9b8a65ce8
|
||||
Create Date: 2026-07-01 00:38:21.828081
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2387cd18fde7'
|
||||
down_revision: Union[str, Sequence[str], None] = '7cd9b8a65ce8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
pass
|
||||
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(
|
||||
vehicle_class=bt["vehicle_class"],
|
||||
code=bt["code"],
|
||||
name_hu=bt["name_hu"],
|
||||
name_i18n={"hu": bt["name_hu"]},
|
||||
)
|
||||
session.add(entry)
|
||||
|
||||
|
||||
@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
|
||||
|
||||
tag = ExpertiseTag(
|
||||
key=key,
|
||||
name_hu=node["name_hu"],
|
||||
name_en=node["name_en"],
|
||||
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||
category=node.get("category", ""),
|
||||
search_keywords=node.get("search_keywords", []),
|
||||
description=node.get("description", ""),
|
||||
is_official=True,
|
||||
level=level,
|
||||
parent_id=parent_id,
|
||||
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
|
||||
for key, node in UNIVERSAL_CATEGORIES.items():
|
||||
tag = ExpertiseTag(
|
||||
key=key,
|
||||
name_hu=node["name_hu"],
|
||||
name_en=node["name_en"],
|
||||
name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
|
||||
category=node.get("category", ""),
|
||||
search_keywords=node.get("search_keywords", []),
|
||||
description=node.get("description", ""),
|
||||
is_official=True,
|
||||
level=1,
|
||||
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()
|
||||
131
backend/static/locales/cz.json
Normal file
131
backend/static/locales/cz.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Přihlášení úspěšné. Vítejte zpět!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Tento email je již zaregistrován.",
|
||||
"UNAUTHORIZED": "Neoprávněný přístup."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Účet byl z bezpečnostních důvodů uzamčen."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Vyžadováno schválení"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Úspěšně uloženo!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Potvrďte svou registraci - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Děkujeme za registraci! Kliknutím na tlačítko níže aktivujete svůj účet:",
|
||||
"BUTTON": "Aktivovat účet",
|
||||
"FOOTER": "Pokud jste se nezaregistrovali, ignorujte prosím tento email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Žádost o obnovení hesla",
|
||||
"GREETING": "Ahoj!",
|
||||
"BODY": "Požádali jste o obnovení hesla. Kliknutím na tlačítko níže nastavíte nové heslo:",
|
||||
"BUTTON": "Obnovit heslo",
|
||||
"FOOTER": "Pokud jste o obnovení hesla nežádali, ignorujte prosím tento email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Testovací email - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Toto je testovací email ze systému Service Finder. Pokud to vidíte, odesílání emailů funguje!",
|
||||
"BUTTON": "Přejít na Dashboard",
|
||||
"FOOTER": "Service Finder - Testovací systémová zpráva"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organizace nenalezena.",
|
||||
"INVALID_TAX_FORMAT": "Neplatný formát maďarského daňového čísla!",
|
||||
"TAX_LOOKUP_FAILED": "Ověření daňového čísla selhalo.",
|
||||
"FORBIDDEN": "Nemáte oprávnění k přístupu k této organizaci.",
|
||||
"INVALID_ROLE": "Neplatná role.",
|
||||
"CANNOT_REMOVE_OWNER": "Vlastník nemůže být odstraněn z organizace.",
|
||||
"CANNOT_DEMOTE_OWNER": "Role vlastníka nemůže být změněna.",
|
||||
"ALREADY_MEMBER": "Již jste členem této organizace.",
|
||||
"NO_ACTIVE_ADMIN": "Tato organizace nemá aktivního správce. Použijte koncový bod /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Tato organizace má aktivního správce. Použijte koncový bod /join-request.",
|
||||
"EMAIL_MISMATCH": "Email neodpovídá emailu vlastníka organizace.",
|
||||
"INVALID_CODE": "Neplatný nebo expirovaný kód.",
|
||||
"CODE_ORG_MISMATCH": "Kód nepatří k této organizaci.",
|
||||
"INVITE_EXPIRED": "Pozvánka vypršela.",
|
||||
"INVITE_INVALID": "Neplatný token pozvánky."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Společnost úspěšně vytvořena a zaregistrována.",
|
||||
"MEMBER_REMOVED": "Člen úspěšně odstraněn.",
|
||||
"ROLE_UPDATED": "Role úspěšně aktualizována.",
|
||||
"UPDATED": "Data organizace úspěšně aktualizována.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Vizuální nastavení úspěšně aktualizováno."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Pozvánka úspěšně vytvořena.",
|
||||
"ACCEPTED": "Pozvánka přijata.",
|
||||
"REVOKED": "Pozvánka odvolána."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Vaše žádost o připojení byla odeslána. Čeká na schválení správcem."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "6místný ověřovací kód byl odeslán na email vlastníka organizace.",
|
||||
"SUCCESS": "Úspěšně jste převzali organizaci. Nyní jste správcem."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Číslo osvědčení o registraci",
|
||||
"CERTIFICATE_VALIDITY": "Platnost osvědčení o registraci",
|
||||
"DOCUMENT_NUMBER": "Číslo dokladu vozidla",
|
||||
"TITLE": "Osvědčení o registraci & Doklad vozidla"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Z důvodu omezení sebeobrany nemůžete odesílat nová servisní data.",
|
||||
"SUCCESS": "Servis odeslán do systému k analýze!",
|
||||
"POINTS_ERROR": "Chyba při bodování: {error}",
|
||||
"SUBMIT_ERROR": "Chyba při odesílání: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Dnes jste již hráli denní kvíz. Vraťte se zítra!",
|
||||
"NO_QUIZ_AVAILABLE": "Dnes není k dispozici žádný kvíz.",
|
||||
"ALREADY_COMPLETED": "Denní kvíz byl dnes již označen jako dokončený.",
|
||||
"COMPLETED_SUCCESS": "Denní kvíz označen jako dokončený pro dnešek.",
|
||||
"QUESTION_NOT_FOUND": "Otázka nenalezena.",
|
||||
"POINTS_FAILED": "Nepodařilo se udělit body za kvíz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Odznak nenalezen.",
|
||||
"ALREADY_AWARDED": "Odznak již byl tomuto uživateli udělen.",
|
||||
"AWARDED_SUCCESS": "Odznak '{badge_name}' byl udělen uživateli.",
|
||||
"POINTS_FAILED": "Nepodařilo se udělit body za odznak: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Sezóna nenalezena.",
|
||||
"NO_ACTIVE": "Nebyla nalezena žádná aktivní sezóna."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Začátečník",
|
||||
"XP_APPRENTICE": "Učeň",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Mistr",
|
||||
"XP_NOVICE_DESC": "Získej 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Získej 500 XP",
|
||||
"XP_EXPERT_DESC": "Získej 2000 XP",
|
||||
"XP_MASTER_DESC": "Získej 5000 XP",
|
||||
"QUIZ_BEGINNER": "Kvízový začátečník",
|
||||
"QUIZ_ENTHUSIAST": "Kvízový nadšenec",
|
||||
"QUIZ_MASTER": "Kvízový mistr",
|
||||
"QUIZ_BEGINNER_DESC": "Získej 50 kvízových bodů",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Získej 200 kvízových bodů",
|
||||
"QUIZ_MASTER_DESC": "Získej 500 kvízových bodů"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/de.json
Normal file
131
backend/static/locales/de.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Anmeldung erfolgreich. Willkommen zurück!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Diese E-Mail ist bereits registriert.",
|
||||
"UNAUTHORIZED": "Unbefugter Zugriff."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Konto aus Sicherheitsgründen gesperrt."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Genehmigung erforderlich"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Erfolgreich gespeichert!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Bestätigen Sie Ihre Registrierung - Service Finder",
|
||||
"GREETING": "Hallo {{first_name}}!",
|
||||
"BODY": "Vielen Dank für Ihre Registrierung! Klicken Sie auf die Schaltfläche unten, um Ihr Konto zu aktivieren:",
|
||||
"BUTTON": "Konto aktivieren",
|
||||
"FOOTER": "Wenn Sie sich nicht registriert haben, ignorieren Sie bitte diese E-Mail."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Passwort zurücksetzen",
|
||||
"GREETING": "Hallo!",
|
||||
"BODY": "Sie haben eine Passwortzurücksetzung angefordert. Klicken Sie auf die Schaltfläche unten, um ein neues Passwort festzulegen:",
|
||||
"BUTTON": "Passwort zurücksetzen",
|
||||
"FOOTER": "Wenn Sie keine Passwortzurücksetzung angefordert haben, ignorieren Sie bitte diese E-Mail."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Test-E-Mail - Service Finder",
|
||||
"GREETING": "Hallo {{first_name}}!",
|
||||
"BODY": "Dies ist eine Test-E-Mail vom Service Finder System. Wenn Sie dies sehen, funktioniert der E-Mail-Versand!",
|
||||
"BUTTON": "Zum Dashboard",
|
||||
"FOOTER": "Service Finder - Test-Systemnachricht"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organisation nicht gefunden.",
|
||||
"INVALID_TAX_FORMAT": "Ungültiges ungarisches Steuernummernformat!",
|
||||
"TAX_LOOKUP_FAILED": "Steuernummernprüfung fehlgeschlagen.",
|
||||
"FORBIDDEN": "Sie haben keine Berechtigung für diese Organisation.",
|
||||
"INVALID_ROLE": "Ungültige Rolle angegeben.",
|
||||
"CANNOT_REMOVE_OWNER": "Der Eigentümer kann nicht aus der Organisation entfernt werden.",
|
||||
"CANNOT_DEMOTE_OWNER": "Die Rolle des Eigentümers kann nicht geändert werden.",
|
||||
"ALREADY_MEMBER": "Sie sind bereits Mitglied dieser Organisation.",
|
||||
"NO_ACTIVE_ADMIN": "Diese Organisation hat keinen aktiven Administrator. Verwenden Sie den /claim-Endpunkt.",
|
||||
"HAS_ACTIVE_ADMIN": "Diese Organisation hat einen aktiven Administrator. Verwenden Sie den /join-request-Endpunkt.",
|
||||
"EMAIL_MISMATCH": "Die E-Mail stimmt nicht mit der E-Mail des Organisationsinhabers überein.",
|
||||
"INVALID_CODE": "Ungültiger oder abgelaufener Code.",
|
||||
"CODE_ORG_MISMATCH": "Der Code gehört nicht zu dieser Organisation.",
|
||||
"INVITE_EXPIRED": "Die Einladung ist abgelaufen.",
|
||||
"INVITE_INVALID": "Ungültiges Einladungstoken."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Unternehmen erfolgreich erstellt und registriert.",
|
||||
"MEMBER_REMOVED": "Mitglied erfolgreich entfernt.",
|
||||
"ROLE_UPDATED": "Rolle erfolgreich aktualisiert.",
|
||||
"UPDATED": "Organisationsdaten erfolgreich aktualisiert.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Visuelle Einstellungen erfolgreich aktualisiert."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Einladung erfolgreich erstellt.",
|
||||
"ACCEPTED": "Einladung angenommen.",
|
||||
"REVOKED": "Einladung widerrufen."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Ihre Beitrittsanfrage wurde übermittelt. Warten auf Administratorgenehmigung."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Ein 6-stelliger Bestätigungscode wurde an die E-Mail des Organisationsinhabers gesendet.",
|
||||
"SUCCESS": "Sie haben die Organisation erfolgreich übernommen. Sie sind jetzt der Administrator."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Zulassungsbescheinigung Nummer",
|
||||
"CERTIFICATE_VALIDITY": "Gültigkeit der Zulassungsbescheinigung",
|
||||
"DOCUMENT_NUMBER": "Fahrzeugdokument Nummer",
|
||||
"TITLE": "Zulassungsbescheinigung & Fahrzeugdokument"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Sie können aufgrund einer Selbstverteidigungseinschränkung keine neuen Servicedaten einreichen.",
|
||||
"SUCCESS": "Service zur Analyse an das System übermittelt!",
|
||||
"POINTS_ERROR": "Fehler bei der Bewertung: {error}",
|
||||
"SUBMIT_ERROR": "Fehler bei der Übermittlung: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Sie haben heute bereits am täglichen Quiz teilgenommen. Kommen Sie morgen wieder!",
|
||||
"NO_QUIZ_AVAILABLE": "Heute ist kein Quiz verfügbar.",
|
||||
"ALREADY_COMPLETED": "Das tägliche Quiz wurde heute bereits als abgeschlossen markiert.",
|
||||
"COMPLETED_SUCCESS": "Tägliches Quiz wurde heute als abgeschlossen markiert.",
|
||||
"QUESTION_NOT_FOUND": "Frage nicht gefunden.",
|
||||
"POINTS_FAILED": "Fehler bei der Vergabe von Quizpunkten: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Abzeichen nicht gefunden.",
|
||||
"ALREADY_AWARDED": "Abzeichen wurde diesem Benutzer bereits verliehen.",
|
||||
"AWARDED_SUCCESS": "Abzeichen '{badge_name}' wurde dem Benutzer verliehen.",
|
||||
"POINTS_FAILED": "Fehler bei der Vergabe von Abzeichenpunkten: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Saison nicht gefunden.",
|
||||
"NO_ACTIVE": "Keine aktive Saison gefunden."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Anfänger",
|
||||
"XP_APPRENTICE": "Lehrling",
|
||||
"XP_EXPERT": "Experte",
|
||||
"XP_MASTER": "Meister",
|
||||
"XP_NOVICE_DESC": "Sammle 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Sammle 500 XP",
|
||||
"XP_EXPERT_DESC": "Sammle 2000 XP",
|
||||
"XP_MASTER_DESC": "Sammle 5000 XP",
|
||||
"QUIZ_BEGINNER": "Quiz-Anfänger",
|
||||
"QUIZ_ENTHUSIAST": "Quiz-Enthusiast",
|
||||
"QUIZ_MASTER": "Quiz-Meister",
|
||||
"QUIZ_BEGINNER_DESC": "Sammle 50 Quizpunkte",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Sammle 200 Quizpunkte",
|
||||
"QUIZ_MASTER_DESC": "Sammle 500 Quizpunkte"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/fr.json
Normal file
131
backend/static/locales/fr.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Connexion réussie. Bon retour parmi nous!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Cet email est déjà enregistré.",
|
||||
"UNAUTHORIZED": "Accès non autorisé."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Compte verrouillé pour des raisons de sécurité."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Approbation requise"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Enregistré avec succès!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Confirmez votre inscription - Service Finder",
|
||||
"GREETING": "Bonjour {{first_name}}!",
|
||||
"BODY": "Merci de vous être inscrit! Cliquez sur le bouton ci-dessous pour activer votre compte:",
|
||||
"BUTTON": "Activer le compte",
|
||||
"FOOTER": "Si vous ne vous êtes pas inscrit, veuillez ignorer cet email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Demande de réinitialisation du mot de passe",
|
||||
"GREETING": "Bonjour!",
|
||||
"BODY": "Vous avez demandé une réinitialisation de mot de passe. Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe:",
|
||||
"BUTTON": "Réinitialiser le mot de passe",
|
||||
"FOOTER": "Si vous n'avez pas demandé de réinitialisation, veuillez ignorer cet email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Email de test - Service Finder",
|
||||
"GREETING": "Bonjour {{first_name}}!",
|
||||
"BODY": "Ceci est un email de test du système Service Finder. Si vous voyez ceci, l'envoi d'emails fonctionne!",
|
||||
"BUTTON": "Aller au tableau de bord",
|
||||
"FOOTER": "Service Finder - Message système de test"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organisation non trouvée.",
|
||||
"INVALID_TAX_FORMAT": "Format de numéro de TVA hongrois invalide!",
|
||||
"TAX_LOOKUP_FAILED": "La vérification du numéro de TVA a échoué.",
|
||||
"FORBIDDEN": "Vous n'avez pas la permission d'accéder à cette organisation.",
|
||||
"INVALID_ROLE": "Rôle spécifié invalide.",
|
||||
"CANNOT_REMOVE_OWNER": "Le propriétaire ne peut pas être retiré de l'organisation.",
|
||||
"CANNOT_DEMOTE_OWNER": "Le rôle du propriétaire ne peut pas être modifié.",
|
||||
"ALREADY_MEMBER": "Vous êtes déjà membre de cette organisation.",
|
||||
"NO_ACTIVE_ADMIN": "Cette organisation n'a pas d'administrateur actif. Utilisez le point de terminaison /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Cette organisation a un administrateur actif. Utilisez le point de terminaison /join-request.",
|
||||
"EMAIL_MISMATCH": "L'email ne correspond pas à l'email du propriétaire de l'organisation.",
|
||||
"INVALID_CODE": "Code invalide ou expiré.",
|
||||
"CODE_ORG_MISMATCH": "Le code n'appartient pas à cette organisation.",
|
||||
"INVITE_EXPIRED": "L'invitation a expiré.",
|
||||
"INVITE_INVALID": "Jeton d'invitation invalide."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Entreprise créée et enregistrée avec succès.",
|
||||
"MEMBER_REMOVED": "Membre supprimé avec succès.",
|
||||
"ROLE_UPDATED": "Rôle mis à jour avec succès.",
|
||||
"UPDATED": "Données de l'organisation mises à jour avec succès.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Paramètres visuels mis à jour avec succès."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Invitation créée avec succès.",
|
||||
"ACCEPTED": "Invitation acceptée.",
|
||||
"REVOKED": "Invitation révoquée."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Votre demande d'adhésion a été soumise. En attente de l'approbation de l'administrateur."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Un code de vérification à 6 chiffres a été envoyé à l'email du propriétaire de l'organisation.",
|
||||
"SUCCESS": "Vous avez repris l'organisation avec succès. Vous êtes maintenant l'administrateur."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Numéro du certificat d'immatriculation",
|
||||
"CERTIFICATE_VALIDITY": "Validité du certificat d'immatriculation",
|
||||
"DOCUMENT_NUMBER": "Numéro du document du véhicule",
|
||||
"TITLE": "Certificat d'immatriculation & Document du véhicule"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Vous ne pouvez pas soumettre de nouvelles données de service en raison d'une restriction d'autodéfense.",
|
||||
"SUCCESS": "Service soumis au système pour analyse!",
|
||||
"POINTS_ERROR": "Erreur lors de l'attribution des points: {error}",
|
||||
"SUBMIT_ERROR": "Erreur lors de la soumission: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Vous avez déjà participé au quiz quotidien aujourd'hui. Revenez demain!",
|
||||
"NO_QUIZ_AVAILABLE": "Aucun quiz disponible aujourd'hui.",
|
||||
"ALREADY_COMPLETED": "Le quiz quotidien a déjà été marqué comme terminé aujourd'hui.",
|
||||
"COMPLETED_SUCCESS": "Quiz quotidien marqué comme terminé pour aujourd'hui.",
|
||||
"QUESTION_NOT_FOUND": "Question non trouvée.",
|
||||
"POINTS_FAILED": "Échec de l'attribution des points de quiz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Badge non trouvé.",
|
||||
"ALREADY_AWARDED": "Badge déjà attribué à cet utilisateur.",
|
||||
"AWARDED_SUCCESS": "Badge '{badge_name}' attribué à l'utilisateur.",
|
||||
"POINTS_FAILED": "Échec de l'attribution des points de badge: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Saison non trouvée.",
|
||||
"NO_ACTIVE": "Aucune saison active trouvée."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Novice",
|
||||
"XP_APPRENTICE": "Apprenti",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Maître",
|
||||
"XP_NOVICE_DESC": "Gagnez 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Gagnez 500 XP",
|
||||
"XP_EXPERT_DESC": "Gagnez 2000 XP",
|
||||
"XP_MASTER_DESC": "Gagnez 5000 XP",
|
||||
"QUIZ_BEGINNER": "Débutant Quiz",
|
||||
"QUIZ_ENTHUSIAST": "Passionné de Quiz",
|
||||
"QUIZ_MASTER": "Maître du Quiz",
|
||||
"QUIZ_BEGINNER_DESC": "Gagnez 50 points de quiz",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Gagnez 200 points de quiz",
|
||||
"QUIZ_MASTER_DESC": "Gagnez 500 points de quiz"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/ro.json
Normal file
131
backend/static/locales/ro.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Autentificare reușită. Bun venit înapoi!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Acest email este deja înregistrat.",
|
||||
"UNAUTHORIZED": "Acces neautorizat."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Cont blocat din motive de securitate."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Aprobare necesară"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Salvat cu succes!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Confirmați înregistrarea - Service Finder",
|
||||
"GREETING": "Salut {{first_name}}!",
|
||||
"BODY": "Vă mulțumim pentru înregistrare! Faceți clic pe butonul de mai jos pentru a vă activa contul:",
|
||||
"BUTTON": "Activează Contul",
|
||||
"FOOTER": "Dacă nu v-ați înregistrat, vă rugăm să ignorați acest email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Solicitare resetare parolă",
|
||||
"GREETING": "Salut!",
|
||||
"BODY": "Ați solicitat resetarea parolei. Faceți clic pe butonul de mai jos pentru a seta o nouă parolă:",
|
||||
"BUTTON": "Resetează Parola",
|
||||
"FOOTER": "Dacă nu ați solicitat resetarea parolei, vă rugăm să ignorați acest email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Email de test - Service Finder",
|
||||
"GREETING": "Salut {{first_name}}!",
|
||||
"BODY": "Acesta este un email de test de la sistemul Service Finder. Dacă vedeți acest mesaj, trimiterea de emailuri funcționează!",
|
||||
"BUTTON": "Mergi la Dashboard",
|
||||
"FOOTER": "Service Finder - Mesaj de sistem de test"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organizație negăsită.",
|
||||
"INVALID_TAX_FORMAT": "Format invalid al numărului de TVA maghiar!",
|
||||
"TAX_LOOKUP_FAILED": "Verificarea numărului de TVA a eșuat.",
|
||||
"FORBIDDEN": "Nu aveți permisiunea de a accesa această organizație.",
|
||||
"INVALID_ROLE": "Rol specificat invalid.",
|
||||
"CANNOT_REMOVE_OWNER": "Proprietarul nu poate fi eliminat din organizație.",
|
||||
"CANNOT_DEMOTE_OWNER": "Rolul proprietarului nu poate fi modificat.",
|
||||
"ALREADY_MEMBER": "Sunteți deja membru al acestei organizații.",
|
||||
"NO_ACTIVE_ADMIN": "Această organizație nu are un administrator activ. Utilizați punctul final /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Această organizație are un administrator activ. Utilizați punctul final /join-request.",
|
||||
"EMAIL_MISMATCH": "Emailul nu corespunde cu emailul proprietarului organizației.",
|
||||
"INVALID_CODE": "Cod invalid sau expirat.",
|
||||
"CODE_ORG_MISMATCH": "Codul nu aparține acestei organizații.",
|
||||
"INVITE_EXPIRED": "Invitația a expirat.",
|
||||
"INVITE_INVALID": "Token de invitație invalid."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Companie creată și înregistrată cu succes.",
|
||||
"MEMBER_REMOVED": "Membru eliminat cu succes.",
|
||||
"ROLE_UPDATED": "Rol actualizat cu succes.",
|
||||
"UPDATED": "Datele organizației actualizate cu succes.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Setările vizuale actualizate cu succes."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Invitație creată cu succes.",
|
||||
"ACCEPTED": "Invitație acceptată.",
|
||||
"REVOKED": "Invitație revocată."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Cererea dvs. de aderare a fost trimisă. Se așteaptă aprobarea administratorului."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "Un cod de verificare din 6 cifre a fost trimis pe emailul proprietarului organizației.",
|
||||
"SUCCESS": "Ați preluat cu succes organizația. Acum sunteți administratorul."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Numărul certificatului de înmatriculare",
|
||||
"CERTIFICATE_VALIDITY": "Valabilitatea certificatului de înmatriculare",
|
||||
"DOCUMENT_NUMBER": "Numărul documentului vehiculului",
|
||||
"TITLE": "Certificat de înmatriculare & Document vehicul"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Nu puteți trimite date noi de service din cauza unei restricții de autoapărare.",
|
||||
"SUCCESS": "Service trimis sistemului pentru analiză!",
|
||||
"POINTS_ERROR": "Eroare la punctare: {error}",
|
||||
"SUBMIT_ERROR": "Eroare la trimitere: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Ați jucat deja quizul zilnic astăzi. Reveniți mâine!",
|
||||
"NO_QUIZ_AVAILABLE": "Niciun quiz disponibil pentru astăzi.",
|
||||
"ALREADY_COMPLETED": "Quizul zilnic a fost deja marcat ca finalizat astăzi.",
|
||||
"COMPLETED_SUCCESS": "Quizul zilnic marcat ca finalizat pentru astăzi.",
|
||||
"QUESTION_NOT_FOUND": "Întrebare negăsită.",
|
||||
"POINTS_FAILED": "Eroare la acordarea punctelor de quiz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Insignă negăsită.",
|
||||
"ALREADY_AWARDED": "Insigna a fost deja acordată acestui utilizator.",
|
||||
"AWARDED_SUCCESS": "Insigna '{badge_name}' a fost acordată utilizatorului.",
|
||||
"POINTS_FAILED": "Eroare la acordarea punctelor insignei: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Sezon negăsit.",
|
||||
"NO_ACTIVE": "Niciun sezon activ găsit."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Începător",
|
||||
"XP_APPRENTICE": "Ucenic",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Maestru",
|
||||
"XP_NOVICE_DESC": "Câștigă 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Câștigă 500 XP",
|
||||
"XP_EXPERT_DESC": "Câștigă 2000 XP",
|
||||
"XP_MASTER_DESC": "Câștigă 5000 XP",
|
||||
"QUIZ_BEGINNER": "Începător Quiz",
|
||||
"QUIZ_ENTHUSIAST": "Entuziast Quiz",
|
||||
"QUIZ_MASTER": "Maestru Quiz",
|
||||
"QUIZ_BEGINNER_DESC": "Câștigă 50 de puncte de quiz",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Câștigă 200 de puncte de quiz",
|
||||
"QUIZ_MASTER_DESC": "Câștigă 500 de puncte de quiz"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
backend/static/locales/sk.json
Normal file
131
backend/static/locales/sk.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"AUTH": {
|
||||
"LOGIN": {
|
||||
"SUCCESS": "Prihlásenie úspešné. Vitajte späť!"
|
||||
},
|
||||
"ERROR": {
|
||||
"EMAIL_EXISTS": "Tento email je už zaregistrovaný.",
|
||||
"UNAUTHORIZED": "Neoprávnený prístup."
|
||||
}
|
||||
},
|
||||
"SENTINEL": {
|
||||
"LOCK": {
|
||||
"MSG": "Účet bol z bezpečnostných dôvodov uzamknutý."
|
||||
},
|
||||
"APPROVAL": {
|
||||
"REQUIRED": "Vyžaduje sa schválenie"
|
||||
}
|
||||
},
|
||||
"COMMON": {
|
||||
"SAVE_SUCCESS": "Úspešne uložené!"
|
||||
},
|
||||
"EMAIL": {
|
||||
"REG": {
|
||||
"SUBJECT": "Potvrďte svoju registráciu - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Ďakujeme za registráciu! Kliknutím na tlačidlo nižšie aktivujete svoj účet:",
|
||||
"BUTTON": "Aktivovať účet",
|
||||
"FOOTER": "Ak ste sa nezaregistrovali, ignorujte prosím tento email."
|
||||
},
|
||||
"PWD_RESET": {
|
||||
"SUBJECT": "Žiadosť o obnovenie hesla",
|
||||
"GREETING": "Ahoj!",
|
||||
"BODY": "Požiadali ste o obnovenie hesla. Kliknutím na tlačidlo nižšie nastavíte nové heslo:",
|
||||
"BUTTON": "Obnoviť heslo",
|
||||
"FOOTER": "Ak ste nepožiadali o obnovenie hesla, ignorujte prosím tento email."
|
||||
},
|
||||
"TEST": {
|
||||
"SUBJECT": "🧪 Testovací email - Service Finder",
|
||||
"GREETING": "Ahoj {{first_name}}!",
|
||||
"BODY": "Toto je testovací email zo systému Service Finder. Ak to vidíte, odosielanie emailov funguje!",
|
||||
"BUTTON": "Prejsť na Dashboard",
|
||||
"FOOTER": "Service Finder - Testovacia systémová správa"
|
||||
}
|
||||
},
|
||||
"ORGANIZATION": {
|
||||
"ERROR": {
|
||||
"NOT_FOUND": "Organizácia nenájdená.",
|
||||
"INVALID_TAX_FORMAT": "Neplatný formát maďarského daňového čísla!",
|
||||
"TAX_LOOKUP_FAILED": "Overenie daňového čísla zlyhalo.",
|
||||
"FORBIDDEN": "Nemáte oprávnenie na prístup k tejto organizácii.",
|
||||
"INVALID_ROLE": "Neplatná rola.",
|
||||
"CANNOT_REMOVE_OWNER": "Vlastník nemôže byť odstránený z organizácie.",
|
||||
"CANNOT_DEMOTE_OWNER": "Rola vlastníka nemôže byť zmenená.",
|
||||
"ALREADY_MEMBER": "Už ste členom tejto organizácie.",
|
||||
"NO_ACTIVE_ADMIN": "Táto organizácia nemá aktívneho správcu. Použite koncový bod /claim.",
|
||||
"HAS_ACTIVE_ADMIN": "Táto organizácia má aktívneho správcu. Použite koncový bod /join-request.",
|
||||
"EMAIL_MISMATCH": "Email nezodpovedá emailu vlastníka organizácie.",
|
||||
"INVALID_CODE": "Neplatný alebo expirovaný kód.",
|
||||
"CODE_ORG_MISMATCH": "Kód nepatrí k tejto organizácii.",
|
||||
"INVITE_EXPIRED": "Pozvánka vypršala.",
|
||||
"INVITE_INVALID": "Neplatný token pozvánky."
|
||||
},
|
||||
"SUCCESS": {
|
||||
"ONBOARDED": "Spoločnosť úspešne vytvorená a zaregistrovaná.",
|
||||
"MEMBER_REMOVED": "Člen úspešne odstránený.",
|
||||
"ROLE_UPDATED": "Rola úspešne aktualizovaná.",
|
||||
"UPDATED": "Dáta organizácie úspešne aktualizované.",
|
||||
"VISUAL_SETTINGS_UPDATED": "Vizuálne nastavenia úspešne aktualizované."
|
||||
},
|
||||
"INVITATION": {
|
||||
"CREATED": "Pozvánka úspešne vytvorená.",
|
||||
"ACCEPTED": "Pozvánka prijatá.",
|
||||
"REVOKED": "Pozvánka odvolaná."
|
||||
},
|
||||
"JOIN_REQUEST": {
|
||||
"SENT": "Vaša žiadosť o pripojenie bola odoslaná. Čaká na schválenie správcom."
|
||||
},
|
||||
"CLAIM": {
|
||||
"OTP_SENT": "6-miestny overovací kód bol odoslaný na email vlastníka organizácie.",
|
||||
"SUCCESS": "Úspešne ste prevzali organizáciu. Teraz ste správcom."
|
||||
}
|
||||
},
|
||||
"REGISTRATION_DOCUMENTS": {
|
||||
"CERTIFICATE_NUMBER": "Číslo osvedčenia o registrácii",
|
||||
"CERTIFICATE_VALIDITY": "Platnosť osvedčenia o registrácii",
|
||||
"DOCUMENT_NUMBER": "Číslo dokladu vozidla",
|
||||
"TITLE": "Osvedčenie o registrácii & Doklad vozidla"
|
||||
},
|
||||
"GAMIFICATION": {
|
||||
"SUBMIT_SERVICE": {
|
||||
"SELF_DEFENSE_BLOCKED": "Z dôvodu obmedzenia sebaobrany nemôžete odosielať nové servisné dáta.",
|
||||
"SUCCESS": "Servis odoslaný do systému na analýzu!",
|
||||
"POINTS_ERROR": "Chyba pri bodovaní: {error}",
|
||||
"SUBMIT_ERROR": "Chyba pri odosielaní: {error}"
|
||||
},
|
||||
"QUIZ": {
|
||||
"ALREADY_PLAYED": "Dnes ste už hrali denný kvíz. Vráťte sa zajtra!",
|
||||
"NO_QUIZ_AVAILABLE": "Dnes nie je k dispozícii žiadny kvíz.",
|
||||
"ALREADY_COMPLETED": "Denný kvíz bol dnes už označený ako dokončený.",
|
||||
"COMPLETED_SUCCESS": "Denný kvíz označený ako dokončený pre dnešok.",
|
||||
"QUESTION_NOT_FOUND": "Otázka nenájdená.",
|
||||
"POINTS_FAILED": "Nepodarilo sa udeliť body za kvíz: {error}"
|
||||
},
|
||||
"BADGE": {
|
||||
"NOT_FOUND": "Odznak nenájdený.",
|
||||
"ALREADY_AWARDED": "Odznak už bol tomuto používateľovi udelený.",
|
||||
"AWARDED_SUCCESS": "Odznak '{badge_name}' bol udelený používateľovi.",
|
||||
"POINTS_FAILED": "Nepodarilo sa udeliť body za odznak: {error}"
|
||||
},
|
||||
"SEASON": {
|
||||
"NOT_FOUND": "Sezóna nenájdená.",
|
||||
"NO_ACTIVE": "Nebola nájdená žiadna aktívna sezóna."
|
||||
},
|
||||
"ACHIEVEMENTS": {
|
||||
"XP_NOVICE": "Začiatočník",
|
||||
"XP_APPRENTICE": "Učeň",
|
||||
"XP_EXPERT": "Expert",
|
||||
"XP_MASTER": "Majster",
|
||||
"XP_NOVICE_DESC": "Získaj 100 XP",
|
||||
"XP_APPRENTICE_DESC": "Získaj 500 XP",
|
||||
"XP_EXPERT_DESC": "Získaj 2000 XP",
|
||||
"XP_MASTER_DESC": "Získaj 5000 XP",
|
||||
"QUIZ_BEGINNER": "Kvízový začiatočník",
|
||||
"QUIZ_ENTHUSIAST": "Kvízový nadšenec",
|
||||
"QUIZ_MASTER": "Kvízový majster",
|
||||
"QUIZ_BEGINNER_DESC": "Získaj 50 kvízových bodov",
|
||||
"QUIZ_ENTHUSIAST_DESC": "Získaj 200 kvízových bodov",
|
||||
"QUIZ_MASTER_DESC": "Získaj 500 kvízových bodov"
|
||||
}
|
||||
}
|
||||
}
|
||||
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_HOST=0.0.0.0
|
||||
- NUXT_PUBLIC_API_BASE_URL=https://dev.servicefinder.hu
|
||||
- NODE_OPTIONS=--max-old-space-size=4096
|
||||
volumes:
|
||||
- ./frontend_admin:/app
|
||||
- /app/node_modules
|
||||
@@ -297,6 +298,10 @@ services:
|
||||
- sf_net
|
||||
- shared_db_net
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 8G
|
||||
|
||||
# --- PUBLIC FRONTEND (Epic 11 - Public Portal) ---
|
||||
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
|
||||
129
docs/admin_providers_root_cause_analysis.md
Normal file
129
docs/admin_providers_root_cause_analysis.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 🔍 Admin Service Provider Root Cause Analysis
|
||||
|
||||
## Összefoglaló
|
||||
|
||||
**Felfedezett hiba:** Az admin felület szolgáltató (provider) listája és "Jóváhagyásra váró" oldala csak a `marketplace.service_providers` táblában tárolt 6 rekordot jeleníti meg. A robotok által begyűjtött 8569 rekord a `marketplace.service_staging` táblában teljesen láthatatlan az admin számára.
|
||||
|
||||
**Dátum:** 2026-06-30
|
||||
**Vizsgáló:** Rendszer-Architect
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Adatbázis Táblák Állapota
|
||||
|
||||
| Tábla | Séma | Rekordszám | Státuszértékek | Forrás |
|
||||
|-------|------|-----------|----------------|--------|
|
||||
| `service_providers` | `marketplace` | **6** | approved, rejected | API (user/manuális) |
|
||||
| `service_staging` | `marketplace` | **8569** | research_in_progress (5096), no_web_presence (3472), auditing (1) | Robotok (bot) |
|
||||
| `organizations` | `fleet` | **1** (service_provider = true) | active | Regisztráció |
|
||||
|
||||
---
|
||||
|
||||
## 🔬 Root Cause Analysis
|
||||
|
||||
### 1. Admin API csak a `service_providers` táblát kérdezi
|
||||
|
||||
A [`backend/app/api/v1/endpoints/admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:231) [`list_providers()`](../backend/app/api/v1/endpoints/admin_providers.dart:231) metódusa kizárólag a `ServiceProvider` modellre hivatkozik:
|
||||
|
||||
```python
|
||||
@router.get("", response_model=List[ProviderListItem])
|
||||
async def list_providers(...):
|
||||
query = select(ServiceProvider) # ← CSAK marketplace.service_providers
|
||||
...
|
||||
```
|
||||
|
||||
Ugyanez a helyzet a [`get_provider_stats()`](../backend/app/api/v1/endpoints/admin_providers.dart:261) endpointtal (261. sor).
|
||||
|
||||
**Következmény:** A botok által felfedezett 8569 rekord admin felületről nem látható, nem moderálható, nem kereshető.
|
||||
|
||||
### 2. A Public API már helyesen kezeli a 3 forrást
|
||||
|
||||
A [`backend/app/services/provider_service.py`](../backend/app/services/provider_service.dart:216) [`search_providers()`](../backend/app/services/provider_service.dart:216) (public API) UNION ALL lekérdezéssel egyesíti mindhárom forrást:
|
||||
|
||||
```python
|
||||
org_query = select(Organization.id, Organization.name, ...)
|
||||
staging_query = select(ServiceStaging.id, ServiceStaging.name, ...)
|
||||
provider_query = select(ServiceProvider.id, ServiceProvider.name, ...)
|
||||
union_query = union_all(org_query, staging_query, provider_query)
|
||||
```
|
||||
|
||||
Ezt a mintát kell alkalmazni az admin API-ban is.
|
||||
|
||||
### 3. A "Jóváhagyásra váró" lista azért üres, mert...
|
||||
|
||||
- A `service_providers` táblában **nincs** `pending` státuszú rekord (csak approved/rejected)
|
||||
- A `service_staging` tábla 8569 rekordja **ki van zárva** az admin lekérdezésből
|
||||
- A frontend [`pending.vue`](../frontend_admin/pages/providers/pending.dart:1) ugyanezt az admin API-t hívja `status=pending` paraméterrel
|
||||
|
||||
### 4. Validációs rendszer már létezik, csak csatornázni kell
|
||||
|
||||
A meglévő adatbázis tartalmazza az összes szükséges validációs infrastruktúrát:
|
||||
|
||||
- `marketplace.provider_validations` - upvote/downvote rendszer súlyozással, dedikált validációs tábla
|
||||
- `marketplace.service_providers.validation_score` - közvetlen validációs pontszám
|
||||
- `marketplace.service_providers.status` - moderációs státusz (ModerationStatus enum)
|
||||
- `marketplace.service_profiles.trust_score` - bizalmi pontszám
|
||||
- `marketplace.service_profiles.is_verified` - ellenőrzöttségi jelző
|
||||
- `marketplace.service_profiles.verification_log` - JSON validációs napló
|
||||
|
||||
**Nem kell új mezőket létrehozni** - a `ServiceStaging` adatokat megfelelően be kell csatornázni a meglévő validációs rendszerbe.
|
||||
|
||||
---
|
||||
|
||||
## 🩹 Javasolt Javítási Terv
|
||||
|
||||
### Rövid távú (P0) - Admin API UNION ALL kiterjesztése
|
||||
|
||||
1. **`list_providers()`** módosítása [`admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:231)-ban:
|
||||
- UNION ALL lekérdezés a `ServiceStaging` táblából is (a [`search_providers()`](../backend/app/services/provider_service.dart:216) mintájára)
|
||||
- A `ServiceStaging` rekordok leképezése a `ProviderListItem` response modelre
|
||||
- A `ServiceStaging` rekordoknál: `status` -> `research_in_progress` mint `pending`, `source` -> `bot`
|
||||
|
||||
2. **`get_provider_stats()`** módosítása [`admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:261)-ban:
|
||||
- A `pending` count kiegészítése a `research_in_progress` ServiceStaging rekordokkal
|
||||
|
||||
3. **Jóváhagyási flow** (approve/reject):
|
||||
- `ServiceStaging` rekord jóváhagyásakor: `ServiceProvider` rekord létrehozása az adatokkal
|
||||
- A meglévő `provider_validations` és `service_profiles` validációs rendszer használata
|
||||
|
||||
### Implementációs Részletek
|
||||
|
||||
A `ProviderListItem` modelt ki kell egészíteni egy `source_table` mezővel, hogy a frontend tudja, melyik táblából jött a rekord:
|
||||
|
||||
```python
|
||||
class ProviderListItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
address: Optional[str]
|
||||
city: Optional[str]
|
||||
status: str # "pending" | "approved" | "rejected"
|
||||
source: str # "api" | "manual" | "bot"
|
||||
source_table: str # "service_providers" | "service_staging" | "organizations"
|
||||
validation_score: Optional[int]
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Érintett Fájlok
|
||||
|
||||
| Fájl | Szerep |
|
||||
|------|--------|
|
||||
| [`backend/app/api/v1/endpoints/admin_providers.py`](../backend/app/api/v1/endpoints/admin_providers.dart:1) | **Root cause** - Javítandó fájl |
|
||||
| [`backend/app/services/provider_service.py`](../backend/app/services/provider_service.dart:216) | **Minta** - UNION ALL implementáció |
|
||||
| [`backend/app/models/identity/social.py`](../backend/app/models/identity/social.dart:23) | `ServiceProvider` modell |
|
||||
| [`backend/app/models/marketplace/service.py`](../backend/app/models/marketplace/service.dart:162) | `ServiceStaging` modell |
|
||||
| [`frontend_admin/pages/providers/index.vue`](../frontend_admin/pages/providers/index.dart:1) | Admin provider lista oldal |
|
||||
| [`frontend_admin/pages/providers/pending.vue`](../frontend_admin/pages/providers/pending.dart:1) | Admin pending queue oldal |
|
||||
| [`plans/logic_spec_service_provider_discovery_admin.md`](../plans/logic_spec_service_provider_discovery_admin.dart:1) | Meglévő tervdokumentáció |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Következő Lépések
|
||||
|
||||
1. [ ] Gitea kártya létrehozása: Admin API UNION ALL kiterjesztése ServiceStaging táblával
|
||||
2. [ ] `ProviderListItem` response model kiegészítése `source_table` mezővel
|
||||
3. [ ] `list_providers()` módosítása: UNION ALL 3 forrás
|
||||
4. [ ] `get_provider_stats()` módosítása: staging rekordok beszámítása
|
||||
5. [ ] Jóváhagyási flow: ServiceStaging -> ServiceProvider átvezetés
|
||||
6. [ ] Tesztelés: frontend pending lista megjelenése
|
||||
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.*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user