Compare commits

...

13 Commits

Author SHA1 Message Date
Roo
6f28d3e70d felhasználói felületre pü 2026-07-27 08:39:18 +00:00
Roo
c9118bf52f admin_nyelvi_javítas 2026-07-25 10:22:03 +00:00
Roo
33c4d793db L1L2 gen1Gen2 kialakítása 2026-07-24 09:56:21 +00:00
Roo
4594de6c11 garázs fejlesztés 2026-07-20 11:17:52 +00:00
Roo
2e0abc62a7 frontend admin refakctorálás 2026-07-08 08:03:57 +00:00
Roo
07b59032ce refaktor címjegyzék 2026-07-02 11:52:22 +00:00
Roo
7654913d21 refaktor címtár 2026-07-01 19:49:58 +00:00
Roo
6e627d0ebe admin_szolgáltatók_ 2026-07-01 02:27:38 +00:00
Roo
189cbfd7ca feat(admin): add Szolgáltatók sidebar menu group with providers pages
- Added 'Szolgáltatók' menu group to sidebar with 'Összes szolgáltató' (/providers) and 'Jóváhagyásra váró' (/providers/pending) sub-items
- Extended pageTitle computed property with provider route titles
- Providers pages: index.vue (list), pending.vue (moderation queue), [id].vue (details)

Closes #351
2026-06-30 10:26:57 +00:00
Roo
cbfb955580 gemification_admin bekötve 2026-06-30 08:56:55 +00:00
Roo
df4a0f07ff admin_users_personels 2026-06-29 14:11:15 +00:00
Roo
383e5511b6 HOTFIX: Fix broken imports in get_organization_details
- from app.models.fleet.vehicle → app.models.vehicle.vehicle (Vehicle)
- from app.models.fleet.organization → app.models.marketplace.organization (Branch)

These modules don't exist under app.models.fleet, causing
ModuleNotFoundError on GET /api/v1/admin/organizations/45/details
2026-06-28 12:15:21 +00:00
Roo
f2935cbd64 P0 HOTFIX: Subscription asset_limit calculation fix
Root cause: Old code used rules.get('max_vehicles') which only searched
JSONB root level. Corporate tiers store limits under rules['allowances'].

Changes:
1. Added _extract_limit() helper (3-level search: direct → allowances → limits)
2. Replaced single-sub .limit(1) with multi-sub .all() for true aggregation
3. Fixed fallback path to use _extract_limit() instead of rules.get()
4. Added clamping: asset_limit = max(asset_limit, 1) right before
   SubscriptionSummary creation on BOTH code paths
5. Added 'type' column to SubscriptionTier model (base vs addon)
2026-06-28 12:09:56 +00:00
574 changed files with 71003 additions and 17565 deletions

View File

@@ -1,825 +1,49 @@
1 | # Service Finder Fejlesztési Történet ### ✅ Verifikáció
2 | 1. **Build ellenőrzés**`npx vite build` sikeres (7.08s, 0 error)
3 | ## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt 2. **Chunk generálva**: `CostEntryWizard-C_-R4OzC.js` (29.95 kB) - refaktorált komponens helyesen fordul
4 | 3. **ProviderAutocomplete** újrafelhasználva mindhárom komponensben - nincs duplikáció
5 | ### 🎯 Cél
6 | 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.
7 |
8 | ### 🔧 Eredmények
9 |
10 | **1. ADATBÁZIS TISZTASÁG:** ✅ PASS
11 | - API modul import: ✅ Sikeres (2 route: GET, POST)
12 | - Sync Engine: ✅ 1210 OK, 0 Fixed, 0 Extra
13 | - E2E test: ⚠️ Pre-existing conftest hiba (verification token timeout - nem kapcsolódó)
14 |
15 | ## 2026-06-23 - Cost Entry Wizard (CostEntryWizard.vue) - 4-lépéses Számla Űrlap
16 |
17 | ### 🎯 Cél
18 | 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.
19 |
20 | ### 🔧 Módosított fájlok
21 |
22 | ## 2026-06-24 - P0 Fix & Enhance: Permissions Page Data Flow, Editing & i18n
23 |
24 | ### 🎯 Cél
25 | Permissions oldal adatáramlás javítása, szerkesztés/mentés funkció bevezetése, és @nuxtjs/i18n telepítése/konfigurálása.
26 |
27 | ### 🔧 Módosított fájlok
28 | - `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
29 | - `frontend_admin/nuxt.config.ts` - @nuxtjs/i18n modul hozzáadása, locales konfiguráció (hu/en)
30 | - `frontend_admin/i18n/locales/hu.json` - Magyar fordítási fájl (permissions oldal összes szövege)
31 | - `frontend_admin/i18n/locales/en.json` - Angol fordítási fájl
32 |
33 | ### ✅ Eredmények
34 | - Build: ✅ Sikeres (hu-DbjfOUfH.mjs, en-DnJ23n0t.mjs locale chunk-ok)
35 | - i18n modul: @nuxtjs/i18n v10.4.0 telepítve, no_prefix stratégiával
36 | - Szerkesztés: modified/original Map-ek, handleToggle, savePermissions (PATCH /admin/permissions/override/{org_id})
37 | - Adatmentés: success/error toast notification auto-clear-el
38 |
39 | ## 2026-06-24 - P0 Execution: Wire Packages UI to Real Database
40 |
41 | ### 🎯 Cél
42 | 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.
43 |
44 | ### 🔧 Módosított fájlok
45 | - `backend/app/schemas/subscription.py` - `tier_level` és `feature_capabilities` mezők hozzáadva a SubscriptionTierResponse, SubscriptionTierCreate, SubscriptionTierUpdate modellekhez
46 | - `backend/app/api/v1/endpoints/admin_packages.py` - create/update végpontok frissítve az új mezők kezelésére
47 | - `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
48 | - `frontend_admin/locales/hu.json` - packages i18n kulcsok (magyar)
49 | - `frontend_admin/locales/en.json` - packages i18n kulcsok (angol)
50 |
51 | ### ✅ Eredmények
52 | - Backend API: GET /api/v1/admin/packages → 200 OK, 10 tier (tier_level, feature_capabilities mezőkkel)
53 | - Frontend: mock adatok eltávolítva, valós API hívás onMounted-ben, loading/error/empty állapotok
54 | - CRUD: Létrehozás (POST), Szerkesztés (PATCH), Törlés (DELETE soft-delete) teljes körűen implementálva
55 | - i18n: Minden UI szöveg magyar és angol nyelven elérhető
56 |
57 | ## 2026-06-24 - P0 CRITICAL FIX: Deep Merge JSONB (Multi-zone Pricing Data Loss)
58 |
59 | ### 🎯 Cél
60 | Javítani a `PATCH /api/v1/admin/packages/{tier_id}` végpontot, hogy a `rules` JSONB oszlop frissítésekor a meglévő nested kulcsok (pl. `pricing_zones.HU`, `pricing_zones.US`, `lifecycle.available_until`) ne vesszenek el, ha a frontend csak egy részhalmazt küld (pl. csak `pricing_zones.DEFAULT`).
61 |
62 | ### 🔧 Módosított fájlok
63 | - `backend/app/api/v1/endpoints/admin_packages.py``deep_merge_dict()` függvény hozzáadva, `update_package` endpoint átírva deep merge szemantikára
64 | - `tests/active/test_deep_merge_fix.py` — Verifikációs teszt (unit + API PATCH)
65 |
66 | ### ✅ Eredmények
67 | - **Unit test:** ✅ PASS — `deep_merge_dict()` helyesen működik: nested dict-ek rekurzív merge, None értékek megőrzése, üres override megőrzése
68 | - **API test:** ✅ PASS — `private_pro_v1` (id=14) csomagon: HU, US, DEFAULT zónák mind megmaradtak a PATCH után, DEFAULT ára frissült, lifecycle.available_until megőrződött
69 | - **Sync Engine:** ✅ 1220 OK, 0 Fixed — adatbázis séma konzisztens
70 | - **Root cause:** A frontend `packages/index.vue` `rulesPayload` függvénye csak `pricing_zones.DEFAULT`-ot állítja elő, a backend pedig wholesale replace-t végzett → multi-zone adatvesztés
## 2026-06-24 - P0 System Architecture: Global Region Config & Smart Pricing
### 🎯 Cél
Globális régió konfigurációs rendszer (RegionConfig modell + API) megvalósítása, frontend region store és formatter composable létrehozása, Smart Pricing Calculator beépítése a package edit modalba, valamint nyelvi preferencia perzisztálása a layoutban.
### 🔧 Módosított fájlok
- `backend/app/models/core_logic.py``RegionConfig` osztály hozzáadva (`system.region_config` séma, mezők: country_code, name, currency, default_vat_rate, locale_code, timezone, is_active)
- `backend/app/models/__init__.py``RegionConfig` import hozzáadva
- `backend/app/api/v1/endpoints/regions.py`**LÉTREHOZVA**: `GET /system/regions` publikus végpont
- `backend/app/api/v1/api.py` — regions router regisztrálva `/system` prefix-szel
- `backend/scripts/seed_regions.py`**LÉTREHOZVA**: 3 régió seedelése (HU, GB, DEFAULT)
- `backend/app/api/v1/endpoints/auth.py``PATCH /auth/me/language` végpont hozzáadva nyelvi preferencia perzisztáláshoz
- `frontend_admin/stores/region.ts`**LÉTREHOZVA**: Pinia store régiók lekérésére, aktív régió getterekkel (currency, locale, VAT, timezone)
- `frontend_admin/composables/useFormatter.ts`**LÉTREHOZVA**: Lokalizált formatter (number, currency, date, datetime, percent, VAT számítás)
- `frontend_admin/pages/packages/index.vue` — Tabbed modal refaktor (Basic Info, Pricing, Features), per-régió árazási grid, Smart Pricing Calculator net/VAT/gross kijelzéssel
- `frontend_admin/layouts/default.vue` — Nyelvváltó perzisztálás PATCH hívással, region store inicializálás onMounted-ban
### ✅ Eredmények
- **Sync Engine:** ✅ 1220 OK, 1 Fixed, 0 Shadow — `system.region_config` tábla létrejött
- **GET /api/v1/system/regions:** ✅ 200 — 3 régió (DEFAULT/EUR/0%, GB/GBP/20%, HU/HUF/27%)
- **PATCH /auth/me/language:** ✅ 200 — `preferred_language` perzisztálódik és visszaolvasható
- **Frontend build (admin):** ✅ Sikeres — `region-*.mjs` chunk generálódott
- **Adatbázis:** `country_code` VARCHAR(5)→VARCHAR(10) manuális ALTER a seed előtt
## 2026-06-25 - P0 CRITICAL SECURITY & UI FIX - RBAC Middleware & Mock Data Removal
### 🎯 Cél
Szigorú frontend Route-Based Access Control (RBAC) bevezetése az admin middleware-ben, és a hardcoded mock email (`admin@servicefinder.hu`) eltávolítása a Topbar-ból.
### 🔧 Módosított fájlok
- `frontend_admin/middleware/auth.ts` — Teljes átírás: token cookie ellenőrzés után `authStore.fetchUser()` hívás, majd `authStore.user.role` ellenőrzés az allowed staff role-ok listája (`SUPERADMIN`, `ADMIN`, `MODERATOR`, `SALES_REP`, `SERVICE_MGR`) alapján. Ha a role nem staff szintű → `authStore.logout()` + `navigateTo('/login')`.
- `frontend_admin/layouts/default.vue``userEmail` computed fallback `'admin@servicefinder.hu'``'Ismeretlen Felhasználó'`, `userInitials` fallback `'AD'``'??'`, dropdown role label `'Administrator'` → dinamikus `authStore.userRole` alapú.
### ✅ Eredmények
- **RBAC Middleware:** ✅ Standard USER role esetén a middleware kilépteti a felhasználót és a `/login` oldalra irányítja
- **Mock adatok eltávolítva:** ✅ `admin@servicefinder.hu` sehol sem szerepel hardcoded értékként
- **Auth Store:** ✅ Tiszta, nincs benne mock/default user adat
- **Minden admin oldal** (`index.vue`, `garages/index.vue`, `packages/index.vue`, `permissions/index.vue`) használja a `definePageMeta({ middleware: 'auth' })` direktívát
## 2026-06-25 - P0 Data Architecture: Populate Full EU Region Registry
### 🎯 Cél
A `system.region_config` tábla feltöltése mind a 27 EU tagállammal, plusz UK, Svájc és DEFAULT/Eurozone fallback rekorddal. Korábban csak 3 dummy/seed régió (HU, GB, DEFAULT) volt az adatbázisban.
### 🔧 Módosított fájlok
- `backend/scripts/seed_regions.py` — Teljes átírás: 30 régiós adatbázis (27 EU + GB + CH + DEFAULT), UPSERT logika (meglévő rekordok frissítése, új rekordok beszúrása), pontos VAT kulcsok, devizanemek, locale kódok és időzónák minden országra.
### ✅ Eredmények
- **Seed futtatás:** ✅ 27 új régió létrehozva, 3 meglévő frissítve (HU, GB, DEFAULT)
- **API verifikáció:** ✅ `GET /api/v1/system/regions` → 30 régió visszaadva, minden adat helyes
- **UPSERT idempotencia:** ✅ A script többször is futtatható, frissíti a meglévő rekordokat ahelyett, hogy duplikációt okozna
## 2026-06-25 - Garages CRM Wiring & Custom B2B Deals UI
### 🎯 Cél
Garázsok (Organizations) CRM felületének teljes körű implementálása: backend API végpontok, frontend adattábla mock adatok nélkül, előfizetéskezelő modál egyedi lejárati dátumokkal (B2B deal-ekhez).
### 🔧 Módosított/Létrehozott fájlok
**1. `backend/app/api/v1/endpoints/admin_organizations.py`** (LÉTREHOZVA)
- `GET /admin/organizations` — Garázsok listázása előfizetési adatokkal, taglétszámmal, kereséssel, szűréssel, lapozással
- `PATCH /admin/organizations/{org_id}/subscription` — Előfizetés módosítása tier_id és opcionális expires_at megadásával
- Tömeges subscription és member_count lekérdezés (N+1 elkerülése)
**2. `backend/app/api/v1/api.py`** (MÓDOSÍTVA)
- `admin_organizations` router regisztrációja
**3. `frontend_admin/pages/garages/index.vue`** (ÁTÍRVA)
- Mock adatok eltávolítva, valós API hívások (`$fetch`)
- Statisztikai kártyák (összes, aktív, premium, enterprise)
- Keresés és szűrés (tier alapján)
- Adattábla: ID, cégnév, státusz, csomag, lejárat, műveletek
- Előfizetéskezelő modál: tier dropdown + egyéni datetime-local input
- Mentés utáni automatikus refresh
**4. `frontend_admin/i18n/locales/hu.json`** (MÓDOSÍTVA)
- Garázs CRM-hez kapcsolódó összes magyar fordítás hozzáadva
**5. `frontend_admin/i18n/locales/en.json`** (MÓDOSÍTVA)
- Garázs CRM-hez kapcsolódó összes angol fordítás hozzáadva
### ✅ Teszt Eredmények
- Sync Engine: ✅ 1230 OK, 0 Fixed, 0 Extra (tökéletes szinkron)
- `GET /admin/organizations?limit=5`: ✅ 200 — 31 garázs, 5 visszaadva
- `GET /admin/packages?limit=5`: ✅ 200 — 5 tier elérhető
- `PATCH /admin/organizations/2406/subscription`: ✅ 200 — Celebro Kft. → corp_premium_v1, lejárat: 2027-06-25
- Verifikáció: ✅ Tier és lejárati dátum helyesen perzisztálva
## 2026-06-25 - P0 FIX & ENHANCE: Auth Token Missing & Duplicate Package Button
### 🎯 Cél
1. Garázsok oldal "Not authenticated" hiba javítása — hiányzó Authorization header pótlása
2. "Másolás" (Duplicate) gomb implementálása a csomagok oldalon
### 🔧 Módosított fájlok
**1. `frontend_admin/pages/garages/index.vue`** (MÓDOSÍTVA)
- `getAuthHeaders()` helper hozzáadva — `access_token` cookie-ból olvassa a tokent
- `fetchGarages()` és `fetchTiers()` hívásokhoz `headers: getAuthHeaders()` hozzáadva
- `saveSubscription()` PATCH híváshoz `Authorization` header hozzáadva
**2. `frontend_admin/pages/packages/index.vue`** (MÓDOSÍTVA)
- "Másolás" (Duplicate) gomb hozzáadva a kártya akcióihoz (Szerkesztés mellett, zöld színnel)
- `duplicatePackage(pkg)` függvény implementálva:
- Megnyitja a Create Modalt a kiválasztott csomag adataival előtöltve
- `id` mező törölve (új rekordként mentődik)
- Névhez `_copy` utótag fűzve, display_name-hez ` (másolat)` fűzve
- `is_default_fallback` false-ra állítva (nem duplikálható a visszaesési státusz)
- Árazási zónák, allowance-ok, feature_capabilities teljes körű másolása
**3. `frontend_admin/i18n/locales/en.json`** (MÓDOSÍTVA)
- `packages.duplicate: "Duplicate"` kulcs hozzáadva
**4. `frontend_admin/i18n/locales/hu.json`** (MÓDOSÍTVA)
- `packages.duplicate: "Másolás"` kulcs hozzáadva
### ✅ Domain Audit
- `backend/app/api/v1/endpoints/admin_organizations.py` végpont kizárólag core táblákat érint:
- `Organization`, `OrganizationMember`, `OrganizationSubscription`, `SubscriptionTier`, `User`
- Nincs scraped provider tábla lekérdezés — domain határok helyesek
## 2026-06-25: P0 Audit - Domain Conflation in Expense/Cost Service Provider Flow
**Gitea Card:** #291
**Scope:** Backend
**Type:** Bug / Domain Conflation
### Findings
Identified **6 code locations** causing domain conflation when `quick_add_provider()` creates `Organization()` (→ `fleet.organizations`) instead of `ServiceProvider()` (→ `marketplace.service_providers`):
1. **PRIMARY (provider_service.py:507-550):** `quick_add_provider()` creates `Organization()` instead of `ServiceProvider()`
2. **SECONDARY (provider_service.py:561-570):** `ServiceProfile.organization_id` FK to incorrectly created fleet.organizations
3. **TERTIARY (provider_service.py:618-630):** Branch creation in fleet.branches for lightweight vendor
4. **QUATERNARY (provider_service.py:639-646):** OrganizationMember addition for creating user
5. **SYSTEMIC (fleet_finance/models.py:104-110):** `AssetCost.vendor_organization_id` FK references ONLY `fleet.organizations.id`
6. **STRUCTURAL (provider_service.py):** `search_providers()` UNION masks domain conflation
### Outcome
- Report: `/opt/docker/docs/p0_domain_conflation_cost_provider_audit.md` (in roo-helper container)
- **No code changes made.** Awaiting Architect decision on refactoring approach.
- Refactoring Options proposed: A (flag in fleet), B (split to marketplace), C (hybrid - recommended)
## 2026-06-25 - P0 Hybrid Vendor Refactor & Categorization (Option C)
### 🎯 Cél
Implementálni az Option C (Hybrid) architektúrát: `quick_add_provider()` mostantól `ServiceProvider`-t (`marketplace.service_providers`) hoz létre `Organization` helyett (`fleet.organizations`). A valódi garázsok (safe list: 13 ID) továbbra is Organization-ként maradnak.
### 🔧 Módosított fájlok
**PHASE 1 - Schema Updates:**
- `backend/app/models/fleet_finance/models.py``service_provider_id` hozzáadva `AssetCost`-hoz + FK javítás (relációk indentálása)
- `backend/app/models/marketplace/service.py``service_provider_id` hozzáadva `ServiceProfile`-hoz
- `backend/app/models/identity/social.py``ServiceProvider` kiterjesztve 10 új mezővel (cím, kontakt)
- `unified_db_sync.py` futtatva → 10 új oszlop létrehozva az adatbázisban
**PHASE 2 - Refactor quick_add_provider():**
- `backend/app/services/provider_service.py``quick_add_provider()` átírva: `ServiceProvider` + `ServiceProfile` létrehozása (NO Organization, NO Branch, NO OrganizationMember)
- `backend/app/schemas/asset_cost.py``service_provider_id` hozzáadva a Pydantic modellekhez
- `backend/app/schemas/provider.py``ProviderQuickAddIn` kiegészítve új mezőkkel
- `backend/app/api/v1/endpoints/expenses.py``service_provider_id` átadva `AssetCost` creation-nél
**PHASE 3 - Data Migration:**
- `backend/scripts/migrate_providers.py` — Létrehozva: 7 Organization → ServiceProvider migrálva
**PHASE 4 - Verification:**
- 7 ServiceProvider rekord létrejött (`marketplace.service_providers`)
- 6/7 rendelkezik ServiceProfile kapcsolattal (MOL-nak 0 profilja volt)
- 1 AssetCost linkelve (Aszalós Motorszervíz)
- Safe list garázsok (13) érintetlenek a `fleet.organizations` táblában
## 2026-06-25 - P0 DATABASE PURGE & E2E VERIFICATION
### 🎯 Cél
Teljes adatbázis tisztítás: fake organization-ök eltávolítása a `fleet.organizations` táblából, az összes hivatkozó FK rekord előzetes felszabadításával. E2E verifikáció: quick_add_provider flow tesztelése.
### 🔧 Eredmények
**deep_purge_fake_orgs.py** — Sikeres purge:
- **Step A (Financial Relink):** 6 ServiceProfile organization_id → NULL (már volt sp_id), 1 residual vendor_org cleaned
- **Step B (Garbage Destroyed):** 12 org_subscriptions, 26 asset_assignments, 24+19 vehicle.assets org refs NULLed, 5 asset_costs reassigned to org_id=15, 15 branches, 9 org_members
- **Step C (Final Purge):** 18 fake organizations DELETED (IDs: 1,26,27,32,33,34,37,38,44,46,51,57,58,62,63,2406,4859,7676)
- **Verification:** ✅ PASS — No fake organizations remain
**test_quick_add_flow.py** — E2E teszt:
- Assert 1 ✅: ServiceProvider 'OMV Teszt Kút' létezik `marketplace.service_providers`-ben
- Assert 2 ✅: Nincs Organization `fleet.organizations`-ben
- Assert 3 ✅: AssetCost helyesen linkelve (service_provider_id=8, vendor_organization_id=NULL)
- **Eredmény: 3/3 PASS**
### 📁 Létrehozott fájlok
- `backend/scripts/deep_purge_fake_orgs.py` — Deep purge & relink script
- `backend/scripts/test_quick_add_flow.py` — E2E verification script
## 2026-06-25 - P0 UI POLISH: Garages CRM & Display Name Normalization
### 🎯 Cél
Private garázsok display_name normalizálása és a Garages CRM admin UI frissítése a clean display name-ek megjelenítésére.
### 🔧 Módosított fájlok
- `backend/scripts/safe_rename_garages.py` — LÉTREHOZVA: Safe rename script (17 private garage updated)
- `frontend_admin/pages/garages/index.vue` — MÓDOSÍTVA: display_name prioritás a cégnév oszlopban + "Részletek" gomb
- `frontend_admin/i18n/locales/hu.json` — MÓDOSÍTVA: "details" i18n kulcs hozzáadva
- `frontend_admin/i18n/locales/en.json` — MÓDOSÍTVA: "details" i18n kulcs hozzáadva
### ✅ Eredmények
1. **Safe Rename Script:** 17 private garage `name` és `display_name` mezője frissítve. `name` tartalmazza az ID-t az egyediségért (`#{user_id}`), `display_name` tiszta, emberi olvasásra szánt formátumban (`{last_name} {first_name} - Privát Garázs`).
2. **Frontend UI:** A "Cégnév" oszlop most `display_name`-t használ elsődlegesen, `name`-et csak fallback-ként. Új "Részletek" gomb hozzáadva az akciókhoz.
3. **Corporate orgs érintetlenek:** Csak `individual` típusú szervezetek lettek módosítva.
--- ---
## 2026-06-25 - P0 UI MICRO-FIX: Page Title & Expiration Date Logic ## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
### 🎯 Cél **Dátum:** 2026-07-25
Két UX hiba javítása az admin felületen: (1) böngésző fül címének beállítása, (2) lejárati dátum megjelenítése "Határozatlan" szöveggel NULL esetén.
### 🔧 Módosított fájlok ...
- [`frontend_admin/app.vue`](frontend_admin/app.vue) — `useHead` hozzáadva `titleTemplate`-mel: "Service Finder Admin" alapértelmezett, oldalnév esetén "Oldalnév | Service Finder Admin" formátum.
- [`frontend_admin/pages/garages/index.vue`](frontend_admin/pages/garages/index.vue:183) — Lejárati dátum cella: NULL esetén a "—" helyett `$t('garages.indefinite')` i18n kulcs jelenik meg.
- [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json:141) — `garages.indefinite`: "Határozatlan" kulcs hozzáadva.
- [`frontend_admin/i18n/locales/en.json`](frontend_admin/i18n/locales/en.json:141) — `garages.indefinite`: "Indefinite" kulcs hozzáadva.
### ✅ Verifikáció
- Nuxt build sikeres (`sf_admin_frontend` konténerben).
- Böngésző fül: alapértelmezetten "Service Finder Admin", oldalnézetben "Page Name | Service Finder Admin".
- Garázs tábla: subscription_expires_at = NULL esetén "Határozatlan" (HU) / "Indefinite" (EN) szöveg jelenik meg.
## 2026-06-25 - Garage Details (General Tab) - P0 Implementation
### 🎯 Cél
Garázs részletes adatlap implementálása General Tab-bal: backend API végpont + frontend oldal + i18n fordítások.
### 🔧 Backend API
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:301) — `GET /{org_id}/details` végpont:
- Pydantic modellek: `ContactPersonInfo`, `SubscriptionSummary`, `GarageDetailsResponse`
- Lekérdezi: Organization (selectinload subscription_tier), OrganizationSubscription (active, limit 1, selectinload tier), ContactPerson (is_primary == True, selectinload person), member_count via func.count
- Fallback: ha nincs OrganizationSubscription, az org.subscription_tier adatait használja
### 🖼️ Frontend Oldal
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue) — Teljes Nuxt 3 oldal:
- **Header:** Vissza gomb, Avatar (kezdőbetűk), Garázs név + státusz badge
- **Tab navigáció:** Általános, Dolgozók, Flotta, Analitika (a 3 utolsó "Coming soon")
- **General Tab (3 Info Card):**
1. **Cég adatok:** cégnév, megjelenítési név, szervezet típusa, adószám, cégjegyzékszám, létrehozva, cím
2. **Előfizetés státusza:** csomag, szint, lejárat (Határozatlan ha null), járműkorlát, járművek száma
3. **Elérhetőség:** kapcsolattartó neve, szerepkör, osztály, telefonszám
- Helper függvények: `garageDisplayName()`, `getInitials()`, `getInitialsColor()`, `statusBadgeClass()`, `statusDotClass()`, `statusLabel()`, `tierBadgeClass()`, `orgTypeLabel()`, `formatDate()`, `formattedAddress` computed
### 🌐 i18n Fordítások
- [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json:142) — `garages.details` objektum 20+ kulccsal
- [`frontend_admin/i18n/locales/en.json`](frontend_admin/i18n/locales/en.json:142) — Angol megfelelők
- [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json:140) — `garages.view_details` kulcs (a régi `details` string átnevezve)
### 🔗 Navigáció
- [`frontend_admin/pages/garages/index.vue`](frontend_admin/pages/garages/index.vue:570) — `openDetails()`: "Coming soon" helyett `navigateTo()` a részletes oldalra
### ✅ Verifikáció
- Backend API `GET /api/v1/admin/organizations/51/details` → HTTP 200, teljes válasz (org adatok + subscription + member_count)
- `sf_api` konténer újraindítva a kód élesítéséhez
- `sf_admin_frontend` volume mount miatt automatikusan érzékeli a változásokat
## 2026-06-25 - P0 RECOVERY: Surgical Vehicle Relinking Verification
### 🎯 Cél
Adatbázis restore után ellenőrizni, hogy a `vehicle.assets` táblában lévő járművek `current_organization_id` mezője nem NULL (nem árva rekordok). Ha szükséges, relinkelni a járműveket a `fleet.organization_members` tábla user-to-organization kapcsolatai alapján.
### 🔧 Diagnosztikai Eredmények
**1. DIAGNOSE — Orphaned Vehicles Query:**
- SQL: `SELECT COUNT(*) FROM vehicle.assets WHERE current_organization_id IS NULL AND status NOT IN ('deleted', 'archived') AND owner_person_id IS NOT NULL`
- **Eredmény: 0 orphaned vehicle** — mind a 42 jármű rendelkezik érvényes `current_organization_id` értékkel
**2. Teljes adatbázis konzisztencia ellenőrzés:**
- Összes jármű: **42 db**
- `current_organization_id IS NULL`: **0 db**
- `current_organization_id` + `owner_person_id` együtt NULL: **0 db** (csak archived rekordoknál, ahol nincs owner)
- Minden `current_organization_id` hivatkozás érvényes `fleet.organizations` rekordra mutat
**3. Járművek eloszlása szervezetenként:**
| Org ID | Szervezet Neve | Járművek |
|--------|----------------|----------|
| 1 | Profibot Tester - Privát Garázs (#28) | 18 (10 active + 8 draft) |
| 21 | Admin Super - Privát Garázs (#29) | 6 active |
| 43 | Accipe Tímea - Privát Garázs (#79) | 2 (1 active + 1 draft) |
| 44 | Profibot Kft. | 4 archived |
| 45 | Gyöngyössy Zsolt - Privát Garázs (#86) | 1 active |
| 46 | Gyöngyössy Krisztina - Privát Garázs (#100) | 1 active |
| 48 | User Test - Privát Garázs (#104) | 2 active |
| 49 | Test Final - Privát Garázs (#105) | 1 active |
| 50 | Profibot Tester - Privát Garázs (#106) | 1 active |
| 66 | Admin Super - Privát Garázs (#1) | 1 active |
| 67 | User Admin - Privát Garázs (#2) | 3 archived |
**4. Organization Members verifikáció:**
- Minden olyan org, amelyhez aktív jármű tartozik, rendelkezik OWNER vagy ADMIN taggal a `fleet.organization_members` táblában
- A user-to-person kapcsolatok (`identity.users.person_id`) konzisztensek az org tagságokkal
### ✅ Végkövetkeztetés
- **Recovery script nem szükséges** — az adatbázis restore után a `current_organization_id` kapcsolatok sértetlenek maradtak
- **0 jármű maradt árva** — minden aktív jármű egy valid organizationhöz van linkelve
- **Adatbázis konzisztencia:** ✅ PASS — nincs szükség beavatkozásra
## 2026-06-25 - P0 Phase 1: DB-Driven RBAC Foundation (Backend Schema & Seed)
### 🎯 Cél
RBAC Phase 1 (Foundation) implementálása: DB-driven role/permission rendszer kiépítése a `system` sémában, a hardcoded `SYSTEM_CAPABILITIES_MATRIX` és `UserRole` enum leváltásának első lépése.
### 🔧 Létrehozott fájlok
- `backend/app/models/system/rbac.py` - 3 új SQLAlchemy modell: SystemRole, SystemPermission, SystemRolePermission
- `backend/app/scripts/seed_rbac.py` - Seed script: 6 role, 28 permission, 168 role-permission mapping, 33 user migráció
### 🔧 Módosított fájlok
- `backend/app/models/system/__init__.py` - RBAC modellek exportja
- `backend/app/models/__init__.py` - RBAC modellek globális exportja
- `backend/app/models/identity/identity.py` - `role_id` FK + `system_role` relationship hozzáadva a User modellhez
### ✅ Eredmények
- **Sync Engine:** 4 fix (3 új tábla + 1 új oszlop) ✅
- **Seed:** 6 role, 28 permission, 168 role-permission mapping, 33 user migrálva ✅
- **Adatbázis séma:** system.roles, system.permissions, system.role_permissions létrehozva
- **User modell:** role_id (FK→system.roles.id, nullable=True, ondelete=SET NULL) hozzáadva
## 2026-06-25 - RBAC Phase 2: DB-Driven Permission Check (Backend Service Layer)
### 🎯 Cél
Implementálni a DB-driven RBAC permission check-et: `RBACService.get_role_permissions()` metódus, `RequirePermission(permission_code)` dependency factory, és egy verifikációs végpont.
### 🔧 Módosított fájlok
- `backend/app/services/rbac_service.py` - `get_role_permissions()` metódus hozzáadva (lekérdezi a `system.role_permissions` + `system.permissions` táblákból a granted permission code-okat role_id alapján); `invalidate_role_cache()` placeholder hozzáadva
- `backend/app/api/deps.py` - `RequirePermission(permission_code: str)` dependency factory hozzáadva (SUPERADMIN bypass rank=100, DB lekérdezés, 403 ha hiányzik a permission)
- `backend/app/api/v1/endpoints/admin_permissions.py` - `GET /admin/rbac-test` verifikációs végpont hozzáadva `RequirePermission("fleet:view")` védelemmel; dead code cleanup
### ✅ Eredmények
- **Import check:** ✅ Minden modul importálható hiba nélkül
- **Sync Engine:** ✅ 1265 OK, 0 Fixed - rendszer tökéletes szinkronban
- **HTTP teszt:** ✅ `GET /api/v1/admin/rbac-test` → 200 OK, permission check passed (user_id=2, role=ADMIN, role_id=2, permission=fleet:view)
- **Adatbázis:** 28 permission, 168 role-permission mapping (6 role × 28 permission) létezik és konzisztens
## 2026-06-25 - RBAC Phase 5: Backend Cleanup (Zombie Code Removal)
### 🎯 Cél
Az RBAC Phase 1-3 legacy kód teljes eltávolítása, miután az új DB-driven RBAC rendszer élesben fut a frontenden.
### 🔧 Végrehajtott módosítások
1. **`backend/app/api/deps.py`** - Törölve: `get_current_admin()`, `RequireRole()`, `RequireSystemCapability()` függvények és az import `SYSTEM_CAPABILITIES_MATRIX, role_has_capability` a capabilities modulból. (~114 sor)
2. **`backend/app/core/capabilities.py`** - Törölve: `SYSTEM_CAPABILITIES_MATRIX` dictionary (196 sor), `get_capabilities_for_role()`, `role_has_capability()` helper függvények. Megtartva: `Capability` osztály (string konstansok). (~220 sor)
3. **`backend/app/services/rbac_service.py`** - Törölve: `ADMIN_SCOPE_ACTIONS`, `MODERATOR_SCOPE_ACTIONS`, `SALES_REP_SCOPE_ACTIONS`, `SERVICE_MGR_SCOPE_ACTIONS` hardcoded set-ek, `ROLE_ACTIONS` mapping, és az import `SYSTEM_CAPABILITIES_MATRIX, role_has_capability`. A `check_admin_access()` metódus továbbra is működik, de a `get_permitted_actions()` már csak SUPERADMIN-ra ad vissza action-öket. (~47 sor)
4. **Import javítások:** `organizations.py` (RequireSystemCapability import eltávolítva), `users.py` (get_capabilities_for_role import és hívás eltávolítva), `admin_permissions.py` (scope action set importok eltávolítva). (~9 sor)
### ✅ Eredmények
- **Import check:** ✅ Minden modul importálható hiba nélkül (`deps.py`, `capabilities.py`, `rbac_service.py`)
- **Sync Engine:** ✅ 1265 OK, 0 Fixed - rendszer tökéletes szinkronban
- **Zombie code removed:** ~390 sor legacy kód eltávolítva a projektből
- **Dead-code verification:** 0 maradék import a törölt függvényekre
## 2026-06-25 - RBAC Phase 4: Dynamic Permission Matrix UI & API
### 🎯 Cél
Dinamikus permission management UI építése a DB-driven RBAC rendszerhez. Backend API végpontok (GET/PUT) a SystemRole és SystemPermission entitásokhoz, frontend permission mátrix UI toggle kapcsolókkal, SUPERADMIN védelemmel.
### 🔧 Módosított fájlok
- `backend/app/api/v1/endpoints/admin_permissions.py` — 3 új végpont: GET /admin/permissions/roles, GET /admin/permissions, PUT /admin/permissions/roles/{role_id}/permissions
- `frontend_admin/pages/permissions/index.vue` — Teljes átírás: statikus tömbök eltávolítva, dinamikus API-alapú permission mátrix UI
- `frontend_admin/i18n/locales/en.json` — Elavult statikus permission kulcsok eltávolítva
- `frontend_admin/i18n/locales/hu.json` — Elavult statikus permission kulcsok eltávolítva
### ✅ Eredmények
- **API smoke test:** ✅ Minden végpont működik (GET roles, GET permissions, PUT permissions, SUPERADMIN 403 protection)
- **Sync Engine:** ✅ 1265 OK, 0 Fixed - rendszer tökéletes szinkronban
- **Permissions hozzáadva:** `permissions:view` (id=29), `permissions:edit` (id=30) — SUPERADMIN és ADMIN szerepkörökhöz rendelve
- **SUPERADMIN védelem:** rank>=100, is_system=True szerepkörök módosítása 403-mal tiltva
## 2026-06-25 — RBAC Permission Audit (Gitea #298)
**Auditor:** Rendszer-Architect
**Scope:** Adatbázis ↔ Backend Service ↔ API ↔ Frontend Admin UI szinkron vizsgálata
### Vizsgált fájlok:
- `backend/app/services/rbac_service.py` — Dual RBAC rendszer feltárva (Scope-based AdminAction vs DB-driven SystemPermission)
- `backend/app/models/system/rbac.py` — SystemRole, SystemPermission, SystemRolePermission modellek validálva
- `backend/app/api/v1/endpoints/admin_permissions.py` — Matrix endpoint hibás (Scope-based), roles/permissions endpointok helyesek (DB-driven)
- `backend/app/api/v1/endpoints/users.py``system_capabilities` mindig üres, legacy mátrix eltávolítva, DB lekérdezés nem implementálva
- `backend/app/api/deps.py``RequirePermission()` helyesen működik DB-driven rendszerrel
- `backend/app/core/capabilities.py` — Deprecated Capability class, eltérő formátum
- `frontend_admin/pages/permissions/index.vue` — Valós API hívások, NINCS mock adat
- `frontend_admin/stores/auth.ts` — UserProfile interface helyes
- `frontend_admin/middleware/auth.ts` — Role-alapú auth check OK
### Feltárt hibák:
1. 🔴 KRITIKUS: Dual RBAC rendszer — `get_permitted_actions()` csak SUPERADMIN-nak ad vissza adatot
2. 🔴 KRITIKUS: `system_capabilities`/`org_capabilities` mindig üres a `/auth/me` response-ban
3. 🟡 KÖZEPES: Deprecated Capability class eltérő formátumú konstansokkal
### Pozitívum:
- Frontend admin UI-ban NINCS mock adat — minden végpont valós API-t hív
- Adatbázis séma helyes: 6 role, 30 permission, teljes role-permission mapping
- `RequirePermission()` függőség jól működik
- Permission mátrix UI (2D táblázat) megfelelően épül fel
## 2026-06-25 - P0 Phase 6: DB-Driven RBAC & Phantom Permission Fix
### 🎯 Cél
8 darab "szellem" (orphaned) permission kód beillesztése a `system.permissions` táblába, az RBAC service refaktorálása az `AdminAction` enum eltávolításával, és a `_build_user_response` függvény átalakítása, hogy a `system_capabilities` mezőt DB-driven lekérdezésből töltse.
### 🔧 Módosított fájlok
- `backend/scripts/fix_phantom_permissions.py` (ÚJ) - 8 hiányzó permission beszúrása + role mapping
- `backend/app/services/rbac_service.py` - `AdminAction` enum eltávolítva, `get_permitted_actions` async + DB-driven
- `backend/app/api/v1/endpoints/admin_permissions.py` - `AdminAction` import eltávolítva, DB-driven action validáció
- `backend/app/api/v1/endpoints/users.py` - `_build_user_response` async-re váltva, DB-driven permission lookup
- `backend/app/api/v1/endpoints/auth.py` - `_build_user_response` hívás `await`-el + `db` paraméter átadva
- `backend/app/core/capabilities.py` - `Capability` osztály eltávolítva (deprecated)
- `backend/app/api/v1/endpoints/organizations.py` - `Capability` import eltávolítva
### ✅ Eredmények
- 8 új permission beszúrva (id: 31-38): `dual-control:request/approve/view`, `services:manage`, `subscription:manage`, `user:manage`, `moderation:manage`, `gamification:manage`
- 16 role-permission mapping létrehozva (8 permission × 2 role: SUPERADMIN + ADMIN)
- `get_permitted_actions` most már minden role-ra DB-driven lekérdezést használ
- `_build_user_response` async függvény, ami `rbac_service.get_role_permissions(db, role_id)`-t hív
- `/auth/me` és `/users/me` endpoint-ok 38 db system_capabilities-t adnak vissza (mind True)
- `Capability` osztály eltávolítva a kódbázisból
## 2026-06-25 - P0 EXECUTION: Garage CRM Completion (Status & Edit)
### 🎯 Cél
Garázs CRM funkciók teljes körű implementálása: backend státuszváltó és adatszerkesztő végpontok, frontend UI integráció a listázó és részletes oldalakon.
### 🔧 Módosított fájlok
**1. Backend API - [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:512)**
- `PUT /{org_id}/status` — Státuszváltó végpont (`OrgStatusUpdate` séma): validálja a státuszt (active/inactive/suspended/pending_verification), szinkronizálja `org.is_active`-t, rögzíti `last_deactivated_at`-t. Védve: `Depends(RequirePermission("org:edit"))`.
- `PUT /{org_id}` — Adatszerkesztő végpont (`OrganizationUpdate` séma): 10 mező (name, full_name, display_name, tax_number, reg_number, address_zip/city/street_name/street_type/house_number). Csak a nem-None mezőket frissíti. Védve: `Depends(RequirePermission("org:edit"))`.
**2. Frontend Lista - [`frontend_admin/pages/garages/index.vue`](frontend_admin/pages/garages/index.vue)**
- `toggleStatus()` függvény: valós API hívással (`PUT /api/v1/admin/organizations/{id}/status`), dinamikus gombszín (piros/zöld), success/error toast, automatikus refresh.
**3. Frontend Részletek - [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue)**
- "Adatok Szerkesztése" gomb a header-ben
- Edit Modal: 8 mező (full_name, display_name, tax_number, reg_number, address_zip/city/street_name/street_type/house_number), pre-fill, Mentés → `PUT /api/v1/admin/organizations/{id}`, success/error toast, automatikus refresh
**4. i18n - [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json) + [`frontend_admin/i18n/locales/en.json`](frontend_admin/i18n/locales/en.json)**
- `garages.details.edit_details`, `garages.details.save`, `garages.details.zip`, `garages.details.city`, `garages.details.street_name`, `garages.details.street_type`, `garages.details.house_number` kulcsok (magyar + angol)
### ✅ Verifikáció
- **Sync Engine:** ✅ 1265 OK, 0 Fixed, 0 Extra
- **PUT status (inactive):** ✅ 200 — "A(z) 'KYC Garázsa' garázs státusza frissítve: 'active' → 'inactive'."
- **PUT status (active):** ✅ 200 — "A(z) 'KYC Garázsa' garázs státusza frissítve: 'inactive' → 'active'."
- **PUT update:** ✅ 200 — "A(z) 'KYC Garázsa' garázs adatai frissítve." (updated_fields: ['display_name'])
## 2026-06-25 - P0 Garage CRM Expansion: Contact/Billing Fields, Fleet Tab, RBAC UI
### 🎯 Cél
Teljes Garage CRM profil bővítése: kapcsolattartói, számlázási és értesítési cím mezők hozzáadása a backendhez, flotta járművek végpont létrehozása, és a frontend garancia részletes nézetének teljes körű implementálása RBAC védelemmel.
### 🔧 Módosított Fájlok
**1. BACKEND - Modell (`backend/app/models/marketplace/organization.py`)**
- 14 új oszlop hozzáadva a `Organization` modellhez:
- `contact_person_name`, `contact_email`, `contact_phone` (kapcsolattartó)
- `billing_zip`, `billing_city`, `billing_street_name`, `billing_street_type`, `billing_house_number` (számlázási cím)
- `notification_zip`, `notification_city`, `notification_street_name`, `notification_street_type`, `notification_house_number` (értesítési cím)
**2. BACKEND - API Végpont (`backend/app/api/v1/endpoints/admin_organizations.py`)**
- `OrganizationUpdate` séma bővítve a 14 új mezővel
- `GarageDetailsResponse` séma bővítve ugyanezen mezőkkel
- Új `GET /{org_id}/vehicles` végpont létrehozva (`GarageVehicleItem`, `GarageFleetResponse` sémákkal)
- `GarageVehicleItem` mezői: id, license_plate, vin, brand, model, year, color, status, branch_name, created_at
**3. FRONTEND - i18n (`frontend_admin/i18n/locales/hu.json`, `en.json`)**
- Új kulcsok a `garages.details`, `garages.employees`, `garages.fleet` szekciókban
**4. FRONTEND - Garage Detail Page (`frontend_admin/pages/garages/[id]/index.vue`)**
- Teljes körű implementáció (1366 sor):
- **STEP 4:** "Missing Data" warning banner (tax_number, contact_email, contact_phone, billing_address ellenőrzés)
- **STEP 5:** Tabbed Edit Modal (Alapadatok / Kapcsolat / Címek fülekkel)
- **STEP 6:** Strict RBAC UI enforcement (`hasOrgPermission()` helper)
- **STEP 7:** Fleet tab adattáblával (license_plate, brand, model, year, status)
- **STEP 8:** "Add Employee" gomb a Dolgozók fülön
- Add Employee, Edit Role, Remove confirmation modálok
- Notification Toast komponens
- Teljes `<script setup lang="ts">` szekció (API hívások, állapotkezelés, helper függvények)
### ✅ Verifikáció
- **Sync Engine:** ✅ 1278 OK, 0 Fixed, 0 Extra
- **Adatbázis séma:** 14 új oszlop sikeresen alkalmazva a `fleet.organizations` táblában
## 2026-06-25 - P0 UI Hotfix: Garage Details Tabok, Edit Modal & i18n
### 🎯 Cél
Garage Details oldal (`garages/[id]/index.vue`) UI hibajavítások: hiányzó 4. "Analitika" fül visszaállítása, i18n kulcsok pótlása, edit modal adatkötés javítása, hiányzó adatok riasztó javítása.
### 🔧 Módosított fájlok
1. **`frontend_admin/i18n/locales/hu.json`** — Hozzáadva: `generalTab`, `employeesTab`, `fleetTab`, `analyticsTab`, `editBasicTab`, `editContactTab`, `editAddressTab` kulcsok
2. **`frontend_admin/i18n/locales/en.json`** — Hozzáadva: `generalTab`, `employeesTab`, `fleetTab`, `analyticsTab`, `editBasicTab`, `editContactTab`, `editAddressTab` kulcsok
3. **`frontend_admin/pages/garages/[id]/index.vue`** — Több javítás:
- 4. "analytics" tab hozzáadva a `tabs` tömbhöz
- Analytics tab tartalom (Coming Soon placeholder) hozzáadva a template-hez
- `editForm` ref kibővítve: `full_name`, `display_name`, `reg_number`, `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number` mezőkkel
- Edit form template v-model javítva: `editForm.zip``editForm.address_zip`, stb.
- `address_street_type` input mező hozzáadva az Alapadatok tabhoz
- `openEditModal()` kibővítve az összes hiányzó mező pre-fill-lel
- `saveEdit()` javítva: API válaszból `result.organization` kinyerése
- `missingFields` computed javítva: helyes i18n kulcsok (`missing_tax_number`, `missing_contact_email`, `missing_contact_phone`, `missing_billing`) és `{ key, label }` objektum struktúra
- `formattedAddress` computed javítva: `garage.value.zip``garage.value.address_zip`, stb.
### ✅ Verifikáció
- **Nuxt Build:** ✅ Sikeres (7.27s)
- **Backend:** Nem volt szükség módosításra (model és API séma már tartalmazta az összes billing/contact mezőt)
--- ---
## 2026-06-25 - P0 Hotfix: Garage CRM Smart Validation (i18n, Tab Switching, Missing Data, Contact Propagation) ## Fix: POST /api/v1/expenses/ — 500 Internal Server Error (PG ENUM vs varchar JOIN)
### 🎯 Cél **Dátum:** 2026-07-26
4 kritikus javítás a Garage CRM Smart Validation rendszerben:
1. **i18n rendering** - nyers kulcsok (pl. `garages.details.generalTab`) megjelenítése helyett fordított szöveg
2. **Tab switching** - edit modal tabok nem működtek (halott kattintás)
3. **Missing Data logika** - Private Garages (individual org_type) esetén ne kérjen adószámot/számlázási címet
4. **Edit Modal contact info** - email/phone propagálása Person/User rekordokba individual org_type esetén
### 🔧 Változtatások ### Probléma
A `POST /api/v1/expenses/` végpont 500-as hibát dobott insurance (biztosítás) költség létrehozásakor.
**1. Frontend - i18n javítások:** ### Gyökér-ok (két rétegben)
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:125) - `{{ tab.label }}``{{ $t(tab.label) }}` (fő tab navigáció) 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.
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:509) - `{{ tab.label }}``{{ $t(tab.label) }}` (edit modal tabok) 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.
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:55) - `{{ field.label }}``{{ $t(field.label) }}` (missing data tag-ek)
**2. Frontend - Tab switching javítás:** ### Javítás
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:983) - `editModalTabs` kulcs javítva: `'address'``'addresses'` (egyezés a template `v-if="editModalTab === 'addresses'"` feltétellel) [`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
**3. Frontend - Missing Data logika:** ### Verifikáció
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:995) - `missingFields` computed: - `POST /api/v1/expenses/`**HTTP 201**
- `tax_number` ellenőrzés kihagyása `org_type === 'individual'` esetén - Biztosítási költség sikeresen létrejött
- `contact_email`/`contact_phone` mellett `email`/`phone` fallback ellenőrzés
- Számlázási cím ellenőrzés kihagyása individual org_type esetén
**4. Frontend - Edit Modal contact info:** ---
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:1123) - `saveEdit()`:
- Csak nem-üres mezők küldése a payload-ban
- Individual garages esetén `contact_email``email` és `contact_phone``phone` mapping
**5. Backend - OrganizationUpdate séma bővítés:** ## Fix: 401 Unauthorized on Admin Ledger Endpoint (Gitea #413)
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:623) - `OrganizationUpdate` séma: új `email` és `phone` mezők hozzáadva
**6. Backend - Propagációs logika:** **Dátum:** 2026-07-25
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:702) - `update_organization` végpont:
- Individual org_type + legal_owner_id esetén:
- `contact_phone`/`phone` propagálása `Person.phone`-ba
- `contact_email`/`email` propagálása a hozzá tartozó `User.email`-be
### ✅ Verifikáció ### Probléma
- **Backend Schema:** ✅ Sikeres (`OrganizationUpdate` séma tartalmazza az új `email` és `phone` mezőket) ...
- **Frontend:** Mind a 4 javítás implementálva és ellenőrizve
## 2026-06-25 - P0 HOTFIX: Zero-Duplication Contact & 401 Auth Header ### Documentation
`docs/p0_expense_500_import_bugfix_2026-07-26.md`
### 🎯 Cél **Gitea Issue:** #421 (closed)
Az Architect által jelzett 3 kritikus hiba javítása:
1. Frontend 401 Auth Header (token whitespace/newline trimming)
2. Backend Zero-Duplication Contact Logic (owner fallback)
3. Owner mindig látható a Dolgozók (Employees) tabon
### 🔧 Változtatások
**1. Frontend 401 Auth Header Fix**
- Fájl: `frontend_admin/pages/garages/[id]/index.vue`
- Minden `fetch` hívásnál a token `.trim()`-elve kerül a `Bearer` header-be
- Érintett függvények: `fetchDetails`, `fetchFleetVehicles`, `saveEdit`, `addEmployee`, `saveRoleEdit`, `executeRemove`
**2. Backend Zero-Duplication Contact Logic**
- Fájl: `backend/app/api/v1/endpoints/admin_organizations.py`
- `get_organization_details` endpoint kibővítve:
- `Organization.owner` + `User.person` eager loading
- Ha `contact_person_name` NULL → `owner.person` full name
- Ha `contact_email` NULL → `owner.email`
- Ha `contact_phone` NULL → `owner.person.phone`
**3. Owner Presence in Members List**
- Ha az owner nem szerepel az `OrganizationMember` táblában, dinamikusan prependáljuk
- `synthetic_owner` rekord `id=0` markerrel, `role="OWNER"`
- `member_count` is nő, ha szintetikus owner került hozzáadásra
### ✅ Verifikáció
- **Syntax check:** ✅ Sikeres (py_compile)
- **DB Sync:** ✅ Tökéletes szinkron (1278 elem)
- **API Test:** ✅ Sikeres
- Individual org: contact_person_name="TestUser KYC", contact_email=owner email
- Business org with owner: contact info correctly populated
- Owner minden esetben szerepel a members listában
## 2026-06-25 - P0 Security & UI Execution: 401 Interceptor, Employees Tab, Add Employee Modal
### 🎯 Cél
P0 Security & UI Execution feladat három komponenssel:
1. **Global 401 Unauthorized Interceptor** - lejárt token esetén automatikus logout és redirect /login-ra
2. **Employees Tab renderelése** - OWNER szerepkör arany badge-dzsel
3. **Add Employee Modal** - email alapú tag hozzáadás
### 🔧 Változtatások
**1. Global 401 Interceptor** ([`frontend_admin/plugins/api.ts`](frontend_admin/plugins/api.ts))
- Új Nuxt plugin, amely `globalThis.fetch`-et wrap-eli
- 401-es válasz esetén meghívja `authStore.logout()`-ot, majd redirectel `/login`-ra
- Kivétel: `/auth/login` és `/auth/refresh` végpontok (redirect loop elkerülése)
**2. Backend email-based member lookup** ([`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:18))
- `OrganizationMemberCreate` séma most már támogatja az `email` mezőt `user_id` alternatívaként
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:990) - email alapú felhasználó keresés implementálva
**3. Frontend javítások** ([`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue))
- `openEditRoleModal``openEditRole` függvénynév mismatch javítva
- Add Employee, Edit Role, Remove modálok dark theme-re átállítva (`bg-slate-800`, `text-white`)
- Notification toast dark theme-re átállítva
**4. i18n kiegészítések** ([`frontend_admin/i18n/locales/en.json`](frontend_admin/i18n/locales/en.json:207), [`frontend_admin/i18n/locales/hu.json`](frontend_admin/i18n/locales/hu.json:207))
- `editRoleTitle`, `removeTitle`, `removeConfirm` camelCase kulcsok hozzáadva
### ✅ Verifikáció
- **DB Sync:** ✅ Tökéletes szinkron (1278 elem)
- **Syntax:** ✅ Minden fájl szintaktikailag helyes
## 2026-06-26 - P0 HOTFIX: GET /admin/organizations/{org_id}/vehicles 500 Internal Server Error
### 🔍 Diagnózis
A frontend által hívott `GET /api/v1/admin/organizations/43/vehicles` végpont 500-as hibát dobott. A Docker logok három egymásra épülő `AttributeError`-t mutattak:
1. **`Asset.branch`** — Az [`Asset`](backend/app/models/vehicle/asset.py:69) modellben nincs `branch` relationship, csak `branch_id` oszlop. A kód `.options(selectinload(Asset.branch))`-et használt.
2. **`Asset.year`** — Az Asset modellben `year_of_manufacture` a mező neve, nem `year`.
3. **`Asset.color`** — Az Asset modellben nincs `color` mező.
### 🔧 Javítások ([`admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:1289))
- **`selectinload(Asset.branch)` eltávolítva** — Helyette explicit `outerjoin(Branch, Asset.branch_id == Branch.id)` a `Branch.name` lekéréséhez.
- **`v.year``v.year_of_manufacture`** — Javítva a mezőnév az Asset modell valós oszlopnevére.
- **`color` mező eltávolítva** a `GarageVehicleItem` Pydantic sémából és a leképzésből, mert az Asset modell nem tartalmaz ilyen attribútumot.
### ✅ Verifikáció
- `GET /api/v1/admin/organizations/43/vehicles`**200 OK**
- Visszaadott 3 jármű helyes adatokkal (brand, model, year, license_plate, branch_name)
## 2026-06-26 - P0 HOTFIX: Corporate 500 Error & Shrinking Filter Paradox
### 🎯 Cél
Két kritikus bug javítása: (1) `org_type_filter=corporate` 500-as hibát dobott a backendben, mert a "corporate" nem létező érték a `fleet.orgtype` PostgreSQL enum-ban. (2) A frontend előfizetési csomag legördülő menüje összezsugorodott szűréskor, mert a `uniqueTiers` computed property a szűrt `garages` tömbből számolt.
### 🔧 Változtatások
**Backend** (`backend/app/api/v1/endpoints/admin_organizations.py:219`):
- `org_type_filter=corporate` esetén a backend most az összes nem-individual `OrgType` értékre szűr (`business`, `fleet_owner`, `service`, `service_provider`, `club`)
- A `OrgType` enum dinamikus lekérdezése biztosítja, hogy új típus hozzáadásakor is működjön
**Frontend** (`frontend_admin/pages/garages/index.vue`):
- `uniqueTiers` computed property eltávolítva
- Helyette `availableTierNames` ref + watcher, ami csak bővül, sosem zsugorodik
- `watch(garages, ...)` `immediate: true, deep: true` paraméterekkel
### ✅ Verifikáció
- `GET /api/v1/admin/organizations?org_type_filter=corporate`**200 OK**, 8 garázs (business, fleet_owner, service_provider)
- `GET /api/v1/admin/organizations?org_type_filter=individual`**200 OK**, 17 garázs (individual)
- Admin frontend újraindítva, build hiba nélkül
## 2026-06-26 - P0 UX/UI Polish: Subscription Tab, History & Header ID Fix
### 🎯 Cél
Admin garázs részletek oldal UX/UI javítása: Header ID bug fix, kattintható garázsnevek listában, Subscription card sűrítése utilization statokkal, Subscription History szekció hozzáadása.
### 🔧 Módosított fájlok
**Frontend** (`frontend_admin/pages/garages/[id]/index.vue`):
- **Header ID Fix**: Eltávolítva a duplikált `#{{ garage.id }}` a subtitle-ből. `garageDisplayName()` fallback javítva: `'Garage #' + garage.value.id` formátumra.
- **Subscription Card Densification**: 4-oszlopos grid layout utilization statokkal (Járművek, Telephelyek, Dolgozók) progress bar-okkal, színkódolt threshold-okkal (piros ≥90%, borostyán ≥70%).
- **Subscription History Section**: Új kártya adat táblával és üres állapottal (clock SVG ikon + "Még nincsenek előfizetési előzmények" üzenet).
- **Új computed properties**: `employeeQuotaLimit`, `branchQuotaPercent`, `employeeQuotaPercent`.
**Frontend** (`frontend_admin/pages/garages/index.vue`):
- **Kattintható garázsnevek**: A garázs név `<NuxtLink>`-be csomagolva, hover stylinggal (`hover:underline cursor-pointer hover:text-indigo-400`).
**Frontend i18n** (`frontend_admin/i18n/locales/en.json`, `frontend_admin/i18n/locales/hu.json`):
- 10 új i18n kulcs hozzáadva: `vehicles_used`, `branches_used`, `employees_used`, `status`, `subscription_history`, `history_date`, `history_package`, `history_action`, `history_status`, `no_subscription_history`.
### ✅ Verifikáció
- `docker compose exec sf_public_frontend npm run build`**✓ built in 7.13s**, hiba nélkül
## 2026-06-26 - Admin Garage System Identifiers Bugfix
### 🎯 Cél
Admin garázs részletek oldalon a "Rendszer Azonosítók" szekcióban a User ID és Person ID mezők "N/A"-t mutattak, mert a backend `GarageDetailsResponse` nem tartalmazott `owner_user_id` mezőt, a frontend pedig egy nem létező `garage.owner?.id` property-t próbált olvasni.
### 🔧 Változtatások
**Backend** (`backend/app/api/v1/endpoints/admin_organizations.py`):
- `GarageDetailsResponse` osztályhoz hozzáadva: `owner_user_id: Optional[int] = None` (169. sor)
- Visszatérési értékhez hozzáadva: `owner_user_id=owner.id if owner else None` (650. sor)
**Frontend** (`frontend_admin/pages/garages/[id]/index.vue`):
- 200. sor: `garage.owner?.id || 'N/A'``garage.owner_user_id ?? 'N/A'`
### ✅ Verifikáció
- `GET /api/v1/admin/organizations/43/details``owner_user_id: 79`, `owner_person_id: 86` (korábban hiányzott)
## 2026-06-26 — Bug Analysis: Admin Garage Subscription Tab Shows Wrong Package Data
**Gitea Issue:** #301
**Scope:** Backend + Frontend
**Type:** Bug
### What was found
Investigated why the admin frontend's garage detail page subscription tab doesn't display data matching the selected package.
**Root Causes:**
1. `SubscriptionSummary` Pydantic model (admin_organizations.py:116-124) missing `branch_limit` field — frontend (index.vue:526) accesses `garage.subscription?.branch_limit` which is always undefined, showing `∞`
2. `asset_limit` extraction fragile (admin_organizations.py:462-465): `or` logic treats `0` as falsy, falls back to `1` on missing/alternate keys
3. `GarageDetailsResponse` has no `branches` field — `garage.branches?.length` always 0
4. `asset_count` hardcoded to 0 (admin_organizations.py:473)
5. `OrganizationSubscription` query (admin_organizations.py:414-425) doesn't check `valid_until >= now()` — expired subs appear active
6. Fallback path (admin_organizations.py:476-488) incomplete — missing `valid_from` and `is_active`
### Fix created
Detailed fix spec written to `docs/admin_garage_subscription_tab_bugfix_spec.md`
## 2026-06-26 — P0 CORE LOGIC & ADD-ON UPGRADE — Execute Bugfix #301 & Extra Allowances
**Gitea Issue:** #301 (Bugfix) + P0 Add-on Upgrade
**Scope:** Backend + Frontend
**Type:** Bug + Feature
### 🔧 Módosított fájlok
- [`backend/app/api/v1/endpoints/admin_organizations.py`](backend/app/api/v1/endpoints/admin_organizations.py:47) — Backend API changes
- `OrgSubscriptionUpdate`: Added `extra_vehicles`, `extra_branches`, `extra_users` fields (lines 56-67)
- `SubscriptionSummary`: Added `branch_limit`, `user_limit`, `extra_allowances` fields (lines 138-142)
- `BranchBrief`: New Pydantic model for branch data (lines 145-150)
- `GarageDetailsResponse`: Added `branches: List[BranchBrief]` field (line 202)
- `get_organization_details` (line 404):
- Added `valid_until >= now` check to subscription query (line 449)
- Added real `asset_count` query from Vehicle table (lines 458-464)
- Added branches query from Branch table (lines 467-482)
- **Bugfix**: Replaced `or` operator with explicit `dict.get()` for limit calculations (lines 524-537)
- **Add-on math**: Base (from tier rules) + Extra (from extra_allowances JSONB) = Total limit
- `update_org_subscription` (line 983):
- Constructs `extra_allowances` JSONB from payload fields (lines 1044-1051)
- Passes to new `OrganizationSubscription` (line 1060)
- Returns `extra_allowances` in response (line 1090)
- [`frontend_admin/pages/garages/[id]/index.vue`](frontend_admin/pages/garages/[id]/index.vue:1463) — Frontend UI changes
- Added `extraVehicles`, `extraBranches`, `extraUsers` reactive state (lines 1463-1466)
- Added `employeeQuotaPercent` computed property (lines 1510-1515)
- Updated Employees tab: utilization bar with `user_limit` (line 544-547)
- Added add-on input cards: Extra Jármű, Extra Telephely, Extra Dolgozó (template section ~lines 611-685)
- Updated `updateSubscription`: sends extra_allowances payload (lines 1858-1861)
### ✅ Verifikáció
- `sync_engine.py`: 1278 elements OK — system perfectly in sync
- `test_subscription_feature_flags.py`: All 6 steps passed (login, feature flags, check, public tiers)
- Python syntax check: ✅ Syntax OK

View File

@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""Create all 18 Gitea cards for Admin User & Person Management."""
import subprocess, sys
def run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
print(f"ERROR: {r.stderr}")
return None
return r.stdout.strip()
# Base command
BASE = ["docker", "exec", "roo-helper", "python3", "/scripts/gitea_manager.py"]
MILESTONE = "Epic 10 (Admin UI) Jegyek Létrehozása"
cards = [
# ===== EPIC-1: Backend User Management =====
{
"title": "1.1: GET /admin/users/{user_id} — Felhasználó részletek endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** REST végpont létrehozása egy felhasználó összes adatának lekérésére, beleértve Person, Address, Wallet, TrustProfile, OrganizationMembership adatokat.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** Meglévő User, Person, OrganizationMember modellek; Pydantic séma (UserResponse); require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend user részlet oldal (3.2), user szerkesztés endpoint (1.2)
### 📝 Elemzés
Hiányzó endpoint: GET /admin/users/{user_id}. SQL logika: LEFT JOIN identity.users, identity.persons, identity.addresses, identity.wallets, identity.user_trust_profiles, marketplace.organization_members. Válasz tartalmazza: User alapadatok, Person adatok lakcímmel, Wallet, TrustProfile, Memberships, SystemCapabilities, OrgCapabilities. Helye: backend/app/api/v1/endpoints/admin.py a meglévő list_users endpoint mellett. Authorization: RequirePermission(permissions=['users:view'])""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "1.2: PATCH /admin/users/{user_id} — Felhasználó szerkesztése admin által",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** REST végpont admin általi felhasználó módosításhoz. Korlátozott mezők: email, is_active, is_vip, preferred_language, region_code, preferred_currency, subscription_plan, subscription_expires_at, max_vehicles, max_garages, scope_level, scope_id, role_id (csak superadmin), custom_permissions. Person almzők: last_name, first_name, phone, mothers data, birth data, identity_docs.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), Pydantic séma (UserUpdate), require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend user szerkesztés funkció
### 📝 Elemzés
PATCH végpont a meglévő admin.py-ban. Ellenőrizni kell a rangot (superadmin vs admin). Audit log bejegyzés minden módosításról. Person adatok módosítása opcionális nested JSON-ben.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "1.3: GET /admin/users/stats — Felhasználói statisztikák endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Dashboard statisztikák a felhasználókról: total_users, active_users, deleted_users, banned_users, new_users_today/week/month, users_by_role, users_by_plan, users_by_language, users_with_person, users_without_person, registration_trend (napi bontás), active_organizations_count, total_memberships.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** User, OrganizationMember modellek; require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend dashboard (5.1), user lista oldal statisztikai kártyái (3.1)
### 📝 Elemzés
Új GET /admin/users/stats endpoint. SQL aggregációk: COUNT, GROUP BY role/plan/language. Registration trend: DATE(created_at) GROUP BY. JOIN marketplace.organization_members a memberships számhoz.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "1.4: GET /admin/users/{user_id}/memberships — Szervezeti tagságok endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Egy felhasználó szervezeti tagságainak listázása. Válasz: organization_id, organization_name, role, status, is_permanent, expires_at, invited_email, is_verified.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), OrganizationMember modell
- **Kimenet (Mik támaszkodnak rá):** Frontend user részlet oldal Memberships tab (3.2)
### 📝 Elemzés
Új GET /admin/users/{user_id}/memberships endpoint. JOIN marketplace.organization_members ON user_id, majd JOIN fleet.organizations ON organization_id a névhez.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
# ===== EPIC-2: Backend Person Management =====
{
"title": "2.1: GET /admin/persons — Person lista endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person entitások listázása kereséssel, szűréssel. Paraméterek: search (name, phone, birth data), is_ghost, is_active, has_user, is_merged, skip, limit. Válasz: total, items (id, last_name, first_name, phone, birth_date, is_ghost, is_active, merged_into_id, users_count, active_user, address).
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** Person modell; require_admin függőség
- **Kimenet (Mik támaszkodnak rá):** Frontend Person lista oldal (4.1)
### 📝 Elemzés
Új GET /admin/persons endpoint. SQL: LEFT JOIN identity.users ON person_id (users_count, active_user), LEFT JOIN identity.addresses ON address_id. Szűrés: WHERE is_ghost, is_active, merged_into_id IS NULL/IS NOT NULL.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "2.2: GET /admin/persons/{person_id} — Person részletek endpoint",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Egy Person összes adata, kapcsolódó User-ekkel, szervezeti tagságokkal, tulajdonolt cégekkel, merge history-val.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons (2.1), Person modell
- **Kimenet (Mik támaszkodnak rá):** Frontend Person részlet oldal (4.2), Person merge (2.4)
### 📝 Elemzés
GET /admin/persons/{person_id}. Válasz: Person adatok + users lista + address + memberships + owned_business_entities + merged_into (ha merge-elt) + merged_sources (ha ő a cél).""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "2.3: PATCH /admin/persons/{person_id} — Person szerkesztése",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person adatok admin általi módosítása. Szerkeszthető mezők: last_name, first_name, phone, mothers_last_name, mothers_first_name, birth_place, birth_date, identity_docs, ice_contact, is_active, is_ghost. Address almzők is szerkeszthetők.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2), PersonUpdate séma
- **Kimenet (Mik támaszkodnak rá):** Frontend Person szerkesztés funkció
### 📝 Elemzés
PATCH /admin/persons/{person_id}. Audit log bejegyzés. Address rekord frissítése ha változott.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
{
"title": "2.4: POST /admin/persons/{person_id}/merge — Person merge (duplum kezelés)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Duplum Person-ok összeolvasztása. Body: {source_person_id, keep_source_user}. Logika: (1) Ellenőrizze, hogy mindkét Person létezik és nincs merge-elve. (2) Forrás Person merged_into_id = cél Person ID. (3) Forrás User-ek person_id = cél Person ID. (4) Forrás OrganizationMember person_id = cél Person ID. (5) Audit log.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2), Person modell
- **Kimenet (Mik támaszkodnak rá):** Frontend Person merge UI
### 📝 Elemzés
POST /admin/persons/{target_id}/merge. Tranzakcióban: UPDATE identity.users SET person_id = target WHERE person_id = source. UPDATE marketplace.organization_members SET person_id = target WHERE person_id = source. UPDATE identity.persons SET merged_into_id = target WHERE id = source.""",
"tags": ["Scope: Backend", "Type: Feature"]
},
# ===== EPIC-3: Frontend User List & Detail =====
{
"title": "3.1: users/index.vue — Felhasználói lista oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Felhasználói lista oldal létrehozása a frontend admin felületen. Tartalmaz: statisztikai kártyák sor (összes, aktív, törölt, kitiltott, mai regisztrációk), keresőmező (email, név, telefon), szűrők (státusz, szerepkör, előfizetés), táblázat (ID, Email, Név, Szerepkör, Státusz, Csomag, Regisztráció, Nyelv, Műveletek), tömeges műveletek checkbox-szal.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users (meglévő), GET /admin/users/stats (1.3)
- **Kimenet (Mik támaszkodnak rá):** Navigációs menü frissítés (3.3)
### 📝 Elemzés
Új fájl: frontend_admin/pages/users/index.vue. API hívás: useFetch('/api/v1/admin/users'). Tömeges művelet: POST /api/v1/admin/users/bulk-action. Statisztikák: GET /api/v1/admin/users/stats. Használja a meglévő api.ts plugint.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "3.2: users/[id]/index.vue — Felhasználó részlet oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Felhasználó részlet oldal tab-okkal: (1) Áttekintés — alapadatok, státusz, szerepkör. (2) Személyes adatok — Person adatok, lakcím. (3) Pénzügyek — Wallet, előfizetés. (4) Tagságok — szervezeti tagságok. (5) Bizalom — Trust score. (6) Tevékenység — naplózott tevékenységek.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/{user_id} (1.1), GET /admin/users/{user_id}/memberships (1.4)
- **Kimenet (Mik támaszkodnak rá):** Szerkesztés funkció
### 📝 Elemzés
Új fájl: frontend_admin/pages/users/[id]/index.vue. API hívás: useFetch('/api/v1/admin/users/{id}'). Tab komponensek: OverviewTab, PersonTab, FinanceTab, MembershipsTab, TrustTab, ActivityTab.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "3.3: Navigációs menü frissítése — Users & Persons linkek",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Az admin oldalsáv navigációs menüjének frissítése új bejegyzésekkel: 👥 Felhasználók -> /users, ezen belül Felhasználók -> /users/ és Személyek -> /persons/.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** users/index.vue (3.1), persons/index.vue (4.1)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
A meglévő sidebar/layout komponens módosítása. Új nav item-ek hozzáadása a megfelelő ikonokkal és linkekkel.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
# ===== EPIC-4: Frontend Person List & Detail =====
{
"title": "4.1: persons/index.vue — Person lista oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person entitások listázása. Kereső: név, telefonszám, születési adatok. Szűrők: ghost státusz, aktív/inaktív, van-e User kapcsolat, merge státusz. Táblázat: ID, Név, Telefon, Születési dátum, User-ek száma, Aktív email, Státusz, Merge státusz, Műveletek.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons (2.1)
- **Kimenet (Mik támaszkodnak rá):** Navigációs menü frissítés (3.3)
### 📝 Elemzés
Új fájl: frontend_admin/pages/persons/index.vue. API hívás: useFetch('/api/v1/admin/persons'). Szűrő paraméterek: search, is_ghost, is_active, has_user, is_merged.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "4.2: persons/[id]/index.vue — Person részlet oldal (Frontend)",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Person részlet oldal: személy adatok, kapcsolódó User-ek listája, cím adatok, szervezeti tagságok, merge információ (forrás/cél), tulajdonolt cégek.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/persons/{person_id} (2.2)
- **Kimenet (Mik támaszkodnak rá):** Person merge UI
### 📝 Elemzés
Új fájl: frontend_admin/pages/persons/[id]/index.vue. API hívás: useFetch('/api/v1/admin/persons/{id}'). Merge gomb ha a Person nincs merge-elve.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
# ===== EPIC-5: Frontend Statistics & Dashboard =====
{
"title": "5.1: Dashboard statisztika kártyák dinamikus adatokkal",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** A dashboard (index.vue) jelenleg hardcoded statisztikáinak (2,847 Active Users, 12,493 Total Vehicles) lecserélése dinamikus API hívásokra. Felhasználói statisztikák: GET /admin/users/stats. Jármű statisztikák: meglévő asset végpont.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/stats (1.3)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
frontend_admin/pages/index.vue módosítása. useFetch('/api/v1/admin/users/stats') a user statisztikákhoz. useFetch('/api/v1/admin/vehicles/stats') a jármű statisztikákhoz.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "5.2: Felhasználói trend diagram komponens",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Regisztrációs trend diagram a dashboardon. A GET /admin/users/stats registration_trend mezőjéből napi bontású oszlopdiagram megjelenítése az elmúlt 30 napra.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** GET /admin/users/stats (1.3)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
Chart.js vagy egyszerű SVG/CSS alapú diagram komponens. Használhatja a meglévő dashboard layout-ot.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
# ===== EPIC-6: i18n & Permission Integration =====
{
"title": "6.1: i18n fordítások felvétele (hu/en) — User & Person management",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Új i18n fordítási kulcsok felvétele a hu.json és en.json fájlokba. Kulcsok: users.title, users.search_placeholder, users.filter_role, users.status.active/deleted/banned, users.table.id/email/name/role/status, users.bulk.ban/unban/delete/restore, persons.title, persons.search_placeholder, person.merge.title/confirm/source/target, stats.total_users/active_users/new_today.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** users/index.vue (3.1), persons/index.vue (4.1)
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
frontend_admin/locales/hu.json és frontend_admin/locales/en.json frissítése. A Nuxt i18n modul automatikusan betölti.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
{
"title": "6.2: RBAC permission check implementáció — User & Person oldalakon",
"body": """**Mérföldkő:** Admin User & Person Management
**Cél:** Minden admin user/person oldalon RBAC permission check implementálása a RequirePermission() függőséggel. Permission-ök: users:view, users:edit, users:ban, users:delete, persons:view, persons:edit, persons:merge.
### 🔗 Függőségek (Dependencies)
- **Bemenet (Mikre támaszkodik):** Meglévő admin_permissions.py RBAC rendszer
- **Kimenet (Mik támaszkodnak rá):** - (nincs)
### 📝 Elemzés
Backend: RequirePermission() dekorátor hozzáadása az új endpointokhoz. Frontend: hasPermission() kompozíció használata a gombok/show/hide logikához.""",
"tags": ["Scope: Frontend", "Type: Feature"]
},
]
for card in cards:
title = card["title"]
body = card["body"]
tags = card["tags"]
cmd = BASE + ["create", title, body, MILESTONE] + tags
print(f"Creating: {title}")
result = run(cmd)
if result:
print(f" -> {result}")
else:
print(f" -> FAILED")
print()

View File

@@ -191,6 +191,49 @@ def finish_issue(issue_num, custom_message=None):
add_comment(issue_num, msg) add_comment(issue_num, msg)
print(f"✅ Siker: A #{issue_num} lezárva.") print(f"✅ Siker: A #{issue_num} lezárva.")
def get_issue(issue_num):
"""Egy adott kártya részletes adatainak lekérése ID alapján."""
res = requests.get(f"{BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_num}", headers=HEADERS)
if res.status_code != 200:
print(f"❌ Hiba: #{issue_num} nem található (HTTP {res.status_code})")
return
issue = res.json()
# Címkék lekérése
labels_res = requests.get(f"{BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_num}/labels", headers=HEADERS)
labels = [l['name'] for l in labels_res.json()] if labels_res.status_code == 200 else []
# Kommentek lekérése
comments_res = requests.get(f"{BASE_URL}/repos/{OWNER}/{REPO}/issues/{issue_num}/comments", headers=HEADERS)
comments = comments_res.json() if comments_res.status_code == 200 else []
ms = issue.get('milestone', {})
milestone_title = ms.get('title', '-') if ms else '-'
print(f"\n{'='*60}")
print(f"📋 #{issue['number']} - {issue['title']}")
print(f"{'='*60}")
print(f"Állapot: {issue['state']}")
print(f"Mérföldkő: {milestone_title}")
print(f"Címkék: {', '.join(labels) if labels else '-'}")
print(f"Létrehozva: {issue['created_at']}")
print(f"Frissítve: {issue['updated_at']}")
if issue.get('closed_at'):
print(f"Lezárva: {issue['closed_at']}")
print(f"\n--- Leírás ---")
print(issue.get('body', '(nincs leírás)'))
if comments:
print(f"\n--- Kommentek ({len(comments)}) ---")
for c in comments:
user = c.get('user', {}).get('login', 'ismeretlen')
created = c.get('created_at', '')
body = c.get('body', '')
print(f"\n[{created}] {user}:")
print(body[:500] + ('...' if len(body) > 500 else ''))
print()
def list_issues(state="open"): def list_issues(state="open"):
issues = fetch_all_pages(f"/repos/{OWNER}/{REPO}/issues?state={state}") issues = fetch_all_pages(f"/repos/{OWNER}/{REPO}/issues?state={state}")
print(f"\n--- {state.upper()} FELADATOK ---") print(f"\n--- {state.upper()} FELADATOK ---")
@@ -213,6 +256,9 @@ if __name__ == "__main__":
state = sys.argv[2] if len(sys.argv) > 2 else "open" state = sys.argv[2] if len(sys.argv) > 2 else "open"
list_issues(state) list_issues(state)
elif cmd == "get" and len(sys.argv) > 2:
get_issue(sys.argv[2])
elif cmd == "start" and len(sys.argv) > 2: elif cmd == "start" and len(sys.argv) > 2:
start_issue(sys.argv[2]) start_issue(sys.argv[2])

View File

@@ -5,8 +5,11 @@ from app.api.v1.endpoints import (
services, admin, expenses, evidence, social, security, services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters, billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports, dictionaries, gamification, translations, users, reports, dictionaries,
admin_packages, admin_services, admin_organizations, constants, providers, admin_packages, admin_services, admin_organizations, admin_users, admin_persons, constants, providers,
subscriptions, marketing, admin_permissions, regions, subscriptions, marketing, admin_permissions, regions,
admin_gamification, admin_providers, locations,
admin_commission,
financial_manager,
) )
api_router = APIRouter() api_router = APIRouter()
@@ -24,6 +27,7 @@ api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expe
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"]) api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"]) api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"]) api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"])
api_router.include_router(finance_admin.router, prefix="/admin/finance", tags=["admin-finance"])
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"]) api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"]) api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"]) api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])
@@ -36,9 +40,16 @@ api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Di
api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"]) api_router.include_router(admin_packages.router, prefix="/admin/packages", tags=["Admin Package Management"])
api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"]) api_router.include_router(admin_services.router, prefix="/admin/services", tags=["Admin Service Catalog"])
api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"]) api_router.include_router(admin_organizations.router, prefix="/admin/organizations", tags=["Admin Garages CRM"])
api_router.include_router(admin_users.router, prefix="/admin/users", tags=["Admin Users"])
api_router.include_router(admin_persons.router, prefix="/admin/persons", tags=["Admin Persons"])
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"]) api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"]) api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"]) api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"]) api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"]) api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])
api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"]) api_router.include_router(admin_permissions.router, prefix="", tags=["Admin Permissions (RBAC)"])
api_router.include_router(admin_gamification.router, prefix="/admin/gamification", tags=["Admin Gamification"])
api_router.include_router(admin_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"])

View File

@@ -1,4 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/admin.py
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks, Query from fastapi import APIRouter, Depends, HTTPException, status, Body, BackgroundTasks, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text, delete, or_ from sqlalchemy import select, func, text, delete, or_
@@ -6,9 +7,11 @@ from sqlalchemy.orm import selectinload
from typing import List, Any, Dict, Optional from typing import List, Any, Dict, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
logger = logging.getLogger("admin-endpoints")
from app.api import deps from app.api import deps
from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import from app.models.identity import User, UserRole, Person # JAVÍTVA: Központi import
from app.models.identity.address import Address from app.models.identity.address import Address, GeoPostalCode
from app.models.system import SystemParameter, ParameterScope from app.models.system import SystemParameter, ParameterScope
from app.services.system_service import system_service from app.services.system_service import system_service
# JAVÍTVA: Security audit modellek # JAVÍTVA: Security audit modellek
@@ -79,7 +82,7 @@ async def approve_action(
_ = Depends(deps.RequirePermission("settings:edit")), _ = Depends(deps.RequirePermission("settings:edit")),
): ):
try: try:
await security_service.approve_action(db, admin.id, action_id) await security_service.approve_action(db, current_user.id, action_id)
return {"status": "success", "message": "Művelet végrehajtva."} return {"status": "success", "message": "Művelet végrehajtva."}
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@@ -420,8 +423,12 @@ async def apply_gamification_penalty(
""" """
Gamification büntetés kiosztása egy felhasználónak. Gamification büntetés kiosztása egy felhasználónak.
Negatív szintek alkalmazása a frissen létrehozott Gamification rendszerben. REFAKTOR: Most a GamificationService process_activity() metódusát használja
is_penalty=True paraméterrel, a direkt level módosítás helyett.
A penalty_level (negatív szám) átalakításra kerül penalty_points értékké.
""" """
from app.services.gamification_service import gamification_service
# Ellenőrizzük, hogy létezik-e a felhasználó # Ellenőrizzük, hogy létezik-e a felhasználó
user_stmt = select(User).where(User.id == user_id) user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt) user_result = await db.execute(user_stmt)
@@ -433,38 +440,34 @@ async def apply_gamification_penalty(
detail=f"User not found with ID: {user_id}" detail=f"User not found with ID: {user_id}"
) )
# Megkeressük a felhasználó gamification profilját (vagy létrehozzuk) # A penalty_level (pl. -3) átalakítása penalty_points értékké
gamification_stmt = select(UserStats).where(UserStats.user_id == user_id) # A régi rendszerben a penalty_level negatív szint volt (-1, -2, -3)
gamification_result = await db.execute(gamification_stmt) # Az új rendszerben penalty_points-ban mérjük (abs(penalty_level) * 100)
gamification = gamification_result.scalar_one_or_none() penalty_amount = abs(penalty.penalty_level) * 100
if not gamification: try:
# Ha nincs profil, létrehozzuk alapértelmezett értékekkel # GamificationService használata a büntetéshez
gamification = UserStats( stats = await gamification_service.process_activity(
db=db,
user_id=user_id, user_id=user_id,
level=0, xp_amount=penalty_amount,
xp=0, social_amount=0,
reputation_score=100, reason=f"ADMIN_PENALTY: {penalty.reason}",
created_at=datetime.now(), is_penalty=True,
updated_at=datetime.now() commit=False, # Ne commitoljon, mert még audit logot is hozzáadunk
action_key=None,
source_type="admin_penalty",
source_id=current_user.id,
) )
db.add(gamification)
await db.flush()
# Alkalmazzuk a büntetést (negatív szint módosítása) # Audit log - SecurityAuditLog has actor_id, target_id, payload_before/payload_after
# A level mező lehet negatív is a büntetések miatt
new_level = gamification.level + penalty.penalty_level
gamification.level = new_level
gamification.updated_at = datetime.now()
# Audit log
audit_log = SecurityAuditLog( audit_log = SecurityAuditLog(
user_id=current_user.id, actor_id=current_user.id,
action="apply_gamification_penalty", action="apply_gamification_penalty",
target_user_id=user_id, target_id=user_id,
details=f"Gamification penalty applied: level change {penalty.penalty_level}, reason: {penalty.reason}", payload_before={},
payload_after={"penalty_points_added": penalty_amount, "reason": penalty.reason},
is_critical=False, is_critical=False,
ip_address="admin_api"
) )
db.add(audit_log) db.add(audit_log)
await db.commit() await db.commit()
@@ -473,10 +476,18 @@ async def apply_gamification_penalty(
"status": "success", "status": "success",
"message": f"Gamification penalty applied to user {user_id}", "message": f"Gamification penalty applied to user {user_id}",
"user_id": user_id, "user_id": user_id,
"penalty_level": penalty.penalty_level, "penalty_points_added": penalty_amount,
"new_level": new_level, "total_penalty_points": stats.penalty_points,
"restriction_level": stats.restriction_level,
"reason": penalty.reason "reason": penalty.reason
} }
except Exception as e:
await db.rollback()
logger.error(f"Failed to apply penalty to user {user_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to apply penalty: {str(e)}",
)
# ==================== USER MANAGEMENT (EPIC 10: Admin Frontend) ==================== # ==================== USER MANAGEMENT (EPIC 10: Admin Frontend) ====================
@@ -497,6 +508,7 @@ async def list_users(
role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"), role: Optional[str] = Query(None, description="Szerepkör szűrés (pl. admin, user)"),
is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"), is_active: Optional[bool] = Query(None, description="Aktív státusz szűrés"),
is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"), is_deleted: Optional[bool] = Query(None, description="Törölt státusz szűrés"),
plan_filter: Optional[str] = Query(None, description="Csomag szűrés (pl. FREE, PREMIUM, ENTERPRISE)"),
db: AsyncSession = Depends(deps.get_db), db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user), current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")), _ = Depends(deps.RequirePermission("user:view")),
@@ -580,8 +592,10 @@ async def list_users(
) )
if role: if role:
# Case-insensitive role matching
role_upper = role.upper()
try: try:
role_enum = UserRole(role) role_enum = UserRole(role_upper)
query = query.where(User.role == role_enum) query = query.where(User.role == role_enum)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
@@ -592,6 +606,8 @@ async def list_users(
query = query.where(User.is_active == is_active) query = query.where(User.is_active == is_active)
if is_deleted is not None: if is_deleted is not None:
query = query.where(User.is_deleted == is_deleted) query = query.where(User.is_deleted == is_deleted)
if plan_filter:
query = query.where(User.subscription_plan == plan_filter.upper())
count_query = select(func.count()).select_from(query.subquery()) count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query) total_result = await db.execute(count_query)
@@ -607,23 +623,18 @@ async def list_users(
seen_ids.add(row.id) seen_ids.add(row.id)
unique_users.append(row) unique_users.append(row)
from app.schemas.address import AddressOut
user_list = [] user_list = []
for u in unique_users: for u in unique_users:
person = u.person person = u.person
address = person.address if person else None address = person.address if person else None
# Cím összefűzése
address_str = None # Személy nevének összefűzése
if address: person_name = None
parts = [] if person:
if address.zip: parts = [p for p in [person.last_name, person.first_name] if p]
parts.append(str(address.zip)) person_name = ' '.join(parts) if parts else None
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
user_list.append({ user_list.append({
"id": u.id, "id": u.id,
@@ -637,15 +648,12 @@ async def list_users(
"created_at": u.created_at.isoformat() if u.created_at else None, "created_at": u.created_at.isoformat() if u.created_at else None,
"deleted_at": u.deleted_at.isoformat() if u.deleted_at else None, "deleted_at": u.deleted_at.isoformat() if u.deleted_at else None,
# Person adatok # Person adatok
"person_name": person_name,
"first_name": person.first_name if person else None, "first_name": person.first_name if person else None,
"last_name": person.last_name if person else None, "last_name": person.last_name if person else None,
"phone": person.phone if person else None, "phone": person.phone if person else None,
# Címadatok # Címadatok — egységes AddressOut formátumban
"postal_code": str(address.zip) if address and address.zip else None, "address": AddressOut.model_validate(address) if address else None,
"city": address.city if address and address.city else None,
"street": address.street_name if address and address.street_name else None,
"house_number": address.house_number if address and address.house_number else None,
"address": address_str,
}) })
return { return {
@@ -656,6 +664,148 @@ async def list_users(
} }
@router.get("/users/stats", tags=["User Management"])
async def get_user_stats(
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
):
"""
Felhasználói statisztikák a Dashboard számára.
Visszaadja az összesítő adatokat: teljes, aktív, törölt, kitiltott felhasználók,
mai/héti/havi új regisztrációk, szerepkör és előfizetés szerinti megoszlás,
nyelvi statisztikák, Person kapcsolatok, szervezeti adatok.
"""
from datetime import date, timedelta
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday())
month_start = today_start.replace(day=1)
# === ALAP STATISZTIKÁK ===
total_result = await db.execute(select(func.count(User.id)))
total_users = total_result.scalar() or 0
active_result = await db.execute(
select(func.count(User.id)).where(User.is_active == True, User.is_deleted == False)
)
active_users = active_result.scalar() or 0
deleted_result = await db.execute(
select(func.count(User.id)).where(User.is_deleted == True)
)
deleted_users = deleted_result.scalar() or 0
# Banned = is_active=False AND is_deleted=False (deactivated but not deleted)
banned_result = await db.execute(
select(func.count(User.id)).where(User.is_active == False, User.is_deleted == False)
)
banned_users = banned_result.scalar() or 0
# === ÚJ REGISZTRÁCIÓK ===
new_today_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= today_start)
)
new_users_today = new_today_result.scalar() or 0
new_week_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= week_start)
)
new_users_this_week = new_week_result.scalar() or 0
new_month_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= month_start)
)
new_users_this_month = new_month_result.scalar() or 0
# === SZEREPKÖR SZERINTI MEGOSZLÁS ===
role_result = await db.execute(
select(User.role, func.count(User.id)).group_by(User.role)
)
users_by_role = {}
for row in role_result:
role_key = row[0].value if hasattr(row[0], "value") else str(row[0])
users_by_role[role_key] = row[1]
# === ELŐFIZETÉS SZERINTI MEGOSZLÁS ===
plan_result = await db.execute(
select(User.subscription_plan, func.count(User.id)).group_by(User.subscription_plan)
)
users_by_plan = {row[0]: row[1] for row in plan_result}
# === NYELV SZERINTI MEGOSZLÁS ===
lang_result = await db.execute(
select(User.preferred_language, func.count(User.id)).group_by(User.preferred_language)
)
users_by_language = {row[0]: row[1] for row in lang_result}
# === PERSON KAPCSOLATOK ===
with_person_result = await db.execute(
select(func.count(User.id)).where(User.person_id.isnot(None))
)
users_with_person = with_person_result.scalar() or 0
users_without_person = total_users - users_with_person
# === SZERVEZETI STATISZTIKÁK ===
from app.models.marketplace.organization import Organization, OrganizationMember
org_count_result = await db.execute(
select(func.count(Organization.id)).where(Organization.is_deleted == False)
)
active_organizations_count = org_count_result.scalar() or 0
memberships_result = await db.execute(
select(func.count(OrganizationMember.id))
)
total_memberships = memberships_result.scalar() or 0
# === JÁRMŰ STATISZTIKÁK ===
from app.models.vehicle.asset import Asset
vehicle_count_result = await db.execute(
select(func.count(Asset.id))
)
total_vehicles = vehicle_count_result.scalar() or 0
# === REGISZTRÁCIÓS TREND (utolsó 30 nap) ===
thirty_days_ago = today_start - timedelta(days=30)
trend_result = await db.execute(
text("""
SELECT DATE(created_at) as reg_date, COUNT(*) as cnt
FROM identity.users
WHERE created_at >= :start_date
GROUP BY DATE(created_at)
ORDER BY reg_date
"""),
{"start_date": thirty_days_ago}
)
registration_trend = [
{"date": row[0].isoformat() if hasattr(row[0], 'isoformat') else str(row[0]), "count": row[1]}
for row in trend_result
]
return {
"total_users": total_users,
"active_users": active_users,
"deleted_users": deleted_users,
"banned_users": banned_users,
"new_users_today": new_users_today,
"new_users_this_week": new_users_this_week,
"new_users_this_month": new_users_this_month,
"users_by_role": users_by_role,
"users_by_plan": users_by_plan,
"users_by_language": users_by_language,
"users_with_person": users_with_person,
"users_without_person": users_without_person,
"total_vehicles": total_vehicles,
"active_organizations_count": active_organizations_count,
"total_memberships": total_memberships,
"registration_trend": registration_trend,
}
@router.post("/users/bulk-action", tags=["User Management"]) @router.post("/users/bulk-action", tags=["User Management"])
async def bulk_user_action( async def bulk_user_action(
request: BulkActionRequest, request: BulkActionRequest,
@@ -732,11 +882,11 @@ async def bulk_user_action(
affected_count += 1 affected_count += 1
audit_log = SecurityAuditLog( audit_log = SecurityAuditLog(
user_id=current_user.id, actor_id=current_user.id,
action=f"bulk_{request.action}", action=f"bulk_{request.action}",
details=f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}", payload_before={},
payload_after={"details": f"Bulk action '{request.action}' on {affected_count} users. IDs: {request.user_ids}"},
is_critical=(request.action in ("hard_delete", "ban")), is_critical=(request.action in ("hard_delete", "ban")),
ip_address="admin_api"
) )
db.add(audit_log) db.add(audit_log)
@@ -778,6 +928,8 @@ async def search_organizations(
""" """
from app.models.marketplace.organization import Organization from app.models.marketplace.organization import Organization
# P0 FIX: Join with system.addresses and system.geo_postal_codes
# instead of selecting the ghost column Organization.address_city.
stmt = ( stmt = (
select( select(
Organization.id, Organization.id,
@@ -790,11 +942,13 @@ async def search_organizations(
Organization.is_verified, Organization.is_verified,
Organization.is_active, Organization.is_active,
Organization.is_deleted, Organization.is_deleted,
Organization.address_city, GeoPostalCode.city.label("city"),
Organization.region, Organization.region,
Organization.segment, Organization.segment,
Organization.created_at, Organization.created_at,
) )
.outerjoin(Address, Organization.address_id == Address.id)
.outerjoin(GeoPostalCode, Address.postal_code_id == GeoPostalCode.id)
.where( .where(
or_( or_(
Organization.full_name.ilike(f"%{name}%"), Organization.full_name.ilike(f"%{name}%"),
@@ -822,7 +976,7 @@ async def search_organizations(
"is_verified": row.is_verified, "is_verified": row.is_verified,
"is_active": row.is_active, "is_active": row.is_active,
"is_deleted": row.is_deleted, "is_deleted": row.is_deleted,
"city": row.address_city, "city": row.city,
"region": row.region, "region": row.region,
"segment": row.segment, "segment": row.segment,
"created_at": row.created_at.isoformat() if row.created_at else None, "created_at": row.created_at.isoformat() if row.created_at else None,

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -229,6 +229,7 @@ async def create_package(
tier = SubscriptionTier( tier = SubscriptionTier(
name=payload.name, name=payload.name,
type=payload.type,
rules=rules_dict, rules=rules_dict,
is_custom=payload.is_custom, is_custom=payload.is_custom,
tier_level=payload.tier_level, tier_level=payload.tier_level,
@@ -309,6 +310,9 @@ async def update_package(
) )
tier.name = update_data["name"] tier.name = update_data["name"]
if "type" in update_data and update_data["type"] is not None:
tier.type = update_data["type"]
if "rules" in update_data and update_data["rules"] is not None: if "rules" in update_data and update_data["rules"] is not None:
# ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ── # ── P0 CRITICAL FIX: Deep merge instead of wholesale replace ──
# Previously: tier.rules = new_rules (wholesale replacement) # Previously: tier.rules = new_rules (wholesale replacement)

View File

@@ -0,0 +1,982 @@
"""
👤 Admin Persons API
Végpontok:
GET /admin/persons — Person lista (keresés, szűrés, lapozás)
GET /admin/persons/{person_id} — Person részletes adatai
PATCH /admin/persons/{person_id} — Person szerkesztése
POST /admin/persons/{person_id}/merge — Person merge (duplum kezelés)
"""
from __future__ import annotations
import logging
from datetime import datetime, date
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, func, or_, and_, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
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.marketplace.organization import Organization, OrganizationMember
from app.models.vehicle.history import AuditLog, LogSeverity
from app.schemas.address import AddressIn, AddressOut
from app.services.address_manager import AddressManager
logger = logging.getLogger("admin-persons")
router = APIRouter()
# =============================================================================
# Pydantic Schemas
# =============================================================================
class PersonListItemActiveUser(BaseModel):
"""Aktív felhasználó adatai a Person lista elemben."""
id: int
email: Optional[str] = None
role: Optional[str] = None
class PersonListItem(BaseModel):
"""Egy Person rekord adatai a listában.
Megfelel a logic_spec_*.md 4.4-es szekció PersonListResponse struktúrájának.
"""
id: int
last_name: str
first_name: str
phone: Optional[str] = None
birth_date: Optional[str] = None
is_ghost: bool = False
is_active: bool = True
merged_into_id: Optional[int] = None
users_count: int = 0
active_user: Optional[PersonListItemActiveUser] = None
address: Optional[AddressOut] = None
class PersonListResponse(BaseModel):
"""Person lista válasz."""
total: int
skip: int
limit: int
items: List[PersonListItem]
# =============================================================================
# GET / — Person lista
# =============================================================================
@router.get(
"",
response_model=PersonListResponse,
summary="Person lista lekérése",
description=(
"Visszaadja az összes Person rekordot lapozással, kereséssel és szűréssel. "
"Támogatott szűrők: is_ghost, is_active, has_user (van-e kapcsolódó User), "
"is_merged (már merge-elve van-e). Keresés név, telefonszám alapján."
),
)
async def list_persons(
skip: int = Query(0, ge=0, description="Hány rekordot hagyjunk ki (lapozás)"),
limit: int = Query(50, ge=1, le=200, description="Maximum visszaadott rekordok száma"),
search: Optional[str] = Query(None, description="Keresés név vagy telefonszám alapján"),
is_ghost: Optional[bool] = Query(None, description="Szűrés ghost státusz szerint"),
is_active: Optional[bool] = Query(None, description="Szűrés aktív/inaktív szerint"),
has_user: Optional[bool] = Query(None, description="Szűrés aszerint, hogy van-e kapcsolódó User"),
is_merged: Optional[bool] = Query(None, description="Szűrés merge státusz szerint (van-e merged_into_id)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("persons:view")),
) -> PersonListResponse:
"""
GET /admin/persons
Visszaadja az összes Person rekordot a megadott szűrők és lapozás alapján.
Minden Person-hoz visszaadja:
- Alapadatokat (név, telefonszám, születési dátum)
- Kapcsolódó userek számát
- Az aktív user adatait (email, role)
- Cím adatokat (ha van)
"""
# Alap lekérdezés — Person + User kapcsolat
base_query = (
select(Person)
.options(
joinedload(Person.address),
joinedload(Person.users),
)
.execution_options(innerjoin=False)
)
# ── Szűrés ──
conditions = []
# Keresés név vagy telefonszám alapján
if search:
like_pattern = f"%{search}%"
conditions.append(
or_(
Person.last_name.ilike(like_pattern),
Person.first_name.ilike(like_pattern),
Person.phone.ilike(like_pattern),
)
)
# Ghost státusz szűrés
if is_ghost is not None:
conditions.append(Person.is_ghost == is_ghost)
# Aktív/Inaktív szűrés
if is_active is not None:
conditions.append(Person.is_active == is_active)
# Merge státusz szűrés
if is_merged is not None:
if is_merged:
conditions.append(Person.merged_into_id.isnot(None))
else:
conditions.append(Person.merged_into_id.is_(None))
# Van-e User kapcsolat
if has_user is not None:
# Subquery: Person-ok akiknek van legalább egy User rekordjuk
user_subq = (
select(User.person_id)
.where(User.person_id.isnot(None))
.distinct()
.subquery()
)
if has_user:
conditions.append(Person.id.in_(select(user_subq.c.person_id)))
else:
conditions.append(Person.id.notin_(select(user_subq.c.person_id)))
# Alkalmazzuk a feltételeket
if conditions:
base_query = base_query.where(and_(*conditions))
# Teljes számolás
count_query = select(func.count()).select_from(base_query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Lapozás
persons_query = base_query.order_by(Person.id.desc()).offset(skip).limit(limit)
persons_result = await db.execute(persons_query)
persons = persons_result.unique().scalars().all()
# Person ID-k begyűjtése a tömeges user lekérdezéshez
person_ids = [p.id for p in persons]
# Tömeges user lekérdezés (hogy ne N+1 query legyen)
users_map: Dict[int, List[User]] = {}
if person_ids:
user_stmt = (
select(User)
.where(
User.person_id.in_(person_ids),
User.is_deleted == False,
)
.order_by(User.id)
)
user_result = await db.execute(user_stmt)
for user in user_result.scalars().all():
if user.person_id not in users_map:
users_map[user.person_id] = []
users_map[user.person_id].append(user)
# Válasz összeállítása
items: List[PersonListItem] = []
for person in persons:
# Kapcsolódó userek
related_users = users_map.get(person.id, [])
users_count = len(related_users)
# Aktív user (első nem törölt user)
active_user_data = None
if related_users:
first_user = related_users[0]
active_user_data = PersonListItemActiveUser(
id=first_user.id,
email=first_user.email,
role=first_user.role.value if hasattr(first_user.role, "value") else str(first_user.role),
)
# Cím adatok
address_data = None
if person.address:
address_data = AddressOut.model_validate(person.address)
items.append(PersonListItem(
id=person.id,
last_name=person.last_name or "",
first_name=person.first_name or "",
phone=person.phone,
birth_date=person.birth_date.isoformat() if person.birth_date else None,
is_ghost=person.is_ghost,
is_active=person.is_active,
merged_into_id=person.merged_into_id,
users_count=users_count,
active_user=active_user_data,
address=address_data,
))
logger.info(
f"Admin {current_user.id} ({current_user.email}) fetched persons list: "
f"total={total}, returned={len(items)}, "
f"search={search}, is_ghost={is_ghost}, is_active={is_active}, "
f"has_user={has_user}, is_merged={is_merged}"
)
return PersonListResponse(
total=total,
skip=skip,
limit=limit,
items=items,
)
# =============================================================================
# GET /{person_id} — Person részletek
# =============================================================================
class PersonDetailUserInfo(BaseModel):
"""User adatok a Person részletek nézetben."""
id: int
email: str
role: Optional[str] = None
role_id: Optional[int] = None
is_active: bool = True
is_deleted: bool = False
subscription_plan: str = "FREE"
is_vip: bool = False
preferred_language: str = "hu"
region_code: str = "HU"
preferred_currency: str = "HUF"
created_at: Optional[str] = None
class PersonDetailMembership(BaseModel):
"""Szervezeti tagság adatai a Person részletek nézetben."""
id: int
organization_id: int
organization_name: Optional[str] = None
organization_type: Optional[str] = None
role: str = "MEMBER"
is_verified: bool = False
status: str = "active"
created_at: Optional[str] = None
class PersonDetailOwnedOrganization(BaseModel):
"""Tulajdonolt szervezet adatai a Person részletek nézetben."""
id: int
name: str
org_type: Optional[str] = None
is_active: bool = True
created_at: Optional[str] = None
class PersonDetailMergeHistory(BaseModel):
"""Merge history adatok."""
id: int
merged_into_id: Optional[int] = None
merged_into_name: Optional[str] = None
merged_at: Optional[str] = None
class PersonDetailResponse(BaseModel):
"""Teljes Person adatlap válasz.
Megfelel a logic_spec_*.md 4.5-ös szekciójának:
Teljes Person adatlap kapcsolódó User-ekkel, szervezeti tagságokkal,
tulajdonolt cégekkel, merge history-val.
"""
id: int
id_uuid: str
last_name: str
first_name: str
phone: Optional[str] = None
mothers_last_name: Optional[str] = None
mothers_first_name: Optional[str] = None
birth_place: Optional[str] = None
birth_date: Optional[str] = None
identity_docs: Any = {}
ice_contact: Any = {}
is_ghost: bool = False
is_active: bool = True
is_sales_agent: bool = False
lifetime_xp: int = 0
penalty_points: int = 0
social_reputation: float = 0.0
merged_into_id: Optional[int] = None
deleted_at: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
# Kapcsolódó entitások
address: Optional[AddressOut] = None
users: List[PersonDetailUserInfo] = []
memberships: List[PersonDetailMembership] = []
owned_organizations: List[PersonDetailOwnedOrganization] = []
merge_history: Optional[PersonDetailMergeHistory] = None
@router.get(
"/{person_id}",
response_model=PersonDetailResponse,
summary="Person részletes adatainak lekérése",
description=(
"Visszaadja egy Person rekord összes adatát, beleértve a kapcsolódó "
"User-eket, szervezeti tagságokat, tulajdonolt cégeket és merge history-t."
),
)
async def get_person_detail(
person_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("persons:view")),
) -> PersonDetailResponse:
"""
GET /admin/persons/{person_id}
Teljes Person adatlap:
- Alapadatok (név, telefonszám, születési adatok, identity_docs, ice_contact)
- Cím adatok
- Kapcsolódó User-ek listája
- Szervezeti tagságok (OrganizationMember)
- Tulajdonolt szervezetek (ahol a Person a legal_owner)
- Merge history (ha merged_into_id van)
"""
# Person lekérdezése kapcsolatokkal
stmt = (
select(Person)
.options(
joinedload(Person.address),
joinedload(Person.users),
joinedload(Person.memberships).joinedload(OrganizationMember.organization),
joinedload(Person.owned_business_entities),
)
.where(Person.id == person_id)
)
result = await db.execute(stmt)
person = result.unique().scalar_one_or_none()
if not person:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Person with id {person_id} not found",
)
# ── Cím adatok ──
address_data = None
if person.address:
address_data = AddressOut.model_validate(person.address)
# ── Kapcsolódó User-ek ──
users_data: List[PersonDetailUserInfo] = []
if person.users:
for user in person.users:
if user.is_deleted:
continue
users_data.append(PersonDetailUserInfo(
id=user.id,
email=user.email,
role=user.role.value if hasattr(user.role, "value") else str(user.role),
role_id=user.role_id,
is_active=user.is_active,
is_deleted=user.is_deleted,
subscription_plan=user.subscription_plan,
is_vip=user.is_vip,
preferred_language=user.preferred_language,
region_code=user.region_code,
preferred_currency=user.preferred_currency,
created_at=user.created_at.isoformat() if user.created_at else None,
))
# ── Szervezeti tagságok ──
memberships_data: List[PersonDetailMembership] = []
if person.memberships:
for m in person.memberships:
org_name = m.organization.name if m.organization else None
org_type = m.organization.org_type.value if m.organization and hasattr(m.organization.org_type, "value") else (
m.organization.org_type if m.organization else None
)
memberships_data.append(PersonDetailMembership(
id=m.id,
organization_id=m.organization_id,
organization_name=org_name,
organization_type=org_type,
role=m.role if not hasattr(m.role, "value") else m.role.value,
is_verified=m.is_verified,
status=m.status,
created_at=m.created_at.isoformat() if m.created_at else None,
))
# ── Tulajdonolt szervezetek ──
owned_orgs_data: List[PersonDetailOwnedOrganization] = []
if person.owned_business_entities:
for org in person.owned_business_entities:
owned_orgs_data.append(PersonDetailOwnedOrganization(
id=org.id,
name=org.name,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
is_active=org.is_active,
created_at=org.created_at.isoformat() if org.created_at else None,
))
# ── Merge history ──
merge_history_data = None
if person.merged_into_id:
# Lekérdezzük a target Person nevét
target_person_stmt = select(Person).where(Person.id == person.merged_into_id)
target_result = await db.execute(target_person_stmt)
target_person = target_result.scalar_one_or_none()
target_name = None
if target_person:
target_name = f"{target_person.last_name} {target_person.first_name}".strip()
merge_history_data = PersonDetailMergeHistory(
id=person.id,
merged_into_id=person.merged_into_id,
merged_into_name=target_name,
merged_at=person.updated_at.isoformat() if person.updated_at else None,
)
logger.info(
f"Admin {current_user.id} ({current_user.email}) fetched person detail: "
f"person_id={person_id}, users={len(users_data)}, "
f"memberships={len(memberships_data)}, owned_orgs={len(owned_orgs_data)}"
)
return PersonDetailResponse(
id=person.id,
id_uuid=str(person.id_uuid),
last_name=person.last_name or "",
first_name=person.first_name or "",
phone=person.phone,
mothers_last_name=person.mothers_last_name,
mothers_first_name=person.mothers_first_name,
birth_place=person.birth_place,
birth_date=person.birth_date.isoformat() if person.birth_date else None,
identity_docs=person.identity_docs or {},
ice_contact=person.ice_contact or {},
is_ghost=person.is_ghost,
is_active=person.is_active,
is_sales_agent=person.is_sales_agent,
lifetime_xp=person.lifetime_xp,
penalty_points=person.penalty_points,
social_reputation=float(person.social_reputation) if person.social_reputation else 0.0,
merged_into_id=person.merged_into_id,
deleted_at=person.deleted_at.isoformat() if person.deleted_at else None,
created_at=person.created_at.isoformat() if person.created_at else None,
updated_at=person.updated_at.isoformat() if person.updated_at else None,
address=address_data,
users=users_data,
memberships=memberships_data,
owned_organizations=owned_orgs_data,
merge_history=merge_history_data,
)
# =============================================================================
# PATCH /{person_id} — Person szerkesztése
# =============================================================================
class PersonUpdateRequest(BaseModel):
"""Admin által szerkeszthető Person mezők.
Megfelel a logic_spec_*.md 4.6-os szekciójának.
Csak a megadott mezők módosíthatók admin felületről.
"""
last_name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="Vezetéknév")
first_name: Optional[str] = Field(default=None, min_length=1, max_length=100, description="Keresztnév")
phone: Optional[str] = Field(default=None, max_length=30, description="Telefonszám")
mothers_last_name: Optional[str] = Field(default=None, max_length=100, description="Anyja születési vezetékneve")
mothers_first_name: Optional[str] = Field(default=None, max_length=100, description="Anyja születési keresztneve")
birth_place: Optional[str] = Field(default=None, max_length=100, description="Születési hely")
birth_date: Optional[date] = Field(default=None, description="Születési dátum (ISO: YYYY-MM-DD)")
identity_docs: Optional[Dict[str, Any]] = Field(default=None, description="Személyi okmány adatok")
ice_contact: Optional[Dict[str, Any]] = Field(default=None, description="Emergency contact adatok")
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) — egységes AddressIn séma
address: Optional[AddressIn] = Field(default=None, description="Cím adatok")
@field_validator("phone")
@classmethod
def validate_phone(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v.strip() == "":
return None
return v
@field_validator("last_name", "first_name")
@classmethod
def validate_name_not_empty(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v.strip() == "":
raise ValueError("A név mező nem lehet üres.")
return v
@router.patch(
"/{person_id}",
response_model=PersonDetailResponse,
summary="Person adatainak szerkesztése",
description=(
"Admin általi Person szerkesztés. Csak a megadott mezők módosíthatók. "
"Támogatja a személyes adatok (név, telefonszám, születési adatok, "
"identity_docs, ice_contact) és a cím adatok módosítását."
),
)
async def update_person(
person_id: int,
update_data: PersonUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("persons:edit")),
) -> PersonDetailResponse:
"""
PATCH /admin/persons/{person_id}
Admin általi Person szerkesztés. Csak a megadott mezők módosíthatók.
Szerkeszthető mezők:
- Személyes adatok: last_name, first_name, phone
- Születési adatok: mothers_last_name, mothers_first_name, birth_place, birth_date
- Dokumentumok: identity_docs, ice_contact
- Státusz: is_active, is_ghost
- Cím: address (nested object)
Args:
person_id: A módosítandó Person ID-ja.
update_data: A módosítandó mezők.
Returns:
PersonDetailResponse séma szerinti frissített Person adatok.
Raises:
HTTPException 404: Ha a Person nem található.
HTTPException 409: Ha a Person már merge-elve van (csak olvasható).
"""
# ── 1. Lekérdezzük a Person rekordot ──
stmt = (
select(Person)
.options(
joinedload(Person.address),
joinedload(Person.users),
joinedload(Person.memberships).joinedload(OrganizationMember.organization),
joinedload(Person.owned_business_entities),
)
.where(Person.id == person_id)
)
result = await db.execute(stmt)
person = result.unique().scalar_one_or_none()
if not person:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Person with id {person_id} not found.",
)
# ── 2. Biztonsági ellenőrzések ──
# Merge-elt Person nem szerkeszthető (csak olvasható)
if person.merged_into_id is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"Person {person_id} már merge-elve van a {person.merged_into_id} ID-jú "
"Person-ba. A merge-elt Person-ok csak olvashatóak."
),
)
# ── 3. Változások naplózása audit loghoz ──
changes: Dict[str, Dict[str, Any]] = {}
# ── 4. Person mezők frissítése ──
if update_data.last_name is not None:
changes["last_name"] = {"old": person.last_name, "new": update_data.last_name}
person.last_name = update_data.last_name
if update_data.first_name is not None:
changes["first_name"] = {"old": person.first_name, "new": update_data.first_name}
person.first_name = update_data.first_name
if update_data.phone is not None:
changes["phone"] = {"old": person.phone, "new": update_data.phone}
person.phone = update_data.phone
if update_data.mothers_last_name is not None:
changes["mothers_last_name"] = {"old": person.mothers_last_name, "new": update_data.mothers_last_name}
person.mothers_last_name = update_data.mothers_last_name
if update_data.mothers_first_name is not None:
changes["mothers_first_name"] = {"old": person.mothers_first_name, "new": update_data.mothers_first_name}
person.mothers_first_name = update_data.mothers_first_name
if update_data.birth_place is not None:
changes["birth_place"] = {"old": person.birth_place, "new": update_data.birth_place}
person.birth_place = update_data.birth_place
if update_data.birth_date is not None:
old_birth = person.birth_date.isoformat() if person.birth_date else None
changes["birth_date"] = {"old": old_birth, "new": update_data.birth_date.isoformat()}
person.birth_date = datetime.combine(update_data.birth_date, datetime.min.time())
if update_data.identity_docs is not None:
changes["identity_docs"] = {"old": person.identity_docs, "new": update_data.identity_docs}
person.identity_docs = update_data.identity_docs
if update_data.ice_contact is not None:
changes["ice_contact"] = {"old": person.ice_contact, "new": update_data.ice_contact}
person.ice_contact = update_data.ice_contact
if update_data.is_active is not None:
changes["is_active"] = {"old": person.is_active, "new": update_data.is_active}
person.is_active = update_data.is_active
if update_data.is_ghost is not None:
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 (P1 REFACTORED: AddressManager) ──
if update_data.address is not None:
# P1 FIX: Use AddressManager.create_or_update() instead of
# writing directly to Address model fields.
person.address_id = await AddressManager.create_or_update(
db, person.address_id, update_data.address
)
changes["address"] = {"old": None, "new": "updated via AddressManager"}
# ── 6. Mentés ──
if changes:
await db.flush()
await db.refresh(person)
logger.info(
f"Admin {current_user.id} ({current_user.email}) updated person "
f"person_id={person_id}: changed_fields={list(changes.keys())}"
)
else:
logger.info(
f"Admin {current_user.id} ({current_user.email}) called update on person "
f"person_id={person_id} with no changes."
)
# ── 7. Válasz összeállítása (ugyanaz, mint a GET detail) ──
# Cím adatok
address_data = None
if person.address:
address_data = AddressOut.model_validate(person.address)
# Kapcsolódó User-ek
users_data: List[PersonDetailUserInfo] = []
if person.users:
for user in person.users:
if user.is_deleted:
continue
users_data.append(PersonDetailUserInfo(
id=user.id,
email=user.email,
role=user.role.value if hasattr(user.role, "value") else str(user.role),
role_id=user.role_id,
is_active=user.is_active,
is_deleted=user.is_deleted,
subscription_plan=user.subscription_plan,
is_vip=user.is_vip,
preferred_language=user.preferred_language,
region_code=user.region_code,
preferred_currency=user.preferred_currency,
created_at=user.created_at.isoformat() if user.created_at else None,
))
# Szervezeti tagságok
memberships_data: List[PersonDetailMembership] = []
if person.memberships:
for m in person.memberships:
org_name = m.organization.name if m.organization else None
org_type = m.organization.org_type.value if m.organization and hasattr(m.organization.org_type, "value") else (
m.organization.org_type if m.organization else None
)
memberships_data.append(PersonDetailMembership(
id=m.id,
organization_id=m.organization_id,
organization_name=org_name,
organization_type=org_type,
role=m.role if not hasattr(m.role, "value") else m.role.value,
is_verified=m.is_verified,
status=m.status,
created_at=m.created_at.isoformat() if m.created_at else None,
))
# Tulajdonolt szervezetek
owned_orgs_data: List[PersonDetailOwnedOrganization] = []
if person.owned_business_entities:
for org in person.owned_business_entities:
owned_orgs_data.append(PersonDetailOwnedOrganization(
id=org.id,
name=org.name,
org_type=org.org_type.value if hasattr(org.org_type, "value") else str(org.org_type),
is_active=org.is_active,
created_at=org.created_at.isoformat() if org.created_at else None,
))
# Merge history
merge_history_data = None
if person.merged_into_id:
target_person_stmt = select(Person).where(Person.id == person.merged_into_id)
target_result = await db.execute(target_person_stmt)
target_person = target_result.scalar_one_or_none()
target_name = None
if target_person:
target_name = f"{target_person.last_name} {target_person.first_name}".strip()
merge_history_data = PersonDetailMergeHistory(
id=person.id,
merged_into_id=person.merged_into_id,
merged_into_name=target_name,
merged_at=person.updated_at.isoformat() if person.updated_at else None,
)
return PersonDetailResponse(
id=person.id,
id_uuid=str(person.id_uuid),
last_name=person.last_name or "",
first_name=person.first_name or "",
phone=person.phone,
mothers_last_name=person.mothers_last_name,
mothers_first_name=person.mothers_first_name,
birth_place=person.birth_place,
birth_date=person.birth_date.isoformat() if person.birth_date else None,
identity_docs=person.identity_docs or {},
ice_contact=person.ice_contact or {},
is_ghost=person.is_ghost,
is_active=person.is_active,
is_sales_agent=person.is_sales_agent,
lifetime_xp=person.lifetime_xp,
penalty_points=person.penalty_points,
social_reputation=float(person.social_reputation) if person.social_reputation else 0.0,
merged_into_id=person.merged_into_id,
deleted_at=person.deleted_at.isoformat() if person.deleted_at else None,
created_at=person.created_at.isoformat() if person.created_at else None,
updated_at=person.updated_at.isoformat() if person.updated_at else None,
address=address_data,
users=users_data,
memberships=memberships_data,
owned_organizations=owned_orgs_data,
merge_history=merge_history_data,
)
# =============================================================================
# POST /{person_id}/merge — Person merge (duplum kezelés)
# =============================================================================
class PersonMergeRequest(BaseModel):
"""Person merge kérés body.
A logic_spec 4.7-es szekciója szerint:
- source_person_id: A forrás Person ID-ja (amit beolvasztunk)
- keep_source_user: Ha True, a forrás User megmarad (különben soft-delete)
"""
source_person_id: int = Field(..., description="A forrás Person ID-ja (amit beolvasztunk a célba)")
keep_source_user: bool = Field(
default=False,
description="Ha True, a forrás User-ek nem lesznek soft-deletelve, csak átirányítva"
)
class PersonMergeResponse(BaseModel):
"""Person merge válasz."""
success: bool = True
message: str
target_person_id: int
source_person_id: int
reassigned_users: int = 0
reassigned_memberships: int = 0
@router.post(
"/{person_id}/merge",
response_model=PersonMergeResponse,
summary="Person merge (duplum kezelés)",
description=(
"Két Person rekord összefésülése. A forrás Person (source_person_id) "
"beolvasztásra kerül a cél Person-ba (person_id). A forrás Person "
"merged_into_id mezője beállításra kerül, User-ek és OrganizationMember-ek "
"átkerülnek a cél Person-hoz. Audit log bejegyzés készül."
),
)
async def merge_person(
person_id: int,
merge_data: PersonMergeRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("persons:merge")),
) -> PersonMergeResponse:
"""
POST /admin/persons/{person_id}/merge
Két Person rekord összefésülése (duplum kezelés).
Logika (logic_spec 4.7):
1. Ellenőrizzük, hogy mindkét Person létezik és nincs már merge-elve
2. Forrás Person `merged_into_id` → cél Person ID
3. Forrás User-ek `person_id` → cél Person ID
4. Forrás OrganizationMember-ek `person_id` → cél Person ID
5. Audit log bejegyzés
Args:
person_id: A cél Person ID-ja (amibe beolvasztunk).
merge_data: A merge kérés adatai (source_person_id, keep_source_user).
Returns:
PersonMergeResponse a merge eredményével.
Raises:
HTTPException 404: Ha valamelyik Person nem található.
HTTPException 409: Ha valamelyik Person már merge-elve van,
vagy ha a forrás és cél megegyezik.
"""
# ── 1. Validáció ──
# Nem lehet önmagába merge-elni
if merge_data.source_person_id == person_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A forrás és a cél Person nem lehet ugyanaz.",
)
# Cél Person lekérdezése
target_stmt = select(Person).where(Person.id == person_id)
target_result = await db.execute(target_stmt)
target_person = target_result.scalar_one_or_none()
if not target_person:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"A cél Person (id={person_id}) nem található.",
)
# Forrás Person lekérdezése
source_stmt = (
select(Person)
.options(
joinedload(Person.users),
joinedload(Person.memberships),
)
.where(Person.id == merge_data.source_person_id)
)
source_result = await db.execute(source_stmt)
source_person = source_result.unique().scalar_one_or_none()
if not source_person:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"A forrás Person (id={merge_data.source_person_id}) nem található.",
)
# Ellenőrizzük, hogy a cél Person nincs-e már merge-elve
if target_person.merged_into_id is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"A cél Person (id={person_id}) már merge-elve van a "
f"{target_person.merged_into_id} ID-jú Person-ba. "
"Csak nem merge-elt Person lehet merge célpont."
),
)
# Ellenőrizzük, hogy a forrás Person nincs-e már merge-elve
if source_person.merged_into_id is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
f"A forrás Person (id={merge_data.source_person_id}) már merge-elve van a "
f"{source_person.merged_into_id} ID-jú Person-ba. "
"Csak nem merge-elt Person lehet merge forrás."
),
)
# ── 2. Forrás Person megjelölése merge-eltként ──
source_person.merged_into_id = person_id
source_person.is_active = False
# ── 3. User-ek átirányítása a cél Person-hoz ──
reassigned_users = 0
if source_person.users:
for user in source_person.users:
if user.is_deleted:
continue
user.person_id = person_id
reassigned_users += 1
# Ha keep_source_user False, soft-delete-eljük a forrás User-t
if not merge_data.keep_source_user:
user.is_deleted = True
user.deleted_at = datetime.utcnow()
# ── 4. OrganizationMember-ek átirányítása a cél Person-hoz ──
reassigned_memberships = 0
if source_person.memberships:
for membership in source_person.memberships:
membership.person_id = person_id
reassigned_memberships += 1
# ── 5. Mentés ──
await db.flush()
# ── 6. Audit log ──
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="person.merge",
target_type="Person",
target_id=str(person_id),
old_data={
"source_person_id": merge_data.source_person_id,
"source_person_name": f"{source_person.last_name} {source_person.first_name}".strip(),
},
new_data={
"target_person_id": person_id,
"target_person_name": f"{target_person.last_name} {target_person.first_name}".strip(),
"reassigned_users": reassigned_users,
"reassigned_memberships": reassigned_memberships,
"keep_source_user": merge_data.keep_source_user,
},
)
db.add(audit_entry)
await db.commit()
logger.info(
f"Admin {current_user.id} ({current_user.email}) merged person "
f"source_person_id={merge_data.source_person_id} into "
f"target_person_id={person_id}: "
f"users={reassigned_users}, memberships={reassigned_memberships}"
)
return PersonMergeResponse(
success=True,
message=(
f"A {source_person.last_name} {source_person.first_name} (ID: {merge_data.source_person_id}) "
f"sikeresen beolvasztásra került a {target_person.last_name} {target_person.first_name} "
f"(ID: {person_id}) Person-ba."
),
target_person_id=person_id,
source_person_id=merge_data.source_person_id,
reassigned_users=reassigned_users,
reassigned_memberships=reassigned_memberships,
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,770 @@
"""
👤 Admin Users API
Végpontok:
GET /admin/users/stats — Felhasználói statisztikák (dashboard)
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á
van regisztrálva, és a /users, /users/bulk-action útvonalakat kezeli.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select, func, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
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
logger = logging.getLogger("admin-users")
router = APIRouter()
# =============================================================================
# Pydantic Schemas
# =============================================================================
class RegistrationTrendItem(BaseModel):
"""Napi regisztrációs trend elem."""
date: str = Field(..., description="Dátum (YYYY-MM-DD)")
count: int = Field(..., description="Regisztrációk száma")
class UserStatsResponse(BaseModel):
"""Felhasználói statisztikák a dashboard számára."""
total_users: int = Field(..., description="Összes felhasználó")
active_users: int = Field(..., description="Aktív felhasználók")
deleted_users: int = Field(..., description="Törölt felhasználók")
banned_users: int = Field(..., description="Kitiltott felhasználók")
new_users_today: int = Field(..., description="Ma regisztrált felhasználók")
new_users_this_week: int = Field(..., description="Ezen a héten regisztrált felhasználók")
new_users_this_month: int = Field(..., description="Ebben a hónapban regisztrált felhasználók")
users_by_role: Dict[str, int] = Field(..., description="Felhasználók szerepkör szerint")
users_by_plan: Dict[str, int] = Field(..., description="Felhasználók előfizetési csomag szerint")
users_by_language: Dict[str, int] = Field(..., description="Felhasználók nyelv szerint")
users_with_person: int = Field(..., description="Person rekorddal rendelkező felhasználók")
users_without_person: int = Field(..., description="Person rekord nélküli felhasználók")
registration_trend: List[RegistrationTrendItem] = Field(
default_factory=list, description="Regisztrációs trend (utolsó 30 nap)"
)
active_organizations_count: int = Field(..., description="Aktív szervezetek száma")
total_memberships: int = Field(..., description="Összes szervezeti tagság")
class AdminUserUpdate(BaseModel):
"""Admin által szerkeszthető felhasználói mezők.
Csak a megadott mezők módosíthatók admin felületről.
A role_id módosítása csak SUPERADMIN számára engedélyezett.
"""
email: Optional[EmailStr] = Field(default=None, description="Email cím (csak admin/superadmin)")
is_active: Optional[bool] = Field(default=None, description="Aktív státusz (letiltás/feloldás)")
is_vip: Optional[bool] = Field(default=None, description="VIP státusz")
preferred_language: Optional[str] = Field(default=None, max_length=5, description="Nyelv")
region_code: Optional[str] = Field(default=None, max_length=5, description="Régió kód")
preferred_currency: Optional[str] = Field(default=None, max_length=3, description="Pénznem")
subscription_plan: Optional[str] = Field(default=None, max_length=30, description="Előfizetési csomag")
subscription_expires_at: Optional[datetime] = Field(default=None, description="Előfizetés lejárata")
scope_level: Optional[str] = Field(default=None, max_length=30, description="Scope szint")
scope_id: Optional[str] = Field(default=None, max_length=50, description="Scope azonosító")
role_id: Optional[int] = Field(default=None, description="RBAC szerepkör ID (csak superadmin)")
custom_permissions: Optional[Dict[str, Any]] = Field(default=None, description="Egyedi jogosultságok")
# Person almzők
person: Optional["AdminPersonUpdate"] = Field(default=None, description="Személyes adatok")
class AdminPersonUpdate(BaseModel):
"""Admin által szerkeszthető személyes adatok."""
last_name: Optional[str] = Field(default=None, description="Vezetéknév")
first_name: Optional[str] = Field(default=None, description="Keresztnév")
phone: Optional[str] = Field(default=None, description="Telefonszám")
mothers_last_name: Optional[str] = Field(default=None, description="Anyja születési vezetékneve")
mothers_first_name: Optional[str] = Field(default=None, description="Anyja születési keresztneve")
birth_place: Optional[str] = Field(default=None, description="Születési hely")
birth_date: Optional[datetime] = Field(default=None, description="Születési dátum")
identity_docs: Optional[Dict[str, Any]] = Field(default=None, description="Személyi okmány adatok")
# =============================================================================
# Endpoints
# =============================================================================
@router.get(
"/stats",
response_model=UserStatsResponse,
summary="Felhasználói statisztikák lekérése",
description=(
"Visszaadja a felhasználók összesített statisztikáit a dashboard számára, "
"beleértve a szerepkör, előfizetési csomag és nyelv szerinti megoszlást, "
"valamint a regisztrációs trendet. Csak admin/staff jogosultsággal érhető el."
),
)
async def get_user_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
) -> UserStatsResponse:
"""
GET /admin/users/stats
Összesített felhasználói statisztikák a dashboard számára.
Az összes lekérdezés egyetlen tranzakcióban fut le.
"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday())
month_start = today_start.replace(day=1)
# ── 1. Alap statisztikák ──
total_users_result = await db.execute(
select(func.count(User.id))
)
total_users = total_users_result.scalar() or 0
active_users_result = await db.execute(
select(func.count(User.id)).where(User.is_active.is_(True), User.is_deleted.is_(False))
)
active_users = active_users_result.scalar() or 0
deleted_users_result = await db.execute(
select(func.count(User.id)).where(User.is_deleted.is_(True))
)
deleted_users = deleted_users_result.scalar() or 0
banned_users_result = await db.execute(
select(func.count(User.id)).where(
User.is_active.is_(False),
User.is_deleted.is_(False),
)
)
banned_users = banned_users_result.scalar() or 0
# ── 2. Új felhasználók ──
new_today_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= today_start)
)
new_users_today = new_today_result.scalar() or 0
new_week_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= week_start)
)
new_users_this_week = new_week_result.scalar() or 0
new_month_result = await db.execute(
select(func.count(User.id)).where(User.created_at >= month_start)
)
new_users_this_month = new_month_result.scalar() or 0
# ── 3. Felhasználók szerepkör szerint ──
role_rows = await db.execute(
select(User.role, func.count(User.id).label("cnt"))
.group_by(User.role)
)
users_by_role: Dict[str, int] = {}
for row in role_rows:
role_name = row.role.value if hasattr(row.role, 'value') else str(row.role)
users_by_role[role_name.lower()] = row.cnt
# ── 4. Felhasználók előfizetési csomag szerint ──
plan_rows = await db.execute(
select(User.subscription_plan, func.count(User.id).label("cnt"))
.group_by(User.subscription_plan)
)
users_by_plan: Dict[str, int] = {}
for row in plan_rows:
users_by_plan[row.subscription_plan.lower()] = row.cnt
# ── 5. Felhasználók nyelv szerint ──
lang_rows = await db.execute(
select(User.preferred_language, func.count(User.id).label("cnt"))
.group_by(User.preferred_language)
)
users_by_language: Dict[str, int] = {}
for row in lang_rows:
users_by_language[row.preferred_language] = row.cnt
# ── 6. Person kapcsolat ──
with_person_result = await db.execute(
select(func.count(User.id)).where(User.person_id.isnot(None))
)
users_with_person = with_person_result.scalar() or 0
without_person_result = await db.execute(
select(func.count(User.id)).where(User.person_id.is_(None))
)
users_without_person = without_person_result.scalar() or 0
# ── 7. Regisztrációs trend (utolsó 30 nap) ──
thirty_days_ago = today_start - timedelta(days=30)
day_trunc = func.date_trunc("day", User.created_at)
trend_rows = await db.execute(
select(
day_trunc.label("day"),
func.count(User.id).label("cnt"),
)
.where(User.created_at >= thirty_days_ago)
.group_by(day_trunc)
.order_by(day_trunc)
)
registration_trend: List[RegistrationTrendItem] = []
for row in trend_rows:
day_str = row.day.strftime("%Y-%m-%d") if row.day else ""
registration_trend.append(RegistrationTrendItem(date=day_str, count=row.cnt))
# ── 8. Szervezeti statisztikák ──
active_orgs_result = await db.execute(
select(func.count(Organization.id))
.where(Organization.is_anonymized.is_(False))
)
active_organizations_count = active_orgs_result.scalar() or 0
total_memberships_result = await db.execute(
select(func.count(OrganizationMember.id))
)
total_memberships = total_memberships_result.scalar() or 0
return UserStatsResponse(
total_users=total_users,
active_users=active_users,
deleted_users=deleted_users,
banned_users=banned_users,
new_users_today=new_users_today,
new_users_this_week=new_users_this_week,
new_users_this_month=new_users_this_month,
users_by_role=users_by_role,
users_by_plan=users_by_plan,
users_by_language=users_by_language,
users_with_person=users_with_person,
users_without_person=users_without_person,
registration_trend=registration_trend,
active_organizations_count=active_organizations_count,
total_memberships=total_memberships,
)
@router.get(
"/{user_id}",
response_model=UserResponse,
summary="Felhasználó részletes adatainak lekérése",
description=(
"Visszaadja egy felhasználó összes adatát, beleértve a Person rekordot "
"és a beágyazott Address adatokat. Csak admin/staff jogosultsággal érhető el."
),
)
async def get_admin_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
) -> Dict[str, Any]:
"""
GET /admin/users/{user_id}
Lekérdezi a felhasználót a megadott ID alapján, eager loading-gal betölti
a Person és Address kapcsolatokat, majd visszaadja a teljes UserResponse-t.
Args:
user_id: A lekérdezni kívánt felhasználó ID-ja.
Returns:
UserResponse séma szerinti felhasználói adatok.
Raises:
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
"""
stmt = (
select(User)
.where(User.id == user_id)
.options(
joinedload(User.person)
.joinedload(Person.address)
.joinedload(Address.postal_code)
)
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Felhasználó nem található.",
)
# Reuse the existing _build_user_response helper from users.py
from app.api.v1.endpoints.users import _build_user_response
response_data = await _build_user_response(user, db=db)
return UserResponse.model_validate(response_data)
@router.patch(
"/{user_id}",
response_model=Dict[str, Any],
summary="Felhasználó szerkesztése",
description=(
"Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók. "
"A role_id módosítása csak SUPERADMIN számára engedélyezett. "
"Szuperadmin felhasználó nem módosítható."
),
)
async def update_admin_user(
user_id: int,
update_data: AdminUserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:edit")),
) -> Dict[str, Any]:
"""
PATCH /admin/users/{user_id}
Admin általi felhasználó szerkesztés. Csak a megadott mezők módosíthatók.
A role_id módosítása csak SUPERADMIN számára engedélyezett.
Szuperadmin felhasználó nem módosítható.
Args:
user_id: A módosítandó felhasználó ID-ja.
update_data: A módosítandó mezők.
Returns:
UserResponse séma szerinti frissített felhasználói adatok.
Raises:
HTTPException 404: Ha a felhasználó nem található vagy törölve lett.
HTTPException 403: Ha a módosítás nem engedélyezett.
"""
# ── 1. Lekérdezzük a felhasználót ──
stmt = (
select(User)
.where(User.id == user_id)
.options(
joinedload(User.person)
.joinedload(Person.address)
.joinedload(Address.postal_code)
)
)
result = await db.execute(stmt)
user = result.unique().scalar_one_or_none()
if not user or user.is_deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Felhasználó nem található.",
)
# ── 2. Biztonsági ellenőrzések ──
# Szuperadmin felhasználó nem módosítható (még másik superadmin által sem)
if user.role == UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Szuperadmin felhasználó nem módosítható.",
)
# role_id módosítása csak SUPERADMIN számára engedélyezett
if update_data.role_id is not None and current_user.role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak SUPERADMIN módosíthatja a szerepkört.",
)
# ── 3. Összegyűjtjük a változásokat audit loghoz ──
old_data: Dict[str, Any] = {}
new_data: Dict[str, Any] = {}
# ── 4. User mezők frissítése ──
update_fields: Dict[str, Any] = {}
if update_data.email is not None:
# Email egyediség ellenőrzés
existing = await db.execute(
select(User).where(User.email == update_data.email, User.id != user_id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ez az email cím már használatban van.",
)
old_data["email"] = user.email
new_data["email"] = update_data.email
update_fields["email"] = update_data.email
if update_data.is_active is not None:
old_data["is_active"] = user.is_active
new_data["is_active"] = update_data.is_active
update_fields["is_active"] = update_data.is_active
if update_data.is_vip is not None:
old_data["is_vip"] = user.is_vip
new_data["is_vip"] = update_data.is_vip
update_fields["is_vip"] = update_data.is_vip
if update_data.preferred_language is not None:
old_data["preferred_language"] = user.preferred_language
new_data["preferred_language"] = update_data.preferred_language
update_fields["preferred_language"] = update_data.preferred_language
if update_data.region_code is not None:
old_data["region_code"] = user.region_code
new_data["region_code"] = update_data.region_code
update_fields["region_code"] = update_data.region_code
if update_data.preferred_currency is not None:
old_data["preferred_currency"] = user.preferred_currency
new_data["preferred_currency"] = update_data.preferred_currency
update_fields["preferred_currency"] = update_data.preferred_currency
if update_data.subscription_plan is not None:
old_data["subscription_plan"] = user.subscription_plan
new_data["subscription_plan"] = update_data.subscription_plan
update_fields["subscription_plan"] = update_data.subscription_plan
if update_data.subscription_expires_at is not None:
old_data["subscription_expires_at"] = (
user.subscription_expires_at.isoformat() if user.subscription_expires_at else None
)
new_data["subscription_expires_at"] = update_data.subscription_expires_at.isoformat()
update_fields["subscription_expires_at"] = update_data.subscription_expires_at
if update_data.scope_level is not None:
old_data["scope_level"] = user.scope_level
new_data["scope_level"] = update_data.scope_level
update_fields["scope_level"] = update_data.scope_level
if update_data.scope_id is not None:
old_data["scope_id"] = user.scope_id
new_data["scope_id"] = update_data.scope_id
update_fields["scope_id"] = update_data.scope_id
if update_data.role_id is not None:
old_data["role_id"] = user.role_id
new_data["role_id"] = update_data.role_id
update_fields["role_id"] = update_data.role_id
if update_data.custom_permissions is not None:
old_data["custom_permissions"] = user.custom_permissions
new_data["custom_permissions"] = update_data.custom_permissions
update_fields["custom_permissions"] = update_data.custom_permissions
# ── 5. Person mezők frissítése ──
person_updated = False
if update_data.person is not None and user.person is not None:
person = user.person
person_update_fields: Dict[str, Any] = {}
if update_data.person.last_name is not None:
old_data["person.last_name"] = person.last_name
new_data["person.last_name"] = update_data.person.last_name
person_update_fields["last_name"] = update_data.person.last_name
if update_data.person.first_name is not None:
old_data["person.first_name"] = person.first_name
new_data["person.first_name"] = update_data.person.first_name
person_update_fields["first_name"] = update_data.person.first_name
if update_data.person.phone is not None:
old_data["person.phone"] = person.phone
new_data["person.phone"] = update_data.person.phone
person_update_fields["phone"] = update_data.person.phone
if update_data.person.mothers_last_name is not None:
old_data["person.mothers_last_name"] = person.mothers_last_name
new_data["person.mothers_last_name"] = update_data.person.mothers_last_name
person_update_fields["mothers_last_name"] = update_data.person.mothers_last_name
if update_data.person.mothers_first_name is not None:
old_data["person.mothers_first_name"] = person.mothers_first_name
new_data["person.mothers_first_name"] = update_data.person.mothers_first_name
person_update_fields["mothers_first_name"] = update_data.person.mothers_first_name
if update_data.person.birth_place is not None:
old_data["person.birth_place"] = person.birth_place
new_data["person.birth_place"] = update_data.person.birth_place
person_update_fields["birth_place"] = update_data.person.birth_place
if update_data.person.birth_date is not None:
old_data["person.birth_date"] = (
person.birth_date.isoformat() if person.birth_date else None
)
new_data["person.birth_date"] = update_data.person.birth_date.isoformat()
person_update_fields["birth_date"] = update_data.person.birth_date
if update_data.person.identity_docs is not None:
old_data["person.identity_docs"] = person.identity_docs
new_data["person.identity_docs"] = update_data.person.identity_docs
person_update_fields["identity_docs"] = update_data.person.identity_docs
if person_update_fields:
for field, value in person_update_fields.items():
setattr(person, field, value)
person.updated_at = datetime.now(timezone.utc)
person_updated = True
# ── 6. Ha nincs változás, dobjunk hibát ──
if not update_fields and not person_updated:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Nincs módosítandó mező.",
)
# ── 7. Végrehajtjuk a frissítést ──
if update_fields:
stmt_update = (
sa_update(User)
.where(User.id == user_id)
.values(**update_fields)
)
await db.execute(stmt_update)
await db.commit()
# ── 8. Audit log ──
try:
audit_entry = AuditLog(
user_id=current_user.id,
severity=LogSeverity.info,
action="ADMIN_USER_UPDATE",
target_type="user",
target_id=str(user_id),
old_data=old_data if old_data else None,
new_data=new_data if new_data else None,
)
db.add(audit_entry)
await db.commit()
except Exception as e:
logger.warning(f"Failed to write audit log for user update {user_id}: {e}")
await db.rollback()
# ── 9. Visszaadjuk a frissített adatokat ──
# Újratöltjük a frissített adatokat
stmt_refresh = (
select(User)
.where(User.id == user_id)
.options(
joinedload(User.person)
.joinedload(Person.address)
.joinedload(Address.postal_code)
)
)
result_refresh = await db.execute(stmt_refresh)
updated_user = result_refresh.unique().scalar_one_or_none()
from app.api.v1.endpoints.users import _build_user_response
response_data = await _build_user_response(updated_user, db=db)
# Kibővítjük a hiányzó mezőkkel, amik a User modellen léteznek,
# de a _build_user_response dict-ből és a UserResponse sémából hiányoznak
response_data["is_vip"] = updated_user.is_vip
response_data["preferred_currency"] = updated_user.preferred_currency
response_data["custom_permissions"] = updated_user.custom_permissions
response_data["subscription_expires_at"] = (
updated_user.subscription_expires_at.isoformat() if updated_user.subscription_expires_at else None
)
response_data["scope_level"] = updated_user.scope_level or "individual"
response_data["scope_id"] = str(updated_user.scope_id) if updated_user.scope_id else None
return response_data
# =============================================================================
# Memberships Endpoint
# =============================================================================
class UserMembershipResponse(BaseModel):
"""Egy szervezeti tagság adatai a felhasználó nézetében."""
id: int
organization_id: int
organization_name: str = Field(..., description="Szervezet neve")
organization_display_name: Optional[str] = Field(default=None, description="Szervezet megjelenítési neve")
role: str = Field(..., description="Szerepkör a szervezetben")
status: str = Field(default="active", description="Tagság státusza")
is_verified: bool = Field(default=False, description="Ellenőrzött tagság")
is_permanent: bool = Field(default=False, description="Állandó tagság")
joined_at: Optional[str] = Field(default=None, description="Csatlakozás dátuma")
@router.get(
"/{user_id}/memberships",
response_model=List[UserMembershipResponse],
summary="Felhasználó szervezeti tagságai",
description=(
"Visszaadja egy felhasználó összes szervezeti tagságát. "
"Csak admin/staff jogosultsággal érhető el."
),
)
async def get_user_memberships(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("user:view")),
) -> List[UserMembershipResponse]:
"""
GET /admin/users/{user_id}/memberships
Lekérdezi a felhasználó összes szervezeti tagságát.
"""
stmt = (
select(OrganizationMember)
.where(OrganizationMember.user_id == user_id)
.options(joinedload(OrganizationMember.organization))
)
result = await db.execute(stmt)
memberships = result.unique().scalars().all()
membership_list = []
for m in memberships:
membership_list.append(UserMembershipResponse(
id=m.id,
organization_id=m.organization_id,
organization_name=m.organization.name if m.organization else "Ismeretlen",
organization_display_name=m.organization.display_name if m.organization else None,
role=m.role,
status=m.status,
is_verified=m.is_verified,
is_permanent=m.is_permanent,
joined_at=m.joined_at.isoformat() if m.joined_at else None,
))
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,
}

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.db.session import get_db from app.db.session import get_db
from app.services.auth_service import AuthService from app.services.auth_service import AuthService
from app.services.social_auth_service import SocialAuthService
from app.core.security import create_tokens, DEFAULT_RANK_MAP from app.core.security import create_tokens, DEFAULT_RANK_MAP
from app.core.config import settings from app.core.config import settings
from app.services.config_service import config from app.services.config_service import config
@@ -15,6 +16,10 @@ from app.models.identity import User, Device, UserDeviceLink # JAVÍTVA: Device-
from app.core.translation_helper import t # Translation helper from app.core.translation_helper import t # Translation helper
from pydantic import BaseModel, Field, EmailStr from pydantic import BaseModel, Field, EmailStr
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx
import logging
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -57,6 +62,9 @@ async def login(
detail="AUTH.USER_INACTIVE" detail="AUTH.USER_INACTIVE"
) )
# ── Update last activity timestamp ──
user.last_activity_at = datetime.now(timezone.utc)
# ── Device Fingerprint Tracking ── # ── Device Fingerprint Tracking ──
if device_fingerprint: if device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján # 1. Ellenőrizzük, létezik-e már a Device a hash alapján
@@ -155,6 +163,9 @@ async def verify_email(
if not user: if not user:
raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN")) raise HTTPException(status_code=400, detail=t("AUTH.INVALID_OR_EXPIRED_TOKEN"))
# ── Update last activity timestamp ──
user.last_activity_at = datetime.now(timezone.utc)
# ── Device Fingerprint Tracking (Magic Link) ── # ── Device Fingerprint Tracking (Magic Link) ──
if request.device_fingerprint: if request.device_fingerprint:
# 1. Ellenőrizzük, létezik-e már a Device a hash alapján # 1. Ellenőrizzük, létezik-e már a Device a hash alapján
@@ -380,3 +391,155 @@ async def restore_account_verify(
detail=result.get("message", "Érvénytelen vagy lejárt kód.") detail=result.get("message", "Érvénytelen vagy lejárt kód.")
) )
return result return result
# ── GOOGLE OAUTH ──
class GoogleAuthRequest(BaseModel):
"""Google token from the frontend after successful Google Sign-In.
The frontend sends either an id_token (from Google Sign-In / One Tap)
or an access_token (from Google OAuth2 token client).
"""
id_token: Optional[str] = Field(None, description="Google ID token (JWT) from Google Sign-In")
access_token: Optional[str] = Field(None, description="Google OAuth2 access token from token client")
referred_by_code: Optional[str] = Field(None, description="Optional referral code from registration form")
@router.post("/google", response_model=Token)
async def google_auth(
request: GoogleAuthRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Google OAuth bejelentkezés / regisztráció.
A frontend Google Sign-In gombjára kattintva a felhasználó Google tokent kap.
Ezt a tokent küldi el a backendnek, amely:
1. Ellenőrzi a token érvényességét a Google nyilvános kulcsaival
2. Kinyeri az email-t, nevet és Google ID-t
3. Ha a felhasználó már létezik (Login): visszaadja a JWT tokent
4. Ha nem létezik (Register): létrehozza az új felhasználót a Google adataival
5. Opcionális referral_code tárolása, ha a regisztrációs űrlapon megadták
Supports both id_token (from Google Sign-In) and access_token (from OAuth2 token client).
"""
try:
# 1. Determine which token was provided and build verification URL
if request.id_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?id_token={request.id_token}"
elif request.access_token:
google_verify_url = f"https://oauth2.googleapis.com/tokeninfo?access_token={request.access_token}"
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google token. Adjon meg id_token vagy access_token értéket."
)
# 2. Verify Google token
async with httpx.AsyncClient() as client:
google_resp = await client.get(google_verify_url)
if google_resp.status_code != 200:
logger.error(f"Google token verification failed: {google_resp.text}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token."
)
google_data = google_resp.json()
# 3. Validate audience (client ID) — only for id_token
# For access_token, the 'aud' field may not be present; validate by 'azp' or skip
if request.id_token:
if google_data.get("aud") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token audience mismatch: {google_data.get('aud')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (audience mismatch)."
)
else:
# For access_token, check azp (authorized party) matches our client ID
if google_data.get("azp") != settings.GOOGLE_CLIENT_ID:
logger.error(f"Google token azp mismatch: {google_data.get('azp')}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Érvénytelen Google token (azp mismatch)."
)
# 4. Extract user info
google_id = google_data.get("sub")
email = google_data.get("email", "")
first_name = google_data.get("given_name", "")
last_name = google_data.get("family_name", "")
if not google_id or not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Hiányzó Google felhasználói adatok."
)
# 5. Get or create user via SocialAuthService
user = await SocialAuthService.get_or_create_social_user(
db=db,
provider="google",
social_id=google_id,
email=email,
first_name=first_name,
last_name=last_name,
referred_by_code=request.referred_by_code
)
if not user:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Hiba a felhasználó létrehozása során."
)
# ── Update last activity timestamp ──
user.last_activity_at = datetime.now(timezone.utc)
# 6. Generate JWT tokens (same logic as login)
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
role_key = role_name.upper()
token_data = {
"sub": str(user.id),
"role": role_name,
"rank": ranks.get(role_key, 10),
"scope_level": user.scope_level or "individual",
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
}
access, refresh = create_tokens(data=token_data, remember_me=False)
max_age_days = await config.get_setting(db, "auth_refresh_default_days", default=1)
max_age_sec = int(max_age_days) * 24 * 60 * 60
response.set_cookie(
key="refresh_token",
value=refresh,
httponly=True,
secure=True,
samesite="lax",
domain=settings.COOKIE_DOMAIN,
max_age=max_age_sec
)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "bearer",
"is_active": user.is_active
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Google auth error: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Google hitelesítési hiba: {str(e)}"
)

View File

@@ -87,7 +87,7 @@ async def list_body_types(
current_user = Depends(deps.get_current_user) current_user = Depends(deps.get_current_user)
): ):
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján.""" """Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu) stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_i18n)
if vehicle_class: if vehicle_class:
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class) stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
result = await db.execute(stmt) result = await db.execute(stmt)
@@ -97,7 +97,7 @@ async def list_body_types(
"id": r.id, "id": r.id,
"vehicle_class": r.vehicle_class, "vehicle_class": r.vehicle_class,
"code": r.code, "code": r.code,
"name_hu": r.name_hu, "name_i18n": r.name_i18n if r.name_i18n else {},
} }
for r in rows for r in rows
] ]

View File

@@ -1,70 +1,233 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py # /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 import logging
from datetime import datetime, timezone, date
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.api.deps import get_db, get_current_user, RequireOrgCapability from app.api.deps import get_db, get_current_user
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization from app.models.fleet_finance.models import AssetCost, CostCategory
from app.schemas.asset_cost import AssetCostCreate, AssetCostUpdate from app.models.vehicle.asset import Asset
from datetime import datetime, timezone 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__) 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() 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( async def _resolve_user_role_in_org(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
organization_id: int organization_id: int,
) -> str: ) -> Optional[str]:
""" """Resolve the user's role in the given organization.
Resolve the user's role within the given organization.
Returns: Returns the role name (e.g. 'owner', 'admin', 'driver') or None if not found.
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
""" """
stmt = select(OrganizationMember.role).where( stmt = select(OrganizationMember).where(
OrganizationMember.user_id == user_id, OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id, OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active" OrganizationMember.status == "active",
).limit(1) )
result = await db.execute(stmt) result = await db.execute(stmt)
role = result.scalar_one_or_none() member = result.scalar_one_or_none()
return role or "MEMBER" if member:
return member.role
return None
async def _check_org_capability( async def _check_org_capability(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
organization_id: int, organization_id: int,
capability_name: str capability: str,
) -> bool: ) -> bool:
""" """Check if a user has a specific capability in an organization.
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla Uses the JSONB permissions field from fleet.org_roles.
permissions JSONB oszlopából ellenőrzi a kért képességet. Returns True if the user's role has the capability, False otherwise.
Returns: NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
bool: True ha a user rendelkezik a képességgel. 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( member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id, OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id, OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active" OrganizationMember.status == "active",
).limit(1) ).limit(1)
member_result = await db.execute(member_stmt) member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none() org_role_name = member_result.scalar_one_or_none()
@@ -72,367 +235,188 @@ async def _check_org_capability(
if not org_role_name: if not org_role_name:
return False 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( role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name, OrgRole.name_key == org_role_name,
OrgRole.is_active == True OrgRole.is_active == True,
).limit(1) ).limit(1)
role_result = await db.execute(role_stmt) role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none() permissions = role_result.scalar_one_or_none()
if not permissions: if permissions and isinstance(permissions, dict):
# Fallback to OrganizationMember.permissions return permissions.get(capability, False)
member_perm_stmt = select(OrganizationMember.permissions).where( return False
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)
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal: 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. if vat_rate == 0:
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:
return amount_gross 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("") @router.get("")
async def list_all_expenses( async def list_all_expenses(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user), current_user=Depends(get_current_user),
page: int = Query(1, ge=1, description="Page number"), limit: int = Query(50, ge=1, le=500),
page_size: int = Query(20, ge=1, le=100, description="Items per page"), offset: int = Query(0, ge=0),
asset_id: Optional[str] = Query(None, description="Filter by asset UUID"), asset_id: Optional[uuid.UUID] = Query(None),
organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."), 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, List all expenses (admin endpoint).
ordered by date descending with pagination. Supports filtering by asset_id, organization_id, category_id, status.
Supports optional asset_id filter for vehicle-specific cost views. Supports sorting by date or amount.
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.
""" """
# Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org stmt = select(AssetCost)
if organization_id is not None:
# Verify user is a member of the requested organization if asset_id:
org_stmt = select(OrganizationMember).where( stmt = stmt.where(AssetCost.asset_id == asset_id)
OrganizationMember.user_id == current_user.id, if organization_id:
OrganizationMember.organization_id == organization_id, stmt = stmt.where(AssetCost.organization_id == organization_id)
OrganizationMember.status == "active" if category_id:
).limit(1) stmt = stmt.where(AssetCost.category_id == category_id)
org_result = await db.execute(org_stmt) if status:
membership = org_result.scalar_one_or_none() stmt = stmt.where(AssetCost.status == status)
if not membership:
raise HTTPException( # Sorting
status_code=403, if sort == "date_asc":
detail="You are not an active member of the requested organization." stmt = stmt.order_by(AssetCost.date.asc())
) elif sort == "amount_desc":
org_id = organization_id stmt = stmt.order_by(AssetCost.amount_gross.desc())
elif sort == "amount_asc":
stmt = stmt.order_by(AssetCost.amount_gross.asc())
else: else:
# Fallback: resolve user's first active organization stmt = stmt.order_by(AssetCost.date.desc())
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
# Calculate offset # Count total
offset = (page - 1) * page_size 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 # Paginate
base_select = ( stmt = stmt.offset(offset).limit(limit)
select( result = await db.execute(stmt)
AssetCost, expenses = result.scalars().all()
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)
return { return {
"data": expenses, "status": "success",
"total": total, "total": total,
"page": page, "offset": offset,
"page_size": page_size, "limit": limit,
"total_pages": total_pages, "data": expenses,
} }
@router.put("/{expense_id}", status_code=200) @router.put("/{expense_id}", status_code=200)
async def update_expense( async def update_expense(
expense_id: UUID, expense_id: uuid.UUID,
update: AssetCostUpdate, expense_update: AssetCostUpdate,
db: AsyncSession = Depends(get_db), 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. All fields in AssetCostUpdate are optional — only provided fields will be updated.
The expense_id is the UUID of the existing AssetCost record. The expense_id is passed via path parameter.
The user must be a member of the organization that owns the expense.
""" """
# Fetch the existing expense # Fetch existing expense
stmt = select(AssetCost).where(AssetCost.id == expense_id) stmt = select(AssetCost).where(AssetCost.id == expense_id)
result = await db.execute(stmt) 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.") raise HTTPException(status_code=404, detail="Expense not found.")
# Verify user has access to the expense's organization # Update only provided fields
org_stmt = select(OrganizationMember).where( update_data = expense_update.model_dump(exclude_unset=True)
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()
if not membership: # Handle special fields
raise HTTPException( if "date" in update_data:
status_code=403, update_data["date"] = update_data.pop("date")
detail="You are not an active member of the organization that owns this expense." if "mileage_at_cost" in update_data:
) # mileage_at_cost is stored inside data JSONB
if existing.data is None:
# Build update dict from non-None fields existing.data = {}
update_data = update.model_dump(exclude_unset=True) existing.data["mileage_at_cost"] = update_data.pop("mileage_at_cost")
if "description" in update_data:
if not update_data: if existing.data is None:
raise HTTPException(status_code=400, detail="No fields provided for update.") existing.data = {}
existing.data["description"] = update_data.pop("description")
# 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
# Apply updates # Apply updates
for field, value in update_data.items(): for field, value in update_data.items():
setattr(cost, field, value) setattr(existing, field, value)
try:
await db.flush()
await db.commit() await db.commit()
await db.refresh(cost) await db.refresh(existing)
logger.info(f"Expense {expense_id} updated by user {current_user.id}: fields={list(update_data.keys())}")
return { return {
"status": "success", "status": "success",
"id": cost.id, "id": existing.id,
"asset_id": cost.asset_id, "message": "Expense updated successfully.",
"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,
} }
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}") @router.get("/{asset_id}")
async def list_asset_expenses( async def list_asset_expenses(
asset_id: UUID, asset_id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(get_current_user), current_user=Depends(get_current_user),
limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0, description="Number of expenses to skip"), 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. List expenses for a specific asset.
Returns enriched expense data including category name, code, and vendor info. Supports pagination and sorting by date.
Used by the OverviewTab and CostManagerModal to display real expense data.
""" """
# Validate asset exists stmt = (
stmt = select(Asset).where(Asset.id == asset_id) select(AssetCost)
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)
.where(AssetCost.asset_id == asset_id) .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 # Sorting
expenses = [] if sort == "date_asc":
for row in rows: stmt = stmt.order_by(AssetCost.date.asc())
cost = row[0] # AssetCost instance else:
cat_code = row[1] # CostCategory.code stmt = stmt.order_by(AssetCost.date.desc())
cat_name = row[2] # CostCategory.name
vendor_name = row[3] # Organization.name (resolved from vendor_organization_id)
# Extract description and mileage from data JSONB # Count total
data = cost.data or {} count_stmt = select(func.count()).select_from(stmt.subquery())
description = data.get("description") total_result = await db.execute(count_stmt)
mileage_at_cost = data.get("mileage_at_cost") total = total_result.scalar() or 0
expenses.append({ # Paginate
"id": str(cost.id), stmt = stmt.offset(offset).limit(limit)
"asset_id": str(cost.asset_id), result = await db.execute(stmt)
"organization_id": cost.organization_id, expenses = result.scalars().all()
"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()
return { return {
"data": expenses, "status": "success",
"total": total, "total": total,
"limit": limit,
"offset": offset, "offset": offset,
"limit": limit,
"data": expenses,
} }
@@ -440,7 +424,7 @@ async def list_asset_expenses(
async def create_expense( async def create_expense(
expense: AssetCostCreate, expense: AssetCostCreate,
db: AsyncSession = Depends(get_db), 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. Create a new expense (fuel, service, tax, insurance) for an asset.
@@ -563,8 +547,88 @@ async def create_expense(
if expense.description: if expense.description:
data["description"] = 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: 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( new_cost = AssetCost(
asset_id=expense.asset_id, asset_id=expense.asset_id,
organization_id=organization_id, organization_id=organization_id,
@@ -578,9 +642,10 @@ async def create_expense(
status=expense_status, status=expense_status,
data=data, data=data,
# === B2B VENDOR FIELDS === # === 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 # 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, external_vendor_name=expense.external_vendor_name,
# === INVOICE DATES === # === INVOICE DATES ===
invoice_date=expense.invoice_date, invoice_date=expense.invoice_date,
@@ -590,7 +655,76 @@ async def create_expense(
db.add(new_cost) db.add(new_cost)
await db.flush() # Flush to get new_cost.id 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 event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS: if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type # 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): 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 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.commit()
await db.refresh(new_cost) await db.refresh(new_cost)
@@ -646,6 +825,8 @@ async def create_expense(
"expense_status": new_cost.status, "expense_status": new_cost.status,
"date": new_cost.date.isoformat() if new_cost.date else None, "date": new_cost.date.isoformat() if new_cost.date else None,
"event_id": str(event_id) if event_id 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: except Exception as e:

View File

@@ -1,22 +1,29 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
""" """
Finance Admin API endpoints for managing Issuers with strict RBAC protection. Finance Admin API endpoints for managing Issuers and FinancialLedger with strict RBAC protection.
Protected by DB-driven RequirePermission("finance:view") dependency. Protected by DB-driven RequirePermission("finance:view") dependency.
""" """
from fastapi import APIRouter, Depends, HTTPException, status import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, func, text
from typing import List from typing import List, Optional
from app.api import deps from app.api import deps
from app.models.identity import User from app.models.identity import User
from app.models.marketplace.finance import Issuer from app.models.system.audit import FinancialLedger
from app.schemas.finance import IssuerResponse, IssuerUpdate from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# Issuer Endpoints (existing)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"]) @router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
async def list_issuers( async def list_issuers(
db: AsyncSession = Depends(deps.get_db), db: AsyncSession = Depends(deps.get_db),
@@ -27,6 +34,7 @@ async def list_issuers(
List all Issuers (billing entities). List all Issuers (billing entities).
Protected by RequirePermission("finance:view"). Protected by RequirePermission("finance:view").
""" """
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).order_by(Issuer.id)) result = await db.execute(select(Issuer).order_by(Issuer.id))
issuers = result.scalars().all() issuers = result.scalars().all()
return issuers return issuers
@@ -44,6 +52,7 @@ async def update_issuer(
Update an Issuer's details (activate/deactivate, revenue limit, API config). Update an Issuer's details (activate/deactivate, revenue limit, API config).
Protected by RequirePermission("finance:edit"). Protected by RequirePermission("finance:edit").
""" """
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id)) result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
issuer = result.scalar_one_or_none() issuer = result.scalar_one_or_none()
@@ -62,3 +71,133 @@ async def update_issuer(
await db.refresh(issuer) await db.refresh(issuer)
return issuer return issuer
# ──────────────────────────────────────────────────────────────────────────────
# FinancialLedger Admin Endpoints (NEW)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/ledger", response_model=FinancialLedgerListResponse, tags=["finance-admin"])
async def list_financial_ledger(
page: int = Query(1, ge=1, description="Oldalszám (1-indexelt)"),
page_size: int = Query(50, ge=1, le=200, description="Rekordok száma oldalanként"),
user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"),
transaction_type: Optional[str] = Query(None, description="Tranzakció típus szűrés"),
entry_type: Optional[str] = Query(None, description="Könyvelési irány (DEBIT / CREDIT)"),
wallet_type: Optional[str] = Query(None, description="Tárcatípus szűrés"),
date_from: Optional[str] = Query(None, description="Dátum szűrés kezdete (ISO, pl. 2026-01-01)"),
date_to: Optional[str] = Query(None, description="Dátum szűrés vége (ISO, pl. 2026-12-31)"),
sort_by: str = Query("created_at", description="Rendezési mező (created_at, amount, id)"),
sort_order: str = Query("desc", description="Rendezési irány (asc / desc)"),
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("finance:view")),
):
"""
List FinancialLedger entries with pagination, filtering, and user details.
Uses raw SQL with explicit joins to avoid SQLAlchemy relationship issues.
Protected by RequirePermission("finance:view").
Returns paginated results with joined user email and person name.
"""
# ── Build WHERE clauses dynamically ──
where_clauses = []
params = {}
if user_id is not None:
where_clauses.append("fl.user_id = :user_id")
params["user_id"] = user_id
if transaction_type is not None:
where_clauses.append("fl.transaction_type = :transaction_type")
params["transaction_type"] = transaction_type
if entry_type is not None:
where_clauses.append("fl.entry_type = :entry_type")
params["entry_type"] = entry_type
if wallet_type is not None:
where_clauses.append("fl.wallet_type = :wallet_type")
params["wallet_type"] = wallet_type
if date_from is not None:
where_clauses.append("fl.created_at >= :date_from::timestamp")
params["date_from"] = date_from
if date_to is not None:
where_clauses.append("fl.created_at <= :date_to::timestamp")
params["date_to"] = date_to
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
# Validate sort_by to prevent SQL injection
allowed_sort_fields = {"created_at", "amount", "id"}
if sort_by not in allowed_sort_fields:
sort_by = "created_at"
sort_direction = "ASC" if sort_order == "asc" else "DESC"
# ── Count query ──
count_sql = f"""
SELECT COUNT(*) FROM audit.financial_ledger fl
WHERE {where_sql}
"""
count_result = await db.execute(text(count_sql), params)
total = count_result.scalar() or 0
# ── Data query with joins ──
offset = (page - 1) * page_size
data_sql = f"""
SELECT
fl.id,
fl.user_id,
fl.person_id,
fl.amount,
fl.currency,
fl.transaction_type,
fl.entry_type::text,
fl.wallet_type::text,
fl.status::text,
fl.details,
fl.created_at,
u.email AS user_email,
p.first_name AS person_first_name,
p.last_name AS person_last_name
FROM audit.financial_ledger fl
LEFT JOIN identity.users u ON u.id = fl.user_id
LEFT JOIN identity.persons p ON p.id = u.person_id
WHERE {where_sql}
ORDER BY fl.{sort_by} {sort_direction}
LIMIT :limit OFFSET :offset
"""
params["limit"] = page_size
params["offset"] = offset
data_result = await db.execute(text(data_sql), params)
rows = data_result.all()
# ── Build response ──
items = []
for row in rows:
user_name = None
if row.person_first_name or row.person_last_name:
parts = [p for p in [row.person_last_name, row.person_first_name] if p]
user_name = " ".join(parts)
items.append(FinancialLedgerResponse(
id=row.id,
user_id=row.user_id,
person_id=row.person_id,
amount=float(row.amount) if row.amount else 0.0,
currency=row.currency,
transaction_type=row.transaction_type,
entry_type=row.entry_type,
wallet_type=row.wallet_type,
status=row.status,
details=row.details,
created_at=row.created_at,
user_email=row.user_email,
user_name=user_name,
))
return FinancialLedgerListResponse(
total=total,
page=page,
page_size=page_size,
items=items,
)

View 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",
}

View File

@@ -1,10 +1,13 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/gamification.py # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/gamification.py
import logging
from fastapi import APIRouter, Depends, HTTPException, Body, Query from fastapi import APIRouter, Depends, HTTPException, Body, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func, and_ from sqlalchemy import select, desc, func, and_
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
logger = logging.getLogger("gamification-endpoints")
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.models.identity import User from app.models.identity import User
@@ -12,6 +15,7 @@ from app.models import UserStats, PointsLedger, LevelConfig, UserContribution, B
from app.models.system import SystemParameter, ParameterScope from app.models.system import SystemParameter, ParameterScope
from app.models.marketplace.service import ServiceStaging from app.models.marketplace.service import ServiceStaging
from app.schemas.gamification import SeasonResponse, UserStatResponse, LeaderboardEntry from app.schemas.gamification import SeasonResponse, UserStatResponse, LeaderboardEntry
from app.core.i18n import t
router = APIRouter() router = APIRouter()
@@ -140,7 +144,7 @@ async def get_season_standings(
season = (await db.execute(season_stmt)).scalar_one_or_none() season = (await db.execute(season_stmt)).scalar_one_or_none()
if not season: if not season:
raise HTTPException(status_code=404, detail="Season not found") raise HTTPException(status_code=404, detail=t("GAMIFICATION.SEASON.NOT_FOUND"))
# Top hozzájárulók lekérdezése # Top hozzájárulók lekérdezése
stmt = ( stmt = (
@@ -276,12 +280,14 @@ async def submit_new_service(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
from app.services.gamification_service import gamification_service
# 1. Önvédelmi státusz ellenőrzése # 1. Önvédelmi státusz ellenőrzése
defense_status = await get_self_defense_status(db, current_user) defense_status = await get_self_defense_status(db, current_user)
if not defense_status["can_submit_services"]: if not defense_status["can_submit_services"]:
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Önvédelmi korlátozás miatt nem küldhetsz be új szerviz adatokat." detail=t("GAMIFICATION.SUBMIT_SERVICE.SELF_DEFENSE_BLOCKED")
) )
# 2. Beállítások lekérése az Admin által vezérelt táblából # 2. Beállítások lekérése az Admin által vezérelt táblából
@@ -290,13 +296,6 @@ async def submit_new_service(
{"points": 50, "xp": 100, "social_credits": 10} {"points": 50, "xp": 100, "social_credits": 10}
) )
contribution_config = await get_system_param(
db, "contribution_types_config",
{
"service_submission": {"points": 50, "xp": 100, "weight": 1.0}
}
)
# 3. Aktuális szezon lekérdezése # 3. Aktuális szezon lekérdezése
season_stmt = select(Season).where( season_stmt = select(Season).where(
and_( and_(
@@ -351,50 +350,41 @@ async def submit_new_service(
points_awarded=submission_rewards.get("points", 50), points_awarded=submission_rewards.get("points", 50),
xp_awarded=submission_rewards.get("xp", 100), xp_awarded=submission_rewards.get("xp", 100),
status="pending", # Robot 5 jóváhagyására vár status="pending", # Robot 5 jóváhagyására vár
metadata={ provided_fields={
"service_name": name, "service_name": name,
"city": city, "city": city,
"staging_id": new_staging.id "staging_id": new_staging.id
}, },
created_at=datetime.utcnow() action_type=1,
earned_xp=submission_rewards.get("xp", 100),
) )
db.add(contribution) db.add(contribution)
# 8. PointsLedger bejegyzés # 8. GamificationService hívás a pontok jóváírásához (a direkt UserStats módosítás helyett)
ledger = PointsLedger( # A GamificationService kezeli a szorzókat, szintlépést, recovery-t és naplózást
try:
await gamification_service.process_activity(
db=db,
user_id=current_user.id, user_id=current_user.id,
points=submission_rewards.get("points", 50), xp_amount=submission_rewards.get("xp", 100),
xp=submission_rewards.get("xp", 100), social_amount=submission_rewards.get("social_credits", 10),
reason=f"SERVICE_SUBMISSION: {name}",
is_penalty=False,
commit=False, # Ne commitoljon, mert a contribution-t is hozzáadjuk
action_key="service_submission",
source_type="service_submission", source_type="service_submission",
source_id=new_staging.id, source_id=new_staging.id,
description=f"Szerviz beküldés: {name}",
created_at=datetime.utcnow()
) )
db.add(ledger) except Exception as e:
await db.rollback()
# 9. UserStats frissítése logger.error(f"GamificationService error during service submission for user {current_user.id}: {e}")
if stats: raise HTTPException(status_code=400, detail=t("GAMIFICATION.SUBMIT_SERVICE.POINTS_ERROR", error=str(e)))
stats.total_points += submission_rewards.get("points", 50)
stats.total_xp += submission_rewards.get("xp", 100)
stats.services_submitted += 1
stats.updated_at = datetime.utcnow()
else:
# Ha nincs még UserStats, létrehozzuk
stats = UserStats(
user_id=current_user.id,
total_points=submission_rewards.get("points", 50),
total_xp=submission_rewards.get("xp", 100),
services_submitted=1,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(stats)
try: try:
await db.commit() await db.commit()
return { return {
"status": "success", "status": "success",
"message": "Szerviz beküldve a rendszerbe elemzésre!", "message": t("GAMIFICATION.SUBMIT_SERVICE.SUCCESS"),
"xp_earned": submission_rewards.get("xp", 100), "xp_earned": submission_rewards.get("xp", 100),
"points_earned": submission_rewards.get("points", 50), "points_earned": submission_rewards.get("points", 50),
"staging_id": new_staging.id, "staging_id": new_staging.id,
@@ -402,7 +392,7 @@ async def submit_new_service(
} }
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
raise HTTPException(status_code=400, detail=f"Hiba a beküldés során: {str(e)}") raise HTTPException(status_code=400, detail=t("GAMIFICATION.SUBMIT_SERVICE.SUBMIT_ERROR", error=str(e)))
# --- Gamification 2.0 API végpontok (Frontend/Mobil) --- # --- Gamification 2.0 API végpontok (Frontend/Mobil) ---
@@ -441,7 +431,7 @@ async def get_active_season(
stmt = select(Season).where(Season.is_active == True) stmt = select(Season).where(Season.is_active == True)
season = (await db.execute(stmt)).scalar_one_or_none() season = (await db.execute(stmt)).scalar_one_or_none()
if not season: if not season:
raise HTTPException(status_code=404, detail="No active season found") raise HTTPException(status_code=404, detail=t("GAMIFICATION.SEASON.NO_ACTIVE"))
return SeasonResponse.from_orm(season) return SeasonResponse.from_orm(season)
@@ -499,33 +489,13 @@ async def get_daily_quiz(
if already_played: if already_played:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="You have already played the daily quiz today. Try again tomorrow." detail=t("GAMIFICATION.QUIZ.ALREADY_PLAYED")
) )
# Return quiz questions (for now, using mock questions - in production these would come from a database) # Load quiz questions from JSON locale files (i18n)
quiz_questions = [ from app.core.i18n import get_quiz_data
{ quiz_data = get_quiz_data()
"id": 1, quiz_questions = quiz_data.get("questions", [])
"question": "Melyik alkatrész felelős a motor levegőüzemanyag keverékének szabályozásáért?",
"options": ["Generátor", "Lambdaszonda", "Féktárcsa", "Olajszűrő"],
"correctAnswer": 1,
"explanation": "A lambdaszonda méri a kipufogógáz oxigéntartalmát, és ezen alapul a befecskendezés."
},
{
"id": 2,
"question": "Mennyi ideig érvényes egy gépjármű műszaki vizsgája Magyarországon?",
"options": ["1 év", "2 év", "4 év", "6 év"],
"correctAnswer": 1,
"explanation": "A személygépkocsik műszaki vizsgája 2 évre érvényes, kivéve az újonnan forgalomba helyezett autókat."
},
{
"id": 3,
"question": "Melyik anyag NEM része a hibrid autók akkumulátorának?",
"options": ["Lítium", "Nikkel", "Ólom", "Kobalt"],
"correctAnswer": 2,
"explanation": "A hibrid és elektromos autók akkumulátoraiban általában lítium, nikkel és kobalt található, ólom az ólomsavas akkukban van."
}
]
return { return {
"questions": quiz_questions, "questions": quiz_questions,
@@ -557,48 +527,50 @@ async def submit_quiz_answer(
if already_played: if already_played:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="You have already played the daily quiz today. Try again tomorrow." detail=t("GAMIFICATION.QUIZ.ALREADY_PLAYED")
) )
# Mock quiz data - in production this would come from a database # Load quiz data from JSON locale files (i18n)
quiz_data = { from app.core.i18n import get_quiz_data
1: {"correct_answer": 1, "points": 10, "explanation": "A lambdaszonda méri a kipufogógáz oxigéntartalmát, és ezen alapul a befecskendezés."}, quiz_locale = get_quiz_data()
2: {"correct_answer": 1, "points": 10, "explanation": "A személygépkocsik műszaki vizsgája 2 évre érvényes, kivéve az újonnan forgalomba helyezett autókat."}, quiz_questions = quiz_locale.get("questions", [])
3: {"correct_answer": 2, "points": 10, "explanation": "A hibrid és elektromos autók akkumulátoraiban általában lítium, nikkel és kobalt található, ólom az ólomsavas akkukban van."}
# Build lookup dict from questions list
quiz_lookup = {}
for q in quiz_questions:
quiz_lookup[q["id"]] = {
"correct_answer": q["correctAnswer"],
"points": 10,
"explanation": q["explanation"]
} }
if question_id not in quiz_data: if question_id not in quiz_lookup:
raise HTTPException(status_code=404, detail="Question not found") raise HTTPException(status_code=404, detail=t("GAMIFICATION.QUIZ.QUESTION_NOT_FOUND"))
question_info = quiz_data[question_id] question_info = quiz_lookup[question_id]
is_correct = selected_option == question_info["correct_answer"] is_correct = selected_option == question_info["correct_answer"]
# Award points if correct # Award points if correct (GamificationService-en keresztül)
if is_correct: if is_correct:
# Update user stats from app.services.gamification_service import gamification_service
stats_stmt = select(UserStats).where(UserStats.user_id == current_user.id)
stats_result = await db.execute(stats_stmt)
user_stats = stats_result.scalar_one_or_none()
if not user_stats: try:
# Create user stats if they don't exist await gamification_service.process_activity(
user_stats = UserStats( db=db,
user_id=current_user.id, user_id=current_user.id,
total_xp=question_info["points"], xp_amount=question_info["points"],
current_level=1 social_amount=0,
reason=f"DAILY_QUIZ: Question {question_id}",
is_penalty=False,
commit=False,
action_key="daily_quiz_correct",
source_type="daily_quiz",
source_id=question_id,
) )
db.add(user_stats) except Exception as e:
else: await db.rollback()
user_stats.total_xp += question_info["points"] logger.error(f"GamificationService error during quiz answer for user {current_user.id}: {e}")
raise HTTPException(status_code=500, detail=t("GAMIFICATION.QUIZ.POINTS_FAILED", error=str(e)))
# Add points ledger entry
points_ledger = PointsLedger(
user_id=current_user.id,
points=question_info["points"],
reason=f"Daily quiz correct answer - Question {question_id}",
created_at=datetime.now()
)
db.add(points_ledger)
await db.commit() await db.commit()
@@ -633,7 +605,7 @@ async def complete_daily_quiz(
if already_completed: if already_completed:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Daily quiz already marked as completed today." detail=t("GAMIFICATION.QUIZ.ALREADY_COMPLETED")
) )
# Add completion entry # Add completion entry
@@ -646,7 +618,7 @@ async def complete_daily_quiz(
db.add(completion_ledger) db.add(completion_ledger)
await db.commit() await db.commit()
return {"message": "Daily quiz marked as completed for today."} return {"message": t("GAMIFICATION.QUIZ.COMPLETED_SUCCESS")}
@router.get("/quiz/stats") @router.get("/quiz/stats")
@@ -775,7 +747,7 @@ async def award_badge_to_user(
badge = badge_result.scalar_one_or_none() badge = badge_result.scalar_one_or_none()
if not badge: if not badge:
raise HTTPException(status_code=404, detail="Badge not found") raise HTTPException(status_code=404, detail=t("GAMIFICATION.BADGE.NOT_FOUND"))
# Determine target user (default to current user if not specified) # Determine target user (default to current user if not specified)
target_user_id = user_id if user_id else current_user.id target_user_id = user_id if user_id else current_user.id
@@ -789,7 +761,7 @@ async def award_badge_to_user(
existing = existing_result.scalar_one_or_none() existing = existing_result.scalar_one_or_none()
if existing: if existing:
raise HTTPException(status_code=400, detail="User already has this badge") raise HTTPException(status_code=400, detail=t("GAMIFICATION.BADGE.ALREADY_AWARDED"))
# Award the badge # Award the badge
user_badge = UserBadge( user_badge = UserBadge(
@@ -799,34 +771,31 @@ async def award_badge_to_user(
) )
db.add(user_badge) db.add(user_badge)
# Also add points for earning a badge # Award points via GamificationService (a direkt UserStats módosítás helyett)
points_ledger = PointsLedger( from app.services.gamification_service import gamification_service
user_id=target_user_id,
points=50, # Points for earning a badge
reason=f"Badge earned: {badge.name}",
created_at=datetime.now()
)
db.add(points_ledger)
# Update user stats try:
stats_stmt = select(UserStats).where(UserStats.user_id == target_user_id) await gamification_service.process_activity(
stats_result = await db.execute(stats_stmt) db=db,
user_stats = stats_result.scalar_one_or_none()
if user_stats:
user_stats.total_xp += 50
else:
user_stats = UserStats(
user_id=target_user_id, user_id=target_user_id,
total_xp=50, xp_amount=50,
current_level=1 social_amount=0,
reason=f"BADGE_EARNED: {badge.name}",
is_penalty=False,
commit=False,
action_key="badge_earned",
source_type="badge",
source_id=badge_id,
) )
db.add(user_stats) except Exception as e:
await db.rollback()
logger.error(f"GamificationService error during badge award for user {target_user_id}: {e}")
raise HTTPException(status_code=500, detail=t("GAMIFICATION.BADGE.POINTS_FAILED", error=str(e)))
await db.commit() await db.commit()
return { return {
"message": f"Badge '{badge.name}' awarded to user", "message": t("GAMIFICATION.BADGE.AWARDED_SUCCESS", badge_name=badge.name),
"badge_id": badge.id, "badge_id": badge.id,
"badge_name": badge.name, "badge_name": badge.name,
"points_awarded": 50 "points_awarded": 50
@@ -873,10 +842,10 @@ async def get_achievements_progress(
# XP-based achievements # XP-based achievements
xp_levels = [ xp_levels = [
{"title": "Novice", "xp_required": 100, "description": "Earn 100 XP"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.XP_NOVICE"), "xp_required": 100, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_NOVICE_DESC")},
{"title": "Apprentice", "xp_required": 500, "description": "Earn 500 XP"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.XP_APPRENTICE"), "xp_required": 500, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_APPRENTICE_DESC")},
{"title": "Expert", "xp_required": 2000, "description": "Earn 2000 XP"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.XP_EXPERT"), "xp_required": 2000, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_EXPERT_DESC")},
{"title": "Master", "xp_required": 5000, "description": "Earn 5000 XP"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.XP_MASTER"), "xp_required": 5000, "description": t("GAMIFICATION.ACHIEVEMENTS.XP_MASTER_DESC")},
] ]
current_xp = user_stats.total_xp if user_stats else 0 current_xp = user_stats.total_xp if user_stats else 0
@@ -901,9 +870,9 @@ async def get_achievements_progress(
quiz_points = quiz_points_result.scalar() or 0 quiz_points = quiz_points_result.scalar() or 0
quiz_achievements = [ quiz_achievements = [
{"title": "Quiz Beginner", "points_required": 50, "description": "Earn 50 quiz points"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_BEGINNER"), "points_required": 50, "description": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_BEGINNER_DESC")},
{"title": "Quiz Enthusiast", "points_required": 200, "description": "Earn 200 quiz points"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_ENTHUSIAST"), "points_required": 200, "description": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_ENTHUSIAST_DESC")},
{"title": "Quiz Master", "points_required": 500, "description": "Earn 500 quiz points"}, {"title": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_MASTER"), "points_required": 500, "description": t("GAMIFICATION.ACHIEVEMENTS.QUIZ_MASTER_DESC")},
] ]
for achievement in quiz_achievements: for achievement in quiz_achievements:

View 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.",
)

View File

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

View File

@@ -15,9 +15,10 @@ import logging
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, or_ from sqlalchemy import select, or_, cast, String
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.dialects.postgresql import JSONB
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
@@ -76,8 +77,8 @@ async def get_category_tree(
nodes.append(CategoryTreeNode( nodes.append(CategoryTreeNode(
id=tag.id, id=tag.id,
key=tag.key, key=tag.key,
name_hu=tag.name_hu, name_i18n=tag.name_i18n if tag.name_i18n else {},
name_en=tag.name_en, description_i18n=tag.description_i18n,
level=tag.level, level=tag.level,
path=tag.path, path=tag.path,
is_official=tag.is_official, is_official=tag.is_official,
@@ -106,18 +107,17 @@ async def autocomplete_categories(
Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez. Szöveges kereső a Szint 2 (Szakma) és Szint 3 (Specifikus feladat) címkékhez.
Csak azokat listázza, ahol is_official=True. Csak azokat listázza, ahol is_official=True.
A keresés a name_hu, name_en és key mezőkben történik (ILIKE). A keresés a name_i18n JSONB (minden nyelven) és key mezőkben történik.
""" """
try: try:
stmt = select(ExpertiseTag).where( stmt = select(ExpertiseTag).where(
ExpertiseTag.level >= 2, ExpertiseTag.level >= 2,
ExpertiseTag.is_official == True, ExpertiseTag.is_official == True,
or_( or_(
ExpertiseTag.name_hu.ilike(f"%{q}%"), cast(ExpertiseTag.name_i18n, String).ilike(f"%{q}%"),
ExpertiseTag.name_en.ilike(f"%{q}%"),
ExpertiseTag.key.ilike(f"%{q}%"), ExpertiseTag.key.ilike(f"%{q}%"),
), ),
).order_by(ExpertiseTag.level, ExpertiseTag.name_hu).limit(20) ).order_by(ExpertiseTag.level, ExpertiseTag.name_i18n).limit(20)
result = await db.execute(stmt) result = await db.execute(stmt)
tags = result.scalars().all() tags = result.scalars().all()
@@ -126,8 +126,7 @@ async def autocomplete_categories(
CategoryAutocompleteItem( CategoryAutocompleteItem(
id=t.id, id=t.id,
key=t.key, key=t.key,
name_hu=t.name_hu, name_i18n=t.name_i18n if t.name_i18n else {},
name_en=t.name_en,
level=t.level, level=t.level,
path=t.path, path=t.path,
parent_id=t.parent_id, parent_id=t.parent_id,
@@ -164,8 +163,7 @@ async def list_expertise_categories(
ExpertiseCategoryOut( ExpertiseCategoryOut(
id=t.id, id=t.id,
key=t.key, key=t.key,
name_hu=t.name_hu, name_i18n=t.name_i18n if t.name_i18n else {},
name_en=t.name_en,
category=t.category, category=t.category,
level=t.level, level=t.level,
parent_id=t.parent_id, parent_id=t.parent_id,
@@ -183,7 +181,7 @@ async def list_expertise_categories(
@router.get("/search", response_model=ProviderSearchResponse) @router.get("/search", response_model=ProviderSearchResponse)
async def search_service_providers( async def search_service_providers(
q: Optional[str] = Query(None, min_length=2, max_length=200, description="Keresőszó"), q: Optional[str] = Query(None, min_length=3, max_length=200, description="Keresőszó (min. 3 karakter)"),
category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"), category: Optional[str] = Query(None, max_length=50, description="Kategória szűrő (expertise_tags.key)"),
city: Optional[str] = Query(None, max_length=100, description="Város szűrő"), city: Optional[str] = Query(None, max_length=100, description="Város szűrő"),
category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"), category_ids: Optional[str] = Query(None, description="Kategória ID-k vesszővel elválasztva (pl. '201,205') — hierarchikus szűrés"),

View File

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

View File

@@ -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}) """), {"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 # MB 2.0 Gamification: Dinamikus pontszám a felfedezésért
reward_points = await ConfigService.get_int(db, "GAMIFICATION_HUNT_REWARD", 50) # 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, reward_points, f"Service Hunt: {name}") await GamificationService.award_points(db, current_user.id, amount=0, reason=f"Service Hunt: {name}", action_key="SERVICE_HUNT")
await db.commit() await db.commit()
return {"status": "success", "message": "Discovery registered and points awarded."} 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 # 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) # 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, validation_reward, f"Service Validation: staging #{staging_id}") 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 # 6. Növeld a current_user places_validated értékét a UserStats-ban
await db.execute( await db.execute(

View File

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

View File

@@ -1,53 +1,349 @@
# /opt/docker/dev/service_finder/backend/app/core/i18n.py # /opt/docker/dev/service_finder/backend/app/core/i18n.py
"""
Backend i18n helper module.
Loads locale JSON files from backend/static/locales/ into a cached dictionary,
provides dot-notation key resolution with variable interpolation,
and integrates with the request-scoped context locale system.
Usage:
from app.core.i18n import t, get_locale
# With explicit language
msg = t("AUTH.LOGIN.SUCCESS", lang="en")
# With variable interpolation
msg = t("EMAIL.REG.GREETING", lang="hu", first_name="Péter")
# Using request context locale (set by I18nMiddleware)
msg = t("COMMON.SAVE_SUCCESS")
# Get full locale dictionary
locale_data = get_locale("en")
"""
import json import json
import os import os
import logging
from typing import Dict, Any, Optional
class LocaleManager: logger = logging.getLogger(__name__)
_locales = {}
def get(self, key: str, lang: str = "hu", **kwargs) -> str: # ── Locale file directory ──────────────────────────────────────────────
if not self._locales: # In the Docker container, the static directory is mounted at:
self._load() # /app/backend/static/locales/
# We search multiple possible paths to cover both container and local dev.
_LOCALE_PATHS = [
"/app/backend/static/locales", # Docker container (sf_api)
"/opt/docker/dev/service_finder/backend/static/locales", # Host / dev
"backend/static/locales", # Relative from project root
"static/locales", # Relative from backend/
]
data = self._locales.get(lang, self._locales.get("hu", {})) # ── In-memory cache ────────────────────────────────────────────────────
# Biztonságos bejárás a pontokkal elválasztott kulcsokhoz _locale_cache: Dict[str, Dict[str, Any]] = {}
for k in key.split("."): _cache_loaded: bool = False
if isinstance(data, dict):
data = data.get(k, {})
else:
return key # Ha elakadunk, adjuk vissza magát a kulcsot
if isinstance(data, str):
return data.format(**kwargs)
return key
def _load(self): def _discover_locale_path() -> Optional[str]:
# A konténeren belül ez a biztos útvonal """Find the first existing locale directory."""
possible_paths = [ for path in _LOCALE_PATHS:
"/app/app/locales", if os.path.isdir(path):
"app/locales", logger.debug("i18n locale directory found: %s", path)
"backend/app/locales" return path
] logger.warning("i18n: No locale directory found among %s", _LOCALE_PATHS)
return None
path = ""
for p in possible_paths:
if os.path.exists(p):
path = p
break
if not path: def _load_locales(force: bool = False) -> None:
print("FIGYELEM: Nem található a locales könyvtár!") """
Load all JSON locale files from the discovered locale directory
into the in-memory cache.
Args:
force: If True, reload even if cache is already populated.
"""
global _cache_loaded
if _cache_loaded and not force:
return return
for file in os.listdir(path): path = _discover_locale_path()
if file.endswith(".json"): if not path:
lang = file.split(".")[0] _locale_cache.clear()
try: _cache_loaded = True
with open(os.path.join(path, file), "r", encoding="utf-8") as f: return
self._locales[lang] = json.load(f)
except Exception as e:
print(f"Hiba a {file} betöltésekor: {e}")
locale_manager = LocaleManager() loaded: Dict[str, Dict[str, Any]] = {}
# Rövid alias a könnyebb használathoz for filename in os.listdir(path):
t = locale_manager.get if not filename.endswith(".json"):
continue
lang = filename[:-5] # strip ".json"
filepath = os.path.join(path, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
loaded[lang] = json.load(f)
logger.debug("i18n: Loaded locale '%s' from %s (%d keys)", lang, filepath, _count_keys(loaded[lang]))
except Exception as exc:
logger.error("i18n: Failed to load locale file %s: %s", filepath, exc)
_locale_cache.clear()
_locale_cache.update(loaded)
_cache_loaded = True
logger.info("i18n: %d locale(s) loaded into cache: %s", len(loaded), list(loaded.keys()))
def _count_keys(data: Any) -> int:
"""Count leaf string values in a nested dict structure."""
if isinstance(data, dict):
return sum(_count_keys(v) for v in data.values())
return 1 if isinstance(data, str) else 0
def _interpolate(text: str, variables: Dict[str, Any]) -> str:
"""
Replace ``{{var_name}}`` placeholders with actual values.
The JSON locale files use the ``{{variable}}`` syntax (Jinja2/Mustache style),
which is different from Python's ``str.format()`` that uses ``{variable}``.
"""
if not variables:
return text
for var_name, var_value in variables.items():
text = text.replace("{{" + var_name + "}}", str(var_value))
return text
def _resolve_dot_key(data: Dict[str, Any], key: str) -> Any:
"""
Resolve a dot-notation key against a nested dictionary.
Example:
data = {"AUTH": {"LOGIN": {"SUCCESS": "Hello"}}}
_resolve_dot_key(data, "AUTH.LOGIN.SUCCESS") # -> "Hello"
"""
current: Any = data
for part in key.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def get_locale(lang: str) -> Dict[str, Any]:
"""
Return the full locale dictionary for the given language code.
Falls back to an empty dict if the language is not loaded.
Args:
lang: Language code, e.g. "en", "hu".
Returns:
The nested dictionary of translations for that locale.
"""
_load_locales()
return _locale_cache.get(lang, {})
def t(key: str, lang: Optional[str] = None, **kwargs: Any) -> str:
"""
Translate a dot-notation key to the localized string.
Resolution order:
1. Explicit ``lang`` parameter.
2. Request-scoped locale from ``context.get_current_locale()``
(set by I18nMiddleware from Accept-Language header or ?lang=).
3. Default locale ``"hu"``.
If the key is not found in any locale, the key itself is returned
as a fallback.
Variable interpolation:
Placeholders in the translation string like ``{{first_name}}``
are replaced with the corresponding keyword argument.
Examples:
>>> t("AUTH.LOGIN.SUCCESS", lang="en")
"Login successful. Welcome back!"
>>> t("EMAIL.REG.GREETING", lang="hu", first_name="Péter")
"Szia Péter!"
>>> t("NONEXISTENT.KEY")
"NONEXISTENT.KEY"
"""
_load_locales()
# 1. Determine language
if lang is None:
try:
from app.core.context import get_current_locale
lang = get_current_locale()
except (ImportError, Exception):
lang = "hu"
# 2. Try requested language
locale_data = _locale_cache.get(lang)
if locale_data is not None:
result = _resolve_dot_key(locale_data, key)
if isinstance(result, str):
return _interpolate(result, kwargs) if kwargs else result
# 3. Fallback to English
if lang != "en":
en_data = _locale_cache.get("en")
if en_data is not None:
result = _resolve_dot_key(en_data, key)
if isinstance(result, str):
return _interpolate(result, kwargs) if kwargs else result
# 4. Fallback to any available locale
for fallback_lang, fallback_data in _locale_cache.items():
if fallback_lang == lang or fallback_lang == "en":
continue
result = _resolve_dot_key(fallback_data, key)
if isinstance(result, str):
return _interpolate(result, kwargs) if kwargs else result
# 5. Key not found return the key itself
logger.debug("i18n: Key '%s' not found for lang='%s'", key, lang)
return key
def reload_locales() -> None:
"""
Force-reload all locale files into the cache.
Useful after a translation update without restarting the server.
"""
global _cache_loaded
_cache_loaded = False
_load_locales(force=True)
logger.info("i18n: Locales reloaded successfully")
# ── Quiz data loader ───────────────────────────────────────────────────
_QUIZ_PATHS = [
"/app/backend/static/quiz", # Docker container (sf_api)
"/opt/docker/dev/service_finder/backend/static/quiz", # Host / dev
"backend/static/quiz", # Relative from project root
"static/quiz", # Relative from backend/
]
_quiz_cache: Dict[str, Dict[str, Any]] = {}
_quiz_cache_loaded: bool = False
def _discover_quiz_path() -> Optional[str]:
"""Find the first existing quiz directory."""
for path in _QUIZ_PATHS:
if os.path.isdir(path):
logger.debug("Quiz directory found: %s", path)
return path
logger.warning("Quiz: No quiz directory found among %s", _QUIZ_PATHS)
return None
def _load_quiz_data(force: bool = False) -> None:
"""
Load all quiz JSON files from the discovered quiz directory
into the in-memory cache.
"""
global _quiz_cache_loaded
if _quiz_cache_loaded and not force:
return
path = _discover_quiz_path()
if not path:
_quiz_cache.clear()
_quiz_cache_loaded = True
return
loaded: Dict[str, Dict[str, Any]] = {}
for filename in os.listdir(path):
if not filename.endswith(".json"):
continue
lang = filename[:-5] # strip ".json"
filepath = os.path.join(path, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
loaded[lang] = json.load(f)
logger.debug("Quiz: Loaded '%s' from %s", lang, filepath)
except Exception as exc:
logger.error("Quiz: Failed to load file %s: %s", filepath, exc)
_quiz_cache.clear()
_quiz_cache.update(loaded)
_quiz_cache_loaded = True
logger.info("Quiz: %d language(s) loaded into cache: %s", len(loaded), list(loaded.keys()))
def get_quiz_data(lang: Optional[str] = None) -> Dict[str, Any]:
"""
Return the quiz questions for the given language code.
Resolution order:
1. Explicit ``lang`` parameter.
2. Request-scoped locale from ``context.get_current_locale()``.
3. Default locale ``"hu"``.
Falls back to English if the requested language is not available,
then to any available language, then to an empty dict.
Returns:
A dict with a ``questions`` key containing the list of quiz questions.
"""
_load_quiz_data()
# 1. Determine language
if lang is None:
try:
from app.core.context import get_current_locale
lang = get_current_locale()
except (ImportError, Exception):
lang = "hu"
# 2. Try requested language
if lang in _quiz_cache:
return _quiz_cache[lang]
# 3. Fallback to English
if lang != "en" and "en" in _quiz_cache:
return _quiz_cache["en"]
# 4. Fallback to any available
for fallback_lang, fallback_data in _quiz_cache.items():
return fallback_data
# 5. Nothing loaded
logger.warning("Quiz: No quiz data available for lang='%s'", lang)
return {"questions": []}
def reload_quiz_data() -> None:
"""Force-reload all quiz JSON files into the cache."""
global _quiz_cache_loaded
_quiz_cache_loaded = False
_load_quiz_data(force=True)
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()

View File

@@ -4,8 +4,13 @@ Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul, Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
és leálláskor megáll. és leálláskor megáll.
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel Feladatok:
véletlenszerűen 0:15 és 0:30 között fog lefutni. 1. daily_financial_maintenance (00:15 UTC) — Voucher/Withdrawal/User downgrade
2. subscription_monitor (01:00 UTC) — UserSubscription & OrganizationSubscription lejárat
3. inactivity_monitor (02:00 UTC) — 180 napos inaktivitás detektálás
Biztonsági Jitter: Minden napi feladat jitter=900 (15 perc) paraméterrel rendelkezik,
így véletlenszerűen eltolódnak a megadott időponthoz képest.
""" """
import asyncio import asyncio
@@ -190,7 +195,7 @@ def setup_scheduler() -> None:
"""Beállítja a scheduler-t a napi feladatokkal.""" """Beállítja a scheduler-t a napi feladatokkal."""
scheduler = get_scheduler() scheduler = get_scheduler()
# Napi futás 00:15-kor, jitter=900 (15 perc véletlenszerű eltolás) # ── 1. Napi pénzügyi karbantartás 00:15-kor ──
scheduler.add_job( scheduler.add_job(
daily_financial_maintenance, daily_financial_maintenance,
trigger=CronTrigger(hour=0, minute=15, jitter=900), trigger=CronTrigger(hour=0, minute=15, jitter=900),
@@ -199,7 +204,85 @@ def setup_scheduler() -> None:
replace_existing=True replace_existing=True
) )
logger.info("Scheduler jobs registered") # ── 2. Subscription Monitor 01:00-kor ──
# Kezeli a lejárt UserSubscription és OrganizationSubscription rekordokat.
from app.workers.system.subscription_monitor_worker import run_full_monitor as run_sub_monitor
async def subscription_monitor_job():
logger.info("Scheduler: Starting subscription monitor job...")
async with AsyncSessionLocal() as db:
try:
stats = await run_sub_monitor()
total = (
stats["user_subscriptions"]["processed"]
+ stats["org_subscriptions"]["processed"]
)
# Naplózás ProcessLog-ba
process_log = ProcessLog(
process_name="Subscription-Monitor",
items_processed=total,
items_failed=len(stats["user_subscriptions"]["errors"]) + len(stats["org_subscriptions"]["errors"]),
end_time=datetime.utcnow(),
details={
"status": "COMPLETED",
"user_processed": stats["user_subscriptions"]["processed"],
"org_processed": stats["org_subscriptions"]["processed"],
"user_downgraded": len(stats["user_subscriptions"]["downgraded"]),
"org_downgraded": len(stats["org_subscriptions"]["downgraded"]),
}
)
db.add(process_log)
await db.commit()
logger.info(f"Scheduler: Subscription monitor completed ({total} processed)")
except Exception as e:
logger.error(f"Scheduler: Subscription monitor failed: {e}", exc_info=True)
await db.rollback()
scheduler.add_job(
subscription_monitor_job,
trigger=CronTrigger(hour=1, minute=0, jitter=900),
id="subscription_monitor",
name="Subscription Monitor",
replace_existing=True
)
# ── 3. Inactivity Monitor 02:00-kor ──
# Detektálja a 180+ napja inaktív usereket.
from app.workers.system.inactivity_monitor_worker import run_full_monitor as run_inactivity_monitor
async def inactivity_monitor_job():
logger.info("Scheduler: Starting inactivity monitor job...")
async with AsyncSessionLocal() as db:
try:
stats = await run_inactivity_monitor()
# Naplózás ProcessLog-ba
process_log = ProcessLog(
process_name="Inactivity-Monitor",
items_processed=stats["processed"],
items_failed=len(stats["errors"]),
end_time=datetime.utcnow(),
details={
"status": "COMPLETED",
"processed": stats["processed"],
"suspended": len(stats["suspended"]),
}
)
db.add(process_log)
await db.commit()
logger.info(f"Scheduler: Inactivity monitor completed ({stats['processed']} processed)")
except Exception as e:
logger.error(f"Scheduler: Inactivity monitor failed: {e}", exc_info=True)
await db.rollback()
scheduler.add_job(
inactivity_monitor_job,
trigger=CronTrigger(hour=2, minute=0, jitter=900),
id="inactivity_monitor",
name="Inactivity Monitor",
replace_existing=True
)
logger.info("Scheduler jobs registered: daily_financial, subscription_monitor, inactivity_monitor")
@asynccontextmanager @asynccontextmanager

View File

@@ -37,11 +37,15 @@ from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise
from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData
from .marketplace.service_request import ServiceRequest from .marketplace.service_request import ServiceRequest
# 7b. Jutalék szabályok (Commission Rules)
from .marketplace.commission import CommissionRule, CommissionRuleType, CommissionTier
# 8. Közösségi és értékelési modellek (Social 3) # 8. Közösségi és értékelési modellek (Social 3)
from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType from .identity.social import ServiceProvider, Vote, Competition, UserScore, ServiceReview, ModerationStatus, SourceType
# 9. Rendszer, Gamification és egyebek # 9. Rendszer, Gamification és egyebek
from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season
from .gamification.validation_rule import ValidationRule
from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging
from .system.rbac import SystemRole, SystemPermission, SystemRolePermission from .system.rbac import SystemRole, SystemPermission, SystemRolePermission
@@ -75,7 +79,7 @@ __all__ = [
"Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials", "Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials",
"AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading", "AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading",
"Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch", "Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch",
"PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", "ValidationRule",
"SystemParameter", "ParameterScope", "InternalNotification", "SystemParameter", "ParameterScope", "InternalNotification",
@@ -99,4 +103,6 @@ __all__ = [
"CampaignStatus", "CreativeType", "PlacementType", "CampaignStatus", "CreativeType", "PlacementType",
# RBAC Phase 1 # RBAC Phase 1
"SystemRole", "SystemPermission", "SystemRolePermission", "SystemRole", "SystemPermission", "SystemRolePermission",
# Commission Rules (Phase 2)
"CommissionRule", "CommissionRuleType", "CommissionTier",
] ]

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/models/core_logic.py # /opt/docker/dev/service_finder/backend/app/models/core_logic.py
from typing import Optional, List, Any from typing import Optional, List, Any
from datetime import datetime # Python saját típusa a típusjelöléshez from datetime import datetime # Python saját típusa a típusjelöléshez
from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float from sqlalchemy import String, Integer, ForeignKey, Boolean, DateTime, Numeric, text, Float, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql import func from sqlalchemy.sql import func
@@ -69,6 +69,10 @@ class SubscriptionTier(Base):
""" """
Előfizetési csomagok definíciója (pl. Free, Premium, VIP). Előfizetési csomagok definíciója (pl. Free, Premium, VIP).
A csomagok határozzák meg a korlátokat (pl. max járműszám). A csomagok határozzák meg a korlátokat (pl. max járműszám).
P0 MULTI-SUBSCRIPTION AGGREGATION:
A `type` mező különbözteti meg a Base tier-eket ('private', 'corporate', 'business')
az Add-on tier-ektől ('addon'). Ez lehetővé teszi az "1 Base + N Add-on" modellt.
""" """
__tablename__ = "subscription_tiers" __tablename__ = "subscription_tiers"
__table_args__ = {"schema": "system"} __table_args__ = {"schema": "system"}
@@ -78,6 +82,18 @@ class SubscriptionTier(Base):
rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5} rules: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) # pl. {"max_vehicles": 5}
is_custom: Mapped[bool] = mapped_column(Boolean, default=False) is_custom: Mapped[bool] = mapped_column(Boolean, default=False)
# ── P0 MULTI-SUBSCRIPTION AGGREGATION: Base vs Add-on típus ──
# 'base' = core tier (individual, corporate, business)
# 'addon' = add-on tier (extra vehicles, branches, users)
# None/empty = treated as 'base' for backward compatibility
type: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
default=None,
server_default=text("NULL"),
comment="Tier type: 'base' for core subscriptions, 'addon' for add-on tiers. NULL = base (backward compat)."
)
# ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ── # ── P0 3D CAPABILITY MATRIX: feature_capabilities JSONB ──
# Defines what features/capabilities this subscription tier unlocks. # Defines what features/capabilities this subscription tier unlocks.
# Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50} # Example: {"can_export_data": True, "can_use_analytics": True, "max_vehicles": 50}
@@ -227,11 +243,15 @@ class ServiceCatalog(Base):
Tábla: system.service_catalog Tábla: system.service_catalog
""" """
__tablename__ = "service_catalog" __tablename__ = "service_catalog"
__table_args__ = {"schema": "system"} __table_args__ = (
Index('idx_service_catalog_name_i18n', 'name_i18n', postgresql_using='gin'),
{"schema": "system"}
)
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT' service_code: Mapped[str] = mapped_column(String, unique=True, index=True, nullable=False) # pl. 'SRV_DATA_EXPORT'
name: Mapped[str] = mapped_column(String, nullable=False) # --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
description: Mapped[Optional[str]] = mapped_column(String) name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben credit_cost: Mapped[int] = mapped_column(Integer, default=0) # alapértelmezett ár kreditben
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)

View File

@@ -9,6 +9,7 @@ from .gamification import (
UserContribution, UserContribution,
Season, Season,
) )
from .validation_rule import ValidationRule
__all__ = [ __all__ = [
"PointRule", "PointRule",
@@ -19,4 +20,5 @@ __all__ = [
"UserBadge", "UserBadge",
"UserContribution", "UserContribution",
"Season", "Season",
"ValidationRule",
] ]

View File

@@ -42,6 +42,19 @@ class PointsLedger(Base):
points: Mapped[int] = mapped_column(Integer, default=0) points: Mapped[int] = mapped_column(Integer, default=0)
penalty_change: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0) penalty_change: Mapped[int] = mapped_column(Integer, server_default=text("0"), default=0)
reason: Mapped[str] = mapped_column(String) reason: Mapped[str] = mapped_column(String)
# HIÁNYZÓ MEZŐK - hozzáadva a gamification.py endpointok és service-ek támogatásához
xp: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship("User") user: Mapped["User"] = relationship("User")
@@ -54,6 +67,7 @@ class UserStats(Base):
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), primary_key=True) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), primary_key=True)
total_xp: Mapped[int] = mapped_column(Integer, default=0) total_xp: Mapped[int] = mapped_column(Integer, default=0)
total_points: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
social_points: Mapped[int] = mapped_column(Integer, default=0) social_points: Mapped[int] = mapped_column(Integer, default=0)
current_level: Mapped[int] = mapped_column(Integer, default=1) current_level: Mapped[int] = mapped_column(Integer, default=1)
@@ -63,6 +77,7 @@ class UserStats(Base):
places_discovered: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) places_discovered: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
places_validated: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) places_validated: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
providers_added_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) providers_added_count: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
services_submitted: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0"))
banned_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) banned_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View 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()
)

View File

@@ -30,6 +30,7 @@ from .social import (
ServiceReview, ServiceReview,
ModerationStatus, ModerationStatus,
SourceType, SourceType,
ProviderValidation,
) )
__all__ = [ __all__ = [
@@ -58,4 +59,5 @@ __all__ = [
"ServiceReview", "ServiceReview",
"ModerationStatus", "ModerationStatus",
"SourceType", "SourceType",
"ProviderValidation",
] ]

View File

@@ -57,7 +57,7 @@ class Address(Base):
# Robot és térképes funkciók számára # Robot és térképes funkciók számára
latitude: Mapped[Optional[float]] = mapped_column(Float) latitude: Mapped[Optional[float]] = mapped_column(Float)
longitude: Mapped[Optional[float]] = mapped_column(Float) longitude: Mapped[Optional[float]] = mapped_column(Float)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), default=func.now())
# Kapcsolat az irányítószám táblához (zip/city lekéréshez) # Kapcsolat az irányítószám táblához (zip/city lekéréshez)
postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined") postal_code: Mapped[Optional["GeoPostalCode"]] = relationship("GeoPostalCode", lazy="joined")

View File

@@ -159,11 +159,26 @@ class User(Base):
referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) referred_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) current_sales_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
# Commission tier: determines which commission rules apply to this user
# STANDARD = one-time commission only, CONTRACTED = first-time + recurring renewal commission
commission_tier: Mapped[str] = mapped_column(
String(20), nullable=False, default="STANDARD", server_default=text("'STANDARD'"),
comment="Jutalék besorolás: STANDARD (egyszeri jutalék) vagy CONTRACTED (első + megújítási jutalék)"
)
is_active: Mapped[bool] = mapped_column(Boolean, default=False) is_active: Mapped[bool] = mapped_column(Boolean, default=False)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
# === SOFT DELETE === # === SOFT DELETE ===
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# === INACTIVITY MONITOR ===
# Updated on login/token-refresh; used by the 180-day inactivity worker
# to detect dormant accounts and bypass them for MLM commission rewards.
last_activity_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Last user activity timestamp (login or token refresh). Used by InactivityMonitor worker."
)
folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True) folder_slug: Mapped[Optional[str]] = mapped_column(String(12), unique=True, index=True)
preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu") preferred_language: Mapped[str] = mapped_column(String(5), server_default="hu")

View File

@@ -2,13 +2,16 @@
import enum import enum
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text from sqlalchemy import String, Integer, ForeignKey, DateTime, Boolean, Text, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID as PG_UUID, JSONB, ARRAY
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.database import Base from app.database import Base
if TYPE_CHECKING:
from app.models.identity.address import Address
class ModerationStatus(str, enum.Enum): class ModerationStatus(str, enum.Enum):
pending = "pending" pending = "pending"
approved = "approved" approved = "approved"
@@ -17,6 +20,7 @@ class ModerationStatus(str, enum.Enum):
class SourceType(str, enum.Enum): class SourceType(str, enum.Enum):
manual = "manual" manual = "manual"
ocr = "ocr" ocr = "ocr"
api = "api"
api_import = "import" api_import = "import"
class ServiceProvider(Base): class ServiceProvider(Base):
@@ -26,14 +30,19 @@ class ServiceProvider(Base):
kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz. kapcsolatfelvételi adatokkal a quick_add_provider() refactorhoz.
""" """
__tablename__ = "service_providers" __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) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, nullable=False) name: Mapped[str] = mapped_column(String, nullable=False)
address: Mapped[str] = mapped_column(String, nullable=False) address: Mapped[str] = mapped_column(String, nullable=False)
category: Mapped[Optional[str]] = mapped_column(String) category: Mapped[Optional[str]] = mapped_column(String)
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők === # === P0 ADDRESS UNIFICATION: Normalizált cím FK a system.addresses táblához ===
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True
)
# === P0 HYBRID VENDOR REFACTOR: Atomizált címmezők (DEPRECATED - Phase 3-ban törlendő) ===
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) address_zip: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) address_street_name: Mapped[Optional[str]] = mapped_column(String(150), nullable=True)
@@ -58,6 +67,20 @@ class ServiceProvider(Base):
validation_score: Mapped[int] = mapped_column(Integer, default=0) validation_score: Mapped[int] = mapped_column(Integer, default=0)
evidence_image_path: Mapped[Optional[str]] = mapped_column(String) evidence_image_path: Mapped[Optional[str]] = mapped_column(String)
added_by_user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) 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()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Vote(Base): class Vote(Base):
@@ -73,6 +96,45 @@ class Vote(Base):
provider_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketplace.service_providers.id"), nullable=False) 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 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): class Competition(Base):
""" Gamifikált versenyek (pl. Januári Feltöltő Verseny). """ """ Gamifikált versenyek (pl. Januári Feltöltő Verseny). """
__tablename__ = "competitions" __tablename__ = "competitions"

View File

@@ -30,6 +30,9 @@ from .staged_data import (
from .service_request import ServiceRequest from .service_request import ServiceRequest
# Commission Rules (Phase 2)
from .commission import CommissionRule, CommissionRuleType, CommissionTier
__all__ = [ __all__ = [
"Organization", "Organization",
"OrganizationMember", "OrganizationMember",
@@ -52,4 +55,7 @@ __all__ = [
"LocationType", "LocationType",
"StagedVehicleData", "StagedVehicleData",
"ServiceRequest", "ServiceRequest",
"CommissionRule",
"CommissionRuleType",
"CommissionTier",
] ]

View 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})>"
)

View File

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

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Any, List, Optional from typing import Any, List, Optional
from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger from sqlalchemy import Integer, String, Boolean, DateTime, ForeignKey, text, Text, Float, Index, Numeric, BigInteger
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 geoalchemy2 import Geometry
from sqlalchemy.sql import func 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")) parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketplace.service_profiles.id"))
fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False) fingerprint: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
location: Mapped[Any] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), index=True) location: Mapped[Optional[Any]] = mapped_column(Geometry(geometry_type='POINT', srid=4326, spatial_index=False), nullable=True, index=True)
status: Mapped[ServiceStatus] = mapped_column( status: Mapped[ServiceStatus] = mapped_column(
SQLEnum(ServiceStatus, name="service_status", schema="marketplace"), SQLEnum(ServiceStatus, name="service_status", schema="marketplace"),
@@ -72,6 +72,14 @@ class ServiceProfile(Base):
website: Mapped[Optional[str]] = mapped_column(String) website: Mapped[Optional[str]] = mapped_column(String)
bio: Mapped[Optional[str]] = mapped_column(Text) 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 # Kapcsolatok
organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile") organization: Mapped["Organization"] = relationship("Organization", back_populates="service_profile")
expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service") expertises: Mapped[List["ServiceExpertise"]] = relationship("ServiceExpertise", back_populates="service")
@@ -97,14 +105,15 @@ class ExpertiseTag(Base):
__tablename__ = "expertise_tags" __tablename__ = "expertise_tags"
__table_args__ = ( __table_args__ = (
Index('idx_expertise_path', 'path'), Index('idx_expertise_path', 'path'),
Index('idx_expertise_tags_name_i18n', 'name_i18n', postgresql_using='gin'),
{"schema": "marketplace"} {"schema": "marketplace"}
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(50), unique=True, index=True) key: Mapped[str] = mapped_column(String(50), unique=True, index=True)
name_hu: Mapped[Optional[str]] = mapped_column(String(100)) # --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_en: Mapped[Optional[str]] = mapped_column(String(100)) name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))
name_translations: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) description_i18n: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(30), index=True) category: Mapped[Optional[str]] = mapped_column(String(30), index=True)
# --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) --- # --- 🏗️ 4-LEVEL HIERARCHY (2026-06-17) ---
@@ -169,9 +178,7 @@ class ServiceStaging(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
name: Mapped[str] = mapped_column(String, index=True, nullable=False) name: Mapped[str] = mapped_column(String, index=True, nullable=False)
postal_code: Mapped[Optional[str]] = mapped_column(String(10), index=True) address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"))
city: Mapped[Optional[str]] = mapped_column(String(100), index=True)
full_address: Mapped[Optional[str]] = mapped_column(String)
fingerprint: Mapped[str] = mapped_column(String(255), nullable=False) fingerprint: Mapped[str] = mapped_column(String(255), nullable=False)
raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) raw_data: Mapped[Any] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
@@ -185,6 +192,9 @@ class ServiceStaging(Base):
status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True) status: Mapped[str] = mapped_column(String(20), server_default=text("'pending'"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolat a címhez
address: Mapped[Optional["Address"]] = relationship("Address", lazy="selectin")
class DiscoveryParameter(Base): class DiscoveryParameter(Base):
""" Robot vezérlési paraméterek adminból. """ """ Robot vezérlési paraméterek adminból. """
__tablename__ = "discovery_parameters" __tablename__ = "discovery_parameters"

View File

@@ -1,8 +1,9 @@
import uuid
from datetime import datetime from datetime import datetime
from typing import Optional, Any from typing import Optional, Any
from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey from sqlalchemy import String, Integer, DateTime, text, Boolean, Float, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.database import Base # MB 2.0 Standard: Központi bázis használata from app.database import Base # MB 2.0 Standard: Központi bázis használata
@@ -75,10 +76,14 @@ class ServiceStaging(Base):
# 9. ⚠️ EXTRA OSZLOP: audit_trail # 9. ⚠️ EXTRA OSZLOP: audit_trail
audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB) audit_trail: Mapped[Optional[dict]] = mapped_column(JSONB)
# 10. ⚠️ P0 BUGFIX: address_id — FK to system.addresses (UUID)
# The column exists in the database but was missing from the model.
address_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.addresses.id"), nullable=True)
# Időbélyegek # Időbélyegek
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# 10. ⚠️ EXTRA OSZLOP: updated_at # 11. ⚠️ EXTRA OSZLOP: updated_at
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now()) updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
class DiscoveryParameter(Base): class DiscoveryParameter(Base):

View File

@@ -444,7 +444,7 @@ class AssetEvent(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="events") asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
user: Mapped[Optional["User"]] = relationship("User") user: Mapped[Optional["User"]] = relationship("User")
organization: Mapped[Optional["Organization"]] = relationship("Organization") organization: Mapped[Optional["Organization"]] = relationship("Organization")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id]) cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id], viewonly=True)
# Kapcsolat a hivatkozott költséghez # Kapcsolat a hivatkozott költséghez
linked_expense: Mapped[Optional["AssetCost"]] = relationship( linked_expense: Mapped[Optional["AssetCost"]] = relationship(

View File

@@ -165,10 +165,12 @@ class BodyTypeDictionary(Base):
__tablename__ = "dict_body_types" __tablename__ = "dict_body_types"
__table_args__ = ( __table_args__ = (
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'), UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
Index('idx_dict_body_types_name_i18n', 'name_i18n', postgresql_using='gin'),
{"schema": "vehicle"} {"schema": "vehicle"}
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False) vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False) code: Mapped[str] = mapped_column(String(50), nullable=False)
name_hu: Mapped[str] = mapped_column(String(100), nullable=False) # --- 🏗️ i18n JSONB Migration (Phase 1 - Complete) ---
name_i18n: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{\"hu\": \"\"}'::jsonb"))

View 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)

View File

@@ -120,6 +120,7 @@ class AssetCostResponse(AssetCostBase):
# Derived / enriched fields (not in DB model directly) # Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship category_name: Optional[str] = None # Resolved from CostCategory relationship
category_code: Optional[str] = None # Resolved from CostCategory relationship category_code: Optional[str] = None # Resolved from CostCategory relationship
parent_category_id: Optional[int] = None # Resolved from CostCategory.parent_id — needed for two-level category dropdown on frontend
description: Optional[str] = None # Extracted from data['description'] description: Optional[str] = None # Extracted from data['description']
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost'] mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
@@ -129,4 +130,7 @@ class AssetCostResponse(AssetCostBase):
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban # P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
service_provider_id: Optional[int] = None service_provider_id: Optional[int] = None
# P0 BUGFIX: Enriched provider name (resolved from ServiceProvider.name)
service_provider_name: Optional[str] = None # Resolved from ServiceProvider.name via service_provider_id
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View 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

View File

@@ -34,6 +34,34 @@ class IssuerResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class FinancialLedgerResponse(BaseModel):
"""Response schema for FinancialLedger admin listing."""
id: int
user_id: Optional[int] = None
person_id: Optional[int] = None
amount: float
currency: Optional[str] = None
transaction_type: Optional[str] = None
entry_type: Optional[str] = None
wallet_type: Optional[str] = None
status: Optional[str] = None
details: Optional[Dict[str, Any]] = None
created_at: Optional[datetime] = None
# Joined user info
user_email: Optional[str] = None
user_name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class FinancialLedgerListResponse(BaseModel):
"""Paginated response for FinancialLedger admin listing."""
total: int
page: int
page_size: int
items: List[FinancialLedgerResponse]
class IssuerUpdate(BaseModel): class IssuerUpdate(BaseModel):
"""Update schema for Issuer entities (PATCH).""" """Update schema for Issuer entities (PATCH)."""
is_active: Optional[bool] = None is_active: Optional[bool] = None

View 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}

View File

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

View File

@@ -6,6 +6,12 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
- street -> address_street_name, address_street_type, address_house_number - street -> address_street_name, address_street_type, address_house_number
- A ProviderSearchResult is tartalmazza az atomizált címmezőket. - A ProviderSearchResult is tartalmazza az atomizált címmezőket.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): Egységes AddressIn/AddressOut.
- ProviderSearchResult: address_detail: Optional[AddressOut] a meglévő flat mezők mellett
- ProviderQuickAddIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
- ProviderUpdateIn: address_detail: Optional[AddressIn] a meglévő flat mezők mellett
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK a backward compatibility miatt
4-Level Category Architecture (2026-06-17): 4-Level Category Architecture (2026-06-17):
=========================================== ===========================================
- CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz - CategoryTreeNode: Hierarchikus fa struktúra a frontend checkboxaihoz
@@ -15,6 +21,7 @@ P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők bevezetése.
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List from typing import Optional, List
from app.schemas.address import AddressIn, AddressOut
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@@ -28,8 +35,8 @@ class CategoryTreeNode(BaseModel):
""" """
id: int id: int
key: str key: str
name_hu: Optional[str] = None name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
name_en: Optional[str] = None description_i18n: Optional[dict] = Field(None, description="Lokalizált leírás (JSONB)")
level: int = 0 level: int = 0
path: Optional[str] = None path: Optional[str] = None
is_official: bool = True is_official: bool = True
@@ -46,8 +53,7 @@ class CategoryAutocompleteItem(BaseModel):
""" """
id: int id: int
key: str key: str
name_hu: Optional[str] = None name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
name_en: Optional[str] = None
level: int = 0 level: int = 0
path: Optional[str] = None path: Optional[str] = None
parent_id: Optional[int] = None parent_id: Optional[int] = None
@@ -66,8 +72,7 @@ class ExpertiseCategoryOut(BaseModel):
""" """
id: int id: int
key: str key: str
name_hu: Optional[str] = None name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
name_en: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
level: int = 0 level: int = 0
parent_id: Optional[int] = None parent_id: Optional[int] = None
@@ -84,8 +89,7 @@ class CategoryInfo(BaseModel):
szolgáltatásokat a kártyákon. szolgáltatásokat a kártyákon.
""" """
id: int id: int
name_hu: Optional[str] = None name_i18n: dict = Field(default_factory=dict, description="Lokalizált név (JSONB: {\"hu\": \"...\", \"en\": \"...\"})")
name_en: Optional[str] = None
level: int = 0 level: int = 0
key: str key: str
@@ -97,6 +101,15 @@ class ProviderSearchResult(BaseModel):
- address_street_name, address_street_type, address_house_number - address_street_name, address_street_type, address_house_number
- A frontend ezeket intelligensen fűzi össze megjelenítéskor. - A frontend ezeket intelligensen fűzi össze megjelenítéskor.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressOut] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressOut] — first-class citizen.
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
FEATURE (2026-06-17): categories mező. FEATURE (2026-06-17): categories mező.
- A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája. - A szolgáltatóhoz tartozó kategóriák (ExpertiseTag) listája.
- Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez. - Tartalmazza az ID-t, nevet és szintet a frontend chip megjelenítéshez.
@@ -106,12 +119,12 @@ class ProviderSearchResult(BaseModel):
category: Optional[str] = None category: Optional[str] = None
specialization: List[str] = Field(default_factory=list) specialization: List[str] = Field(default_factory=list)
categories: List[CategoryInfo] = Field(default_factory=list) categories: List[CategoryInfo] = Field(default_factory=list)
city: Optional[str] = None city: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.city instead")
address: Optional[str] = None address: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.full_address_text instead")
address_zip: Optional[str] = None address_zip: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.zip instead")
address_street_name: Optional[str] = None address_street_name: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_name instead")
address_street_type: Optional[str] = None address_street_type: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.street_type instead")
address_house_number: Optional[str] = None address_house_number: Optional[str] = Field(None, description="[DEPRECATED] Use address_detail.house_number instead")
plus_code: Optional[str] = None plus_code: Optional[str] = None
contact_phone: Optional[str] = None contact_phone: Optional[str] = None
contact_email: Optional[str] = None contact_email: Optional[str] = None
@@ -120,6 +133,10 @@ class ProviderSearchResult(BaseModel):
source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added") source: str = Field(..., description="Forrás: verified_org | staged_data | crowd_added")
is_verified: bool = False is_verified: bool = False
rating: Optional[float] = None 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) model_config = ConfigDict(from_attributes=True)
@@ -138,6 +155,15 @@ class ProviderQuickAddIn(BaseModel):
P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők. P1 CRITICAL ALIGN (2026-06-17): Atomizált címmezők.
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number - street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressIn] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressIn] — first-class citizen.
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális. P0 CRITICAL BUGFIX (2026-06-17): category_id most már opcionális.
- A 4-level kategória rendszer bevezetésével a frontend a category_ids - A 4-level kategória rendszer bevezetésével a frontend a category_ids
tömböt használja a kategóriák kiválasztására, nem a régi category_id-t. tömböt használja a kategóriák kiválasztására, nem a régi category_id-t.
@@ -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_id: Optional[int] = Field(None, ge=1, description="Elsődleges kategória ID (opcionális, backward compatibility)")
category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)") category_ids: Optional[List[int]] = Field(None, description="Kiválasztott kategória ID-k (Szint 0-3 checkboxokból)")
new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)") new_tags: Optional[List[str]] = Field(None, description="User által gépelt új címkenevek (is_official=False, level=3)")
city: Optional[str] = Field(None, max_length=100, description="Város") city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám") address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)") address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)") address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)") address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)") plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám") contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím") contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
website: Optional[str] = Field(None, max_length=255, description="Weboldal URL") 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)") 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): class ProviderQuickAddResponse(BaseModel):
@@ -177,16 +211,25 @@ class ProviderUpdateIn(BaseModel):
- street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number - street ELTÁVOLÍTVA, helyette address_street_name, address_street_type, address_house_number
- Tartalmazza a contact_phone, contact_email, website és tags mezőket is. - Tartalmazza a contact_phone, contact_email, website és tags mezőket is.
P3 UNIFIED ADDRESS REFACTOR (2026-07-01): address_detail mező.
- address_detail: Optional[AddressIn] — egységes cím objektum.
- A flat mezők (address_zip, address_street_name, stb.) MEGMARADNAK
a backward compatibility miatt.
P0 PHASE 2 ADDRESS UNIFICATION (2026-07-07):
- address_detail: Optional[AddressIn] — first-class citizen.
- A flat mezők [DEPRECATED] — Phase 3-ban törlendő.
4-Level Category (2026-06-17): 4-Level Category (2026-06-17):
- category_ids: A kiválasztott kategória ID-k listája (Szint 0-3) - category_ids: A kiválasztott kategória ID-k listája (Szint 0-3)
- new_tags: A user által gépelt, új (még nem létező) címkék listája - new_tags: A user által gépelt, új (még nem létező) címkék listája
""" """
name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve") name: str = Field(..., min_length=2, max_length=200, description="Szolgáltató neve")
city: Optional[str] = Field(None, max_length=100, description="Város") city: Optional[str] = Field(None, max_length=100, description="[DEPRECATED] Use address_detail.city instead")
address_zip: Optional[str] = Field(None, max_length=20, description="Irányítószám") address_zip: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.zip instead")
address_street_name: Optional[str] = Field(None, max_length=150, description="Utca neve (pl. Egressy)") address_street_name: Optional[str] = Field(None, max_length=150, description="[DEPRECATED] Use address_detail.street_name instead")
address_street_type: Optional[str] = Field(None, max_length=50, description="Közterület jellege (pl. utca, út, tér)") address_street_type: Optional[str] = Field(None, max_length=50, description="[DEPRECATED] Use address_detail.street_type instead")
address_house_number: Optional[str] = Field(None, max_length=20, description="Házszám (pl. 4/b)") address_house_number: Optional[str] = Field(None, max_length=20, description="[DEPRECATED] Use address_detail.house_number instead")
plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)") plus_code: Optional[str] = Field(None, max_length=20, description="Google Plus Code (pl. 8FVC9X8V+XV)")
contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám") contact_phone: Optional[str] = Field(None, max_length=50, description="Telefonszám")
contact_email: Optional[str] = Field(None, max_length=255, description="Email cím") contact_email: Optional[str] = Field(None, max_length=255, description="Email cím")
@@ -198,6 +241,14 @@ class ProviderUpdateIn(BaseModel):
new_tags: Optional[List[str]] = Field( new_tags: Optional[List[str]] = Field(
None, description="User által gépelt új címkenevek (is_official=False, level=3)" 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): class ProviderUpdateResponse(BaseModel):

View File

@@ -46,9 +46,10 @@ class PricingModel(BaseModel):
class AllowancesModel(BaseModel): class AllowancesModel(BaseModel):
"""Korlátok és keretek a csomagban.""" """Korlátok és keretek a csomagban."""
max_vehicles: int = Field(..., ge=0, description="Maximális járműszám") max_vehicles: int = Field(default=0, ge=0, description="Maximális járműszám")
max_garages: int = Field(..., ge=0, description="Maximális garázsok száma") max_garages: int = Field(default=0, ge=0, description="Maximális garázsok száma")
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma") max_users: int = Field(default=0, ge=0, description="Maximális dolgozók/felhasználók száma (0 = nincs korlátozva)")
monthly_free_credits: int = Field(default=0, ge=0, description="Havi ingyenes kreditek száma")
class DurationModel(BaseModel): class DurationModel(BaseModel):
@@ -100,7 +101,7 @@ class SubscriptionRulesModel(BaseModel):
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days) ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color) marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
""" """
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa") type: Optional[str] = Field(default=None, pattern=r"^(private|corporate|addon)$", description="Csomag típusa (private/corporate/addon)")
display_name: Optional[str] = Field( display_name: Optional[str] = Field(
default=None, default=None,
description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')", description="Emberi olvasásra szánt megjelenítési név (pl. 'Privát Ingyenes')",
@@ -141,9 +142,11 @@ class SubscriptionRulesModel(BaseModel):
@field_validator("type") @field_validator("type")
@classmethod @classmethod
def validate_type(cls, v: str) -> str: def validate_type(cls, v: Optional[str]) -> Optional[str]:
if v.lower() not in ("private", "corporate"): if v is None:
raise ValueError("A 'type' mező csak 'private' vagy 'corporate' lehet.") return v
if v.lower() not in ("private", "corporate", "addon"):
raise ValueError("A 'type' mező csak 'private', 'corporate' vagy 'addon' lehet.")
return v.lower() return v.lower()
@@ -184,6 +187,7 @@ class PublicSubscriptionTierResponse(BaseModel):
class SubscriptionTierCreate(BaseModel): class SubscriptionTierCreate(BaseModel):
"""Új előfizetési csomag létrehozása (POST /).""" """Új előfizetési csomag létrehozása (POST /)."""
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)") name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
type: Optional[str] = Field(default="base", description="Csomag típusa: 'base' (alap) vagy 'addon' (kiegészítő)")
rules: SubscriptionRulesModel rules: SubscriptionRulesModel
is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e") is_custom: bool = Field(default=False, description="Egyedi (custom) csomag-e")
tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise") tier_level: int = Field(default=0, ge=0, description="Entitlement hierarchy level: 0=free, 1=premium, 2=enterprise")
@@ -202,6 +206,7 @@ class SubscriptionTierCreate(BaseModel):
class SubscriptionTierUpdate(BaseModel): class SubscriptionTierUpdate(BaseModel):
"""Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id}).""" """Meglévő előfizetési csomag részleges frissítése (PATCH /{tier_id})."""
name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév") name: Optional[str] = Field(default=None, min_length=2, max_length=100, description="Új csomagnév")
type: Optional[str] = Field(default=None, description="Csomag típusa: 'base' vagy 'addon'")
rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB") rules: Optional[SubscriptionRulesModel] = Field(default=None, description="Frissített rules JSONB")
is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés") is_custom: Optional[bool] = Field(default=None, description="Egyedi csomag jelölés")
tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level") tier_level: Optional[int] = Field(default=None, ge=0, description="Entitlement hierarchy level")

View File

@@ -1,12 +1,12 @@
# /opt/docker/dev/service_finder/backend/app/schemas/system.py # /opt/docker/dev/service_finder/backend/app/schemas/system.py
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Dict, Any, Optional from typing import Any, Optional
from datetime import datetime from datetime import datetime
class SystemParameterBase(BaseModel): class SystemParameterBase(BaseModel):
description: Optional[str] = None description: Optional[str] = None
value: Dict[str, Any] # JSONB mező value: Any # JSONB mező — bármilyen JSON-szeriális érték lehet (int, str, dict, list)
scope_level: str = 'global' scope_level: str = 'global'
scope_id: Optional[str] = None scope_id: Optional[str] = None
is_active: bool = True is_active: bool = True
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
class SystemParameterUpdate(BaseModel): class SystemParameterUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
value: Optional[Dict[str, Any]] = None value: Optional[Any] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None

View File

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

View 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())

View 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())

View 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())

View 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())

View 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()

View File

@@ -16,6 +16,8 @@ logger = logging.getLogger(__name__)
BASE_CATEGORIES = [ BASE_CATEGORIES = [
{ {
"key": "auto_szerelo", "key": "auto_szerelo",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autószerelő", "en": "Car Mechanic"},
"name_hu": "Autószerelő", "name_hu": "Autószerelő",
"name_en": "Car Mechanic", "name_en": "Car Mechanic",
"category": "vehicle_service", "category": "vehicle_service",
@@ -24,6 +26,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "motor_szerelo", "key": "motor_szerelo",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Motorkerékpár szerelő", "en": "Motorcycle Mechanic"},
"name_hu": "Motorkerékpár szerelő", "name_hu": "Motorkerékpár szerelő",
"name_en": "Motorcycle Mechanic", "name_en": "Motorcycle Mechanic",
"category": "vehicle_service", "category": "vehicle_service",
@@ -32,6 +36,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "gumiszerviz", "key": "gumiszerviz",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Gumiszerviz", "en": "Tire Service"},
"name_hu": "Gumiszerviz", "name_hu": "Gumiszerviz",
"name_en": "Tire Service", "name_en": "Tire Service",
"category": "vehicle_service", "category": "vehicle_service",
@@ -40,6 +46,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "karosszerialakatos", "key": "karosszerialakatos",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Karosszérialakatos", "en": "Body Shop"},
"name_hu": "Karosszérialakatos", "name_hu": "Karosszérialakatos",
"name_en": "Body Shop", "name_en": "Body Shop",
"category": "body_paint", "category": "body_paint",
@@ -48,6 +56,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "fényező", "key": "fényező",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Fényező", "en": "Painter"},
"name_hu": "Fényező", "name_hu": "Fényező",
"name_en": "Painter", "name_en": "Painter",
"category": "body_paint", "category": "body_paint",
@@ -56,6 +66,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "autómentő", "key": "autómentő",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autómentő / Motormentő", "en": "Tow Truck / Roadside Assistance"},
"name_hu": "Autómentő / Motormentő", "name_hu": "Autómentő / Motormentő",
"name_en": "Tow Truck / Roadside Assistance", "name_en": "Tow Truck / Roadside Assistance",
"category": "roadside", "category": "roadside",
@@ -64,6 +76,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "benzinkút", "key": "benzinkút",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Benzinkút", "en": "Gas Station"},
"name_hu": "Benzinkút", "name_hu": "Benzinkút",
"name_en": "Gas Station", "name_en": "Gas Station",
"category": "fuel", "category": "fuel",
@@ -72,6 +86,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "alkatrész_kereskedés", "key": "alkatrész_kereskedés",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Alkatrész kereskedés", "en": "Parts Store"},
"name_hu": "Alkatrész kereskedés", "name_hu": "Alkatrész kereskedés",
"name_en": "Parts Store", "name_en": "Parts Store",
"category": "parts", "category": "parts",
@@ -80,6 +96,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "vizsgaállomás", "key": "vizsgaállomás",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Vizsgaállomás", "en": "Inspection Station"},
"name_hu": "Vizsgaállomás", "name_hu": "Vizsgaállomás",
"name_en": "Inspection Station", "name_en": "Inspection Station",
"category": "inspection", "category": "inspection",
@@ -88,6 +106,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "autókozmetika", "key": "autókozmetika",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Autókozmetika", "en": "Car Detailing"},
"name_hu": "Autókozmetika", "name_hu": "Autókozmetika",
"name_en": "Car Detailing", "name_en": "Car Detailing",
"category": "detailing", "category": "detailing",
@@ -96,6 +116,8 @@ BASE_CATEGORIES = [
}, },
{ {
"key": "egyéb", "key": "egyéb",
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
"name_i18n": {"hu": "Egyéb szolgáltató", "en": "Other Service"},
"name_hu": "Egyéb szolgáltató", "name_hu": "Egyéb szolgáltató",
"name_en": "Other Service", "name_en": "Other Service",
"category": "other", "category": "other",
@@ -123,11 +145,9 @@ async def seed_expertise_tags():
for cat in BASE_CATEGORIES: for cat in BASE_CATEGORIES:
tag = ExpertiseTag( tag = ExpertiseTag(
key=cat["key"], key=cat["key"],
name_hu=cat["name_hu"], name_i18n=cat.get("name_i18n", {"hu": cat["name_hu"], "en": cat["name_en"]}),
name_en=cat["name_en"],
category=cat["category"], category=cat["category"],
search_keywords=cat["search_keywords"], search_keywords=cat["search_keywords"],
description=cat["description"],
is_official=True, is_official=True,
discovery_points=10, discovery_points=10,
usage_count=0, usage_count=0,

View 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())

View 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()

View 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())

View 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())

View File

@@ -320,6 +320,15 @@ async def seed_params():
"scope_level": "global" "scope_level": "global"
}, },
# --- 11. KÜLSŐ API-K (DVLA, UK) --- # --- 11. INACTIVITY MONITOR (Robot-21) ---
{
"key": "inactivity_threshold_days",
"value": 180,
"category": "system",
"description": "Felhasználói inaktivitás küszöbértéke napokban. Ha a user utolsó aktivitása régebbi, a fiók felfüggesztésre kerül és a MLM jutalékrendszer megkerüli.",
"scope_level": "global"
},
# --- 12. KÜLSŐ API-K (DVLA, UK) ---
{ {
"key": "dvla_api_en "key": "dvla_api_en

View 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())

View File

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

View File

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

View File

@@ -286,8 +286,8 @@ class AssetService:
)) ))
# Gamification # Gamification
reward = await config.get_setting(db, "xp_reward_asset_register", default=250) # A pontérték a point_rules táblából jön (action_key='ASSET_REGISTER')
await GamificationService.award_points(db, user_id, int(reward), "NEW_ASSET_REG") 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 # 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) await AssetService._award_first_car_badge(db, user_id, target_org_id)
@@ -670,9 +670,13 @@ class AssetService:
A művelet: A művelet:
1. Frissíti a current_mileage értékét a megadott final_mileage-ra 1. Frissíti a current_mileage értékét a megadott final_mileage-ra
2. Állítja: status = 'archived' 2. Állítja: status = 'archived'
3. Nullázza az owner_person_id és owner_org_id mezőket 3. Nullázza az owner_person_id, owner_org_id és current_organization_id mezőket
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be 4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
5. Naplózza a biztonsági auditba 5. Naplózza a biztonsági auditba
P0 CRITICAL FIX: current_organization_id is also cleared so the vehicle
no longer counts toward the org's active vehicle quota. The previous
value is preserved in archive_info for audit/historical purposes.
""" """
from app.models.identity import User from app.models.identity import User
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -712,14 +716,19 @@ class AssetService:
'archived_at': now_iso, 'archived_at': now_iso,
'previous_owner_person_id': asset.owner_person_id, 'previous_owner_person_id': asset.owner_person_id,
'previous_owner_org_id': asset.owner_org_id, 'previous_owner_org_id': asset.owner_org_id,
'previous_current_organization_id': asset.current_organization_id,
'final_mileage': final_mileage, 'final_mileage': final_mileage,
} }
# 5. Frissítjük a jármű adatait # 5. Frissítjük a jármű adatait
# P0 CRITICAL FIX: Clear current_organization_id so the vehicle no longer
# counts toward the org's active vehicle quota. The previous value is
# preserved in archive_info above for historical audit purposes.
asset.current_mileage = final_mileage asset.current_mileage = final_mileage
asset.status = "archived" asset.status = "archived"
asset.owner_person_id = None asset.owner_person_id = None
asset.owner_org_id = None asset.owner_org_id = None
asset.current_organization_id = None
asset.individual_equipment = equipment asset.individual_equipment = equipment
asset.updated_at = datetime.now(timezone.utc) asset.updated_at = datetime.now(timezone.utc)

View File

@@ -295,19 +295,21 @@ class AuthService:
user.region_code = kyc_in.region_code 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) # 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) # P2P Referral XP a meghívónak (ha van referred_by_id)
if user.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( await GamificationService.award_points(
db, db,
user_id=user.referred_by_id, user_id=user.referred_by_id,
amount=int(p2p_xp), amount=0,
reason="P2P_REFERRAL_SUCCESS", 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() await db.commit()
return user return user

View File

@@ -573,16 +573,22 @@ class AtomicTransactionManager:
# Convert to dictionary format # Convert to dictionary format
transactions = [] transactions = []
for entry in ledger_entries: for entry in ledger_entries:
# Extract description from JSONB details field (legacy compatibility)
desc = None
if entry.details and isinstance(entry.details, dict):
desc = entry.details.get("description") or entry.details.get("desc") or None
elif entry.details and isinstance(entry.details, str):
desc = entry.details
transactions.append({ transactions.append({
"id": entry.id, "id": entry.id,
"user_id": entry.user_id, "user_id": entry.user_id,
"amount": float(entry.amount), "amount": float(entry.amount),
"entry_type": entry.entry_type.value, "entry_type": entry.entry_type.value,
"wallet_type": entry.wallet_type.value if entry.wallet_type else None, "wallet_type": entry.wallet_type.value if entry.wallet_type else None,
"description": entry.description, "description": desc,
"transaction_id": str(entry.transaction_id), "transaction_id": str(entry.transaction_id),
"reference_type": entry.reference_type, "reference_type": None,
"reference_id": entry.reference_id, "reference_id": None,
"balance_after": float(entry.balance_after) if entry.balance_after else None, "balance_after": float(entry.balance_after) if entry.balance_after else None,
"created_at": entry.created_at.isoformat() if entry.created_at else None "created_at": entry.created_at.isoformat() if entry.created_at else None
}) })
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
return { return {
"wallet_id": wallet.id, "wallet_id": wallet.id,
"balances": { "balances": {
"earned": float(wallet.earned_credits), "earned": float(wallet.earned_credits or 0),
"purchased": float(wallet.purchased_credits), "purchased": float(wallet.purchased_credits or 0),
"service_coins": float(wallet.service_coins), "service_coins": float(wallet.service_coins or 0),
"voucher": float(voucher_balance), "voucher": float(voucher_balance or 0),
"total": float( "total": float(
wallet.earned_credits + (wallet.earned_credits or 0) +
wallet.purchased_credits + (wallet.purchased_credits or 0) +
wallet.service_coins + (wallet.service_coins or 0) +
voucher_balance (voucher_balance or 0)
) )
}, },
"recent_transactions": recent_transactions, "recent_transactions": recent_transactions,

View 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),
)

View File

@@ -25,8 +25,6 @@ class CostService:
try: try:
# 1. Dinamikus konfiguráció lekérése # 1. Dinamikus konfiguráció lekérése
base_currency = await config.get_setting(db, "finance_base_currency", default="EUR") 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 # 2. Intelligens Árfolyamkezelés
exchange_rate = Decimal("1.0") 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) 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) # 5. Gamification (Értékesebb az adat, ha van róla fotó/OCR)
final_xp = base_xp # A pontérték a point_rules táblából jön (action_key='EXPENSE_LOG')
if new_cost.is_ai_generated: # Az OCR bónusz továbbra is érvényesül a process_activity szorzóin keresztül
final_xp = int(base_xp * float(ocr_multiplier))
await GamificationService.award_points( 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() await db.commit()

View 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

View File

@@ -393,22 +393,42 @@ class FinancialOrchestrator:
await db.flush() await db.flush()
# 3. Pénztárca egyenleg visszaállítása # 3. Pénztárca egyenleg visszaállítása
# JAVÍTÁS: A Wallet modellben NINCS "wallet_type" és "balance" mező.
# A usernek egyetlen wallet-je van (user_id UNIQUE constraint),
# a credit típusok külön oszlopokban: earned_credits, purchased_credits, service_coins.
# A wallet_type-t az eredeti FinancialLedger bejegyzésből olvassuk ki,
# és a megfelelő oszlopot frissítjük (pont mint process_payment()-ben).
wallet_query = select(Wallet).where( wallet_query = select(Wallet).where(
and_( Wallet.user_id == original_entry.user_id
Wallet.user_id == original_entry.user_id,
Wallet.wallet_type == original_entry.wallet_type
)
).with_for_update() ).with_for_update()
wallet_result = await db.execute(wallet_query) wallet_result = await db.execute(wallet_query)
wallet = wallet_result.scalar_one_or_none() wallet = wallet_result.scalar_one_or_none()
if wallet: if wallet:
new_balance = wallet.balance - original_entry.amount wallet_type = original_entry.wallet_type
update_values = {}
if wallet_type == WalletType.EARNED:
new_balance = Decimal(str(wallet.earned_credits)) + original_entry.amount
update_values['earned_credits'] = float(new_balance)
elif wallet_type == WalletType.PURCHASED:
new_balance = Decimal(str(wallet.purchased_credits)) + original_entry.amount
update_values['purchased_credits'] = float(new_balance)
elif wallet_type == WalletType.SERVICE_COINS:
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
update_values['service_coins'] = float(new_balance)
elif wallet_type == WalletType.VOUCHER:
# VOUCHER típusnál nincs dedikált mező, SERVICE_COINS-ba tesszük
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
update_values['service_coins'] = float(new_balance)
else:
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
await db.execute( await db.execute(
update(Wallet) update(Wallet)
.where(Wallet.id == wallet.id) .where(Wallet.id == wallet.id)
.values(balance=new_balance) .values(**update_values)
) )
# 4. Számlakiállító bevételének csökkentése # 4. Számlakiállító bevételének csökkentése

View File

@@ -82,15 +82,17 @@ class FleetService:
db.add(new_event) db.add(new_event)
# 6. DINAMIKUS GAMIFIKÁCIÓ # 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"]) rewards = event_rewards.get(event_data.event_type, event_rewards["default"])
await gamification_service.process_activity( await gamification_service.process_activity(
db, db,
user_id, user_id,
xp_amount=rewards["xp"], xp_amount=0,
social_amount=rewards["social"], 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() await db.commit()

View File

@@ -1,33 +1,75 @@
# /opt/docker/dev/service_finder/backend/app/services/gamification_service.py # /opt/docker/dev/service_finder/backend/app/services/gamification_service.py
import logging import logging
import math import math
from copy import deepcopy
from decimal import Decimal from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc
from app.models import UserStats, PointsLedger, UserBadge, Badge from app.models import UserStats, PointsLedger, UserBadge, Badge
from app.models.gamification.gamification import PointRule
from app.models.identity import User, Wallet from app.models.identity import User, Wallet
from app.models import FinancialLedger from app.models import FinancialLedger
from app.services.config_service import config # 2.0 Központi konfigurátor from app.services.config_service import config # 2.0 Központi konfigurátor
logger = logging.getLogger("Gamification-Service-2.0") 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: class GamificationService:
""" """
Gamification Service 2.0 - A 'Jövevény' lelke. Gamification Service 2.0 - A 'Jövevény' lelke.
Felelős a pontozásért, szintekért, büntetésekért és a jutalom-kreditekért. Felelős a pontozásért, szintekért, büntetésekért és a jutalom-kreditekért.
Refaktor: A process_activity() most már a point_rules táblából is olvas,
így az admin által definiált pontszabályok elsőbbséget élveznek a master configgal szemben.
""" """
@staticmethod @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. """ Statikus segédfüggvény a Robotok számára az egyszerűbb híváshoz.
Args: Args:
commit: If True (default), commits the transaction internally. commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction Set to False when called from within a larger transaction
(e.g., from AuthService.complete_kyc). (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() 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', 'description', 'rule_id' or None if not found/inactive.
"""
stmt = select(PointRule).where(
PointRule.action_key == action_key,
PointRule.is_active == True
)
result = await db.execute(stmt)
rule = result.scalar_one_or_none()
if rule:
return {"points": rule.points, "description": rule.description, "rule_id": rule.id}
return None
async def process_activity( async def process_activity(
self, self,
@@ -37,18 +79,30 @@ class GamificationService:
social_amount: int, social_amount: int,
reason: str, reason: str,
is_penalty: bool = False, is_penalty: bool = False,
commit: bool = True commit: bool = True,
action_key: str | None = None,
source_type: str | None = None,
source_id: int | None = None,
): ):
""" A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés. """ A fő folyamat: Pontozás -> Büntetés szűrés -> Szintszámítás -> Kifizetés.
Refaktor 2.0:
- Ha action_key meg van adva, a point_rules táblából olvassa a pontértékeket.
- A master config csak fallback, ha nincs a point_rules-ben.
- Támogatja a source_type és source_id mezőket a PointsLedger naplózáshoz.
Args: Args:
commit: If True (default), commits the transaction internally. commit: If True (default), commits the transaction internally.
Set to False when called from within a larger transaction. Set to False when called from within a larger transaction.
action_key: Opcionális. Ha meg van adva, a point_rules táblából
olvassa a pontértékeket a master config helyett.
source_type: Opcionális. A pontok forrásának típusa (pl. 'service_submission').
source_id: Opcionális. A pontok forrásának ID-ja.
""" """
try: try:
# 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE # 1. ADMIN KONFIGURÁCIÓ BETÖLTÉSE
# Minden paraméter az admin felületről módosítható JSON-ként # 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}, "xp_logic": {"base_xp": 500, "exponent": 1.5},
"penalty_logic": { "penalty_logic": {
"recovery_rate": 0.5, "recovery_rate": 0.5,
@@ -57,7 +111,33 @@ class GamificationService:
}, },
"conversion_logic": {"social_to_credit_rate": 100}, "conversion_logic": {"social_to_credit_rate": 100},
"level_rewards": {"credits_per_10_levels": 50} "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")
else:
logger.debug(f"No active point rule found for action_key='{action_key}', using fallback values.")
# 2. FELHASZNÁLÓ ÉS STATISZTIKA ELLENŐRZÉSE # 2. FELHASZNÁLÓ ÉS STATISZTIKA ELLENŐRZÉSE
stats_stmt = select(UserStats).where(UserStats.user_id == user_id) stats_stmt = select(UserStats).where(UserStats.user_id == user_id)
@@ -70,7 +150,7 @@ class GamificationService:
# 3. BÜNTETŐ LOGIKA (Ha negatív esemény történik) # 3. BÜNTETŐ LOGIKA (Ha negatív esemény történik)
if is_penalty: if is_penalty:
return await self._apply_penalty(db, stats, xp_amount, reason, cfg) return await self._apply_penalty(db, stats, xp_amount, reason, cfg, source_type=source_type, source_id=source_id)
# 4. SZORZÓK ALKALMAZÁSA (Büntetés alatt állók 'bírsága') # 4. SZORZÓK ALKALMAZÁSA (Büntetés alatt állók 'bírsága')
multiplier = await self._calculate_multiplier(stats, cfg) multiplier = await self._calculate_multiplier(stats, cfg)
@@ -107,8 +187,27 @@ class GamificationService:
stats.social_points %= rate # A maradék pont megmarad stats.social_points %= rate # A maradék pont megmarad
await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION") await self._add_earned_credits(db, user_id, credits_to_add, "SOCIAL_ACTIVITY_CONVERSION")
# 7. NAPLÓZÁS # 7. NAPLÓZÁS (kibővítve xp, source_type, source_id, points_snapshot mezőkkel)
db.add(PointsLedger(user_id=user_id, points=final_xp, reason=reason)) # 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,
xp=final_xp,
reason=reason,
source_type=source_type,
source_id=source_id,
points_snapshot=snapshot,
))
# Only commit if caller wants us to manage the transaction # Only commit if caller wants us to manage the transaction
if commit: if commit:
@@ -124,8 +223,12 @@ class GamificationService:
# --- PRIVÁT SEGÉDFÜGGVÉNYEK --- # --- PRIVÁT SEGÉDFÜGGVÉNYEK ---
async def _apply_penalty(self, db: AsyncSession, stats: UserStats, amount: int, reason: str, cfg: dict): async def _apply_penalty(self, db: AsyncSession, stats: UserStats, amount: int, reason: str, cfg: dict,
"""Büntetőpontok hozzáadása és korlátozási szintek emelése.""" source_type: str | None = None, source_id: int | None = None):
"""Büntetőpontok hozzáadása és korlátozási szintek emelése.
Refaktor: Most már támogatja a source_type és source_id mezőket a naplózáshoz.
"""
stats.penalty_points += amount stats.penalty_points += amount
th = cfg["penalty_logic"]["thresholds"] th = cfg["penalty_logic"]["thresholds"]
@@ -133,7 +236,14 @@ class GamificationService:
elif stats.penalty_points >= th["level_2"]: stats.restriction_level = 2 elif stats.penalty_points >= th["level_2"]: stats.restriction_level = 2
elif stats.penalty_points >= th["level_1"]: stats.restriction_level = 1 elif stats.penalty_points >= th["level_1"]: stats.restriction_level = 1
db.add(PointsLedger(user_id=stats.user_id, points=0, penalty_change=amount, reason=f"🔴 PENALTY: {reason}")) db.add(PointsLedger(
user_id=stats.user_id,
points=0,
penalty_change=amount,
reason=f"🔴 PENALTY: {reason}",
source_type=source_type,
source_id=source_id,
))
await db.commit() await db.commit()
return stats return stats

View 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")

View File

@@ -34,14 +34,17 @@ import hashlib
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, List, Tuple from typing import Optional, List, Tuple
from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete from sqlalchemy import select, func, or_, literal, union_all, cast, String, Text, null, and_, case, delete, join
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole from app.models.marketplace.organization import Organization, OrgType, Branch, OrganizationMember, OrgUserRole
from app.models.marketplace.service import ServiceProfile, ServiceStatus, ExpertiseTag, ServiceExpertise, ServiceStaging from app.models.identity.address import Address, GeoPostalCode
from app.models.identity.social import ServiceProvider 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.models.gamification.gamification import PointRule, UserStats
from app.schemas.address import AddressOut
from app.schemas.provider import ( from app.schemas.provider import (
ProviderSearchResult, ProviderSearchResult,
ProviderSearchResponse, ProviderSearchResponse,
@@ -52,6 +55,8 @@ from app.schemas.provider import (
CategoryInfo, CategoryInfo,
) )
from app.services.gamification_service import gamification_service from app.services.gamification_service import gamification_service
from app.services.address_manager import AddressManager
from app.schemas.address import AddressIn
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,9 +69,12 @@ async def _award_provider_points(
""" """
Dinamikus pontjóváírás a gamification.point_rules tábla alapján. 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 REFAKTOR (2026-06-30): A duplikált point_rules olvasási logika eltávolításra
alapján, így a pontok NINCSENEK beégetve a kódba. Az admin felületről került. A pontértékeket most a GamificationService.award_points() olvassa ki
bármikor módosíthatók a point_rules táblában. 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: Args:
db: AsyncSession db: AsyncSession
@@ -76,39 +84,19 @@ async def _award_provider_points(
Returns: Returns:
int: A jóváírt pontok száma (0 ha a szabály nem található vagy inaktív) 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!) # 1. Pontok jóváírása a Gamification Service-en keresztül (action_key alapján)
rule_stmt = select(PointRule).where( # A GamificationService.award_points() kezeli a point_rules olvasást, szorzókat,
PointRule.action_key == action_key, # szintlépést, büntetés ledolgozást és a naplózást (PointsLedger).
PointRule.is_active == True,
)
rule_result = await db.execute(rule_stmt)
rule = rule_result.scalar_one_or_none()
if not rule:
logger.warning(
f"Pontszabály '{action_key}' nem található vagy inaktív. "
f"Pontjóváírás kihagyva user_id={user_id}."
)
return 0
points_to_award = rule.points
logger.info(
f"Dinamikus pont lekérés: action_key='{action_key}', "
f"points={points_to_award} (forrás: gamification.point_rules tábla)"
)
# 2. Pontok jóváírása a Gamification Service-en keresztül
# A GamificationService.award_points() kezeli a szorzókat, szintlépést,
# büntetés ledolgozást és a naplózást (PointsLedger).
await gamification_service.award_points( await gamification_service.award_points(
db=db, db=db,
user_id=user_id, user_id=user_id,
amount=points_to_award, amount=0,
reason=f"ADD_NEW_PROVIDER: +{points_to_award} pont új szolgáltató rögzítéséért", reason=f"{action_key}",
commit=False, # A hívó (quick_add_provider) kezeli a commit-ot 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_stmt = select(UserStats).where(UserStats.user_id == user_id)
stats = (await db.execute(stats_stmt)).scalar_one_or_none() 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 # Ha még nincs UserStats rekordja, létrehozzuk
stats = UserStats( stats = UserStats(
user_id=user_id, user_id=user_id,
total_xp=points_to_award, total_xp=0,
current_level=1, current_level=1,
providers_added_count=1, providers_added_count=1,
) )
@@ -132,7 +120,97 @@ async def _award_provider_points(
f"providers_added_count=1" 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( async def search_providers(
@@ -209,14 +287,17 @@ async def search_providers(
) )
org_conditions.append(and_(*token_conditions)) org_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith # P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city.
# A város keresés most már ILIKE %{city}% (bárhol a string-ben),
# nem csak StartsWith. Ez lehetővé teszi a részleges városnév
# keresést is (pl. "bud" → "Budapest").
city_clean = city.strip() city_clean = city.strip()
org_conditions.append( org_conditions.append(
or_( or_(
Organization.address_city == city_clean, GeoPostalCode.city == city_clean,
Organization.address_city.ilike(f"{city_clean}%"), GeoPostalCode.city.ilike(f"%{city_clean}%"),
Organization.address_zip == city_clean, GeoPostalCode.zip_code == city_clean,
Organization.address_zip.ilike(f"{city_clean}%"), GeoPostalCode.zip_code.ilike(f"%{city_clean}%"),
) )
) )
@@ -241,30 +322,30 @@ async def search_providers(
ServiceProfile.id.in_(select(profile_subq.c.service_id)) ServiceProfile.id.in_(select(profile_subq.c.service_id))
) )
# P1 CRITICAL ALIGN: Az org lekérdezés most már tartalmazza az atomizált # P0 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# címmezőket (address_street_name, address_street_type, address_house_number). # helyett az Address → GeoPostalCode kapcsolaton keresztül érjük el
# Az 'address' mezőt SQL összefűzéssé alakítjuk, hogy a frontend kártyán # a város, irányítószám és utca adatokat.
# ne duplikálódjon a városnév (pl. "Dunakeszi, Dunakeszi"). # LEFT JOIN-eket használunk, mert az address_id lehet NULL.
org_stmt = select( org_stmt = select(
Organization.id.label("id"), Organization.id.label("id"),
Organization.name.label("name"), Organization.name.label("name"),
Organization.address_city.label("city"), GeoPostalCode.city.label("city"),
func.concat( func.concat(
Organization.address_zip, GeoPostalCode.zip_code,
literal(' '), literal(' '),
Organization.address_city, GeoPostalCode.city,
literal(', '), literal(', '),
Organization.address_street_name, Address.street_name,
literal(' '), literal(' '),
Organization.address_street_type, Address.street_type,
literal(' '), literal(' '),
Organization.address_house_number, Address.house_number,
).label("address"), ).label("address"),
Organization.address_zip.label("address_zip"), GeoPostalCode.zip_code.label("address_zip"),
Organization.address_street_name.label("address_street_name"), Address.street_name.label("address_street_name"),
Organization.address_street_type.label("address_street_type"), Address.street_type.label("address_street_type"),
Organization.address_house_number.label("address_house_number"), Address.house_number.label("address_house_number"),
Organization.plus_code.label("plus_code"), literal(None).label("plus_code"),
Organization.is_verified.label("is_verified"), Organization.is_verified.label("is_verified"),
ServiceProfile.contact_phone.label("contact_phone"), ServiceProfile.contact_phone.label("contact_phone"),
ServiceProfile.contact_email.label("contact_email"), ServiceProfile.contact_email.label("contact_email"),
@@ -277,6 +358,12 @@ async def search_providers(
).outerjoin( ).outerjoin(
ServiceProfile, ServiceProfile,
ServiceProfile.organization_id == Organization.id, ServiceProfile.organization_id == Organization.id,
).outerjoin(
Address,
Address.id == Organization.address_id,
).outerjoin(
GeoPostalCode,
GeoPostalCode.id == Address.postal_code_id,
).where(*org_conditions) ).where(*org_conditions)
# --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) --- # --- 2. LEKÉRDEZÉS: Robot által gyűjtött adatok (marketplace.service_staging) ---
@@ -289,14 +376,14 @@ async def search_providers(
) )
staging_conditions.append(and_(*token_conditions)) staging_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith # P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
city_clean = city.strip() city_clean = city.strip()
staging_conditions.append( staging_conditions.append(
or_( or_(
ServiceStaging.city == city_clean, ServiceStaging.city == city_clean,
ServiceStaging.city.ilike(f"{city_clean}%"), ServiceStaging.city.ilike(f"%{city_clean}%"),
ServiceStaging.postal_code == city_clean, ServiceStaging.postal_code == city_clean,
ServiceStaging.postal_code.ilike(f"{city_clean}%"), ServiceStaging.postal_code.ilike(f"%{city_clean}%"),
) )
) )
@@ -330,12 +417,12 @@ async def search_providers(
) )
crowd_conditions.append(and_(*token_conditions)) crowd_conditions.append(and_(*token_conditions))
if city: if city:
# SZIGORÍTÁS (2026-06-17): City keresés exact match vagy StartsWith # P0 UI/UX OVERHAUL (2026-07-09): Partial fuzzy search on city
city_clean = city.strip() city_clean = city.strip()
crowd_conditions.append( crowd_conditions.append(
or_( or_(
ServiceProvider.address == city_clean, ServiceProvider.address == city_clean,
ServiceProvider.address.ilike(f"{city_clean}%"), ServiceProvider.address.ilike(f"%{city_clean}%"),
) )
) )
@@ -392,8 +479,7 @@ async def search_providers(
select( select(
ServiceProfile.organization_id, ServiceProfile.organization_id,
ExpertiseTag.id, ExpertiseTag.id,
ExpertiseTag.name_hu, ExpertiseTag.name_i18n,
ExpertiseTag.name_en,
ExpertiseTag.level, ExpertiseTag.level,
ExpertiseTag.key, ExpertiseTag.key,
) )
@@ -412,8 +498,7 @@ async def search_providers(
org_categories_map[org_id] = [] org_categories_map[org_id] = []
org_categories_map[org_id].append({ org_categories_map[org_id].append({
"id": cat_row.id, "id": cat_row.id,
"name_hu": cat_row.name_hu, "name_i18n": cat_row.name_i18n,
"name_en": cat_row.name_en,
"level": cat_row.level, "level": cat_row.level,
"key": cat_row.key, "key": cat_row.key,
}) })
@@ -431,25 +516,53 @@ async def search_providers(
categories_out = [ categories_out = [
CategoryInfo( CategoryInfo(
id=cat["id"], id=cat["id"],
name_hu=cat["name_hu"], name_i18n=cat["name_i18n"],
name_en=cat["name_en"],
level=cat["level"], level=cat["level"],
key=cat["key"], key=cat["key"],
) )
for cat in row_categories for cat in row_categories
] ]
# P3: Egységes cím objektum összeállítása a flat mezőkből
row_address_zip = getattr(row, "address_zip", None)
row_address_street_name = getattr(row, "address_street_name", None)
row_address_street_type = getattr(row, "address_street_type", None)
row_address_house_number = getattr(row, "address_house_number", None)
row_city = row.city
row_plus_code = getattr(row, "plus_code", None)
# P0 BUGFIX (2026-07-10): Null-safe AddressOut construction.
# A Pydantic v2 from_attributes=True mode-ban néha hibát dob,
# ha minden mező None. Itt try-excepttel védjük.
address_detail = None
if any([row_address_zip, row_address_street_name, row_address_street_type, row_address_house_number, row_city]):
try:
address_detail = AddressOut(
zip=row_address_zip,
city=row_city,
street_name=row_address_street_name,
street_type=row_address_street_type,
house_number=row_address_house_number,
full_address_text=row.address,
)
except Exception as addr_e:
logger.warning(
f"Failed to create AddressOut for provider {row.id} ({row.name}): {addr_e}. "
f"Skipping address_detail."
)
address_detail = None
results.append( results.append(
ProviderSearchResult( ProviderSearchResult(
id=row.id, id=row.id,
name=row.name, name=row.name,
city=row.city, city=row_city,
address=row.address, address=row.address,
address_zip=getattr(row, "address_zip", None), address_zip=row_address_zip,
address_street_name=getattr(row, "address_street_name", None), address_street_name=row_address_street_name,
address_street_type=getattr(row, "address_street_type", None), address_street_type=row_address_street_type,
address_house_number=getattr(row, "address_house_number", None), address_house_number=row_address_house_number,
plus_code=getattr(row, "plus_code", None), plus_code=row_plus_code,
contact_phone=getattr(row, "contact_phone", None), contact_phone=getattr(row, "contact_phone", None),
contact_email=getattr(row, "contact_email", None), contact_email=getattr(row, "contact_email", None),
website=getattr(row, "website", None), website=getattr(row, "website", None),
@@ -457,6 +570,7 @@ async def search_providers(
categories=categories_out, categories=categories_out,
source=row.source, source=row.source,
is_verified=row.is_verified, is_verified=row.is_verified,
address_detail=address_detail,
) )
) )
@@ -754,8 +868,7 @@ async def _create_new_tags(
existing_stmt = select(ExpertiseTag).where( existing_stmt = select(ExpertiseTag).where(
or_( or_(
ExpertiseTag.key == _slugify(tag_name), ExpertiseTag.key == _slugify(tag_name),
ExpertiseTag.name_hu == tag_name, cast(ExpertiseTag.name_i18n, String).ilike(f"%{tag_name}%"),
ExpertiseTag.name_en == tag_name,
) )
) )
existing_result = await db.execute(existing_stmt) existing_result = await db.execute(existing_stmt)
@@ -773,15 +886,13 @@ async def _create_new_tags(
new_key = _slugify(tag_name) new_key = _slugify(tag_name)
new_tag = ExpertiseTag( new_tag = ExpertiseTag(
key=new_key, key=new_key,
name_hu=tag_name, name_i18n={"hu": tag_name, "en": tag_name},
name_en=tag_name,
level=3, level=3,
is_official=False, is_official=False,
category="user_created", category="user_created",
parent_id=None, parent_id=None,
path=None, path=None,
search_keywords=[tag_name.lower()], search_keywords=[tag_name.lower()],
description=f"User-created tag: {tag_name}",
) )
db.add(new_tag) db.add(new_tag)
await db.flush() await db.flush()
@@ -876,17 +987,23 @@ async def update_provider(
if staging: if staging:
# Migráljuk Organization-be # Migráljuk Organization-be
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
# ── AddressManager: Cím létrehozása a staging adataiból ──
staging_addr_in = AddressIn(
city=staging.city or "",
zip=data.address_zip or "",
street_name=data.address_street_name or "",
street_type=data.address_street_type or "",
house_number=data.address_house_number or "",
)
staging_address_id = await AddressManager.create_or_update(db, None, staging_addr_in)
org = Organization( org = Organization(
id=staging.id, id=staging.id,
name=staging.name[:100], name=staging.name[:100],
full_name=staging.name, full_name=staging.name,
display_name=staging.name[:50], display_name=staging.name[:50],
org_type=OrgType.service_provider, org_type=OrgType.service_provider,
address_city=staging.city, address_id=staging_address_id,
address_zip=data.address_zip,
address_street_name=data.address_street_name,
address_street_type=data.address_street_type,
address_house_number=data.address_house_number,
status="pending_verification", status="pending_verification",
is_verified=False, is_verified=False,
folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12], folder_slug=hashlib.md5(f"sp-{staging.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
@@ -910,6 +1027,16 @@ async def update_provider(
# 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk # 1c. Ha nincs staging-ben sem, ServiceProvider-ben keresünk
crowd = await db.get(ServiceProvider, provider_id) crowd = await db.get(ServiceProvider, provider_id)
if crowd: if crowd:
# ── AddressManager: Cím létrehozása a crowd adataiból ──
crowd_addr_in = AddressIn(
city=data.city or "",
zip=data.address_zip or "",
street_name=data.address_street_name or "",
street_type=data.address_street_type or "",
house_number=data.address_house_number or "",
)
crowd_address_id = await AddressManager.create_or_update(db, None, crowd_addr_in)
# Migráljuk Organization-be # Migráljuk Organization-be
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
org = Organization( org = Organization(
@@ -918,11 +1045,7 @@ async def update_provider(
full_name=crowd.name, full_name=crowd.name,
display_name=crowd.name[:50], display_name=crowd.name[:50],
org_type=OrgType.service_provider, org_type=OrgType.service_provider,
address_city=data.city, address_id=crowd_address_id,
address_zip=data.address_zip,
address_street_name=data.address_street_name,
address_street_type=data.address_street_type,
address_house_number=data.address_house_number,
status="pending_verification", status="pending_verification",
is_verified=False, is_verified=False,
folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12], folder_slug=hashlib.md5(f"cr-{crowd.id}-{uuid.uuid4()}".encode()).hexdigest()[:12],
@@ -946,26 +1069,41 @@ async def update_provider(
# 1d. Ha egyikben sem található # 1d. Ha egyikben sem található
raise ValueError(f"Provider with id={provider_id} not found") raise ValueError(f"Provider with id={provider_id} not found")
# 2. Organization mezők frissítése atomizált címmezőkkel # 2. Organization mezők frissítése AddressManager segítségével
# ADATVÉDELMI SZABÁLY (2026-06-17): Csak akkor írjuk felül a mezőt, # P3 UNIFIED ADDRESS REFACTOR (2026-07-01): A denormalizált címmezők
# ha a frontend explicit értéket küldött (nem None). Ez megakadályozza, # helyett az AddressManager.create_or_update() kezeli a címet.
# hogy a meglévő címadatok véletlenül null-ra állítódjanak, amikor # A flat mezők (city, address_zip, stb.) továbbra is támogatottak
# a felhasználó csak más mezőket szerkeszt. # a ProviderUpdateIn sémában a backward compatibility miatt.
org.name = data.name[:100] org.name = data.name[:100]
org.full_name = data.name org.full_name = data.name
org.display_name = data.name[:50] org.display_name = data.name[:50]
# ── Cím frissítése AddressManager segítségével ──
# Összegyűjtjük a flat mezőkből az AddressIn adatokat
addr_fields = {}
if data.city is not None: if data.city is not None:
org.address_city = data.city addr_fields["city"] = data.city
if data.address_zip is not None: if data.address_zip is not None:
org.address_zip = data.address_zip addr_fields["zip"] = data.address_zip
if data.address_street_name is not None: if data.address_street_name is not None:
org.address_street_name = data.address_street_name addr_fields["street_name"] = data.address_street_name
if data.address_street_type is not None: if data.address_street_type is not None:
org.address_street_type = data.address_street_type addr_fields["street_type"] = data.address_street_type
if data.address_house_number is not None: if data.address_house_number is not None:
org.address_house_number = data.address_house_number addr_fields["house_number"] = data.address_house_number
if data.plus_code is not None: if data.plus_code is not None:
org.plus_code = data.plus_code addr_fields["plus_code"] = data.plus_code
# Ha van address_detail, az elsőbbséget élvez a flat mezőkkel szemben
if data.address_detail is not None:
org.address_id = await AddressManager.create_or_update(
db, org.address_id, data.address_detail
)
elif addr_fields:
addr_in = AddressIn(**addr_fields)
org.address_id = await AddressManager.create_or_update(
db, org.address_id, addr_in
)
# 3. ServiceProfile lekérdezése (ha létezik) # 3. ServiceProfile lekérdezése (ha létezik)
profile_stmt = select(ServiceProfile).where( profile_stmt = select(ServiceProfile).where(

View File

@@ -5,14 +5,24 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.models.identity import User, Person, SocialAccount, UserRole from app.models.identity import User, Person, SocialAccount, UserRole
from app.services.security_service import security_service from app.services.security_service import security_service
from app.core.security import generate_secure_slug
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SocialAuthService: class SocialAuthService:
@staticmethod @staticmethod
async def get_or_create_social_user(db: AsyncSession, provider: str, social_id: str, email: str, first_name: str, last_name: str): async def get_or_create_social_user(
db: AsyncSession,
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. LOGIKA MEGŐRIZVE: Step 1 regisztráció slug és flotta nélkül.
Támogatja a meghívó kód (referral code) átvételét Google SSO regisztrációnál.
""" """
# 1. Meglévő fiók ellenőrzése # 1. Meglévő fiók ellenőrzése
stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id) stmt = select(SocialAccount).where(SocialAccount.provider == provider, SocialAccount.social_id == social_id)
@@ -26,11 +36,37 @@ class SocialAuthService:
user = (await db.execute(stmt_u)).scalar_one_or_none() user = (await db.execute(stmt_u)).scalar_one_or_none()
if not user: if not user:
new_person = Person(first_name=first_name or "Social", last_name=last_name or "User", is_active=False) # Meghívó keresése referral kód alapján
referred_by_id = None
if referred_by_code:
referrer_stmt = select(User).where(User.referral_code == referred_by_code)
referrer = (await db.execute(referrer_stmt)).scalar_one_or_none()
if referrer:
referred_by_id = referrer.id
logger.info(f"Social user {email} referred by {referrer.email} (ID: {referrer.id})")
else:
logger.warning(f"Referral code '{referred_by_code}' not found for social registration, ignoring.")
new_person = Person(
first_name=first_name or "Social",
last_name=last_name or "User",
is_active=False,
identity_docs={},
ice_contact={}
)
db.add(new_person) db.add(new_person)
await db.flush() await db.flush()
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False) referral_code = generate_secure_slug(8).upper()
user = User(
email=email,
person_id=new_person.id,
role=UserRole.USER,
is_active=False,
referral_code=referral_code,
referred_by_id=referred_by_id
)
db.add(user) db.add(user)
await db.flush() await db.flush()

View File

@@ -16,20 +16,20 @@ class SocialService:
""" """
async def create_service_provider(self, db: AsyncSession, obj_in: ServiceProviderCreate, user_id: int): 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) new_provider = ServiceProvider(**obj_in.model_dump(), added_by_user_id=user_id)
db.add(new_provider) db.add(new_provider)
await db.flush() await db.flush()
# Alappontszám az új beküldésért # Dinamikus pontjóváírás a point_rules táblából (ADD_NEW_PROVIDER)
await gamification_service.process_activity(db, user_id, 50, 10, f"New Provider: {new_provider.name}") await _award_provider_points(db, user_id, "ADD_NEW_PROVIDER")
await db.commit() await db.commit()
await db.refresh(new_provider) await db.refresh(new_provider)
return new_provider return new_provider
async def vote_for_provider(self, db: AsyncSession, voter_id: int, provider_id: int, vote_value: int): 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 # 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() 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 provider.status = ModerationStatus.rejected
await self._penalize_user(db, provider.added_by_user_id, provider.name) 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() await db.commit()
return {"status": "success", "score": provider.validation_score, "new_status": provider.status} 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 from app.services.gamification_service import gamification_service
if not user_id: return 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 # Aktuális verseny keresése és pontozása
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -91,8 +95,8 @@ class SocialService:
from app.services.gamification_service import gamification_service from app.services.gamification_service import gamification_service
if not user_id: return if not user_id: return
# JAVÍTVA: is_penalty=True hozzáadva a gamification híváshoz # 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, 50, 0, f"Rejected: {provider_name}", is_penalty=True) 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() user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user and hasattr(user, 'reputation_score'): if user and hasattr(user, 'reputation_score'):

View 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,
)

View File

@@ -6,7 +6,7 @@ A rendszerparaméterek prioritásos felülbírálást támogatnak: User > Region
import logging import logging
from typing import Optional, Any, Dict from typing import Optional, Any, Dict
from sqlalchemy import select, func # HOZZÁADVA: func a NOW() híváshoz from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -60,7 +60,7 @@ class SystemService:
for scope_level, scope_id in scopes: for scope_level, scope_id in scopes:
stmt = select(SystemParameter).where( stmt = select(SystemParameter).where(
SystemParameter.key == key, SystemParameter.key == key,
SystemParameter.scope_level == scope_level, SystemParameter.scope_level == scope_level.value,
SystemParameter.is_active == True, SystemParameter.is_active == True,
) )
if scope_id is not None: if scope_id is not None:
@@ -97,40 +97,63 @@ class SystemService:
""" """
Létrehoz vagy frissít egy rendszerparamétert a megadott scope-ban. Létrehoz vagy frissít egy rendszerparamétert a megadott scope-ban.
Ha már létezik ugyanazzal a kulccsal, scope_level-lel és scope_id-vel, felülírja. Ha már létezik ugyanazzal a kulccsal, scope_level-lel és scope_id-vel, felülírja.
Megjegyzés: PostgreSQL-ben a UNIQUE constraint nem blokkolja a NULL scope_id-jű
duplikátumokat (mert NULL != NULL). Ezért kétlépéses megközelítést használunk:
először megpróbáljuk frissíteni a meglévő rekordot, és ha nem találjuk, beszúrjuk.
""" """
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
# UPSERT logika: ON CONFLICT DO UPDATE # 1. lépés: Próbáljuk megkeresni a meglévő rekordot
stmt_find = select(SystemParameter).where(
SystemParameter.key == key,
SystemParameter.scope_level == scope_level.value,
SystemParameter.is_active == True,
)
if scope_id is not None:
stmt_find = stmt_find.where(SystemParameter.scope_id == scope_id)
else:
stmt_find = stmt_find.where(SystemParameter.scope_id.is_(None))
result = await db.execute(stmt_find)
existing = result.scalar_one_or_none()
if existing:
# 2a. lépés: Frissítjük a meglévő rekordot
existing.value = value
existing.category = category
existing.description = description
existing.last_modified_by = last_modified_by
existing.updated_at = func.now()
await db.commit()
return existing
else:
# 2b. lépés: Beszúrunk egy új rekordot
insert_stmt = insert(SystemParameter).values( insert_stmt = insert(SystemParameter).values(
key=key, key=key,
value=value, value=value,
scope_level=scope_level, scope_level=scope_level.value,
scope_id=scope_id, scope_id=scope_id,
category=category, category=category,
description=description, description=description,
last_modified_by=last_modified_by, last_modified_by=last_modified_by,
is_active=True, is_active=True,
) )
upsert_stmt = insert_stmt.on_conflict_do_update( await db.execute(insert_stmt)
constraint="uix_param_scope",
set_=dict(
value=value,
category=category,
description=description,
last_modified_by=last_modified_by,
updated_at=func.now(),
),
)
await db.execute(upsert_stmt)
await db.commit() await db.commit()
# Visszaolvassuk a létrehozott/frissített rekordot # Visszaolvassuk a létrehozott rekordot
stmt = select(SystemParameter).where( stmt_read = select(SystemParameter).where(
SystemParameter.key == key, SystemParameter.key == key,
SystemParameter.scope_level == scope_level, SystemParameter.scope_level == scope_level.value,
SystemParameter.scope_id == scope_id, SystemParameter.is_active == True,
) )
result = await db.execute(stmt) if scope_id is not None:
stmt_read = stmt_read.where(SystemParameter.scope_id == scope_id)
else:
stmt_read = stmt_read.where(SystemParameter.scope_id.is_(None))
result = await db.execute(stmt_read)
param = result.scalar_one() param = result.scalar_one()
return param return param

View 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

View 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())

View File

@@ -6,7 +6,7 @@ import httpx
from urllib.parse import quote from urllib.parse import quote
from sqlalchemy import select, text from sqlalchemy import select, text
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from app.models.marketplace.service import ServiceStaging # JAVÍTOTT IMPORT ÚTVONAL! from app.models.marketplace.staged_data import ServiceStaging # JAVÍTVA: helyes import a trust_score oszloppal rendelkező modellhez
import re import re
# Logolás MB 2.0 szabvány szerint # Logolás MB 2.0 szabvány szerint
@@ -91,11 +91,10 @@ class OSMScout:
if existing is None: if existing is None:
full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,") full_addr = f"{postcode} {city}, {tags.get('addr:street', '')} {tags.get('addr:housenumber', '')}".strip(" ,")
# Bővített JSON a nyers adatokhoz, mert a modelled nem tartalmazza a source és trust oszlopokat # P0 FIX: trust_score most már az ORM objektum mezője, nem csak JSON-ben
raw_payload = { raw_payload = {
"osm_tags": tags, "osm_tags": tags,
"source": "osm_scout_v2", "source": "osm_scout_v2",
"trust_score": 20
} }
new_entry = ServiceStaging( new_entry = ServiceStaging(
@@ -105,6 +104,7 @@ class OSMScout:
full_address=full_addr, full_address=full_addr,
fingerprint=f_print, fingerprint=f_print,
status="pending", status="pending",
trust_score=20, # P0 FIX: BOT_BASE_SCORE az ORM objektumon
raw_data=raw_payload raw_data=raw_payload
) )
db.add(new_entry) db.add(new_entry)

View 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())

View 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())

View File

@@ -72,7 +72,11 @@ async def process_expired_subscriptions(db: AsyncSession) -> dict:
user_id=user.id, user_id=user.id,
amount=0.0, amount=0.0,
entry_type=LedgerEntryType.DEBIT, entry_type=LedgerEntryType.DEBIT,
wallet_type=WalletType.SYSTEM, # JAVÍTÁS: WalletType.SYSTEM nem létező enum érték.
# A WalletType csak EARNED, PURCHASED, SERVICE_COINS, VOUCHER értékeket támogat.
# Mivel ez csak egy subscription lejárati naplóbejegyzés (amount=0),
# a SERVICE_COINS a legmegfelelőbb alapértelmezett választás.
wallet_type=WalletType.SERVICE_COINS,
transaction_type="SUBSCRIPTION_EXPIRED", transaction_type="SUBSCRIPTION_EXPIRED",
description=f"Előfizetés lejárt: {old_plan} → FREE", description=f"Előfizetés lejárt: {old_plan} → FREE",
reference_type="subscription", reference_type="subscription",

View 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())

View 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())

View 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())

View 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())

View File

@@ -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

View 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())

View File

@@ -58,7 +58,7 @@ async def seed():
entry = BodyTypeDictionary( entry = BodyTypeDictionary(
vehicle_class=bt["vehicle_class"], vehicle_class=bt["vehicle_class"],
code=bt["code"], code=bt["code"],
name_hu=bt["name_hu"], name_i18n={"hu": bt["name_hu"]},
) )
session.add(entry) session.add(entry)

View File

@@ -1030,11 +1030,9 @@ async def _flatten_and_insert(db, parent_id: int | None, level: int, path_prefix
tag = ExpertiseTag( tag = ExpertiseTag(
key=key, key=key,
name_hu=node["name_hu"], name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
name_en=node["name_en"],
category=node.get("category", ""), category=node.get("category", ""),
search_keywords=node.get("search_keywords", []), search_keywords=node.get("search_keywords", []),
description=node.get("description", ""),
is_official=True, is_official=True,
level=level, level=level,
parent_id=parent_id, parent_id=parent_id,
@@ -1095,11 +1093,9 @@ async def seed_expertise_taxonomy():
for key, node in UNIVERSAL_CATEGORIES.items(): for key, node in UNIVERSAL_CATEGORIES.items():
tag = ExpertiseTag( tag = ExpertiseTag(
key=key, key=key,
name_hu=node["name_hu"], name_i18n={"hu": node["name_hu"], "en": node["name_en"]},
name_en=node["name_en"],
category=node.get("category", ""), category=node.get("category", ""),
search_keywords=node.get("search_keywords", []), search_keywords=node.get("search_keywords", []),
description=node.get("description", ""),
is_official=True, is_official=True,
level=1, level=1,
parent_id=None, parent_id=None,

View 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()

View 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ů"
}
}
}

View 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"
}
}
}

View File

@@ -85,5 +85,47 @@
"CERTIFICATE_VALIDITY": "Registration Certificate Validity", "CERTIFICATE_VALIDITY": "Registration Certificate Validity",
"DOCUMENT_NUMBER": "Vehicle Registration Document Number", "DOCUMENT_NUMBER": "Vehicle Registration Document Number",
"TITLE": "Registration Certificate & Vehicle Document" "TITLE": "Registration Certificate & Vehicle Document"
},
"GAMIFICATION": {
"SUBMIT_SERVICE": {
"SELF_DEFENSE_BLOCKED": "You cannot submit new service data due to self-defense restriction.",
"SUCCESS": "Service submitted to the system for analysis!",
"POINTS_ERROR": "Error during scoring: {error}",
"SUBMIT_ERROR": "Error during submission: {error}"
},
"QUIZ": {
"ALREADY_PLAYED": "You have already played the daily quiz today. Come back tomorrow!",
"NO_QUIZ_AVAILABLE": "No quiz available for today.",
"ALREADY_COMPLETED": "Daily quiz already marked as completed today.",
"COMPLETED_SUCCESS": "Daily quiz marked as completed for today.",
"QUESTION_NOT_FOUND": "Question not found.",
"POINTS_FAILED": "Failed to award quiz points: {error}"
},
"BADGE": {
"NOT_FOUND": "Badge not found.",
"ALREADY_AWARDED": "Badge already awarded to this user.",
"AWARDED_SUCCESS": "Badge '{badge_name}' awarded to user.",
"POINTS_FAILED": "Failed to award badge points: {error}"
},
"SEASON": {
"NOT_FOUND": "Season not found.",
"NO_ACTIVE": "No active season found."
},
"ACHIEVEMENTS": {
"XP_NOVICE": "Novice",
"XP_APPRENTICE": "Apprentice",
"XP_EXPERT": "Expert",
"XP_MASTER": "Master",
"XP_NOVICE_DESC": "Earn 100 XP",
"XP_APPRENTICE_DESC": "Earn 500 XP",
"XP_EXPERT_DESC": "Earn 2000 XP",
"XP_MASTER_DESC": "Earn 5000 XP",
"QUIZ_BEGINNER": "Quiz Beginner",
"QUIZ_ENTHUSIAST": "Quiz Enthusiast",
"QUIZ_MASTER": "Quiz Master",
"QUIZ_BEGINNER_DESC": "Earn 50 quiz points",
"QUIZ_ENTHUSIAST_DESC": "Earn 200 quiz points",
"QUIZ_MASTER_DESC": "Earn 500 quiz points"
}
} }
} }

View 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"
}
}
}

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