RABC fejlesztése és beélesítése hibák kijavításával
This commit is contained in:
505
.roo/history.md
505
.roo/history.md
@@ -446,3 +446,508 @@ Két P0 szintű probléma javítása: (1) "Univerzális Szolgáltatások" blokkb
|
|||||||
- Seed script: ✅ PASS (134 rekord, ebből 12 univerzális: 4 Level 1 + 8 Level 2)
|
- Seed script: ✅ PASS (134 rekord, ebből 12 univerzális: 4 Level 1 + 8 Level 2)
|
||||||
- Autocomplete API `q=Benzinkút`: ✅ PASS (1 találat: Benzinkút, level=2)
|
- Autocomplete API `q=Benzinkút`: ✅ PASS (1 találat: Benzinkút, level=2)
|
||||||
- Autocomplete API `q=Étterem`: ✅ PASS (3 találat: Étterem, Gyorsétterem / Büfé, Étterem / Büfé)
|
- Autocomplete API `q=Étterem`: ✅ PASS (3 találat: Étterem, Gyorsétterem / Büfé, Étterem / Büfé)
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 Critical: Financial Math Reversal (Gross-First Accounting)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A Phase 2 Financial Architecture felülvizsgálata során a Vezető Tervező (Architect) jelezte, hogy a pénzügyi logika fordítva van: az EU-s számvitelben a BRUTTÓ (Gross) az elsődleges forrásigazság (Source of Truth), nem a Nettó. A teljes matematika megfordítása: `_calculate_gross_from_net()` → `_calculate_net_from_gross()`, `amount_gross` kötelező, `amount_net` opcionális.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. SCHEMA - [`asset_cost.py`](backend/app/schemas/asset_cost.py:9):**
|
||||||
|
- `amount_gross` áthelyezve kötelező mezővé (`Field(..., description="Bruttó összeg — KÖTELEZŐ, elsődleges forrás")`)
|
||||||
|
- `amount_net` opcionális (`Optional[Decimal] = Field(None, ...)`)
|
||||||
|
- `@model_validator(mode='after')` hozzáadva: ha `amount_net=None` és `vat_rate=None`, akkor `amount_net = amount_gross` és `vat_rate = 0`
|
||||||
|
|
||||||
|
**2. SCHEMA - [`asset_event.py`](backend/app/schemas/asset_event.py:69):**
|
||||||
|
- `model_validate` frissítve: `cost_amount` elsődlegesen `amount_gross`-ból, fallback `amount_net`-ből
|
||||||
|
|
||||||
|
**3. SCHEMA - [`asset.py`](backend/app/schemas/asset.py:415):**
|
||||||
|
- `model_validate` frissítve: `cost_amount` elsődlegesen `amount_gross`-ból, fallback `amount_net`-ből
|
||||||
|
|
||||||
|
**4. ENDPOINT - [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:49):**
|
||||||
|
- `_calculate_gross_from_net()` → `_calculate_net_from_gross(amount_gross, vat_rate)` átnevezve
|
||||||
|
- Képlet megfordítva: `net = gross / (1 + vat_rate/100)`
|
||||||
|
- VAT handling logika átírva: ha `vat_rate` adott és `amount_net` nincs, net kiszámítása gross-ból
|
||||||
|
- Ha `amount_net` és `vat_rate` is adott, `vat_rate` felülírva a back-számított értékkel
|
||||||
|
- Ha egyik sincs, `amount_net = amount_gross`, `vat_rate = 0`
|
||||||
|
|
||||||
|
**5. ENDPOINT - [`assets.py`](backend/app/api/v1/endpoints/assets.py):**
|
||||||
|
- `create_maintenance_record`: `cost` mező gross-ként kezelve, `amount_gross` és `amount_net` is a cost értékkel töltve
|
||||||
|
- `create_asset_event`: `cost_amount` gross-ként kezelve
|
||||||
|
- `list_asset_costs`: response-ban `amount_gross` az `amount_net` előtt
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Python szintaxis ellenőrzés: ✅ 5/5 fájl OK
|
||||||
|
- Sync engine: ✅ 1087/1087 OK, 0 fix, 0 extra
|
||||||
|
- Unit math tesztek: ✅ 5/5 passed (Gross=12700→Net=10000 27% VAT, Gross=10500→Net=10000 5% VAT, stb.)
|
||||||
|
- E2E API tesztek: ✅ 3/3 passed (Gross only, Gross+27%, Gross+5%)
|
||||||
|
- A teljes Gross-first matematika helyesen működik: a bruttó összeg a Source of Truth, a nettó és ÁFA visszaszámolva
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 Frontend Cost Payload Audit & Fix (Gross-First Compliance)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A backend már szigorúan Gross-first rendszer a pénzügyi adatokra, de a frontend űrlapok még `amount_net`-et küldtek a payloadban, ami `amount_gross: null`-t eredményezett az adatbázisban. Teljes audit és javítás.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. FRONTEND - [`cost.ts`](frontend/src/stores/cost.ts:8):**
|
||||||
|
- `AddExpensePayload` interface: `amount_net` → `amount_gross` (elsődleges mező)
|
||||||
|
- `ExpenseResponse` interface: `amount_net` → `amount_gross`
|
||||||
|
- `addExpense()` body builder: `amount_net: payload.amount_net` → `amount_gross: payload.amount_gross`
|
||||||
|
|
||||||
|
**2. FRONTEND - [`ComplexExpenseModal.vue`](frontend/src/components/dashboard/ComplexExpenseModal.vue:268):**
|
||||||
|
- Payload `amount_net: form.amount` → `amount_gross: form.amount`
|
||||||
|
|
||||||
|
**3. FRONTEND - [`SimpleFuelModal.vue`](frontend/src/components/dashboard/SimpleFuelModal.vue:193):**
|
||||||
|
- Payload `amount_net: form.amount` → `amount_gross: form.amount`
|
||||||
|
|
||||||
|
**4. BACKEND - [`asset_cost.py`](backend/app/schemas/asset_cost.py:27):**
|
||||||
|
- `@model_validator(mode='before')` safety net hozzáadva: ha a bejövő JSON `amount_net`-et tartalmaz, de `amount_gross` hiányzik, automatikusan átmásolja az `amount_net` értékét az `amount_gross`-ba. Ez véd a régebbi cache-elt böngészők ellen.
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Vite build: ✅ Sikeres (216 module transformed, 0 error)
|
||||||
|
- Backend schema import: ✅ Sikeres
|
||||||
|
- Safety net tesztek: ✅ 3/3 passed
|
||||||
|
- `amount_net` only → `amount_gross` auto-copy: ✅
|
||||||
|
- Both fields present → no interference: ✅
|
||||||
|
- `amount_gross` only → no change: ✅
|
||||||
|
|
||||||
|
### 🔍 Utólagos Adatjavítás (Data Fix)
|
||||||
|
A felhasználó jelentése alapján az UOK795 (Honda CB1000R) járműnél az utolsó 2 rögzített költség 0 Ft-ot mutatott. Vizsgálat után kiderült:
|
||||||
|
|
||||||
|
**Gyökér ok:** A P0 audit előtt rögzített rekordoknál a frontend `amount_net`-et küldött, de a backend `amount_gross`-t 0-ra állította (a `validate_gross_first` `mode='after'` validátor `None` → `Decimal("0")`-ra állította a hiányzó gross mezőt).
|
||||||
|
|
||||||
|
**Érintett rekordok (országosan 2 db):**
|
||||||
|
1. `3468e81a` - UOK795 (Honda CB1000R) - 12 000 Ft tankolás → `amount_gross` javítva 0→12000
|
||||||
|
2. `ea0395ea` - QWE123 (APRILIA af1) - 4 000 Ft → `amount_gross` javítva 0→4000
|
||||||
|
|
||||||
|
**Javítás:** `UPDATE vehicle.asset_costs SET amount_gross = amount_net, vat_rate = 0.00 WHERE amount_gross=0 AND amount_net>0`
|
||||||
|
|
||||||
|
## 2026-06-18 - Jogosultságkezelés Audit (Permission System Audit)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Teljes körű audit a jogosultsági szintek kezeléséről, kiterjedve az adatbázis táblákra, a backend kódra és a frontend rétegre.
|
||||||
|
|
||||||
|
### 🔍 Vizsgált Területek
|
||||||
|
1. **Adatbázis (6 tábla):** identity.users, fleet.org_roles, fleet.organization_members, system.system_parameters, system.pending_actions, system.subscription_tiers
|
||||||
|
2. **Backend:** UserRole enum, DEFAULT_RANK_MAP, RBAC osztály, get_current_admin, scoped RBAC, Dual Control (SecurityService), JWT token
|
||||||
|
3. **Frontend:** authStore.ts (UserRole típus), Router Guard
|
||||||
|
|
||||||
|
### 🔴 Feltárt Kritikus Problémák
|
||||||
|
- **P1:** fleet.org_roles tábla ÜRES - dinamikus szerepkör rendszer nem seedelt
|
||||||
|
- **P2:** DEFAULT_RANK_MAP inkonzisztens a UserRole enum-mal (6 rang soha nem kerül kiosztásra)
|
||||||
|
- **P3:** Enum duplikáció az adatbázisban (kis/nagybetű variánsok)
|
||||||
|
- **P4:** Frontend/backend UserRole eltérés (5 vs 10 role)
|
||||||
|
- **P5:** parameter_scope enum 3 különböző sémában definiálva
|
||||||
|
|
||||||
|
### 📄 Dokumentáció
|
||||||
|
- [`docs/permission_system_audit_2026-06-18.md`](docs/permission_system_audit_2026-06-18.md) - Teljes audit jelentés Mermaid diagrammal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-18 - RBAC Phase 1: Foundation & Seeding (P0 Critical)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
RBAC Phase 1 migráció Fine-Grained, Capability-Driven architektúrára. System-level (UserRole) és Organization-level (OrgUserRole) szerepkörök szétválasztása, SYSTEM_CAPABILITIES_MATRIX létrehozása, fleet.org_roles tábla seedelése.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. [`UserRole`](backend/app/models/identity/identity.py:24) enum tisztítás:**
|
||||||
|
- 10 régi érték helyett 6 system-level role: `SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER`
|
||||||
|
- Eltávolítva: `region_admin, country_admin, sales_agent, service_owner, fleet_manager, driver`
|
||||||
|
- Hozzáadva: `SALES_REP, SERVICE_MGR`
|
||||||
|
- Értékek UPPERCASE-re változtatva
|
||||||
|
|
||||||
|
**2. [`SYSTEM_CAPABILITIES_MATRIX`](backend/app/core/capabilities.py:1) létrehozása:**
|
||||||
|
- Új fájl: `backend/app/core/capabilities.py`
|
||||||
|
- `Capability` osztály konstansokkal (~26 capability key)
|
||||||
|
- `SYSTEM_CAPABILITIES_MATRIX` dict minden role-hoz boolean capability mátrix
|
||||||
|
- Segédfüggvények: `get_capabilities_for_role()`, `role_has_capability()`
|
||||||
|
|
||||||
|
**3. [`OrgUserRole`](backend/app/models/marketplace/organization.py:27) enum frissítés:**
|
||||||
|
- Új értékek: `OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER`
|
||||||
|
- Eltávolítva: `MANAGER, MEMBER, AGENT`
|
||||||
|
|
||||||
|
**4. [`DEFAULT_RANK_MAP`](backend/app/core/security.py:17) tisztítás:**
|
||||||
|
- Csak az új 6 system-level role + GUEST maradt
|
||||||
|
- SERVICE_MGR(40), SALES_REP(30) hozzáadva
|
||||||
|
|
||||||
|
**5. Függő kódok frissítése:**
|
||||||
|
- [`deps.py`](backend/app/api/deps.py:106) - `check_resource_access()` és `get_current_admin()` frissítve
|
||||||
|
- [`rbac.py`](backend/app/core/rbac.py:7) - `UserRole.SUPERADMIN` referencia javítva
|
||||||
|
- [`billing_engine.py`](backend/app/services/billing_engine.py:37) - `RBAC_DISCOUNTS` tisztítva
|
||||||
|
|
||||||
|
**6. PostgreSQL enum migráció:**
|
||||||
|
- [`migrate_rbac_phase1_enums.py`](backend/app/scripts/migrate_rbac_phase1_enums.py) - CREATE TYPE → ALTER COLUMN → DROP TYPE minta
|
||||||
|
- Régi értékek mapelése: `superadmin→SUPERADMIN, admin→ADMIN, moderator→MODERATOR, sales_agent→SALES_REP, user/service_owner/fleet_manager/driver→USER`
|
||||||
|
- ✅ Sikeres: `identity.userrole` enum 6 értékkel, meglévő user role-ok helyesen átkonvertálva
|
||||||
|
|
||||||
|
**7. [`seed_org_roles.py`](backend/app/scripts/seed_org_roles.py) - fleet.org_roles seedelés:**
|
||||||
|
- 5 org role: OWNER(100), ADMIN(80), ACCOUNTANT(60), DRIVER(40), VIEWER(20)
|
||||||
|
- Minden role JSON permissions objektummal
|
||||||
|
- ✅ Sikeres: 5 rekord beszúrva
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Schema sync: **1087 items OK, 0 fixed, 0 shadow data**
|
||||||
|
- `identity.userrole` enum: `SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER`
|
||||||
|
- `fleet.org_roles`: 5 rekord (OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER)
|
||||||
|
- Meglévő user role-ok: `USER, SUPERADMIN, ADMIN` - helyesen konvertálva
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 EMERGENCY: Superadmin User ID 1 Recreated from Scratch
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A hard-deletelt User ID 1 rekord fizikai újraélesztése: SUPERADMIN joggal, a meglévő Person ID 1-hez kapcsolva, és a Profibot Test Fleet (Organization ID 15) garázshoz OWNER szerepkörrel hozzárendelve.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. Jelszó hash generálás:**
|
||||||
|
- [`security.py`](backend/app/core/security.py:14): `get_password_hash('Superadmin123!')` használatával bcrypt hash generálva
|
||||||
|
- Hash: `$2b$12$ozXD07/qiFl4eKg.RbadDeGcnMukY3tOpBPEgOza7VsqcbuUW7iq2`
|
||||||
|
- Verifikáció: `verify_password('Superadmin123!', hash)` → `True`
|
||||||
|
|
||||||
|
**2. Email konfliktus feloldása:**
|
||||||
|
- User ID 29 (korábbi `superadmin@profibot.hu`) átnevezve → `superadmin_old@profibot.hu`
|
||||||
|
|
||||||
|
**3. User ID 1 létrehozása (`identity.users`):**
|
||||||
|
- `INSERT INTO identity.users (id=1, email='superadmin@profibot.hu', role='SUPERADMIN', person_id=1, ...)`
|
||||||
|
- Kapcsolat a Person ID 1-hez (Super Admin személy)
|
||||||
|
|
||||||
|
**4. Garázs hozzárendelés (`fleet.organization_members`):**
|
||||||
|
- `INSERT INTO fleet.organization_members (organization_id=15, user_id=1, person_id=1, role='OWNER', ...)`
|
||||||
|
- Organization: "Profibot Test Fleet" (ID 15)
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- `identity.users WHERE id=1`: `email=superadmin@profibot.hu, role=SUPERADMIN, person_id=1, is_active=true`
|
||||||
|
- `fleet.organization_members WHERE user_id=1`: `org_id=15 (Profibot Test Fleet), role=OWNER, status=active`
|
||||||
|
- `identity.persons WHERE id=1`: `last_name=Admin, first_name=Super` (meglévő személy)
|
||||||
|
- Jelszó hash verifikáció: `True`
|
||||||
|
- Belépési adatok: `superadmin@profibot.hu` / `Superadmin123!`
|
||||||
|
|
||||||
|
## 2026-06-18: P0 CRITICAL DEBUG - 500 Internal Server Error on POST /api/v1/auth/login (UserRole UPPERCASE migration fix)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A Phase 1 RBAC Enum UPPERCASE migráció után a `/api/v1/auth/login` endpoint 500-as hibát dobott `LookupError: 'ADMIN' is not among the defined enum values` üzenettel. A régi konténer cache-elt SQLAlchemy metaadatokat használt, és a `billing_engine.py`-ben lévő `UserRole.user` (kisbetűs) hivatkozás miatt az új konténer sem indult el.
|
||||||
|
|
||||||
|
### 🔧 Változtatások (25 javítás 14 fájlban)
|
||||||
|
|
||||||
|
**Kritikus javítások (startup-blokkoló):**
|
||||||
|
- [`backend/app/services/billing_engine.py:70`](backend/app/services/billing_engine.py:70) — `UserRole.user` → `UserRole.USER` (2 helyen)
|
||||||
|
- [`backend/app/services/social_auth_service.py:33`](backend/app/services/social_auth_service.py:33) — `UserRole.user` → `UserRole.USER`
|
||||||
|
- [`backend/app/services/search_service.py:41`](backend/app/services/search_service.py:41) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
|
||||||
|
- [`backend/app/services/auth_service.py:110`](backend/app/services/auth_service.py:110) — `UserRole.user` → `UserRole.USER`
|
||||||
|
- [`backend/app/api/v1/endpoints/system_parameters.py:125`](backend/app/api/v1/endpoints/system_parameters.py:125) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
|
||||||
|
- [`backend/app/schemas/admin.py:23`](backend/app/schemas/admin.py:23) — `UserRole.admin, UserRole.superadmin` → `UserRole.ADMIN, UserRole.SUPERADMIN`
|
||||||
|
- [`backend/app/api/v1/endpoints/finance_admin.py:27`](backend/app/api/v1/endpoints/finance_admin.py:27) — `UserRole.superadmin, UserRole.admin` → `UserRole.SUPERADMIN, UserRole.ADMIN`
|
||||||
|
- [`backend/app/api/v1/endpoints/admin.py:195`](backend/app/api/v1/endpoints/admin.py:195) — `UserRole.superadmin` → `UserRole.SUPERADMIN` (2 helyen)
|
||||||
|
- [`backend/app/api/v1/endpoints/security.py:39`](backend/app/api/v1/endpoints/security.py:39) — `UserRole.admin, UserRole.superadmin` → `UserRole.ADMIN, UserRole.SUPERADMIN` (5 helyen)
|
||||||
|
- [`backend/app/core/security.py:56`](backend/app/core/security.py:56) — `DEFAULT_RANK_MAP` már helyes (UPPERCASE kulcsok)
|
||||||
|
- [`backend/app/tests/e2e/test_admin_security.py:21`](backend/app/tests/e2e/test_admin_security.py:21) — `UserRole.user` → `UserRole.USER`, `UserRole.admin` → `UserRole.ADMIN`
|
||||||
|
- [`backend/app/scripts/seed_integration_data.py:158`](backend/app/scripts/seed_integration_data.py:158) — `UserRole.admin` → `UserRole.ADMIN`, `UserRole.superadmin` → `UserRole.SUPERADMIN`
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- `POST /api/v1/auth/login` with `admin@profibot.hu` / `Admin123!` → **200 OK**
|
||||||
|
- JWT token payload: `{"sub": "2", "role": "ADMIN", "rank": 90, "scope_level": "system", "scope_id": "2"}`
|
||||||
|
- Konténer indítás: sikeres (nem omlik össze `AttributeError`-rel)
|
||||||
|
- 3 db seed script nem javítható (EACCES jogosultsági hiba) — nem kritikus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-18 - RBAC Phase 2: Capability Dependencies (Kapuőrök)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
P0 kritikus feladat: JSONB-alapú capability dependency-k bevezetése a hardcoded szerepkör-ellenőrzések helyett. Két új FastAPI dependency: `RequireSystemCapability` (system-level) és `RequireOrgCapability` (org-level).
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. MODELL - [`organization.py`](backend/app/models/marketplace/organization.py:63):**
|
||||||
|
- `permissions` JSONB oszlop hozzáadva az `OrgRole` modellhez (`server_default='{}'::jsonb`)
|
||||||
|
|
||||||
|
**2. DEPENDENCY - [`deps.py`](backend/app/api/deps.py:184):**
|
||||||
|
- [`RequireSystemCapability(capability_name: str)`](backend/app/api/deps.py:184) — Ellenőrzi a `SYSTEM_CAPABILITIES_MATRIX`-ot a system-level role-okhoz. SUPERADMIN automatikusan átmegy.
|
||||||
|
- [`RequireOrgCapability(capability_name: str)`](backend/app/api/deps.py:221) — Lekérdezi a `fleet.org_roles.permissions` JSONB oszlopot. Fallback: `OrganizationMember.permissions`. SUPERADMIN bypass.
|
||||||
|
|
||||||
|
**3. EXPENSES - [`expenses.py`](backend/app/api/v1/endpoints/expenses.py:46):**
|
||||||
|
- `AUTO_APPROVED_ROLES = {"OWNER", "ADMIN"}` eltávolítva
|
||||||
|
- `_check_org_capability()` helper hozzáadva — JSONB-ből olvassa a `can_approve_expense` flag-et
|
||||||
|
- Költség státusz meghatározása: `can_approve` → APPROVED, fuel (category_id=1) → APPROVED, egyéb → PENDING_APPROVAL
|
||||||
|
|
||||||
|
**4. ASSETS - [`assets.py`](backend/app/api/v1/endpoints/assets.py:46):**
|
||||||
|
- `AUTO_APPROVED_ROLES = {"OWNER", "ADMIN"}` eltávolítva
|
||||||
|
- `_check_org_capability()` helper hozzáadva
|
||||||
|
- `create_asset_event` végpontban az esemény státusz JSONB-alapú ellenőrzésre váltva
|
||||||
|
|
||||||
|
**5. SEED - [`seed_org_roles.py`](backend/app/scripts/seed_org_roles.py:154):**
|
||||||
|
- `permissions` paraméter hozzáadva az `OrgRole` konstruktorhoz
|
||||||
|
- Meglévő role-ok permissions mezőjének frissítése (RBAC Phase 2 update loop)
|
||||||
|
|
||||||
|
### 🗄️ Adatbázis
|
||||||
|
- `sync_engine` futtatva: `permissions` JSONB oszlop létrehozva a `fleet.org_roles` táblában
|
||||||
|
- 5 org role permissions adata feltöltve: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Backend indítás: sikeres (nincs import error)
|
||||||
|
- `POST /api/v1/expenses/` ADMIN userrel → **201 CREATED**, `expense_status: "APPROVED"` (mert ADMIN rendelkezik `can_approve_expense: True` képességgel)
|
||||||
|
- `fleet.org_roles` JSONB permissions: mind az 5 role helyesen tárolva
|
||||||
|
|
||||||
|
## 2026-06-18: RBAC Phase 3 - Frontend Reactivity & UI Capabilities Integration
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
P0 kritikus feladat: Frontend reaktivitás javítása (F5 Bug) és UI képesség-alapú megjelenítés bevezetése.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**Task 1 - F5 Bug javítás (Direct Mutation / Explicit Fetch pattern):**
|
||||||
|
- [`CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue:110): `cost-saved` event emit hozzáadva, ami a költség mentése után értesíti a szülőt
|
||||||
|
- [`DashboardView.vue`](frontend/src/views/DashboardView.vue:206): `refreshCostsTrigger` ref (számláló) és `onCostSaved()` handler hozzáadva
|
||||||
|
- [`DashboardView.vue`](frontend/src/views/DashboardView.vue:130): `:refresh-trigger="refreshCostsTrigger"` prop átadva a VehicleDetailModal-nak
|
||||||
|
- [`VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue:778): `refreshTrigger` prop és `watch` hozzáadva a költségek újratöltéséhez
|
||||||
|
|
||||||
|
**Task 2 - Backend /users/me exposure:**
|
||||||
|
- [`schemas/user.py`](backend/app/schemas/user.py:70): `system_capabilities: Dict[str, bool]` és `org_capabilities: Dict[str, Dict[str, bool]]` mezők hozzáadva a `UserResponse`-hoz
|
||||||
|
- [`endpoints/users.py`](backend/app/api/v1/endpoints/users.py:106): `_build_user_response()` kiegészítve `system_capabilities` feloldással a `SYSTEM_CAPABILITIES_MATRIX`-ból
|
||||||
|
- [`endpoints/users.py`](backend/app/api/v1/endpoints/users.py:205): `read_users_me()` kiegészítve `org_capabilities` aszinkron feloldásával az aktív org tagságokból
|
||||||
|
|
||||||
|
**Task 3 - Frontend Auth Store capability helpers:**
|
||||||
|
- [`stores/auth.ts`](frontend/src/stores/auth.ts:73): `UserProfile` interface kiegészítve `system_capabilities` és `org_capabilities` mezőkkel
|
||||||
|
- [`stores/auth.ts`](frontend/src/stores/auth.ts:111): `hasSystemCapability(capability)` helper - ellenőrzi a rendszerszintű képességet
|
||||||
|
- [`stores/auth.ts`](frontend/src/stores/auth.ts:122): `hasOrgCapability(orgId, capability)` helper - ellenőrzi a szervezeti képességet
|
||||||
|
- [`stores/auth.ts`](frontend/src/stores/auth.ts:133): `getOrgCapabilities(orgId)` helper - visszaadja az összes szervezeti képességet
|
||||||
|
|
||||||
|
**Task 4 - UI Integration Pilot:**
|
||||||
|
- [`VehicleDetailModal.vue`](frontend/src/components/vehicle/VehicleDetailModal.vue:741): "Szerkesztés" gomb `v-if`-je kiegészítve `authStore.hasOrgCapability(orgId, 'can_approve_expense')` ellenőrzéssel
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Backend Python szintaxis: `user.py` és `users.py` hibátlanul lefordul
|
||||||
|
- Frontend TypeScript: `vue-tsc --noEmit` sikeres (csak pre-existing `.vue.js` file hibák)
|
||||||
|
- Event propagation chain teljes: Cost modal → CostsActionsCard → DashboardView → VehicleDetailModal
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 Legacy Garage Migration (RBAC Phase 3)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Adatbázis backfill szkript elkészítése és futtatása, amely a legacy `person_id`-alapú ownership struktúrát áttelepíti az új `organization_id`/`organization_members` RBAC struktúrába. Három feladat: (A) hiányzó személyes garázsok létrehozása, (B) userek bindolása a garázsukhoz OWNER role-lal, (C) gazdátlan járművek megmentése.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. BACKEND - [`migrate_legacy_garages.py`](backend/app/scripts/migrate_legacy_garages.py:1):**
|
||||||
|
- **Task A:** Lekérdezi a `identity.users` táblában lévő usereket, akiknek nincs `INDIVIDUAL` típusú személyes garázsuk (`fleet.organizations`). Létrehozza a garázst minden szükséges oszloppal.
|
||||||
|
- **Task B:** Minden userhez hozzárendeli a személyes garázsát `organization_members` táblában `OWNER` szerepkörrel. Frissíti a `scope_id` mezőt, ha az NULL.
|
||||||
|
- **Task C:** Megkeresi a gazdátlan járműveket (`vehicle.assets` táblában ahol `owner_person_id` kitöltött, de `current_organization_id` vagy `owner_org_id` NULL), és átvezeti őket a tulajdonos személyes garázsába.
|
||||||
|
- **Technikai részletek:** `asyncpg`-t használ közvetlen adatbázis kapcsolattal. Dinamikus schema discovery (`information_schema.columns`) a NOT NULL constraint-ek kezelésére.
|
||||||
|
|
||||||
|
**2. BACKEND - [`verify_migration.py`](backend/app/scripts/verify_migration.py:1):**
|
||||||
|
- Verifikációs szkript, amely a migráció után lekérdezi a kulcs metrikákat.
|
||||||
|
|
||||||
|
### ✅ Eredmény
|
||||||
|
- **6 új személyes garázs** létrehozva (Task A)
|
||||||
|
- **16 member** hozzáadva `organization_members`-hez (Task B)
|
||||||
|
- **10 scope_id** frissítve (Task B)
|
||||||
|
- **1 gazdátlan jármű** megmentve (Task C)
|
||||||
|
- **0 user** maradt személyes garázs nélkül
|
||||||
|
- **0 user** NULL scope_id-vel
|
||||||
|
- **0 orphaned jármű** maradt
|
||||||
|
- **17** total INDIVIDUAL garázs
|
||||||
|
- **22** organization member OWNER role-lal
|
||||||
|
- **38/41** jármű organization-höz rendelve
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 Feature: Subscription & Package Assignment Bridge
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Subscription Tier hozzárendelés szervezetekhez (fleet.organizations) a `subscription_tier_id` FK oszlopon keresztül. Teljes admin API + frontend integráció a csomagok kezeléséhez.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. ADATBÁZIS - [`Organization`](backend/app/models/marketplace/organization.py:146):**
|
||||||
|
- `subscription_tier_id` FK oszlop hozzáadva (nullable, FK → `system.subscription_tiers.id`)
|
||||||
|
- Sync engine sikeresen lefuttatva → oszlop létrejött
|
||||||
|
|
||||||
|
**2. CAPABILITIES - [`capabilities.py`](backend/app/core/capabilities.py:61):**
|
||||||
|
- `CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"` új capability
|
||||||
|
- SUPERADMIN és ADMIN mátrixba bevéve
|
||||||
|
|
||||||
|
**3. API ENDPOINT - [`organizations.py`](backend/app/api/v1/endpoints/organizations.py:730):**
|
||||||
|
- `PUT /{org_id}/subscription` végpont `RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS)` RBAC-cal
|
||||||
|
- `SubscriptionAssignIn` Pydantic schema a body-hoz
|
||||||
|
|
||||||
|
**4. BILLING ENGINE - [`billing_engine.py`](backend/app/services/billing_engine.py:805):**
|
||||||
|
- `upgrade_org_subscription()` függvény: beállítja a `subscription_tier_id`-t, frissíti a `subscription_plan` és `base_asset_limit` mezőket, létrehozza az `OrganizationSubscription` audit rekordot
|
||||||
|
|
||||||
|
**5. QUOTA ENGINE - [`evidence.py`](backend/app/api/v1/endpoints/evidence.py:17):**
|
||||||
|
- scan-registration végpont most az org `subscription_tier_id` → `SubscriptionTier.rules.allowances.max_vehicles` értéket olvassa
|
||||||
|
- Fallback: `org.base_asset_limit` ha nincs tier hozzárendelve
|
||||||
|
|
||||||
|
**6. SCHEMA - [`organization.py`](backend/app/schemas/organization.py:66):**
|
||||||
|
- `subscription_tier_id: Optional[int]` hozzáadva `OrganizationUpdate` és `OrganizationResponse` modellekhez
|
||||||
|
|
||||||
|
**7. FRONTEND TYPES - [`organization.ts`](frontend/src/types/organization.ts:5):**
|
||||||
|
- `subscription_tier_id?: number | null` hozzáadva `OrganizationItem` interfészhez
|
||||||
|
|
||||||
|
**8. FRONTEND UI - [`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:1):**
|
||||||
|
- Subscription tier dropdown (select + assign gomb)
|
||||||
|
- `availableTiers` betöltése `GET /admin/packages/`-ből
|
||||||
|
- `assignSubscription()` hívás `PUT /organizations/{id}/subscription`-ra
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- `subscription_tier_id` oszlop létezik (`information_schema` ellenőrizve)
|
||||||
|
- Org 45: `corp_premium_plus_v1` (tier_id=17, max_vehicles=50) ✅
|
||||||
|
- Org 49: `corp_premium_v1` (tier_id=16, max_vehicles=20) ✅
|
||||||
|
- 8 subscription tier elérhető a `system.subscription_tiers` táblában
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 Critical: Admin Button Fix & Orphaned Vehicle Rescue
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
5-lépéses P0 kritikus feladat: (1) Törött Admin/Vezérlőpult gomb javítása a frontenden, (2) Adatbázis felderítés: gazdátlan járművek keresése, (3) Admin felhasználó garázsainak ellenőrzése, (4) Gazdátlan járművek megmentése (UPDATE), (5) Verifikáció és jelentés.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
|
||||||
|
**1. FRONTEND - [`auth.ts`](frontend/src/stores/auth.ts:97):**
|
||||||
|
- **Root Cause:** A `ModeSwitcher.vue` a `stores/auth`-ból importálja az `authStore.isAdmin`-et, de a store-ból hiányzott az `isAdmin` computed getter. Mindig `undefined`-et adott vissza, így a gomb sosem jelent meg.
|
||||||
|
- **Fix:** `isAdmin` computed property hozzáadva, amely ellenőrzi a `superadmin`, `admin`, `region_admin`, `country_admin`, `moderator` szerepköröket (kisbetűs összehasonlítással).
|
||||||
|
|
||||||
|
**2. ADATBÁZIS - Gazdátlan járművek felderítése:**
|
||||||
|
- **3 db orphaned vehicle** találva: TEST-API-01, ABB112, ABC-123 (mind `archived` státuszú, `owner_person_id = NULL`, `current_organization_id = NULL`)
|
||||||
|
- **Admin user** (admin@profibot.hu, user_id=2, person_id=2, role=ADMIN) nem rendelkezik Corporate (fleet_owner/business) garázzsal OWNER joggal - csak "Admin Garázsa" (Org 67, individual típus)
|
||||||
|
- Admin 17 járműve már helyesen a Test Company (Org 1, fleet_owner) alá van rendelve
|
||||||
|
|
||||||
|
**3. ADATBÁZIS - Rescue UPDATE:**
|
||||||
|
- 3 gazdátlan jármű átvezetve az Admin Garázsába (Org 67): `owner_person_id=2, owner_org_id=67, current_organization_id=67`
|
||||||
|
- Parancs: `docker compose exec -T shared-postgres psql ... UPDATE vehicle.assets SET ... WHERE current_organization_id IS NULL AND owner_person_id IS NULL`
|
||||||
|
- Eredmény: `UPDATE 3`
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- `SELECT ... WHERE current_organization_id IS NULL` → **üres eredmény** (0 gazdátlan jármű) ✅
|
||||||
|
- 3 rescued vehicle: TEST-API-01, ABB112, ABC-123 → Admin Garázsa (Org 67) ✅
|
||||||
|
- Admin 17 egyéb járműve: Test Company (Org 1) alatt maradt ✅
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 CRITICAL: Fix Admin Panel Case Sensitivity (SUPERADMIN/ADMIN)
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A backend UPPERCASE role értékei (SUPERADMIN, ADMIN) és a frontend lowercase összehasonlításai közötti mismatch javítása.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
- [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:99) — `isAdmin` getter: lowercase `[superadmin,admin,...]` → UPPERCASE `[SUPERADMIN,ADMIN,MODERATOR]` + `role.toUpperCase()`
|
||||||
|
- [`frontend/src/router/index.ts`](frontend/src/router/index.ts:131) — Router guard: ugyanez a javítás a `requiresAdmin` meta ellenőrzésben
|
||||||
|
- [`frontend/src/stores/authStore.ts`](frontend/src/stores/authStore.ts:27) — Secondary auth store `isAdmin`/`isSuperAdmin` getterek: `role.toUpperCase()`
|
||||||
|
- [`frontend/src/components/header/HeaderProfile.vue`](frontend/src/components/header/HeaderProfile.vue:145) — Inline `isAdmin` computed: lowercase → UPPERCASE + `toUpperCase()`
|
||||||
|
- [`frontend/src/views/admin/AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue:130,299) — Két inline `=== superadmin` check → `=== SUPERADMIN` + `toUpperCase()`
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Backend admin ping endpoint: `role: "ADMIN"` (UPPERCASE) ✅
|
||||||
|
- Frontend Vite HMR: minden módosított fájl újratöltve ✅
|
||||||
|
- Admin felület minden ponton (store getter, router guard, header button, users view) case-insensitive módon ellenőrzi a SUPERADMIN/ADMIN szerepköröket ✅
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 Critical: Precise Subscription Backfill & Legacy Cleanup
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
Admin garázsok VIP csomag hozzárendelése, tömeges free tier fallback, legacy string oszlopok kivezetése a kvóta logikából.
|
||||||
|
|
||||||
|
### 🔧 Változtatások
|
||||||
|
1. **Adatbázis backfill** ([`backend/app/scripts/p0_subscription_backfill.py`](backend/app/scripts/p0_subscription_backfill.py)):
|
||||||
|
- VIP: Admin Garázsa (org_id=67) → `private_test_v01` (tier_id=19, max_vehicles=100) ✅
|
||||||
|
- VIP: Test Company (org_id=1) → `org_test_v01` (tier_id=20, max_vehicles=100) ✅
|
||||||
|
- Free Tier Fallback: 14 individual org → `private_free_v1`, 13 other org → `corp_free_v1` ✅
|
||||||
|
- Eredmény: 31/31 org assigned, 0 NULL ✅
|
||||||
|
|
||||||
|
2. **Kvóta Motor refaktor** ([`backend/app/services/asset_service.py`](backend/app/services/asset_service.py:491)):
|
||||||
|
- `get_user_vehicle_limit()`: Eltávolítva a legacy `subscription_plan` string lookup
|
||||||
|
- Single Source of Truth: `SubscriptionTier.rules['allowances']['max_vehicles']` JSONB
|
||||||
|
- Fallback lánc: user subscription → org subscription tier → org.base_asset_limit → config
|
||||||
|
|
||||||
|
3. **Dokumentum helper cleanup** ([`backend/app/api/v1/endpoints/documents.py`](backend/app/api/v1/endpoints/documents.py:90)):
|
||||||
|
- `_check_premium_or_admin()`: Eltávolítva a `subscription_plan` string alapú premium check
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- Schema szinkron: sync_engine → 1089/1089 OK ✅
|
||||||
|
- 0 org with NULL subscription_tier_id ✅
|
||||||
|
- Distribution: private_free_v1(14), corp_free_v1(13), corp_premium_v1(1), private_test_v01(1), corp_premium_plus_v1(1), org_test_v01(1) ✅
|
||||||
|
|
||||||
|
## 2026-06-18 - P0 CRITICAL: PATCH Organization 500 Error Fix
|
||||||
|
|
||||||
|
### 🎯 Cél
|
||||||
|
A `PATCH /api/v1/organizations/{id}` végpont 500 Internal Server Error hibájának javítása, amely a subscription backfill után jelentkezett.
|
||||||
|
|
||||||
|
### 🔍 Root Cause Analysis
|
||||||
|
|
||||||
|
**Kiváltó ok #1:** PostgreSQL ENUM típus mismatch.
|
||||||
|
|
||||||
|
A `fleet.organization_members.role` oszlop az adatbázisban `fleet.orguserrole` ENUM típusként volt definiálva, de a SQLAlchemy modellben [`OrganizationMember.role`](backend/app/models/marketplace/organization.py:246) `String(50)`-ként. Amikor a `PATCH /organizations/{id}` végpont [`update_organization`](backend/app/api/v1/endpoints/organizations.py:672) függvénye lefutott, az SQLAlchemy a következő SQL-t generálta:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WHERE fleet.organization_members.role IN ('OWNER', 'ADMIN')
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL nem tud implicit módon összehasonlítani egy ENUM típust (`fleet.orguserrole`) egy string literállal (`character varying`), ezért dobta:
|
||||||
|
|
||||||
|
```
|
||||||
|
operator does not exist: fleet.orguserrole = character varying
|
||||||
|
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kiváltó ok #2:** Hiányzó `OrganizationMember` rekord az `owner_id` alapú tulajdonosoknak.
|
||||||
|
|
||||||
|
A subscription backfill során számos szervezetnél az `owner_id` mező be lett állítva, de a `fleet.organization_members` táblába nem került be a megfelelő OWNER szerepkörű rekord. A PATCH végpont csak a `OrganizationMember` táblában keresett, így a jogos tulajdonos is 403-as hibát kapott.
|
||||||
|
|
||||||
|
**Érintett fájlok:**
|
||||||
|
- [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:246) - `OrganizationMember.role` oszlop típusa
|
||||||
|
- [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:668) - A PATCH végpont RBAC ellenőrzése
|
||||||
|
|
||||||
|
### 🔧 Javítások
|
||||||
|
|
||||||
|
**1. MODELL JAVÍTÁS** ([`organization.py`](backend/app/models/marketplace/organization.py:246)):
|
||||||
|
- `OrganizationMember.role` oszlop típusa `String(50)` → `PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False)`-re változtatva
|
||||||
|
- `create_type=False` paraméter biztosítja, hogy nem próbáljuk újra létrehozni a már meglévő ENUM típust
|
||||||
|
|
||||||
|
**2. VÉGPONT JAVÍTÁS** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:668)):
|
||||||
|
- Az RBAC ellenőrzés kiegészítve: ha a user nem található `OrganizationMember`-ként, de `Organization.owner_id`-ként szerepel, akkor is engedélyezve van a módosítás
|
||||||
|
- A szervezet lekérése előrébb került (az RBAC ellenőrzés előtt), hogy elérhető legyen az `owner_id` mező
|
||||||
|
- Ezzel elkerültük a duplikált SQL lekérdezést is
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- `PATCH /api/v1/organizations/44` → **200 OK** ✅ (korábban 500, majd 403)
|
||||||
|
- Nincs több `UndefinedFunctionError` a logokban ✅
|
||||||
|
- Az SQLAlchemy által generált SQL helyesen kezeli az ENUM összehasonlítást ✅
|
||||||
|
- A tulajdonos (owner_id) sikeresen tudja módosítani a szervezet adatait ✅
|
||||||
|
|
||||||
|
## 2026-06-18: Address fields missing from OrganizationResponse and GET /my (#3-as probléma)
|
||||||
|
|
||||||
|
### 🔍 Root Cause Analysis
|
||||||
|
|
||||||
|
A PATCH `/api/v1/organizations/{id}` végpont 200 OK-val tért vissza, és az adatok az adatbázisba is bekerültek, de a frontend UI-ban nem jelentek meg a cím adatok. A probléma **három rétegben** jelentkezett:
|
||||||
|
|
||||||
|
**1. Backend `OrganizationResponse` séma hiányossága** ([`organization.py`](backend/app/schemas/organization.py:70)):
|
||||||
|
- A `OrganizationResponse` Pydantic osztály nem tartalmazta a cím mezőket (`address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`)
|
||||||
|
- A PATCH végpont `response_model=OrganizationResponse`-et használ, így a válaszból kimaradtak a cím adatok
|
||||||
|
|
||||||
|
**2. Backend `GET /my` végpont hiányossága** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:210)):
|
||||||
|
- A `get_my_organizations` függvény által visszaadott dictionary-ből hiányoztak a cím mezők
|
||||||
|
- A frontend a PATCH után `authStore.fetchMyOrganizations()`-t hív, ami ezt a végpontot használja
|
||||||
|
|
||||||
|
**3. Frontend `OrganizationItem` típus és `onMounted` hiányossága** ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:222)):
|
||||||
|
- A TypeScript `OrganizationItem` interfészből hiányoztak a cím mezők
|
||||||
|
- Az `onMounted` hook nem töltötte be a cím mezőket a `form` objektumba
|
||||||
|
|
||||||
|
**További probléma:** A `OrganizationUpdate` séma tartalmazott olyan mezőket (`address_stairwell`, `address_floor`, `address_door`), amelyek nem léteznek az `Organization` SQLAlchemy modellben. Ezeket a PATCH végpont csendben figyelmen kívül hagyta, de a `GET /my` végpont AttributeError-t dobott volna rájuk.
|
||||||
|
|
||||||
|
### 🔧 Javítások
|
||||||
|
|
||||||
|
**1. `OrganizationResponse` séma bővítése** ([`organization.py`](backend/app/schemas/organization.py:88)):
|
||||||
|
- Hozzáadva: `address_zip`, `address_city`, `address_street_name`, `address_street_type`, `address_house_number`, `address_hrsz`
|
||||||
|
- Eltávolítva a nem létező mezők: `address_stairwell`, `address_floor`, `address_door`
|
||||||
|
|
||||||
|
**2. `OrganizationUpdate` séma tisztítása** ([`organization.py`](backend/app/schemas/organization.py:54)):
|
||||||
|
- Eltávolítva a nem létező mezők: `address_stairwell`, `address_floor`, `address_door`
|
||||||
|
|
||||||
|
**3. `GET /my` végpont bővítése** ([`organizations.py`](backend/app/api/v1/endpoints/organizations.py:227)):
|
||||||
|
- Hozzáadva a cím mezők a visszaadott dictionary-hez
|
||||||
|
|
||||||
|
**4. Frontend `OrganizationItem` típus bővítése** ([`organization.ts`](frontend/src/types/organization.ts:33)):
|
||||||
|
- Hozzáadva a cím mezők az interfészhez
|
||||||
|
|
||||||
|
**5. Frontend `OrganizationSettingsModal.vue` javítása** ([`OrganizationSettingsModal.vue`](frontend/src/components/organization/OrganizationSettingsModal.vue:222)):
|
||||||
|
- `form` objektum bővítve a cím mezőkkel
|
||||||
|
- `onMounted` hook kiegészítve a cím mezők betöltésével (mindkét ágban)
|
||||||
|
|
||||||
|
### ✅ Verifikáció
|
||||||
|
- `GET /api/v1/organizations/my` → **200 OK**, tartalmazza a cím mezőket ✅
|
||||||
|
- `PATCH /api/v1/organizations/{id}` → **200 OK**, a válasz tartalmazza a cím mezőket ✅
|
||||||
|
- A cím adatok elérhetők a frontend `OrganizationSettingsModal` form-jában ✅
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from sqlalchemy.orm import selectinload, joinedload
|
|||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.core.security import decode_token, DEFAULT_RANK_MAP
|
from app.core.security import decode_token, DEFAULT_RANK_MAP
|
||||||
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
|
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
|
||||||
|
from app.models.marketplace.organization import OrgRole, OrganizationMember
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.translation_helper import t # Translation helper
|
from app.core.translation_helper import t # Translation helper
|
||||||
|
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ async def check_resource_access(
|
|||||||
"""
|
"""
|
||||||
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
|
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
|
||||||
"""
|
"""
|
||||||
if current_user.role == UserRole.superadmin:
|
if current_user.role == UserRole.SUPERADMIN:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
user_scope = str(current_user.scope_id) if current_user.scope_id else None
|
user_scope = str(current_user.scope_id) if current_user.scope_id else None
|
||||||
@@ -161,13 +163,11 @@ async def get_current_admin(
|
|||||||
"""
|
"""
|
||||||
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
|
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
|
||||||
"""
|
"""
|
||||||
# A UserRole Enum értékeit használjuk
|
# A UserRole Enum értékeit használjuk (RBAC Phase 1)
|
||||||
allowed_roles = {
|
allowed_roles = {
|
||||||
UserRole.superadmin,
|
UserRole.SUPERADMIN,
|
||||||
UserRole.admin,
|
UserRole.ADMIN,
|
||||||
UserRole.region_admin,
|
UserRole.MODERATOR,
|
||||||
UserRole.country_admin,
|
|
||||||
UserRole.moderator,
|
|
||||||
}
|
}
|
||||||
if current_user.role not in allowed_roles:
|
if current_user.role not in allowed_roles:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -175,3 +175,126 @@ async def get_current_admin(
|
|||||||
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
|
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
|
||||||
)
|
)
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def RequireSystemCapability(capability_name: str):
|
||||||
|
"""
|
||||||
|
🎯 Rendszerszintű képesség-ellenőrző dependency.
|
||||||
|
|
||||||
|
Használat:
|
||||||
|
@router.get("/admin/users")
|
||||||
|
async def list_users(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_ = Depends(RequireSystemCapability("can_manage_users"))
|
||||||
|
):
|
||||||
|
|
||||||
|
Logika:
|
||||||
|
1. Ha a user SUPERADMIN → azonnal átengedi (True).
|
||||||
|
2. Egyébként lekéri a user role-ját, és ellenőrzi a
|
||||||
|
SYSTEM_CAPABILITIES_MATRIX-ból, hogy a kért capability True-e.
|
||||||
|
3. Ha nincs meg a capability → 403 Forbidden.
|
||||||
|
"""
|
||||||
|
async def system_capability_checker(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> bool:
|
||||||
|
# SUPERADMIN mindent visz
|
||||||
|
if current_user.role == UserRole.SUPERADMIN:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Ellenőrizzük a SYSTEM_CAPABILITIES_MATRIX-ból
|
||||||
|
role_key = current_user.role.value # pl. "ADMIN", "MODERATOR", "USER"
|
||||||
|
if not role_has_capability(role_key, capability_name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"SYSTEM_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{role_key}' szerepkör számára."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return system_capability_checker
|
||||||
|
|
||||||
|
|
||||||
|
def RequireOrgCapability(capability_name: str):
|
||||||
|
"""
|
||||||
|
🎯 Szervezeti képesség-ellenőrző dependency.
|
||||||
|
|
||||||
|
Használat:
|
||||||
|
@router.post("/expenses")
|
||||||
|
async def create_expense(
|
||||||
|
expense: AssetCostCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_ = Depends(RequireOrgCapability("can_add_expense"))
|
||||||
|
):
|
||||||
|
|
||||||
|
Logika:
|
||||||
|
1. Ha a user SUPERADMIN → azonnal átengedi (True).
|
||||||
|
2. Lekéri a user OrgRole-ját az adott organization_id alapján
|
||||||
|
a fleet.organization_members táblából.
|
||||||
|
3. Kikeresi a fleet.org_roles táblából a szerepkörhöz tartozó
|
||||||
|
permissions JSONB oszlopot.
|
||||||
|
4. Ha a JSONB-ben a kért capability_name nem True → 403 Forbidden.
|
||||||
|
|
||||||
|
Fontos: Ez a dependency egy organization_id paramétert vár a végponttól.
|
||||||
|
A végpontnak át kell adnia az org_id-t a dependency-nek.
|
||||||
|
"""
|
||||||
|
async def org_capability_checker(
|
||||||
|
organization_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> bool:
|
||||||
|
# SUPERADMIN mindent visz
|
||||||
|
if current_user.role == UserRole.SUPERADMIN:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 1. Lekérjük a user szerepkörét a szervezetben
|
||||||
|
member_stmt = select(OrganizationMember.role).where(
|
||||||
|
OrganizationMember.user_id == current_user.id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
member_result = await db.execute(member_stmt)
|
||||||
|
org_role_name = member_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org_role_name:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="ORG_MEMBERSHIP_REQUIRED: You are not a member of this organization."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Lekérjük a szerepkör permissions JSONB-jét a fleet.org_roles táblából
|
||||||
|
role_stmt = select(OrgRole.permissions).where(
|
||||||
|
OrgRole.name_key == org_role_name,
|
||||||
|
OrgRole.is_active == True
|
||||||
|
).limit(1)
|
||||||
|
role_result = await db.execute(role_stmt)
|
||||||
|
permissions = role_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not permissions:
|
||||||
|
# Fallback: ha nincs a fleet.org_roles táblában, használjuk a
|
||||||
|
# OrganizationMember.permissions mezőt (örökölt viselkedés)
|
||||||
|
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||||
|
OrganizationMember.user_id == current_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 {}
|
||||||
|
|
||||||
|
# 3. Ellenőrizzük a kért képességet
|
||||||
|
if not isinstance(permissions, dict):
|
||||||
|
permissions = {}
|
||||||
|
|
||||||
|
if not permissions.get(capability_name, False):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"ORG_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{org_role_name}' szerepkör számára ebben a szervezetben."
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return org_capability_checker
|
||||||
@@ -192,7 +192,7 @@ async def ban_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 2. Ellenőrizd, hogy nem superadmin-e
|
# 2. Ellenőrizd, hogy nem superadmin-e
|
||||||
if user.role == UserRole.superadmin:
|
if user.role == UserRole.SUPERADMIN:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Cannot ban a superadmin user"
|
detail="Cannot ban a superadmin user"
|
||||||
@@ -698,7 +698,7 @@ async def bulk_user_action(
|
|||||||
affected_count = 0
|
affected_count = 0
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
if user.role == UserRole.superadmin and user.id != current_admin.id:
|
if user.role == UserRole.SUPERADMIN and user.id != current_admin.id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if request.action == "ban":
|
if request.action == "ban":
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
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 import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
|
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.services.cost_service import cost_service
|
from app.services.cost_service import cost_service
|
||||||
from app.services.asset_service import AssetService
|
from app.services.asset_service import AssetService
|
||||||
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_user_role_in_org(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
organization_id: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Resolve the user's role within the given organization.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
||||||
|
Returns 'MEMBER' as default if no membership found.
|
||||||
|
"""
|
||||||
|
stmt = select(OrganizationMember.role).where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
role = result.scalar_one_or_none()
|
||||||
|
return role or "MEMBER"
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_org_capability(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
organization_id: int,
|
||||||
|
capability_name: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True ha a user rendelkezik a képességgel.
|
||||||
|
"""
|
||||||
|
# 1. Get user's org role
|
||||||
|
member_stmt = select(OrganizationMember.role).where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
member_result = await db.execute(member_stmt)
|
||||||
|
org_role_name = member_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org_role_name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Get permissions from fleet.org_roles table
|
||||||
|
role_stmt = select(OrgRole.permissions).where(
|
||||||
|
OrgRole.name_key == org_role_name,
|
||||||
|
OrgRole.is_active == True
|
||||||
|
).limit(1)
|
||||||
|
role_result = await db.execute(role_stmt)
|
||||||
|
permissions = role_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not permissions:
|
||||||
|
# Fallback to OrganizationMember.permissions
|
||||||
|
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
member_perm_result = await db.execute(member_perm_stmt)
|
||||||
|
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||||
|
|
||||||
|
if not isinstance(permissions, dict):
|
||||||
|
permissions = {}
|
||||||
|
|
||||||
|
return permissions.get(capability_name, False)
|
||||||
|
|
||||||
|
|
||||||
class ArchiveVehicleRequest(BaseModel):
|
class ArchiveVehicleRequest(BaseModel):
|
||||||
"""Payload a jármű archiválásához (Soft Delete)."""
|
"""Payload a jármű archiválásához (Soft Delete)."""
|
||||||
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
|
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
|
||||||
@@ -40,10 +113,18 @@ async def get_vehicle_quota_status(
|
|||||||
|
|
||||||
GET /api/v1/assets/vehicles/quota-status
|
GET /api/v1/assets/vehicles/quota-status
|
||||||
|
|
||||||
|
Resolves the user's active organization:
|
||||||
|
- If scope_id is set, use that organization
|
||||||
|
- If scope_id is null, resolve the user's primary (individual) garage
|
||||||
|
from fleet.organizations via owner_id
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{ "can_add": bool, "current_count": int, "limit": int }
|
{ "can_add": bool, "current_count": int, "limit": int }
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
|
||||||
# Determine organization ID based on user's active scope (garage isolation)
|
# Determine organization ID based on user's active scope (garage isolation)
|
||||||
org_id = None
|
org_id = None
|
||||||
if current_user.scope_id is not None:
|
if current_user.scope_id is not None:
|
||||||
@@ -51,6 +132,20 @@ async def get_vehicle_quota_status(
|
|||||||
org_id = int(current_user.scope_id)
|
org_id = int(current_user.scope_id)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
# Resolve the user's primary individual garage
|
||||||
|
org_stmt = select(Organization.id).where(
|
||||||
|
Organization.owner_id == current_user.id,
|
||||||
|
Organization.org_type == "individual",
|
||||||
|
Organization.is_active == True
|
||||||
|
).limit(1)
|
||||||
|
org_result = await db.execute(org_stmt)
|
||||||
|
resolved_org_id = org_result.scalar_one_or_none()
|
||||||
|
if resolved_org_id is not None:
|
||||||
|
org_id = resolved_org_id
|
||||||
|
logger.info(
|
||||||
|
f"Resolved primary org for user {current_user.id}: org_id={org_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Get the user's vehicle limit
|
# Get the user's vehicle limit
|
||||||
allowed_limit = await AssetService.get_user_vehicle_limit(
|
allowed_limit = await AssetService.get_user_vehicle_limit(
|
||||||
@@ -59,17 +154,15 @@ async def get_vehicle_quota_status(
|
|||||||
# SAFETY: Ensure limit is at least 1
|
# SAFETY: Ensure limit is at least 1
|
||||||
allowed_limit = max(allowed_limit or 1, 1)
|
allowed_limit = max(allowed_limit or 1, 1)
|
||||||
|
|
||||||
# Count only active vehicles (draft vehicles don't count toward the limit)
|
# Count only active vehicles in the resolved organization
|
||||||
from sqlalchemy import func
|
|
||||||
if org_id is not None:
|
if org_id is not None:
|
||||||
count_stmt = select(func.count(Asset.id)).where(
|
count_stmt = select(func.count(Asset.id)).where(
|
||||||
Asset.current_organization_id == org_id,
|
Asset.current_organization_id == org_id,
|
||||||
Asset.owner_person_id == current_user.person_id,
|
|
||||||
Asset.status == "active"
|
Asset.status == "active"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
# Fallback: count by owner_person_id if no org could be resolved
|
||||||
count_stmt = select(func.count(Asset.id)).where(
|
count_stmt = select(func.count(Asset.id)).where(
|
||||||
Asset.current_organization_id.is_(None),
|
|
||||||
Asset.owner_person_id == current_user.person_id,
|
Asset.owner_person_id == current_user.person_id,
|
||||||
Asset.status == "active"
|
Asset.status == "active"
|
||||||
)
|
)
|
||||||
@@ -158,29 +251,35 @@ async def get_user_vehicles(
|
|||||||
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
|
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
|
||||||
|
|
||||||
if current_user.scope_id is None:
|
if current_user.scope_id is None:
|
||||||
# Personal mode: only show vehicles owned/operated personally (no organization)
|
# Personal mode: show vehicles in organizations where user is a member
|
||||||
stmt = (
|
org_stmt = select(OrganizationMember.organization_id).where(
|
||||||
select(Asset)
|
OrganizationMember.user_id == current_user.id
|
||||||
.where(
|
)
|
||||||
or_(
|
org_result = await db.execute(org_stmt)
|
||||||
Asset.owner_org_id.is_(None),
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
Asset.operator_org_id.is_(None)
|
|
||||||
),
|
if user_org_ids:
|
||||||
or_(
|
stmt = (
|
||||||
Asset.owner_person_id == current_user.person_id,
|
select(Asset)
|
||||||
Asset.operator_person_id == current_user.person_id
|
.where(
|
||||||
|
Asset.current_organization_id.in_(user_org_ids),
|
||||||
|
Asset.status == "active"
|
||||||
|
)
|
||||||
|
.order_by(order_expr, Asset.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.options(
|
||||||
|
selectinload(Asset.catalog),
|
||||||
|
selectinload(Asset.odometer_readings)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(order_expr, Asset.created_at.desc())
|
else:
|
||||||
.offset(skip)
|
# No organizations — return empty list
|
||||||
.limit(limit)
|
return []
|
||||||
.options(
|
|
||||||
selectinload(Asset.catalog),
|
|
||||||
selectinload(Asset.odometer_readings)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Corporate mode: only show vehicles belonging to the active organization's garages
|
# Corporate mode: show vehicles belonging to the active organization
|
||||||
|
# Uses current_organization_id (same logic as quota-status endpoint)
|
||||||
|
# for consistency — vehicles belong to organizations, not branches.
|
||||||
try:
|
try:
|
||||||
scope_org_id = int(current_user.scope_id)
|
scope_org_id = int(current_user.scope_id)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -191,25 +290,11 @@ async def get_user_vehicles(
|
|||||||
# Fallback: no valid organization, return empty list
|
# Fallback: no valid organization, return empty list
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# First, get all branch IDs (garages) for this organization
|
# Query assets that belong to the active organization
|
||||||
from app.models.marketplace.organization import Branch
|
|
||||||
branch_stmt = select(Branch.id).where(
|
|
||||||
Branch.organization_id == scope_org_id,
|
|
||||||
Branch.is_deleted == False,
|
|
||||||
Branch.status == "active"
|
|
||||||
)
|
|
||||||
branch_result = await db.execute(branch_stmt)
|
|
||||||
branch_ids = [row[0] for row in branch_result.all()]
|
|
||||||
|
|
||||||
if not branch_ids:
|
|
||||||
# Organization has no active garages, return empty list
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Query assets that are in any of the organization's garages
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Asset)
|
select(Asset)
|
||||||
.where(
|
.where(
|
||||||
Asset.branch_id.in_(branch_ids),
|
Asset.current_organization_id == scope_org_id,
|
||||||
Asset.status == "active"
|
Asset.status == "active"
|
||||||
)
|
)
|
||||||
.order_by(order_expr, Asset.created_at.desc())
|
.order_by(order_expr, Asset.created_at.desc())
|
||||||
@@ -271,7 +356,9 @@ async def list_asset_costs(
|
|||||||
"id": cost.id,
|
"id": cost.id,
|
||||||
"asset_id": cost.asset_id,
|
"asset_id": cost.asset_id,
|
||||||
"organization_id": cost.organization_id,
|
"organization_id": cost.organization_id,
|
||||||
|
"amount_gross": cost.amount_gross,
|
||||||
"amount_net": cost.amount_net,
|
"amount_net": cost.amount_net,
|
||||||
|
"vat_rate": cost.vat_rate,
|
||||||
"currency": cost.currency,
|
"currency": cost.currency,
|
||||||
"date": cost.date,
|
"date": cost.date,
|
||||||
"invoice_number": cost.invoice_number,
|
"invoice_number": cost.invoice_number,
|
||||||
@@ -281,6 +368,8 @@ async def list_asset_costs(
|
|||||||
"category_code": cost.category.code if cost.category else None,
|
"category_code": cost.category.code if cost.category else None,
|
||||||
"description": (cost.data or {}).get("description"),
|
"description": (cost.data or {}).get("description"),
|
||||||
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
|
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
|
||||||
|
"status": cost.status,
|
||||||
|
"linked_asset_event_id": cost.linked_asset_event_id,
|
||||||
}
|
}
|
||||||
result.append(AssetCostResponse(**cost_dict))
|
result.append(AssetCostResponse(**cost_dict))
|
||||||
|
|
||||||
@@ -308,22 +397,95 @@ async def get_asset(
|
|||||||
user_org_ids = [row[0] for row in org_result.all()]
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
|
|
||||||
# Query asset with catalog and master definition
|
# Query asset with catalog and master definition
|
||||||
stmt = (
|
# Unified Workspace: access is granted if the asset belongs to an organization
|
||||||
select(Asset)
|
# where the current user is an active member
|
||||||
.where(
|
if user_org_ids:
|
||||||
Asset.id == asset_id,
|
stmt = (
|
||||||
or_(
|
select(Asset)
|
||||||
Asset.owner_person_id == current_user.id,
|
.where(
|
||||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
Asset.id == asset_id,
|
||||||
Asset.operator_person_id == current_user.id,
|
Asset.current_organization_id.in_(user_org_ids)
|
||||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||||
|
selectinload(Asset.odometer_readings)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.options(
|
else:
|
||||||
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
stmt = (
|
||||||
selectinload(Asset.odometer_readings)
|
select(Asset)
|
||||||
|
.where(
|
||||||
|
Asset.id == asset_id,
|
||||||
|
Asset.current_organization_id.is_(None)
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||||
|
selectinload(Asset.odometer_readings)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
asset = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Asset not found or you don't have permission to access it"
|
||||||
|
)
|
||||||
|
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
|
||||||
|
async def get_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get detailed information about a specific vehicle/asset by vehicle_id.
|
||||||
|
|
||||||
|
GET /api/v1/assets/vehicles/{vehicle_id}
|
||||||
|
|
||||||
|
This endpoint is the primary single-vehicle lookup used by the frontend.
|
||||||
|
Uses Unified Workspace (OrganizationMember-based) permission check.
|
||||||
|
|
||||||
|
Returns the asset's full technical profile including catalog data
|
||||||
|
and vehicle model definition specifications.
|
||||||
|
"""
|
||||||
|
# Get user's organization memberships
|
||||||
|
org_stmt = select(OrganizationMember.organization_id).where(
|
||||||
|
OrganizationMember.user_id == current_user.id
|
||||||
)
|
)
|
||||||
|
org_result = await db.execute(org_stmt)
|
||||||
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
|
|
||||||
|
# Unified Workspace: access is granted if the asset belongs to an organization
|
||||||
|
# where the current user is an active member
|
||||||
|
if user_org_ids:
|
||||||
|
stmt = (
|
||||||
|
select(Asset)
|
||||||
|
.where(
|
||||||
|
Asset.id == vehicle_id,
|
||||||
|
Asset.current_organization_id.in_(user_org_ids)
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||||
|
selectinload(Asset.odometer_readings)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = (
|
||||||
|
select(Asset)
|
||||||
|
.where(
|
||||||
|
Asset.id == vehicle_id,
|
||||||
|
Asset.current_organization_id.is_(None)
|
||||||
|
)
|
||||||
|
.options(
|
||||||
|
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
|
||||||
|
selectinload(Asset.odometer_readings)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
asset = result.scalar_one_or_none()
|
asset = result.scalar_one_or_none()
|
||||||
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
|
|||||||
existing_asset = existing_result.scalar_one_or_none()
|
existing_asset = existing_result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_asset:
|
if existing_asset:
|
||||||
# Szabály A: Saját jármű duplikációja
|
# Szabály A: Saját jármű duplikációja — check via organization membership
|
||||||
if existing_asset.owner_person_id == current_user.person_id:
|
# Get user's organization IDs
|
||||||
|
member_org_stmt = select(OrganizationMember.organization_id).where(
|
||||||
|
OrganizationMember.user_id == current_user.id
|
||||||
|
)
|
||||||
|
member_org_result = await db.execute(member_org_stmt)
|
||||||
|
user_org_ids = [row[0] for row in member_org_result.all()]
|
||||||
|
|
||||||
|
if existing_asset.current_organization_id in user_org_ids:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Ez a jármű már szerepel a garázsodban!"
|
detail="Ez a jármű már szerepel a garázsodban!"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Szabály B: Tulajdonosváltás / Átmeneti állapot
|
# Szabály B: Tulajdonosváltás / Átmeneti állapot
|
||||||
# A rendszám létezik, de más a tulajdonosa
|
# A rendszám létezik, de más szervezetben van
|
||||||
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
|
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
|
||||||
payload.data_status = "draft"
|
payload.data_status = "draft"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"License plate {license_plate_clean} exists with different owner "
|
f"License plate {license_plate_clean} exists in different organization "
|
||||||
f"(owner_person_id={existing_asset.owner_person_id} != "
|
f"(current_organization_id={existing_asset.current_organization_id}). "
|
||||||
f"current_user.person_id={current_user.person_id}). "
|
|
||||||
f"Setting data_status='draft' for transfer scenario."
|
f"Setting data_status='draft' for transfer scenario."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -458,20 +626,25 @@ async def update_vehicle(
|
|||||||
org_result = await db.execute(org_stmt)
|
org_result = await db.execute(org_stmt)
|
||||||
user_org_ids = [row[0] for row in org_result.all()]
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
|
|
||||||
# Find the asset
|
# Find the asset — Unified Workspace: access via organization membership only
|
||||||
stmt = (
|
if user_org_ids:
|
||||||
select(Asset)
|
stmt = (
|
||||||
.where(
|
select(Asset)
|
||||||
Asset.id == asset_id,
|
.where(
|
||||||
or_(
|
Asset.id == asset_id,
|
||||||
Asset.owner_person_id == current_user.person_id,
|
Asset.current_organization_id.in_(user_org_ids)
|
||||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
|
||||||
Asset.operator_person_id == current_user.person_id,
|
|
||||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
|
||||||
)
|
)
|
||||||
|
.options(selectinload(Asset.catalog))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = (
|
||||||
|
select(Asset)
|
||||||
|
.where(
|
||||||
|
Asset.id == asset_id,
|
||||||
|
Asset.current_organization_id.is_(None)
|
||||||
|
)
|
||||||
|
.options(selectinload(Asset.catalog))
|
||||||
)
|
)
|
||||||
.options(selectinload(Asset.catalog))
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
asset = result.scalar_one_or_none()
|
asset = result.scalar_one_or_none()
|
||||||
@@ -540,16 +713,17 @@ async def list_maintenance_records(
|
|||||||
org_result = await db.execute(org_stmt)
|
org_result = await db.execute(org_stmt)
|
||||||
user_org_ids = [row[0] for row in org_result.all()]
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
|
|
||||||
# Check asset access
|
# Check asset access — Unified Workspace: via organization membership only
|
||||||
asset_stmt = select(Asset).where(
|
if user_org_ids:
|
||||||
Asset.id == asset_id,
|
asset_stmt = select(Asset).where(
|
||||||
or_(
|
Asset.id == asset_id,
|
||||||
Asset.owner_person_id == current_user.id,
|
Asset.current_organization_id.in_(user_org_ids)
|
||||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
)
|
||||||
Asset.operator_person_id == current_user.id,
|
else:
|
||||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
asset_stmt = select(Asset).where(
|
||||||
|
Asset.id == asset_id,
|
||||||
|
Asset.current_organization_id.is_(None)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
asset_result = await db.execute(asset_stmt)
|
asset_result = await db.execute(asset_stmt)
|
||||||
asset = asset_result.scalar_one_or_none()
|
asset = asset_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -599,16 +773,17 @@ async def create_maintenance_record(
|
|||||||
org_result = await db.execute(org_stmt)
|
org_result = await db.execute(org_stmt)
|
||||||
user_org_ids = [row[0] for row in org_result.all()]
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
|
|
||||||
# Check asset access and get asset
|
# Check asset access and get asset — Unified Workspace: via organization membership only
|
||||||
asset_stmt = select(Asset).where(
|
if user_org_ids:
|
||||||
Asset.id == asset_id,
|
asset_stmt = select(Asset).where(
|
||||||
or_(
|
Asset.id == asset_id,
|
||||||
Asset.owner_person_id == current_user.id,
|
Asset.current_organization_id.in_(user_org_ids)
|
||||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
)
|
||||||
Asset.operator_person_id == current_user.id,
|
else:
|
||||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
asset_stmt = select(Asset).where(
|
||||||
|
Asset.id == asset_id,
|
||||||
|
Asset.current_organization_id.is_(None)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
asset_result = await db.execute(asset_stmt)
|
asset_result = await db.execute(asset_stmt)
|
||||||
asset = asset_result.scalar_one_or_none()
|
asset = asset_result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -663,11 +838,14 @@ async def create_maintenance_record(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create AssetCost record with MAINTENANCE category (id=2)
|
# Create AssetCost record with MAINTENANCE category (id=2)
|
||||||
|
# GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
|
||||||
|
cost_gross = float(payload["cost"])
|
||||||
maintenance_cost = AssetCost(
|
maintenance_cost = AssetCost(
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
category_id=2,
|
category_id=2,
|
||||||
amount_net=float(payload["cost"]),
|
amount_gross=cost_gross,
|
||||||
|
amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
|
||||||
currency=payload.get("currency", "EUR"),
|
currency=payload.get("currency", "EUR"),
|
||||||
date=date,
|
date=date,
|
||||||
invoice_number=payload.get("invoice_number"),
|
invoice_number=payload.get("invoice_number"),
|
||||||
@@ -736,18 +914,23 @@ async def _check_asset_access(
|
|||||||
org_result = await db.execute(org_stmt)
|
org_result = await db.execute(org_stmt)
|
||||||
user_org_ids = [row[0] for row in org_result.all()]
|
user_org_ids = [row[0] for row in org_result.all()]
|
||||||
|
|
||||||
stmt = (
|
# Unified Workspace: access via organization membership only
|
||||||
select(Asset)
|
if user_org_ids:
|
||||||
.where(
|
stmt = (
|
||||||
Asset.id == asset_id,
|
select(Asset)
|
||||||
or_(
|
.where(
|
||||||
Asset.owner_person_id == current_user.id,
|
Asset.id == asset_id,
|
||||||
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
|
Asset.current_organization_id.in_(user_org_ids)
|
||||||
Asset.operator_person_id == current_user.id,
|
)
|
||||||
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
|
)
|
||||||
|
else:
|
||||||
|
stmt = (
|
||||||
|
select(Asset)
|
||||||
|
.where(
|
||||||
|
Asset.id == asset_id,
|
||||||
|
Asset.current_organization_id.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
asset = result.scalar_one_or_none()
|
asset = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -863,6 +1046,14 @@ async def create_asset_event(
|
|||||||
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
|
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
|
||||||
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
|
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
|
||||||
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
|
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
|
||||||
|
|
||||||
|
**Role-Based Auto-Approval (PHASE 2):**
|
||||||
|
- OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
|
||||||
|
- DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
|
||||||
|
|
||||||
|
**Smart Linking (Double-Entry Avoidance):**
|
||||||
|
- When cost_amount > 0, the auto-created AssetCost is linked back
|
||||||
|
to the event via linked_expense_id / linked_asset_event_id.
|
||||||
"""
|
"""
|
||||||
# Jogosultság ellenőrzés
|
# Jogosultság ellenőrzés
|
||||||
asset = await _check_asset_access(db, asset_id, current_user)
|
asset = await _check_asset_access(db, asset_id, current_user)
|
||||||
@@ -873,9 +1064,30 @@ async def create_asset_event(
|
|||||||
# Szervezet meghatározása
|
# Szervezet meghatározása
|
||||||
organization_id = asset.current_organization_id or asset.owner_org_id
|
organization_id = asset.current_organization_id or asset.owner_org_id
|
||||||
|
|
||||||
|
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
|
||||||
|
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
|
||||||
|
|
||||||
|
# Check if user has can_approve_expense capability
|
||||||
|
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
|
||||||
|
|
||||||
|
# Determine event and cost status based on capability
|
||||||
|
if can_approve:
|
||||||
|
event_status = "COMPLETED"
|
||||||
|
cost_status = "APPROVED"
|
||||||
|
else:
|
||||||
|
event_status = "MISSING_TECH_DATA"
|
||||||
|
cost_status = "PENDING_APPROVAL"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
|
||||||
|
f"in org {organization_id}: can_approve_expense={can_approve}, "
|
||||||
|
f"event_status={event_status}, cost_status={cost_status}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
# ── Üzleti logika: AssetCost automatikus létrehozása ──
|
||||||
cost_id = payload.cost_id
|
cost_id = payload.cost_id
|
||||||
|
auto_created_cost_id = None
|
||||||
if payload.cost_amount is not None and payload.cost_amount > 0:
|
if payload.cost_amount is not None and payload.cost_amount > 0:
|
||||||
# Költségkategória feloldása az esemény típusa alapján
|
# Költségkategória feloldása az esemény típusa alapján
|
||||||
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
|
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
|
||||||
@@ -883,13 +1095,17 @@ async def create_asset_event(
|
|||||||
|
|
||||||
currency = payload.currency or "HUF"
|
currency = payload.currency or "HUF"
|
||||||
|
|
||||||
|
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
|
||||||
|
cost_gross = payload.cost_amount
|
||||||
cost_record = AssetCost(
|
cost_record = AssetCost(
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
category_id=cost_category_id,
|
category_id=cost_category_id,
|
||||||
amount_net=payload.cost_amount,
|
amount_gross=cost_gross,
|
||||||
|
amount_net=cost_gross, # Default: net = gross (0% VAT)
|
||||||
currency=currency,
|
currency=currency,
|
||||||
date=event_date,
|
date=event_date,
|
||||||
|
status=cost_status,
|
||||||
data={
|
data={
|
||||||
"description": payload.description or f"Service event: {payload.event_type}",
|
"description": payload.description or f"Service event: {payload.event_type}",
|
||||||
"mileage_at_cost": payload.odometer_reading,
|
"mileage_at_cost": payload.odometer_reading,
|
||||||
@@ -900,13 +1116,15 @@ async def create_asset_event(
|
|||||||
db.add(cost_record)
|
db.add(cost_record)
|
||||||
await db.flush() # Flush to get the cost_record.id
|
await db.flush() # Flush to get the cost_record.id
|
||||||
cost_id = cost_record.id
|
cost_id = cost_record.id
|
||||||
|
auto_created_cost_id = cost_record.id
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"AssetCost auto-created for asset {asset_id}: "
|
f"AssetCost auto-created for asset {asset_id}: "
|
||||||
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
|
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
|
||||||
|
f"cost_id={cost_id}, status={cost_status})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Új esemény létrehozása
|
# Új esemény létrehozása (with status and bidirectional link)
|
||||||
event = AssetEvent(
|
event = AssetEvent(
|
||||||
asset_id=asset_id,
|
asset_id=asset_id,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
@@ -915,9 +1133,28 @@ async def create_asset_event(
|
|||||||
odometer_reading=payload.odometer_reading,
|
odometer_reading=payload.odometer_reading,
|
||||||
description=payload.description,
|
description=payload.description,
|
||||||
cost_id=cost_id,
|
cost_id=cost_id,
|
||||||
|
linked_expense_id=auto_created_cost_id, # Bidirectional link
|
||||||
|
status=event_status,
|
||||||
event_date=event_date,
|
event_date=event_date,
|
||||||
)
|
)
|
||||||
db.add(event)
|
db.add(event)
|
||||||
|
await db.flush() # Flush to get event.id
|
||||||
|
|
||||||
|
# If we auto-created a cost, link it back to the event
|
||||||
|
if auto_created_cost_id:
|
||||||
|
# Update the cost record with the link back to the event
|
||||||
|
stmt_update = (
|
||||||
|
select(AssetCost)
|
||||||
|
.where(AssetCost.id == auto_created_cost_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt_update)
|
||||||
|
cost_record = result.scalar_one_or_none()
|
||||||
|
if cost_record:
|
||||||
|
cost_record.linked_asset_event_id = event.id
|
||||||
|
logger.info(
|
||||||
|
f"Smart Sync: Bidirectional link established between "
|
||||||
|
f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# ── Üzleti logika: Km óra állás frissítése ──
|
# ── Üzleti logika: Km óra állás frissítése ──
|
||||||
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
|
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:
|
||||||
|
|||||||
@@ -89,11 +89,15 @@ async def get_document_status(
|
|||||||
|
|
||||||
# RBAC helper function
|
# RBAC helper function
|
||||||
def _check_premium_or_admin(user: User) -> bool:
|
def _check_premium_or_admin(user: User) -> bool:
|
||||||
"""Check if user has premium subscription or admin role."""
|
"""Check if user has premium subscription or admin role.
|
||||||
premium_plans = ['PREMIUM', 'PREMIUM_PLUS', 'VIP', 'VIP_PLUS']
|
|
||||||
if user.role == 'admin':
|
P0: Legacy subscription_plan string check removed.
|
||||||
return True
|
The Single Source of Truth is now subscription_tier JSONB rules.
|
||||||
if hasattr(user, 'subscription_plan') and user.subscription_plan in premium_plans:
|
Premium entitlement should be checked via the SubscriptionTier entitlements array.
|
||||||
|
"""
|
||||||
|
premium_roles = ['admin', 'superadmin']
|
||||||
|
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||||
|
if user_role.lower() in premium_roles:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, text
|
from sqlalchemy import select, func
|
||||||
from app.api.deps import get_db, get_current_user
|
from app.api.deps import get_db, get_current_user
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models import Asset # JAVÍTVA: Asset modell
|
from app.models import Asset # JAVÍTVA: Asset modell
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
from app.models.core_logic import SubscriptionTier
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -13,10 +15,28 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.post("/scan-registration")
|
@router.post("/scan-registration")
|
||||||
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
|
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
|
||||||
plan_key = (current_user.subscription_plan or "free").lower()
|
# Instead of hardcoded plan-based lookup from system_parameters,
|
||||||
res = await db.execute(stmt_limit, {"plan": plan_key})
|
# we now read the organization's assigned subscription_tier rules.
|
||||||
max_allowed = max(res.scalar() or 1, 1)
|
max_allowed = 1 # default fallback
|
||||||
|
|
||||||
|
if current_user.scope_id:
|
||||||
|
# Get the organization and its subscription tier
|
||||||
|
stmt_org = select(Organization).where(Organization.id == current_user.scope_id)
|
||||||
|
result = await db.execute(stmt_org)
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if org and org.subscription_tier_id:
|
||||||
|
stmt_tier = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
|
||||||
|
tier_result = await db.execute(stmt_tier)
|
||||||
|
tier = tier_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if tier and tier.rules:
|
||||||
|
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
|
||||||
|
max_allowed = max(int(max_vehicles), 1)
|
||||||
|
elif org:
|
||||||
|
# Fallback to legacy base_asset_limit if no tier assigned
|
||||||
|
max_allowed = max(org.base_asset_limit or 1, 1)
|
||||||
|
|
||||||
stmt_count = select(func.count(Asset.id)).where(
|
stmt_count = select(func.count(Asset.id)).where(
|
||||||
Asset.owner_org_id == current_user.scope_id,
|
Asset.owner_org_id == current_user.scope_id,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
|
||||||
import logging
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from app.api.deps import get_db, get_current_user
|
from app.api.deps import get_db, get_current_user, RequireOrgCapability
|
||||||
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
|
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
|
||||||
from app.schemas.asset_cost import AssetCostCreate
|
from app.schemas.asset_cost import AssetCostCreate
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -14,8 +15,101 @@ logger = logging.getLogger(__name__)
|
|||||||
# These trigger automatic AssetEvent creation
|
# These trigger automatic AssetEvent creation
|
||||||
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_user_role_in_org(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
organization_id: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Resolve the user's role within the given organization.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
|
||||||
|
Returns 'MEMBER' as default if no membership found.
|
||||||
|
"""
|
||||||
|
stmt = select(OrganizationMember.role).where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
role = result.scalar_one_or_none()
|
||||||
|
return role or "MEMBER"
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_org_capability(
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
organization_id: int,
|
||||||
|
capability_name: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
permissions JSONB oszlopából ellenőrzi a kért képességet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True ha a user rendelkezik a képességgel.
|
||||||
|
"""
|
||||||
|
# 1. Get user's org role
|
||||||
|
member_stmt = select(OrganizationMember.role).where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
member_result = await db.execute(member_stmt)
|
||||||
|
org_role_name = member_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org_role_name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Get permissions from fleet.org_roles table
|
||||||
|
role_stmt = select(OrgRole.permissions).where(
|
||||||
|
OrgRole.name_key == org_role_name,
|
||||||
|
OrgRole.is_active == True
|
||||||
|
).limit(1)
|
||||||
|
role_result = await db.execute(role_stmt)
|
||||||
|
permissions = role_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not permissions:
|
||||||
|
# Fallback to OrganizationMember.permissions
|
||||||
|
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||||
|
OrganizationMember.user_id == user_id,
|
||||||
|
OrganizationMember.organization_id == organization_id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
).limit(1)
|
||||||
|
member_perm_result = await db.execute(member_perm_stmt)
|
||||||
|
permissions = member_perm_result.scalar_one_or_none() or {}
|
||||||
|
|
||||||
|
if not isinstance(permissions, dict):
|
||||||
|
permissions = {}
|
||||||
|
|
||||||
|
return permissions.get(capability_name, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
|
||||||
|
"""
|
||||||
|
Calculate net amount from gross amount and VAT rate.
|
||||||
|
|
||||||
|
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
|
||||||
|
is the absolute Source of Truth. Net is calculated back from gross.
|
||||||
|
|
||||||
|
Formula: net = gross / (1 + vat_rate / 100)
|
||||||
|
|
||||||
|
If vat_rate is 0 or None, net = gross (0% VAT content).
|
||||||
|
"""
|
||||||
|
if vat_rate is None or vat_rate == 0:
|
||||||
|
return amount_gross
|
||||||
|
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("/", status_code=201)
|
||||||
async def create_expense(
|
async def create_expense(
|
||||||
expense: AssetCostCreate,
|
expense: AssetCostCreate,
|
||||||
@@ -26,9 +120,14 @@ async def create_expense(
|
|||||||
Create a new expense (fuel, service, tax, insurance) for an asset.
|
Create a new expense (fuel, service, tax, insurance) for an asset.
|
||||||
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
|
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
|
||||||
|
|
||||||
**Bidirectional Sync:**
|
**Role-Based Auto-Approval:**
|
||||||
|
- OWNER/ADMIN → status = APPROVED
|
||||||
|
- DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
|
||||||
|
|
||||||
|
**Smart Linking (Double-Entry Avoidance):**
|
||||||
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
|
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
|
||||||
an AssetEvent is automatically created and linked to the cost via cost_id.
|
an AssetEvent is automatically created with MISSING_TECH_DATA status
|
||||||
|
and linked via linked_asset_event_id / linked_expense_id.
|
||||||
"""
|
"""
|
||||||
# Validate asset exists
|
# Validate asset exists
|
||||||
stmt = select(Asset).where(Asset.id == expense.asset_id)
|
stmt = select(Asset).where(Asset.id == expense.asset_id)
|
||||||
@@ -89,6 +188,48 @@ async def create_expense(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||||
|
|
||||||
|
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
|
||||||
|
# JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
|
||||||
|
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
|
||||||
|
|
||||||
|
# Check if user has can_approve_expense capability → auto-approved
|
||||||
|
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
|
||||||
|
|
||||||
|
# Determine expense status based on capabilities
|
||||||
|
if can_approve:
|
||||||
|
# User has can_approve_expense → auto-approved
|
||||||
|
expense_status = "APPROVED"
|
||||||
|
elif expense.category_id in FUEL_CATEGORY_IDS:
|
||||||
|
# Fuel costs are auto-approved even without can_approve_expense
|
||||||
|
expense_status = "APPROVED"
|
||||||
|
else:
|
||||||
|
# User lacks can_approve_expense → pending approval
|
||||||
|
expense_status = "PENDING_APPROVAL"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Capability-based approval for user {current_user.id} (role={user_role}) "
|
||||||
|
f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ──
|
||||||
|
# In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth.
|
||||||
|
amount_gross = expense.amount_gross
|
||||||
|
vat_rate = expense.vat_rate
|
||||||
|
amount_net = expense.amount_net
|
||||||
|
|
||||||
|
# Auto-calculate net from gross if vat_rate is provided but net is not
|
||||||
|
if vat_rate is not None and amount_net is None:
|
||||||
|
amount_net = _calculate_net_from_gross(amount_gross, vat_rate)
|
||||||
|
# Auto-calculate vat_rate if net is provided but vat_rate is not
|
||||||
|
elif amount_net is not None and vat_rate is None and amount_gross > 0:
|
||||||
|
# vat_rate = ((gross / net) - 1) * 100
|
||||||
|
vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100")
|
||||||
|
vat_rate = vat_rate.quantize(Decimal("0.01"))
|
||||||
|
# If only gross is provided (no net, no vat) → net = gross (0% VAT)
|
||||||
|
elif amount_net is None and vat_rate is None:
|
||||||
|
amount_net = amount_gross
|
||||||
|
vat_rate = Decimal("0")
|
||||||
|
|
||||||
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
|
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
|
||||||
data = expense.data.copy() if expense.data else {}
|
data = expense.data.copy() if expense.data else {}
|
||||||
if expense.mileage_at_cost is not None:
|
if expense.mileage_at_cost is not None:
|
||||||
@@ -97,22 +238,25 @@ async def create_expense(
|
|||||||
data["description"] = expense.description
|
data["description"] = expense.description
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create AssetCost instance
|
# 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,
|
||||||
category_id=expense.category_id,
|
category_id=expense.category_id,
|
||||||
amount_net=expense.amount_net,
|
amount_net=amount_net,
|
||||||
|
amount_gross=amount_gross,
|
||||||
|
vat_rate=vat_rate,
|
||||||
currency=expense.currency,
|
currency=expense.currency,
|
||||||
date=expense.date,
|
date=expense.date,
|
||||||
invoice_number=data.get("invoice_number"),
|
invoice_number=data.get("invoice_number"),
|
||||||
|
status=expense_status,
|
||||||
data=data
|
data=data
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
|
# ── PHASE 2: 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
|
||||||
@@ -121,6 +265,10 @@ async def create_expense(
|
|||||||
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
|
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
|
||||||
mileage = expense.mileage_at_cost
|
mileage = expense.mileage_at_cost
|
||||||
|
|
||||||
|
# Determine event status based on capability
|
||||||
|
# can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA
|
||||||
|
event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA"
|
||||||
|
|
||||||
new_event = AssetEvent(
|
new_event = AssetEvent(
|
||||||
asset_id=expense.asset_id,
|
asset_id=expense.asset_id,
|
||||||
user_id=getattr(current_user, 'id', None),
|
user_id=getattr(current_user, 'id', None),
|
||||||
@@ -129,15 +277,21 @@ async def create_expense(
|
|||||||
odometer_reading=mileage,
|
odometer_reading=mileage,
|
||||||
description=description,
|
description=description,
|
||||||
cost_id=new_cost.id,
|
cost_id=new_cost.id,
|
||||||
|
linked_expense_id=new_cost.id, # Bidirectional link
|
||||||
|
status=event_status,
|
||||||
event_date=expense.date or datetime.now(timezone.utc),
|
event_date=expense.date or datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
db.add(new_event)
|
db.add(new_event)
|
||||||
await db.flush() # Flush to get new_event.id
|
await db.flush() # Flush to get new_event.id
|
||||||
event_id = new_event.id
|
event_id = new_event.id
|
||||||
|
|
||||||
|
# Update the cost record with the link back to the event
|
||||||
|
new_cost.linked_asset_event_id = new_event.id
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
|
f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
|
||||||
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
|
f"for AssetCost {new_cost.id} (category_id={expense.category_id}). "
|
||||||
|
f"Bidirectional link established."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update Asset.current_mileage if mileage_at_cost is higher
|
# Update Asset.current_mileage if mileage_at_cost is higher
|
||||||
@@ -152,8 +306,11 @@ async def create_expense(
|
|||||||
"id": new_cost.id,
|
"id": new_cost.id,
|
||||||
"asset_id": new_cost.asset_id,
|
"asset_id": new_cost.asset_id,
|
||||||
"category_id": new_cost.category_id,
|
"category_id": new_cost.category_id,
|
||||||
"amount_net": new_cost.amount_net,
|
"amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
|
||||||
"date": new_cost.date,
|
"amount_net": str(new_cost.amount_net),
|
||||||
|
"vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None,
|
||||||
|
"expense_status": new_cost.status,
|
||||||
|
"date": new_cost.date.isoformat() if new_cost.date else None,
|
||||||
"event_id": str(event_id) if event_id else None,
|
"event_id": str(event_id) if event_id else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async def check_finance_admin_access(
|
|||||||
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
|
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
|
||||||
In our system, this translates to role being 'superadmin' or 'admin'.
|
In our system, this translates to role being 'superadmin' or 'admin'.
|
||||||
"""
|
"""
|
||||||
if current_user.role not in [UserRole.superadmin, UserRole.admin]:
|
if current_user.role not in [UserRole.SUPERADMIN, UserRole.ADMIN]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."
|
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ from sqlalchemy.orm import selectinload
|
|||||||
from pydantic import BaseModel, Field, ConfigDict, EmailStr
|
from pydantic import BaseModel, Field, ConfigDict, EmailStr
|
||||||
|
|
||||||
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, RequireSystemCapability
|
||||||
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.services.billing_engine import upgrade_org_subscription
|
||||||
|
from app.core.capabilities import Capability
|
||||||
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.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -219,7 +222,14 @@ async def get_my_organizations(
|
|||||||
"subscription_plan": o.subscription_plan,
|
"subscription_plan": o.subscription_plan,
|
||||||
"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) ──
|
||||||
|
"address_zip": o.address_zip,
|
||||||
|
"address_city": o.address_city,
|
||||||
|
"address_street_name": o.address_street_name,
|
||||||
|
"address_street_type": o.address_street_type,
|
||||||
|
"address_house_number": o.address_house_number,
|
||||||
|
"address_hrsz": o.address_hrsz,
|
||||||
}
|
}
|
||||||
for o in orgs
|
for o in orgs
|
||||||
]
|
]
|
||||||
@@ -663,19 +673,7 @@ async def update_organization(
|
|||||||
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
|
||||||
"""
|
"""
|
||||||
# 1. Jogosultság ellenőrzése
|
# 1. Jogosultság ellenőrzése
|
||||||
stmt_member = select(OrganizationMember).where(
|
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
|
||||||
(OrganizationMember.organization_id == org_id) &
|
|
||||||
(OrganizationMember.user_id == current_user.id) &
|
|
||||||
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
|
||||||
)
|
|
||||||
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
|
||||||
if not member:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Szervezet lekérése
|
|
||||||
stmt_org = select(Organization).where(
|
stmt_org = select(Organization).where(
|
||||||
Organization.id == org_id,
|
Organization.id == org_id,
|
||||||
Organization.is_deleted == False
|
Organization.is_deleted == False
|
||||||
@@ -688,7 +686,23 @@ async def update_organization(
|
|||||||
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
|
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. JSONB merge visual_settings esetén
|
# Jogosultság: OWNER/ADMIN a OrganizationMember táblában VAGY owner_id a Organization-ben
|
||||||
|
is_owner_by_field = (org.owner_id == current_user.id)
|
||||||
|
|
||||||
|
stmt_member = select(OrganizationMember).where(
|
||||||
|
(OrganizationMember.organization_id == org_id) &
|
||||||
|
(OrganizationMember.user_id == current_user.id) &
|
||||||
|
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
|
||||||
|
)
|
||||||
|
member = (await db.execute(stmt_member)).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not member and not is_owner_by_field:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. JSONB merge visual_settings esetén
|
||||||
update_dict = update_data.model_dump(exclude_unset=True)
|
update_dict = update_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
|
||||||
@@ -717,6 +731,63 @@ async def update_organization(
|
|||||||
return OrganizationResponse.model_validate(org)
|
return OrganizationResponse.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
# ── P0 FEATURE: SUBSCRIPTION & PACKAGE ASSIGNMENT BRIDGE ──
|
||||||
|
|
||||||
|
class SubscriptionAssignIn(BaseModel):
|
||||||
|
"""PUT body a szervezet előfizetési csomagjának beállításához."""
|
||||||
|
tier_id: int = Field(..., description="SubscriptionTier ID a system.subscription_tiers táblából")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{org_id}/subscription", response_model=dict)
|
||||||
|
async def assign_organization_subscription(
|
||||||
|
org_id: int,
|
||||||
|
body: SubscriptionAssignIn,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
_: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
|
||||||
|
|
||||||
|
P0 Feature: This endpoint assigns a subscription_tier to an organization.
|
||||||
|
It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
|
||||||
|
|
||||||
|
The endpoint:
|
||||||
|
1. Validates the tier exists in system.subscription_tiers
|
||||||
|
2. Sets subscription_tier_id on the Organization record
|
||||||
|
3. Updates subscription_plan and base_asset_limit from tier rules
|
||||||
|
4. Creates/updates a finance.org_subscriptions audit record
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await upgrade_org_subscription(
|
||||||
|
db=db,
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=body.tier_id,
|
||||||
|
actor_user_id=current_user.id
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
await security_service.log_event(
|
||||||
|
db, user_id=current_user.id, action="ORG_SUBSCRIPTION_ASSIGNED",
|
||||||
|
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
|
||||||
|
new_data={"tier_id": body.tier_id, "tier_name": result.get("tier_name")}
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"Subscription assignment failed: org_id={org_id}, tier_id={body.tier_id}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
|
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
|
||||||
|
|
||||||
class JoinRequestIn(BaseModel):
|
class JoinRequestIn(BaseModel):
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async def request_action(
|
|||||||
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
|
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
|
||||||
"""
|
"""
|
||||||
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
|
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
|
||||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
|
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
|
||||||
@@ -69,7 +69,7 @@ async def list_pending_actions(
|
|||||||
Admin és superadmin látja az összes függőben lévő műveletet.
|
Admin és superadmin látja az összes függőben lévő műveletet.
|
||||||
Egyéb felhasználók csak a sajátjaikat láthatják.
|
Egyéb felhasználók csak a sajátjaikat láthatják.
|
||||||
"""
|
"""
|
||||||
if current_user.role in [UserRole.admin, UserRole.superadmin]:
|
if current_user.role in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||||
user_id = None
|
user_id = None
|
||||||
else:
|
else:
|
||||||
user_id = current_user.id
|
user_id = current_user.id
|
||||||
@@ -89,7 +89,7 @@ async def approve_action(
|
|||||||
|
|
||||||
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
|
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
|
||||||
"""
|
"""
|
||||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
|
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
|
||||||
@@ -122,7 +122,7 @@ async def reject_action(
|
|||||||
|
|
||||||
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
|
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
|
||||||
"""
|
"""
|
||||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
|
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
|
||||||
@@ -164,7 +164,7 @@ async def get_action(
|
|||||||
if not action:
|
if not action:
|
||||||
raise HTTPException(status_code=404, detail="Művelet nem található.")
|
raise HTTPException(status_code=404, detail="Művelet nem található.")
|
||||||
|
|
||||||
if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN] and action.requester_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Nincs jogosultságod ehhez a művelethez."
|
detail="Nincs jogosultságod ehhez a művelethez."
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ async def update_system_parameter(
|
|||||||
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
|
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
|
||||||
"""
|
"""
|
||||||
# Jogosultság ellenőrzése
|
# Jogosultság ellenőrzése
|
||||||
if current_user.role not in (UserRole.superadmin, UserRole.admin):
|
if current_user.role not in (UserRole.SUPERADMIN, UserRole.ADMIN):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Insufficient permissions. Only superadmin or admin can update system parameters."
|
detail="Insufficient permissions. Only superadmin or admin can update system parameters."
|
||||||
|
|||||||
@@ -41,13 +41,15 @@ class NetworkResponse(BaseModel):
|
|||||||
level3: List[NetworkMemberL2L3]
|
level3: List[NetworkMemberL2L3]
|
||||||
|
|
||||||
|
|
||||||
def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
|
def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
Segédfüggvény a UserResponse dict előállításához.
|
Segédfüggvény a UserResponse dict előállításához.
|
||||||
Beágyazza a Person és Address adatokat a 'person' mezőbe.
|
Beágyazza a Person és Address adatokat a 'person' mezőbe.
|
||||||
|
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
|
||||||
"""
|
"""
|
||||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
from app.models.marketplace.organization import OrgUserRole
|
from app.models.marketplace.organization import OrgUserRole
|
||||||
|
from app.core.capabilities import get_capabilities_for_role
|
||||||
|
|
||||||
# Determine active organization ID
|
# Determine active organization ID
|
||||||
if active_org_id is None:
|
if active_org_id is None:
|
||||||
@@ -101,6 +103,17 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
|||||||
first_name = person.first_name if person else ""
|
first_name = person.first_name if person else ""
|
||||||
last_name = person.last_name if person else ""
|
last_name = person.last_name if person else ""
|
||||||
|
|
||||||
|
# ── RBAC Phase 3: Resolve system capabilities ──
|
||||||
|
role_key = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||||
|
system_capabilities = get_capabilities_for_role(role_key)
|
||||||
|
|
||||||
|
# ── RBAC Phase 3: Resolve org capabilities ──
|
||||||
|
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||||
|
if db is not None:
|
||||||
|
# We need to run this synchronously since _build_user_response is not async
|
||||||
|
# The async version is handled in read_users_me directly
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
@@ -109,7 +122,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
|||||||
"is_active": user.is_active,
|
"is_active": user.is_active,
|
||||||
"region_code": user.region_code,
|
"region_code": user.region_code,
|
||||||
"person_id": user.person_id,
|
"person_id": user.person_id,
|
||||||
"role": user.role.value if hasattr(user.role, 'value') else str(user.role),
|
"role": role_key,
|
||||||
"subscription_plan": user.subscription_plan,
|
"subscription_plan": user.subscription_plan,
|
||||||
"scope_level": user.scope_level or "individual",
|
"scope_level": user.scope_level or "individual",
|
||||||
"scope_id": str(active_org_id) if active_org_id else None,
|
"scope_id": str(active_org_id) if active_org_id else None,
|
||||||
@@ -118,6 +131,10 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
|
|||||||
"preferred_language": user.preferred_language or "hu",
|
"preferred_language": user.preferred_language or "hu",
|
||||||
"person": person_data,
|
"person": person_data,
|
||||||
"is_last_admin": False, # Default, will be overridden in read_users_me
|
"is_last_admin": False, # Default, will be overridden in read_users_me
|
||||||
|
# RBAC Phase 3: Rendszerszintű képességek
|
||||||
|
"system_capabilities": system_capabilities,
|
||||||
|
# RBAC Phase 3: Szervezeti képességek (feltöltve a read_users_me végpontban)
|
||||||
|
"org_capabilities": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -126,9 +143,11 @@ async def read_users_me(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
|
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.
|
||||||
|
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba."""
|
||||||
from app.models.marketplace.organization import Organization, OrganizationMember
|
from app.models.marketplace.organization import Organization, OrganizationMember
|
||||||
from app.models.marketplace.organization import OrgUserRole
|
from app.models.marketplace.organization import OrgUserRole
|
||||||
|
from app.core.capabilities import get_capabilities_for_role
|
||||||
|
|
||||||
# Determine active organization ID
|
# Determine active organization ID
|
||||||
active_org_id = None
|
active_org_id = None
|
||||||
@@ -178,8 +197,53 @@ async def read_users_me(
|
|||||||
|
|
||||||
# Check if user is the last admin in any organization
|
# Check if user is the last admin in any organization
|
||||||
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
|
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
|
||||||
|
|
||||||
|
# ── RBAC Phase 3: Build base response ──
|
||||||
response_data = _build_user_response(current_user, active_org_id)
|
response_data = _build_user_response(current_user, active_org_id)
|
||||||
response_data["is_last_admin"] = is_last_admin
|
response_data["is_last_admin"] = is_last_admin
|
||||||
|
|
||||||
|
# ── RBAC Phase 3: Resolve org_capabilities ──
|
||||||
|
# Get all organizations the user is a member of
|
||||||
|
org_memberships_stmt = select(
|
||||||
|
OrganizationMember.organization_id,
|
||||||
|
OrganizationMember.role
|
||||||
|
).where(
|
||||||
|
OrganizationMember.user_id == current_user.id,
|
||||||
|
OrganizationMember.status == "active"
|
||||||
|
)
|
||||||
|
org_memberships_result = await db.execute(org_memberships_stmt)
|
||||||
|
org_memberships = org_memberships_result.all()
|
||||||
|
|
||||||
|
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||||
|
for membership in org_memberships:
|
||||||
|
org_id = str(membership.organization_id)
|
||||||
|
org_role_name = membership.role
|
||||||
|
|
||||||
|
# Try to get permissions from OrgRole table first
|
||||||
|
role_stmt = select(OrgRole.permissions).where(
|
||||||
|
OrgRole.name_key == org_role_name,
|
||||||
|
OrgRole.is_active == True
|
||||||
|
).limit(1)
|
||||||
|
role_result = await db.execute(role_stmt)
|
||||||
|
permissions = role_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not permissions:
|
||||||
|
# Fallback: use OrganizationMember.permissions
|
||||||
|
member_perm_stmt = select(OrganizationMember.permissions).where(
|
||||||
|
OrganizationMember.user_id == current_user.id,
|
||||||
|
OrganizationMember.organization_id == membership.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 = {}
|
||||||
|
|
||||||
|
org_capabilities[org_id] = permissions
|
||||||
|
|
||||||
|
response_data["org_capabilities"] = org_capabilities
|
||||||
|
|
||||||
return UserResponse.model_validate(response_data)
|
return UserResponse.model_validate(response_data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
295
backend/app/core/capabilities.py
Normal file
295
backend/app/core/capabilities.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/core/capabilities.py
|
||||||
|
"""
|
||||||
|
🎯 SYSTEM CAPABILITIES MATRIX (RBAC Phase 1)
|
||||||
|
|
||||||
|
Fine-grained, capability-driven permission matrix for system-level roles.
|
||||||
|
Each role maps to a set of boolean capabilities that define what the role
|
||||||
|
can do within the platform.
|
||||||
|
|
||||||
|
Architecture:
|
||||||
|
- SUPERADMIN: Full system access (rank 100)
|
||||||
|
- ADMIN: Full admin panel access (rank 90)
|
||||||
|
- MODERATOR: Content moderation capabilities (rank 75)
|
||||||
|
- SALES_REP: Sales & discount management (rank 30)
|
||||||
|
- SERVICE_MGR: Service provider & taxonomy management (rank 40)
|
||||||
|
- USER: Basic platform user (rank 10)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Set
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# CAPABILITY KEYS (for type-safe references)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Capability:
|
||||||
|
"""Central registry of all capability keys."""
|
||||||
|
|
||||||
|
# ── Admin & System ──
|
||||||
|
CAN_MANAGE_USERS = "can_manage_users"
|
||||||
|
CAN_MANAGE_ROLES = "can_manage_roles"
|
||||||
|
CAN_VIEW_SYSTEM_LOGS = "can_view_system_logs"
|
||||||
|
CAN_MANAGE_SYSTEM_CONFIG = "can_manage_system_config"
|
||||||
|
CAN_MANAGE_TRANSLATIONS = "can_manage_translations"
|
||||||
|
|
||||||
|
# ── Moderation ──
|
||||||
|
CAN_MODERATE_CONTENT = "can_moderate_content"
|
||||||
|
CAN_VIEW_REPORTS = "can_view_reports"
|
||||||
|
CAN_SUSPEND_USERS = "can_suspend_users"
|
||||||
|
CAN_APPROVE_PROVIDER = "can_approve_provider"
|
||||||
|
|
||||||
|
# ── Sales & Marketing ──
|
||||||
|
CAN_VIEW_ORG_STATS = "can_view_org_stats"
|
||||||
|
CAN_MANAGE_DISCOUNTS = "can_manage_discounts"
|
||||||
|
CAN_MANAGE_PROMOTIONS = "can_manage_promotions"
|
||||||
|
CAN_VIEW_SALES_PIPELINE = "can_view_sales_pipeline"
|
||||||
|
|
||||||
|
# ── Service Management ──
|
||||||
|
CAN_EDIT_MASTER_TAXONOMY = "can_edit_master_taxonomy"
|
||||||
|
CAN_MANAGE_SERVICE_CATEGORIES = "can_manage_service_categories"
|
||||||
|
CAN_VERIFY_SERVICE_PROVIDERS = "can_verify_service_providers"
|
||||||
|
CAN_MANAGE_EXPERTISE_TAGS = "can_manage_expertise_tags"
|
||||||
|
|
||||||
|
# ── Vehicle & Catalog ──
|
||||||
|
CAN_MANAGE_VEHICLE_CATALOG = "can_manage_vehicle_catalog"
|
||||||
|
CAN_APPROVE_VEHICLE_DEFINITIONS = "can_approve_vehicle_definitions"
|
||||||
|
CAN_VIEW_TECHNICAL_SPECS = "can_view_technical_specs"
|
||||||
|
|
||||||
|
# ── Finance ──
|
||||||
|
CAN_VIEW_FINANCIAL_REPORTS = "can_view_financial_reports"
|
||||||
|
CAN_MANAGE_BILLING = "can_manage_billing"
|
||||||
|
CAN_ISSUE_REFUNDS = "can_issue_refunds"
|
||||||
|
CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"
|
||||||
|
|
||||||
|
# ── Basic User ──
|
||||||
|
CAN_MANAGE_OWN_PROFILE = "can_manage_own_profile"
|
||||||
|
CAN_MANAGE_OWN_VEHICLES = "can_manage_own_vehicles"
|
||||||
|
CAN_CREATE_SERVICE_REQUEST = "can_create_service_request"
|
||||||
|
CAN_VIEW_PUBLIC_CATALOG = "can_view_public_catalog"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# SYSTEM CAPABILITIES MATRIX
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
SYSTEM_CAPABILITIES_MATRIX: Dict[str, Dict[str, bool]] = {
|
||||||
|
# ── SUPERADMIN: Full system access ──
|
||||||
|
"SUPERADMIN": {
|
||||||
|
# Admin & System
|
||||||
|
Capability.CAN_MANAGE_USERS: True,
|
||||||
|
Capability.CAN_MANAGE_ROLES: True,
|
||||||
|
Capability.CAN_VIEW_SYSTEM_LOGS: True,
|
||||||
|
Capability.CAN_MANAGE_SYSTEM_CONFIG: True,
|
||||||
|
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||||
|
# Moderation
|
||||||
|
Capability.CAN_MODERATE_CONTENT: True,
|
||||||
|
Capability.CAN_VIEW_REPORTS: True,
|
||||||
|
Capability.CAN_SUSPEND_USERS: True,
|
||||||
|
Capability.CAN_APPROVE_PROVIDER: True,
|
||||||
|
# Sales
|
||||||
|
Capability.CAN_VIEW_ORG_STATS: True,
|
||||||
|
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||||
|
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||||
|
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||||
|
# Service Management
|
||||||
|
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||||
|
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||||
|
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||||
|
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||||
|
# Vehicle & Catalog
|
||||||
|
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
|
||||||
|
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
|
||||||
|
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||||
|
# Finance
|
||||||
|
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
|
||||||
|
Capability.CAN_MANAGE_BILLING: True,
|
||||||
|
Capability.CAN_ISSUE_REFUNDS: True,
|
||||||
|
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
|
||||||
|
# Basic User
|
||||||
|
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||||
|
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||||
|
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── ADMIN: Full admin panel access ──
|
||||||
|
"ADMIN": {
|
||||||
|
Capability.CAN_MANAGE_USERS: True,
|
||||||
|
Capability.CAN_MANAGE_ROLES: False,
|
||||||
|
Capability.CAN_VIEW_SYSTEM_LOGS: True,
|
||||||
|
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||||
|
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||||
|
Capability.CAN_MODERATE_CONTENT: True,
|
||||||
|
Capability.CAN_VIEW_REPORTS: True,
|
||||||
|
Capability.CAN_SUSPEND_USERS: True,
|
||||||
|
Capability.CAN_APPROVE_PROVIDER: True,
|
||||||
|
Capability.CAN_VIEW_ORG_STATS: True,
|
||||||
|
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||||
|
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||||
|
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||||
|
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||||
|
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||||
|
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||||
|
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||||
|
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
|
||||||
|
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
|
||||||
|
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||||
|
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
|
||||||
|
Capability.CAN_MANAGE_BILLING: False,
|
||||||
|
Capability.CAN_ISSUE_REFUNDS: False,
|
||||||
|
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||||
|
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||||
|
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── MODERATOR: Content moderation ──
|
||||||
|
"MODERATOR": {
|
||||||
|
Capability.CAN_MANAGE_USERS: False,
|
||||||
|
Capability.CAN_MANAGE_ROLES: False,
|
||||||
|
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||||
|
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||||
|
Capability.CAN_MANAGE_TRANSLATIONS: True,
|
||||||
|
Capability.CAN_MODERATE_CONTENT: True,
|
||||||
|
Capability.CAN_VIEW_REPORTS: True,
|
||||||
|
Capability.CAN_SUSPEND_USERS: False,
|
||||||
|
Capability.CAN_APPROVE_PROVIDER: True,
|
||||||
|
Capability.CAN_VIEW_ORG_STATS: False,
|
||||||
|
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||||
|
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||||
|
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||||
|
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||||
|
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||||
|
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||||
|
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||||
|
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||||
|
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||||
|
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||||
|
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||||
|
Capability.CAN_MANAGE_BILLING: False,
|
||||||
|
Capability.CAN_ISSUE_REFUNDS: False,
|
||||||
|
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||||
|
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||||
|
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── SALES_REP: Sales & discount management ──
|
||||||
|
"SALES_REP": {
|
||||||
|
Capability.CAN_MANAGE_USERS: False,
|
||||||
|
Capability.CAN_MANAGE_ROLES: False,
|
||||||
|
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||||
|
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||||
|
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||||
|
Capability.CAN_MODERATE_CONTENT: False,
|
||||||
|
Capability.CAN_VIEW_REPORTS: False,
|
||||||
|
Capability.CAN_SUSPEND_USERS: False,
|
||||||
|
Capability.CAN_APPROVE_PROVIDER: False,
|
||||||
|
Capability.CAN_VIEW_ORG_STATS: True,
|
||||||
|
Capability.CAN_MANAGE_DISCOUNTS: True,
|
||||||
|
Capability.CAN_MANAGE_PROMOTIONS: True,
|
||||||
|
Capability.CAN_VIEW_SALES_PIPELINE: True,
|
||||||
|
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||||
|
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
|
||||||
|
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
|
||||||
|
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
|
||||||
|
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||||
|
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||||
|
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
|
||||||
|
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||||
|
Capability.CAN_MANAGE_BILLING: False,
|
||||||
|
Capability.CAN_ISSUE_REFUNDS: False,
|
||||||
|
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||||
|
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||||
|
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── SERVICE_MGR: Service provider & taxonomy management ──
|
||||||
|
"SERVICE_MGR": {
|
||||||
|
Capability.CAN_MANAGE_USERS: False,
|
||||||
|
Capability.CAN_MANAGE_ROLES: False,
|
||||||
|
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||||
|
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||||
|
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||||
|
Capability.CAN_MODERATE_CONTENT: False,
|
||||||
|
Capability.CAN_VIEW_REPORTS: False,
|
||||||
|
Capability.CAN_SUSPEND_USERS: False,
|
||||||
|
Capability.CAN_APPROVE_PROVIDER: True,
|
||||||
|
Capability.CAN_VIEW_ORG_STATS: False,
|
||||||
|
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||||
|
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||||
|
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||||
|
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
|
||||||
|
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
|
||||||
|
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
|
||||||
|
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
|
||||||
|
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||||
|
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||||
|
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
|
||||||
|
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||||
|
Capability.CAN_MANAGE_BILLING: False,
|
||||||
|
Capability.CAN_ISSUE_REFUNDS: False,
|
||||||
|
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||||
|
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||||
|
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── USER: Basic platform user ──
|
||||||
|
"USER": {
|
||||||
|
Capability.CAN_MANAGE_USERS: False,
|
||||||
|
Capability.CAN_MANAGE_ROLES: False,
|
||||||
|
Capability.CAN_VIEW_SYSTEM_LOGS: False,
|
||||||
|
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
|
||||||
|
Capability.CAN_MANAGE_TRANSLATIONS: False,
|
||||||
|
Capability.CAN_MODERATE_CONTENT: False,
|
||||||
|
Capability.CAN_VIEW_REPORTS: False,
|
||||||
|
Capability.CAN_SUSPEND_USERS: False,
|
||||||
|
Capability.CAN_APPROVE_PROVIDER: False,
|
||||||
|
Capability.CAN_VIEW_ORG_STATS: False,
|
||||||
|
Capability.CAN_MANAGE_DISCOUNTS: False,
|
||||||
|
Capability.CAN_MANAGE_PROMOTIONS: False,
|
||||||
|
Capability.CAN_VIEW_SALES_PIPELINE: False,
|
||||||
|
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
|
||||||
|
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
|
||||||
|
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
|
||||||
|
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
|
||||||
|
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
|
||||||
|
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
|
||||||
|
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
|
||||||
|
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
|
||||||
|
Capability.CAN_MANAGE_BILLING: False,
|
||||||
|
Capability.CAN_ISSUE_REFUNDS: False,
|
||||||
|
Capability.CAN_MANAGE_OWN_PROFILE: True,
|
||||||
|
Capability.CAN_MANAGE_OWN_VEHICLES: True,
|
||||||
|
Capability.CAN_CREATE_SERVICE_REQUEST: True,
|
||||||
|
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_capabilities_for_role(role_key: str) -> Dict[str, bool]:
|
||||||
|
"""Return the capability dict for a given role key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN', 'USER')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of capability -> bool, or empty dict if role not found.
|
||||||
|
"""
|
||||||
|
return SYSTEM_CAPABILITIES_MATRIX.get(role_key.upper(), {})
|
||||||
|
|
||||||
|
|
||||||
|
def role_has_capability(role_key: str, capability: str) -> bool:
|
||||||
|
"""Check if a role has a specific capability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN')
|
||||||
|
capability: The capability key (e.g. 'can_manage_users')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the role has the capability, False otherwise.
|
||||||
|
"""
|
||||||
|
caps = get_capabilities_for_role(role_key)
|
||||||
|
return caps.get(capability, False)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
|
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
|
||||||
from fastapi import HTTPException, Depends, status
|
from fastapi import HTTPException, Depends, status
|
||||||
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, UserRole
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
class RBAC:
|
class RBAC:
|
||||||
@@ -11,11 +11,11 @@ class RBAC:
|
|||||||
|
|
||||||
async def __call__(self, current_user: User = Depends(get_current_user)):
|
async def __call__(self, current_user: User = Depends(get_current_user)):
|
||||||
# 1. Superadmin mindent visz (Rank 100)
|
# 1. Superadmin mindent visz (Rank 100)
|
||||||
if current_user.role == "superadmin":
|
if current_user.role == UserRole.SUPERADMIN:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
|
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
|
||||||
role_key = current_user.role.value.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
|
role_key = current_user.role.value # A DEFAULT_RANK_MAP már nagybetűs kulcsokat használ
|
||||||
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
|
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
|
||||||
if user_rank < self.min_rank:
|
if user_rank < self.min_rank:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -51,23 +51,14 @@ def generate_secure_slug(length: int = 16) -> str:
|
|||||||
alphabet = string.ascii_letters + string.digits
|
alphabet = string.ascii_letters + string.digits
|
||||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
# Teljesen a margón van, így globális konstans lesz!
|
# RBAC Phase 1: Tisztított rangtábla a rendszerszintű szerepkörökhöz.
|
||||||
# Lefedi a UserRole enum MIND a 10 értékét
|
# A szervezeti szerepkörök (OrgUserRole) külön kezelendők.
|
||||||
DEFAULT_RANK_MAP = {
|
DEFAULT_RANK_MAP = {
|
||||||
"SUPERADMIN": 100,
|
"SUPERADMIN": 100,
|
||||||
"ADMIN": 90,
|
"ADMIN": 90,
|
||||||
"REGION_ADMIN": 85,
|
|
||||||
"COUNTRY_ADMIN": 80,
|
|
||||||
"MODERATOR": 75,
|
"MODERATOR": 75,
|
||||||
"AUDITOR": 70,
|
"SERVICE_MGR": 40,
|
||||||
"ORGANIZATION_OWNER": 65,
|
"SALES_REP": 30,
|
||||||
"ORGANIZATION_MANAGER": 60,
|
|
||||||
"ORGANIZATION_MEMBER": 50,
|
|
||||||
"SERVICE_PROVIDER": 40,
|
|
||||||
"SALES_AGENT": 30,
|
|
||||||
"FLEET_MANAGER": 25,
|
|
||||||
"PREMIUM_USER": 20,
|
|
||||||
"USER": 10,
|
"USER": 10,
|
||||||
"DRIVER": 5,
|
|
||||||
"GUEST": 0
|
"GUEST": 0
|
||||||
}
|
}
|
||||||
@@ -22,16 +22,17 @@ if TYPE_CHECKING:
|
|||||||
from ..marketplace.service_request import ServiceRequest
|
from ..marketplace.service_request import ServiceRequest
|
||||||
|
|
||||||
class UserRole(str, enum.Enum):
|
class UserRole(str, enum.Enum):
|
||||||
superadmin = "superadmin"
|
"""Rendszerszintű (System-level) szerepkörök.
|
||||||
admin = "admin"
|
|
||||||
region_admin = "region_admin"
|
Csak a globális platform szerepkörök maradnak itt.
|
||||||
country_admin = "country_admin"
|
Szervezeti szerepkörök: lásd OrgUserRole a marketplace/organization.py-ban.
|
||||||
moderator = "moderator"
|
"""
|
||||||
sales_agent = "sales_agent"
|
SUPERADMIN = "SUPERADMIN"
|
||||||
user = "user"
|
ADMIN = "ADMIN"
|
||||||
service_owner = "service_owner"
|
MODERATOR = "MODERATOR"
|
||||||
fleet_manager = "fleet_manager"
|
SALES_REP = "SALES_REP"
|
||||||
driver = "driver"
|
SERVICE_MGR = "SERVICE_MGR"
|
||||||
|
USER = "USER"
|
||||||
|
|
||||||
class Person(Base):
|
class Person(Base):
|
||||||
"""
|
"""
|
||||||
@@ -133,7 +134,7 @@ class User(Base):
|
|||||||
|
|
||||||
role: Mapped[UserRole] = mapped_column(
|
role: Mapped[UserRole] = mapped_column(
|
||||||
PG_ENUM(UserRole, name="userrole", schema="identity"),
|
PG_ENUM(UserRole, name="userrole", schema="identity"),
|
||||||
default=UserRole.user
|
default=UserRole.USER
|
||||||
)
|
)
|
||||||
|
|
||||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||||
|
|||||||
@@ -25,17 +25,25 @@ class OrgType(str, enum.Enum):
|
|||||||
business = "business"
|
business = "business"
|
||||||
|
|
||||||
class OrgUserRole(str, enum.Enum):
|
class OrgUserRole(str, enum.Enum):
|
||||||
|
"""Szervezeti (Organization-level) szerepkörök.
|
||||||
|
|
||||||
|
Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
|
||||||
|
Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
|
||||||
|
"""
|
||||||
OWNER = "OWNER"
|
OWNER = "OWNER"
|
||||||
ADMIN = "ADMIN"
|
ADMIN = "ADMIN"
|
||||||
MANAGER = "MANAGER"
|
ACCOUNTANT = "ACCOUNTANT"
|
||||||
MEMBER = "MEMBER"
|
DRIVER = "DRIVER"
|
||||||
AGENT = "AGENT"
|
VIEWER = "VIEWER"
|
||||||
|
|
||||||
class OrgRole(Base):
|
class OrgRole(Base):
|
||||||
"""
|
"""
|
||||||
Dinamikus szerepkör modell (RBAC).
|
Dinamikus szerepkör modell (RBAC Phase 2).
|
||||||
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
|
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
|
||||||
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
|
Alapértelmezett szerepkörök: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER.
|
||||||
|
|
||||||
|
permissions: JSONB oszlop a capability-alapú jogosultságok tárolására.
|
||||||
|
Példa: {"can_add_expense": True, "can_approve_expense": False}
|
||||||
"""
|
"""
|
||||||
__tablename__ = "org_roles"
|
__tablename__ = "org_roles"
|
||||||
__table_args__ = {"schema": "fleet"}
|
__table_args__ = {"schema": "fleet"}
|
||||||
@@ -49,6 +57,16 @@ class OrgRole(Base):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=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())
|
||||||
|
|
||||||
|
# ── RBAC Phase 2: JSONB permissions column ──
|
||||||
|
# Stores capability flags for this role, e.g.:
|
||||||
|
# {"can_add_expense": True, "can_approve_expense": True, "can_view_financials": True}
|
||||||
|
permissions: Mapped[dict] = mapped_column(
|
||||||
|
JSONB,
|
||||||
|
nullable=False,
|
||||||
|
default=dict,
|
||||||
|
server_default=text("'{}'::jsonb")
|
||||||
|
)
|
||||||
|
|
||||||
class Organization(Base):
|
class Organization(Base):
|
||||||
"""
|
"""
|
||||||
Szervezet entitás (MB 2.0).
|
Szervezet entitás (MB 2.0).
|
||||||
@@ -119,10 +137,19 @@ class Organization(Base):
|
|||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
|
||||||
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
|
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
|
||||||
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
|
||||||
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
|
||||||
|
|
||||||
|
# P0 FEATURE: Subscription Tier FK — connects this org to a system.subscription_tiers record
|
||||||
|
subscription_tier_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("system.subscription_tiers.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
|
||||||
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||||
|
|
||||||
@@ -215,9 +242,12 @@ class OrganizationMember(Base):
|
|||||||
# Meghívó lejárati ideje
|
# Meghívó lejárati ideje
|
||||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
|
# Dinamikus szerepkör - PostgreSQL ENUM típus (fleet.orguserrole)
|
||||||
|
# A PG_ENUM használata biztosítja, hogy az SQLAlchemy megfelelő típuskényszerítést
|
||||||
|
# (type cast) alkalmazzon a lekérdezéseknél, elkerülve az
|
||||||
|
# "operator does not exist: fleet.orguserrole = character varying" hibát.
|
||||||
role: Mapped[str] = mapped_column(
|
role: Mapped[str] = mapped_column(
|
||||||
String(50),
|
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
|
||||||
default="MEMBER",
|
default="MEMBER",
|
||||||
server_default=text("'MEMBER'")
|
server_default=text("'MEMBER'")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -247,6 +247,13 @@ class AssetFinancials(Base):
|
|||||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
|
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
|
||||||
|
|
||||||
|
|
||||||
|
class AssetCostStatusEnum(str, enum.Enum):
|
||||||
|
"""Költség státuszok a jóváhagyási munkafolyamathoz."""
|
||||||
|
DRAFT = "DRAFT"
|
||||||
|
PENDING_APPROVAL = "PENDING_APPROVAL"
|
||||||
|
APPROVED = "APPROVED"
|
||||||
|
|
||||||
|
|
||||||
class AssetCost(Base):
|
class AssetCost(Base):
|
||||||
""" II. Üzemeltetés és TCO kimutatás. """
|
""" II. Üzemeltetés és TCO kimutatás. """
|
||||||
__tablename__ = "asset_costs"
|
__tablename__ = "asset_costs"
|
||||||
@@ -257,16 +264,36 @@ class AssetCost(Base):
|
|||||||
|
|
||||||
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
|
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
|
||||||
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
|
||||||
|
amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
|
||||||
|
vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
|
||||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
|
||||||
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
|
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
|
||||||
|
|
||||||
|
# Státusz a jóváhagyási munkafolyamathoz
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
|
||||||
|
|
||||||
|
# Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
|
||||||
|
linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
PG_UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicle.asset_events.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
|
||||||
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
|
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
|
||||||
organization: Mapped["Organization"] = relationship("Organization")
|
organization: Mapped["Organization"] = relationship("Organization")
|
||||||
category: Mapped["CostCategory"] = relationship("CostCategory")
|
category: Mapped["CostCategory"] = relationship("CostCategory")
|
||||||
|
|
||||||
|
# Kapcsolat a hivatkozott eseményhez
|
||||||
|
linked_event: Mapped[Optional["AssetEvent"]] = relationship(
|
||||||
|
"AssetEvent",
|
||||||
|
foreign_keys=[linked_asset_event_id],
|
||||||
|
post_update=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class VehicleLogbook(Base):
|
class VehicleLogbook(Base):
|
||||||
""" Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """
|
""" Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """
|
||||||
@@ -396,6 +423,13 @@ class AssetEventTypeEnum(str, enum.Enum):
|
|||||||
RECALL = "RECALL" # Visszahívás
|
RECALL = "RECALL" # Visszahívás
|
||||||
|
|
||||||
|
|
||||||
|
class AssetEventStatusEnum(str, enum.Enum):
|
||||||
|
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
|
||||||
|
DRAFT = "DRAFT"
|
||||||
|
MISSING_TECH_DATA = "MISSING_TECH_DATA"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
|
||||||
|
|
||||||
class AssetEvent(Base):
|
class AssetEvent(Base):
|
||||||
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
|
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
|
||||||
__tablename__ = "asset_events"
|
__tablename__ = "asset_events"
|
||||||
@@ -411,6 +445,17 @@ class AssetEvent(Base):
|
|||||||
description: Mapped[Optional[str]] = mapped_column(Text)
|
description: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
|
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
|
||||||
|
|
||||||
|
# Státusz a feldolgozási munkafolyamathoz
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
|
||||||
|
|
||||||
|
# Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése)
|
||||||
|
linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
PG_UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicle.asset_costs.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
event_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
event_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
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())
|
||||||
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())
|
||||||
@@ -419,7 +464,15 @@ 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")
|
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
|
||||||
|
|
||||||
|
# Kapcsolat a hivatkozott költséghez
|
||||||
|
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
|
||||||
|
"AssetCost",
|
||||||
|
foreign_keys=[linked_expense_id],
|
||||||
|
post_update=True,
|
||||||
|
primaryjoin="AssetEvent.linked_expense_id == AssetCost.id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ExchangeRate(Base):
|
class ExchangeRate(Base):
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ router = APIRouter()
|
|||||||
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
|
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
|
||||||
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
|
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
|
||||||
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
|
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
|
||||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Sentinel jogosultság szükséges a művelethez!"
|
detail="Sentinel jogosultság szükséges a művelethez!"
|
||||||
|
|||||||
@@ -386,6 +386,8 @@ class AssetEventCreate(BaseModel):
|
|||||||
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
|
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
|
||||||
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
|
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
|
||||||
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
|
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
|
||||||
|
status: Optional[str] = Field(None, description="Event status (DRAFT, MISSING_TECH_DATA, COMPLETED) - auto-set by backend")
|
||||||
|
linked_expense_id: Optional[UUID] = Field(None, description="Associated AssetCost ID (bidirectional link)")
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -402,6 +404,8 @@ class AssetEventResponse(BaseModel):
|
|||||||
cost_id: Optional[UUID] = None
|
cost_id: Optional[UUID] = None
|
||||||
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
||||||
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
||||||
|
status: str = "DRAFT"
|
||||||
|
linked_expense_id: Optional[UUID] = None
|
||||||
event_date: datetime
|
event_date: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
@@ -410,7 +414,10 @@ class AssetEventResponse(BaseModel):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def model_validate(cls, obj, **kwargs):
|
def model_validate(cls, obj, **kwargs):
|
||||||
"""Override to resolve cost_amount and currency from the related AssetCost."""
|
"""Override to resolve cost_amount and currency from the related AssetCost.
|
||||||
|
|
||||||
|
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
|
||||||
|
"""
|
||||||
data = {}
|
data = {}
|
||||||
if hasattr(obj, '__dict__'):
|
if hasattr(obj, '__dict__'):
|
||||||
# Copy ORM attributes
|
# Copy ORM attributes
|
||||||
@@ -422,7 +429,10 @@ class AssetEventResponse(BaseModel):
|
|||||||
cost = getattr(obj, 'cost', None)
|
cost = getattr(obj, 'cost', None)
|
||||||
if cost is not None:
|
if cost is not None:
|
||||||
if data.get('cost_amount') is None:
|
if data.get('cost_amount') is None:
|
||||||
data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
|
# GROSS-FIRST: prefer amount_gross as the source of truth
|
||||||
|
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
|
||||||
|
float(cost.amount_net) if cost.amount_net is not None else None
|
||||||
|
)
|
||||||
if data.get('currency') is None:
|
if data.get('currency') is None:
|
||||||
data['currency'] = cost.currency
|
data['currency'] = cost.currency
|
||||||
return cls(**data)
|
return cls(**data)
|
||||||
@@ -1,13 +1,38 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
|
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
class AssetCostBase(BaseModel):
|
class AssetCostBase(BaseModel):
|
||||||
"""Base schema — matches the AssetCost SQLAlchemy model fields."""
|
"""Base schema — matches the AssetCost SQLAlchemy model fields.
|
||||||
amount_net: Decimal
|
|
||||||
|
GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary/required field.
|
||||||
|
In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth.
|
||||||
|
amount_net is optional — if only gross is provided, net = gross (0% VAT).
|
||||||
|
If vat_rate is also provided, net is calculated back from gross.
|
||||||
|
"""
|
||||||
|
amount_gross: Optional[Decimal] = Field(None, description="Bruttó összeg (ÁFA-val) — ELSŐDLEGES forrás, de lehet NULL legacy rekordoknál")
|
||||||
|
amount_net: Optional[Decimal] = Field(None, description="Nettó összeg (ÁFA nélkül) — OPCIONÁLIS, ha nincs megadva, bruttóból számolva")
|
||||||
|
|
||||||
|
@model_validator(mode='before')
|
||||||
|
@classmethod
|
||||||
|
def safety_net_gross_from_net(cls, data: Any) -> Any:
|
||||||
|
"""SAFETY NET (Graceful Degradation): If the incoming payload contains
|
||||||
|
amount_net but amount_gross is missing/None, automatically copy amount_net
|
||||||
|
to amount_gross. This protects against legacy/cached browsers that still
|
||||||
|
send amount_net in their payloads.
|
||||||
|
|
||||||
|
GROSS-FIRST (Masterbook 2.0.1): The backend is strictly Gross-first.
|
||||||
|
"""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
has_net = data.get('amount_net') is not None
|
||||||
|
has_gross = data.get('amount_gross') is not None
|
||||||
|
if has_net and not has_gross:
|
||||||
|
data['amount_gross'] = data['amount_net']
|
||||||
|
return data
|
||||||
|
vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)")
|
||||||
currency: str = "HUF"
|
currency: str = "HUF"
|
||||||
date: datetime = Field(default_factory=datetime.now)
|
date: datetime = Field(default_factory=datetime.now)
|
||||||
invoice_number: Optional[str] = None
|
invoice_number: Optional[str] = None
|
||||||
@@ -16,15 +41,45 @@ class AssetCostBase(BaseModel):
|
|||||||
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
|
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
|
||||||
description: Optional[str] = None # Stored inside data JSONB
|
description: Optional[str] = None # Stored inside data JSONB
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def validate_gross_first(self):
|
||||||
|
"""Gross-First validation: ensure amount_gross is the primary source.
|
||||||
|
|
||||||
|
If amount_net is not provided but vat_rate is, calculate net from gross.
|
||||||
|
If neither amount_net nor vat_rate is provided, net = gross (0% VAT).
|
||||||
|
If amount_gross is None (legacy data), default to 0 to prevent 500 errors.
|
||||||
|
"""
|
||||||
|
gross = self.amount_gross
|
||||||
|
net = self.amount_net
|
||||||
|
vat = self.vat_rate
|
||||||
|
|
||||||
|
# Fault tolerance: if gross is None (legacy NULL), default to 0
|
||||||
|
if gross is None:
|
||||||
|
self.amount_gross = Decimal("0")
|
||||||
|
gross = self.amount_gross
|
||||||
|
|
||||||
|
if net is None and vat is not None and vat > 0:
|
||||||
|
# Net will be calculated in the service layer from gross & vat
|
||||||
|
pass
|
||||||
|
elif net is None:
|
||||||
|
# No net and no vat → net = gross (0% VAT content)
|
||||||
|
self.amount_net = gross
|
||||||
|
self.vat_rate = Decimal("0")
|
||||||
|
return self
|
||||||
|
|
||||||
class AssetCostCreate(AssetCostBase):
|
class AssetCostCreate(AssetCostBase):
|
||||||
asset_id: UUID
|
asset_id: UUID
|
||||||
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
|
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
|
||||||
|
status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
|
||||||
|
linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
|
||||||
|
|
||||||
class AssetCostResponse(AssetCostBase):
|
class AssetCostResponse(AssetCostBase):
|
||||||
"""Response schema — matches DB columns from vehicle.asset_costs."""
|
"""Response schema — matches DB columns from vehicle.asset_costs."""
|
||||||
id: UUID
|
id: UUID
|
||||||
asset_id: UUID
|
asset_id: UUID
|
||||||
organization_id: int
|
organization_id: int
|
||||||
|
status: str = "DRAFT"
|
||||||
|
linked_asset_event_id: Optional[UUID] = None
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/schemas/asset_event.py
|
# /opt/docker/dev/service_finder/backend/app/schemas/asset_event.py
|
||||||
from pydantic import BaseModel, ConfigDict, Field, validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -17,6 +17,12 @@ class AssetEventTypeEnum(str, Enum):
|
|||||||
RECALL = "RECALL" # Visszahívás
|
RECALL = "RECALL" # Visszahívás
|
||||||
OTHER = "OTHER" # Egyéb
|
OTHER = "OTHER" # Egyéb
|
||||||
|
|
||||||
|
class AssetEventStatusEnum(str, Enum):
|
||||||
|
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
|
||||||
|
DRAFT = "DRAFT"
|
||||||
|
MISSING_TECH_DATA = "MISSING_TECH_DATA"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
|
||||||
class AssetEventCreate(BaseModel):
|
class AssetEventCreate(BaseModel):
|
||||||
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
|
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
|
||||||
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa")
|
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa")
|
||||||
@@ -24,9 +30,10 @@ class AssetEventCreate(BaseModel):
|
|||||||
description: str = Field(..., min_length=1, max_length=1000, description="Esemény leírása")
|
description: str = Field(..., min_length=1, max_length=1000, description="Esemény leírása")
|
||||||
event_date: Optional[datetime] = Field(None, description="Esemény dátuma (alapértelmezett: most)")
|
event_date: Optional[datetime] = Field(None, description="Esemény dátuma (alapértelmezett: most)")
|
||||||
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
|
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
|
||||||
|
cost_amount: Optional[float] = Field(None, gt=0, description="Költség összege. Ha > 0, automatikusan létrejön egy AssetCost rekord.")
|
||||||
# RBAC ellenőrzés: csak a tulajdonos vagy operátor szervezet adhat hozzá eseményt
|
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Valutakód (pl. HUF, EUR)")
|
||||||
# Ezt a service rétegben validáljuk, nem a sémában
|
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz (auto-set by backend)")
|
||||||
|
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID (kétirányú hivatkozáshoz)")
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -41,6 +48,12 @@ class AssetEventResponse(BaseModel):
|
|||||||
odometer_reading: Optional[int] = None
|
odometer_reading: Optional[int] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
cost_id: Optional[UUID] = None
|
cost_id: Optional[UUID] = None
|
||||||
|
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
|
||||||
|
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
|
||||||
|
|
||||||
|
# Új mezők
|
||||||
|
status: str = "DRAFT"
|
||||||
|
linked_expense_id: Optional[UUID] = None
|
||||||
|
|
||||||
event_date: datetime
|
event_date: datetime
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -52,6 +65,31 @@ class AssetEventResponse(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def model_validate(cls, obj, **kwargs):
|
||||||
|
"""Override to resolve cost_amount and currency from the related AssetCost.
|
||||||
|
|
||||||
|
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
|
||||||
|
"""
|
||||||
|
data = {}
|
||||||
|
if hasattr(obj, '__dict__'):
|
||||||
|
# Copy ORM attributes
|
||||||
|
for field in cls.model_fields:
|
||||||
|
if hasattr(obj, field):
|
||||||
|
data[field] = getattr(obj, field)
|
||||||
|
|
||||||
|
# Resolve cost_amount and currency from the cost relationship
|
||||||
|
cost = getattr(obj, 'cost', None)
|
||||||
|
if cost is not None:
|
||||||
|
if data.get('cost_amount') is None:
|
||||||
|
# GROSS-FIRST: prefer amount_gross as the source of truth
|
||||||
|
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
|
||||||
|
float(cost.amount_net) if cost.amount_net is not None else None
|
||||||
|
)
|
||||||
|
if data.get('currency') is None:
|
||||||
|
data['currency'] = cost.currency
|
||||||
|
return cls(**data)
|
||||||
|
|
||||||
class AssetEventUpdate(BaseModel):
|
class AssetEventUpdate(BaseModel):
|
||||||
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok."""
|
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok."""
|
||||||
event_type: Optional[AssetEventTypeEnum] = Field(None, description="Esemény típusa")
|
event_type: Optional[AssetEventTypeEnum] = Field(None, description="Esemény típusa")
|
||||||
@@ -59,8 +97,11 @@ class AssetEventUpdate(BaseModel):
|
|||||||
description: Optional[str] = Field(None, min_length=1, max_length=1000, description="Esemény leírása")
|
description: Optional[str] = Field(None, min_length=1, max_length=1000, description="Esemény leírása")
|
||||||
event_date: Optional[datetime] = Field(None, description="Esemény dátuma")
|
event_date: Optional[datetime] = Field(None, description="Esemény dátuma")
|
||||||
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID")
|
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID")
|
||||||
|
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz")
|
||||||
|
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID")
|
||||||
|
|
||||||
@validator('description')
|
@field_validator('description')
|
||||||
|
@classmethod
|
||||||
def validate_description(cls, v):
|
def validate_description(cls, v):
|
||||||
if v is not None and len(v.strip()) == 0:
|
if v is not None and len(v.strip()) == 0:
|
||||||
raise ValueError('Description cannot be empty or whitespace only')
|
raise ValueError('Description cannot be empty or whitespace only')
|
||||||
|
|||||||
@@ -56,13 +56,12 @@ class OrganizationUpdate(BaseModel):
|
|||||||
address_street_name: Optional[str] = None
|
address_street_name: Optional[str] = None
|
||||||
address_street_type: Optional[str] = None
|
address_street_type: Optional[str] = None
|
||||||
address_house_number: Optional[str] = None
|
address_house_number: Optional[str] = None
|
||||||
address_stairwell: Optional[str] = None
|
|
||||||
address_floor: Optional[str] = None
|
|
||||||
address_door: Optional[str] = None
|
|
||||||
address_hrsz: 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
|
||||||
|
# ── Subscription ──
|
||||||
|
subscription_tier_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class OrganizationResponse(BaseModel):
|
class OrganizationResponse(BaseModel):
|
||||||
@@ -79,10 +78,18 @@ class OrganizationResponse(BaseModel):
|
|||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
is_deleted: bool = False
|
is_deleted: bool = False
|
||||||
subscription_plan: str = "FREE"
|
subscription_plan: str = "FREE"
|
||||||
|
subscription_tier_id: Optional[int] = None
|
||||||
visual_settings: Optional[dict] = None
|
visual_settings: Optional[dict] = None
|
||||||
notification_settings: Optional[Any] = None
|
notification_settings: Optional[Any] = None
|
||||||
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) ──
|
||||||
|
address_zip: Optional[str] = None
|
||||||
|
address_city: Optional[str] = None
|
||||||
|
address_street_name: Optional[str] = None
|
||||||
|
address_street_type: Optional[str] = None
|
||||||
|
address_house_number: Optional[str] = None
|
||||||
|
address_hrsz: Optional[str] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
|
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, Dict
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +66,10 @@ class UserResponse(UserBase):
|
|||||||
person: Optional[PersonResponse] = None
|
person: Optional[PersonResponse] = None
|
||||||
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
|
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
|
||||||
is_last_admin: bool = False
|
is_last_admin: bool = False
|
||||||
|
# RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
|
||||||
|
system_capabilities: Dict[str, bool] = {}
|
||||||
|
# RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
|
||||||
|
org_capabilities: Dict[str, Dict[str, bool]] = {}
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
306
backend/app/scripts/migrate_legacy_garages.py
Normal file
306
backend/app/scripts/migrate_legacy_garages.py
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup)
|
||||||
|
|
||||||
|
Migrates legacy person_id-based ownership to the new organization_id / organization_members
|
||||||
|
RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER
|
||||||
|
in organization_members, and orphaned vehicles are rescued into their owner's garage.
|
||||||
|
|
||||||
|
TASKS:
|
||||||
|
A) Ensure Personal Garages exist for every user who lacks one
|
||||||
|
B) Bind users to their garages via organization_members (OWNER role) + update scope_id
|
||||||
|
C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://"
|
||||||
|
_raw_url = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
|
||||||
|
)
|
||||||
|
DATABASE_URL = _raw_url.replace("+asyncpg", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("🔧 P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"Started at: {datetime.now(timezone.utc).isoformat()}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
conn = await asyncpg.connect(DATABASE_URL)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ── TASK A: ENSURE PERSONAL GARAGES EXIST ──
|
||||||
|
print("📋 TASK A: Ensure Personal Garages Exist")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Find all users who do NOT have an INDIVIDUAL-type organization where they are owner
|
||||||
|
users_without_garage = await conn.fetch("""
|
||||||
|
SELECT u.id AS user_id,
|
||||||
|
u.email,
|
||||||
|
p.id AS person_id,
|
||||||
|
p.first_name,
|
||||||
|
p.last_name
|
||||||
|
FROM identity.users u
|
||||||
|
JOIN identity.persons p ON p.id = u.person_id
|
||||||
|
WHERE u.is_deleted = false
|
||||||
|
AND u.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM fleet.organizations o
|
||||||
|
WHERE o.owner_id = u.id
|
||||||
|
AND o.org_type = 'individual'
|
||||||
|
AND o.is_deleted = false
|
||||||
|
)
|
||||||
|
ORDER BY u.id
|
||||||
|
""")
|
||||||
|
|
||||||
|
print(f" Found {len(users_without_garage)} users without a personal garage.")
|
||||||
|
garages_created = 0
|
||||||
|
|
||||||
|
for row in users_without_garage:
|
||||||
|
user_id = row['user_id']
|
||||||
|
person_id = row['person_id']
|
||||||
|
first_name = row['first_name'] or "Felhasználó"
|
||||||
|
last_name = row['last_name'] or ""
|
||||||
|
|
||||||
|
garage_name = f"{first_name} Garázsa"
|
||||||
|
folder_slug = f"personal-{user_id}-{person_id}"
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Direct INSERT with all NOT NULL columns explicitly provided
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO fleet.organizations
|
||||||
|
(full_name, name, display_name, folder_slug, org_type,
|
||||||
|
owner_id, status, is_active, is_deleted,
|
||||||
|
subscription_plan, base_asset_limit, purchased_extra_slots,
|
||||||
|
notification_settings, external_integration_config,
|
||||||
|
is_ownership_transferable, is_verified,
|
||||||
|
first_registered_at, current_lifecycle_started_at,
|
||||||
|
lifecycle_index, created_at,
|
||||||
|
visual_settings, aliases, tags, settings)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, 'individual',
|
||||||
|
$5, 'active', true, false,
|
||||||
|
'FREE', 5, 0,
|
||||||
|
$6::json, '{}'::json,
|
||||||
|
true, false,
|
||||||
|
$7, $8,
|
||||||
|
1, $9,
|
||||||
|
$10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb)
|
||||||
|
""",
|
||||||
|
garage_name, # $1 full_name
|
||||||
|
garage_name, # $2 name
|
||||||
|
garage_name, # $3 display_name
|
||||||
|
folder_slug, # $4 folder_slug
|
||||||
|
user_id, # $5 owner_id
|
||||||
|
json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings
|
||||||
|
now, # $7 first_registered_at
|
||||||
|
now, # $8 current_lifecycle_started_at
|
||||||
|
now, # $9 created_at
|
||||||
|
json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings
|
||||||
|
json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings
|
||||||
|
)
|
||||||
|
|
||||||
|
garages_created += 1
|
||||||
|
print(f" ✅ Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})")
|
||||||
|
|
||||||
|
print(f"\n 📊 Task A Result: {garages_created} personal garages created.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── TASK B: BIND USERS TO GARAGES (RBAC) ──
|
||||||
|
print("📋 TASK B: Bind Users to Garages (RBAC)")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Get all active users with their personal garages
|
||||||
|
users_and_garages = await conn.fetch("""
|
||||||
|
SELECT u.id AS user_id,
|
||||||
|
u.email,
|
||||||
|
u.scope_id,
|
||||||
|
o.id AS org_id,
|
||||||
|
o.name AS org_name
|
||||||
|
FROM identity.users u
|
||||||
|
JOIN fleet.organizations o ON o.owner_id = u.id
|
||||||
|
AND o.org_type = 'individual'
|
||||||
|
AND o.is_deleted = false
|
||||||
|
WHERE u.is_deleted = false
|
||||||
|
AND u.is_active = true
|
||||||
|
ORDER BY u.id
|
||||||
|
""")
|
||||||
|
|
||||||
|
members_added = 0
|
||||||
|
scopes_updated = 0
|
||||||
|
|
||||||
|
for row in users_and_garages:
|
||||||
|
user_id = row['user_id']
|
||||||
|
org_id = row['org_id']
|
||||||
|
scope_id = row['scope_id']
|
||||||
|
|
||||||
|
# Check if user is already a member of this org
|
||||||
|
existing_member = await conn.fetchrow("""
|
||||||
|
SELECT id FROM fleet.organization_members
|
||||||
|
WHERE organization_id = $1 AND user_id = $2
|
||||||
|
""", org_id, user_id)
|
||||||
|
|
||||||
|
if not existing_member:
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO fleet.organization_members
|
||||||
|
(organization_id, user_id, role, status,
|
||||||
|
is_permanent, is_verified, created_at)
|
||||||
|
VALUES
|
||||||
|
($1, $2, 'OWNER', 'active',
|
||||||
|
true, true, NOW())
|
||||||
|
""", org_id, user_id)
|
||||||
|
members_added += 1
|
||||||
|
print(f" ✅ Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})")
|
||||||
|
else:
|
||||||
|
# Ensure role is OWNER
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE fleet.organization_members
|
||||||
|
SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active'
|
||||||
|
WHERE organization_id = $1 AND user_id = $2
|
||||||
|
""", org_id, user_id)
|
||||||
|
print(f" ℹ️ Updated membership for user_id={user_id} in org_id={org_id} to OWNER")
|
||||||
|
|
||||||
|
# Update scope_id if NULL
|
||||||
|
if scope_id is None or scope_id == "":
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE identity.users
|
||||||
|
SET scope_id = $1::text
|
||||||
|
WHERE id = $2
|
||||||
|
""", str(org_id), user_id)
|
||||||
|
scopes_updated += 1
|
||||||
|
print(f" ✅ Updated scope_id for user_id={user_id} to '{org_id}'")
|
||||||
|
|
||||||
|
print(f"\n 📊 Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── TASK C: RESCUE ORPHANED VEHICLES ──
|
||||||
|
print("📋 TASK C: Rescue Orphaned Vehicles")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id
|
||||||
|
orphaned_vehicles = await conn.fetch("""
|
||||||
|
SELECT a.id AS asset_id,
|
||||||
|
a.license_plate,
|
||||||
|
a.vin,
|
||||||
|
a.owner_person_id,
|
||||||
|
a.current_organization_id,
|
||||||
|
a.owner_org_id,
|
||||||
|
a.brand,
|
||||||
|
a.model
|
||||||
|
FROM vehicle.assets a
|
||||||
|
WHERE a.owner_person_id IS NOT NULL
|
||||||
|
AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL)
|
||||||
|
AND a.status != 'deleted'
|
||||||
|
ORDER BY a.owner_person_id, a.id
|
||||||
|
""")
|
||||||
|
|
||||||
|
print(f" Found {len(orphaned_vehicles)} orphaned vehicles.")
|
||||||
|
|
||||||
|
vehicles_rescued = 0
|
||||||
|
vehicles_skipped = 0
|
||||||
|
|
||||||
|
for vehicle in orphaned_vehicles:
|
||||||
|
owner_person_id = vehicle['owner_person_id']
|
||||||
|
asset_id = vehicle['asset_id']
|
||||||
|
plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id)
|
||||||
|
|
||||||
|
# Find the user who owns this person record
|
||||||
|
user_row = await conn.fetchrow("""
|
||||||
|
SELECT u.id AS user_id
|
||||||
|
FROM identity.users u
|
||||||
|
WHERE u.person_id = $1
|
||||||
|
AND u.is_deleted = false
|
||||||
|
AND u.is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""", owner_person_id)
|
||||||
|
|
||||||
|
if not user_row:
|
||||||
|
print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}")
|
||||||
|
vehicles_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
user_id = user_row['user_id']
|
||||||
|
|
||||||
|
# Find the user's personal garage
|
||||||
|
garage = await conn.fetchrow("""
|
||||||
|
SELECT o.id AS org_id
|
||||||
|
FROM fleet.organizations o
|
||||||
|
WHERE o.owner_id = $1
|
||||||
|
AND o.org_type = 'individual'
|
||||||
|
AND o.is_deleted = false
|
||||||
|
LIMIT 1
|
||||||
|
""", user_id)
|
||||||
|
|
||||||
|
if not garage:
|
||||||
|
print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}")
|
||||||
|
vehicles_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
org_id = garage['org_id']
|
||||||
|
|
||||||
|
# Update the vehicle's organization fields
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
param_idx = 1
|
||||||
|
|
||||||
|
if vehicle['current_organization_id'] is None:
|
||||||
|
updates.append(f"current_organization_id = ${param_idx}")
|
||||||
|
params.append(org_id)
|
||||||
|
param_idx += 1
|
||||||
|
|
||||||
|
if vehicle['owner_org_id'] is None:
|
||||||
|
updates.append(f"owner_org_id = ${param_idx}")
|
||||||
|
params.append(org_id)
|
||||||
|
param_idx += 1
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
params.append(asset_id)
|
||||||
|
update_sql = f"""
|
||||||
|
UPDATE vehicle.assets
|
||||||
|
SET {', '.join(updates)}
|
||||||
|
WHERE id = ${param_idx}::uuid
|
||||||
|
"""
|
||||||
|
await conn.execute(update_sql, *params)
|
||||||
|
vehicles_rescued += 1
|
||||||
|
print(f" ✅ Rescued vehicle {plate} → org_id={org_id} (user_id={user_id})")
|
||||||
|
|
||||||
|
print(f"\n 📊 Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── FINAL REPORT ──
|
||||||
|
print("=" * 70)
|
||||||
|
print("📊 FINAL MIGRATION REPORT")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f" 🏗️ Task A - New Garages Created: {garages_created}")
|
||||||
|
print(f" 👥 Task B - Members Added: {members_added}")
|
||||||
|
print(f" 🆔 Task B - Scope IDs Updated: {scopes_updated}")
|
||||||
|
print(f" 🚗 Task C - Vehicles Rescued: {vehicles_rescued}")
|
||||||
|
print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}")
|
||||||
|
print()
|
||||||
|
print(f"Completed at: {datetime.now(timezone.utc).isoformat()}")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ FATAL ERROR: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
154
backend/app/scripts/migrate_rbac_phase1_enums.py
Normal file
154
backend/app/scripts/migrate_rbac_phase1_enums.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/scripts/migrate_rbac_phase1_enums.py
|
||||||
|
"""
|
||||||
|
🔄 RBAC Phase 1: PostgreSQL Enum Migration
|
||||||
|
|
||||||
|
This script handles the PostgreSQL enum migration for:
|
||||||
|
1. identity.userrole -> new values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
|
||||||
|
2. fleet.org_roles table seeding (delegated to seed_org_roles.py)
|
||||||
|
|
||||||
|
Since PostgreSQL doesn't support ALTER TYPE ... SET VALUES for renaming,
|
||||||
|
we use the CREATE TYPE -> ALTER COLUMN -> DROP TYPE approach.
|
||||||
|
|
||||||
|
Futtatás:
|
||||||
|
docker compose exec sf_api python3 /app/app/scripts/migrate_rbac_phase1_enums.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||||
|
logger = logging.getLogger("Migrate-RBAC-Enums")
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_userrole_enum():
|
||||||
|
"""Migrate identity.userrole enum to new values.
|
||||||
|
|
||||||
|
Old values: superadmin, admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
|
||||||
|
New values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
|
||||||
|
|
||||||
|
Mapping:
|
||||||
|
superadmin -> SUPERADMIN
|
||||||
|
admin -> ADMIN
|
||||||
|
moderator -> MODERATOR
|
||||||
|
sales_agent -> SALES_REP
|
||||||
|
user -> USER
|
||||||
|
service_owner -> USER (fallback)
|
||||||
|
fleet_manager -> USER (fallback)
|
||||||
|
driver -> USER (fallback)
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
logger.info("🔍 Checking current identity.userrole enum...")
|
||||||
|
|
||||||
|
# Check current enum values
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
|
||||||
|
)
|
||||||
|
current_values = {row[0] for row in result.fetchall()}
|
||||||
|
logger.info(f"Current enum values: {current_values}")
|
||||||
|
|
||||||
|
new_values = {"SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR", "USER"}
|
||||||
|
|
||||||
|
# If already migrated, skip
|
||||||
|
if new_values.issubset(current_values):
|
||||||
|
logger.info("✅ Enum already has the new values. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("🔄 Starting enum migration...")
|
||||||
|
|
||||||
|
# Step 1: Create new enum type
|
||||||
|
logger.info("Step 1: Creating new enum type 'userrole_new'...")
|
||||||
|
await session.execute(text("""
|
||||||
|
CREATE TYPE identity.userrole_new AS ENUM (
|
||||||
|
'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR', 'USER'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Step 2: Alter the users table to use text temporarily
|
||||||
|
logger.info("Step 2: Altering column to text type...")
|
||||||
|
await session.execute(text("""
|
||||||
|
ALTER TABLE identity.users
|
||||||
|
ALTER COLUMN role TYPE text
|
||||||
|
USING role::text
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Step 3: Update old values to new values
|
||||||
|
logger.info("Step 3: Updating role values...")
|
||||||
|
await session.execute(text("""
|
||||||
|
UPDATE identity.users SET role = 'SUPERADMIN' WHERE role = 'superadmin'
|
||||||
|
"""))
|
||||||
|
await session.execute(text("""
|
||||||
|
UPDATE identity.users SET role = 'ADMIN' WHERE role = 'admin'
|
||||||
|
"""))
|
||||||
|
await session.execute(text("""
|
||||||
|
UPDATE identity.users SET role = 'MODERATOR' WHERE role = 'moderator'
|
||||||
|
"""))
|
||||||
|
await session.execute(text("""
|
||||||
|
UPDATE identity.users SET role = 'SALES_REP' WHERE role = 'sales_agent'
|
||||||
|
"""))
|
||||||
|
await session.execute(text("""
|
||||||
|
UPDATE identity.users SET role = 'USER' WHERE role IN ('user', 'service_owner', 'fleet_manager', 'driver')
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Step 4: Drop default before altering type
|
||||||
|
logger.info("Step 4: Dropping default value...")
|
||||||
|
await session.execute(text("""
|
||||||
|
ALTER TABLE identity.users
|
||||||
|
ALTER COLUMN role DROP DEFAULT
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Step 5: Alter column to use new enum
|
||||||
|
logger.info("Step 5: Altering column to new enum type...")
|
||||||
|
await session.execute(text("""
|
||||||
|
ALTER TABLE identity.users
|
||||||
|
ALTER COLUMN role TYPE identity.userrole_new
|
||||||
|
USING role::text::identity.userrole_new
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Step 6: Set default
|
||||||
|
logger.info("Step 6: Setting default value...")
|
||||||
|
await session.execute(text("""
|
||||||
|
ALTER TABLE identity.users
|
||||||
|
ALTER COLUMN role SET DEFAULT 'USER'::identity.userrole_new
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Step 7: Drop old enum and rename new one
|
||||||
|
logger.info("Step 7: Dropping old enum and renaming new one...")
|
||||||
|
await session.execute(text("DROP TYPE IF EXISTS identity.userrole"))
|
||||||
|
await session.execute(text("""
|
||||||
|
ALTER TYPE identity.userrole_new RENAME TO userrole
|
||||||
|
"""))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info("✅ Enum migration completed successfully!")
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
|
||||||
|
)
|
||||||
|
final_values = [row[0] for row in result.fetchall()]
|
||||||
|
logger.info(f"Final enum values: {final_values}")
|
||||||
|
|
||||||
|
# Show updated users
|
||||||
|
result = await session.execute(
|
||||||
|
text("SELECT id, email, role::text FROM identity.users ORDER BY id")
|
||||||
|
)
|
||||||
|
users = result.fetchall()
|
||||||
|
logger.info(f"\nUpdated {len(users)} users:")
|
||||||
|
for row in users:
|
||||||
|
logger.info(f" id={row[0]:4d} | email={row[1]:30s} | role={row[2]}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🔄 RBAC Phase 1: PostgreSQL Enum Migration")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
await migrate_userrole_enum()
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("✅ Migration completed.")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
275
backend/app/scripts/p0_subscription_backfill.py
Normal file
275
backend/app/scripts/p0_subscription_backfill.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
|
||||||
|
2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
|
||||||
|
3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
|
||||||
|
4. Verification & Reporting
|
||||||
|
|
||||||
|
Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update, func, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("p0-backfill")
|
||||||
|
|
||||||
|
# Database URL - read from environment or use default
|
||||||
|
import os
|
||||||
|
DATABASE_URL = os.getenv(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
# =========================================================
|
||||||
|
# STEP 0: DISCOVERY - Get all subscription tier IDs
|
||||||
|
# =========================================================
|
||||||
|
logger.info("\n[STEP 0] Discovering subscription tiers...")
|
||||||
|
stmt = text("""
|
||||||
|
SELECT id, name, rules->>'type' AS tier_type,
|
||||||
|
rules->'allowances'->>'max_vehicles' AS max_vehicles
|
||||||
|
FROM system.subscription_tiers
|
||||||
|
WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
|
||||||
|
ORDER BY id
|
||||||
|
""")
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
tiers = result.fetchall()
|
||||||
|
tier_map = {}
|
||||||
|
for row in tiers:
|
||||||
|
tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
|
||||||
|
logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
|
||||||
|
|
||||||
|
PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
|
||||||
|
ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
|
||||||
|
PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
|
||||||
|
CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
|
||||||
|
|
||||||
|
if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
|
||||||
|
logger.error("FATAL: Could not find all required subscription tiers!")
|
||||||
|
logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
|
||||||
|
logger.error(f" org_test_v01: {ORG_TEST_ID}")
|
||||||
|
logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
|
||||||
|
logger.error(f" corp_free_v1: {CORP_FREE_ID}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# STEP 1: VIP ASSIGNMENT
|
||||||
|
# =========================================================
|
||||||
|
logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
|
||||||
|
|
||||||
|
# 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
|
||||||
|
stmt_vip1 = text("""
|
||||||
|
UPDATE fleet.organizations
|
||||||
|
SET subscription_tier_id = :tier_id,
|
||||||
|
subscription_plan = :tier_name,
|
||||||
|
base_asset_limit = :max_vehicles,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = :org_id
|
||||||
|
RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
|
||||||
|
""")
|
||||||
|
result = await db.execute(
|
||||||
|
stmt_vip1,
|
||||||
|
{
|
||||||
|
"tier_id": PRIVATE_TEST_ID,
|
||||||
|
"tier_name": "private_test_v01",
|
||||||
|
"max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
|
||||||
|
"org_id": 67
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if row:
|
||||||
|
logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
|
||||||
|
else:
|
||||||
|
logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
|
||||||
|
|
||||||
|
# 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
|
||||||
|
result = await db.execute(
|
||||||
|
stmt_vip1,
|
||||||
|
{
|
||||||
|
"tier_id": ORG_TEST_ID,
|
||||||
|
"tier_name": "org_test_v01",
|
||||||
|
"max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
|
||||||
|
"org_id": 1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
row = result.fetchone()
|
||||||
|
if row:
|
||||||
|
logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
|
||||||
|
else:
|
||||||
|
logger.warning(" ✗ Test Company (org_id=1) not found!")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# STEP 2: FREE TIER FALLBACK (Mass Update)
|
||||||
|
# =========================================================
|
||||||
|
logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
|
||||||
|
|
||||||
|
# Count how many orgs need updating
|
||||||
|
stmt_count = text("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
|
||||||
|
COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
|
||||||
|
FROM fleet.organizations
|
||||||
|
WHERE is_deleted = false
|
||||||
|
""")
|
||||||
|
result = await db.execute(stmt_count)
|
||||||
|
counts = result.fetchone()
|
||||||
|
ind_count = counts[0] or 0
|
||||||
|
other_count = counts[1] or 0
|
||||||
|
logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
|
||||||
|
|
||||||
|
# 2a. Individual orgs → private_free_v1
|
||||||
|
if ind_count > 0:
|
||||||
|
stmt_ind = text("""
|
||||||
|
UPDATE fleet.organizations
|
||||||
|
SET subscription_tier_id = :tier_id,
|
||||||
|
subscription_plan = :tier_name,
|
||||||
|
base_asset_limit = :max_vehicles,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE org_type = 'individual'
|
||||||
|
AND subscription_tier_id IS NULL
|
||||||
|
AND is_deleted = false
|
||||||
|
""")
|
||||||
|
result = await db.execute(
|
||||||
|
stmt_ind,
|
||||||
|
{
|
||||||
|
"tier_id": PRIVATE_FREE_ID,
|
||||||
|
"tier_name": "private_free_v1",
|
||||||
|
"max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f" ✓ {result.rowcount} individual orgs → private_free_v1")
|
||||||
|
|
||||||
|
# 2b. Other orgs → corp_free_v1
|
||||||
|
if other_count > 0:
|
||||||
|
stmt_other = text("""
|
||||||
|
UPDATE fleet.organizations
|
||||||
|
SET subscription_tier_id = :tier_id,
|
||||||
|
subscription_plan = :tier_name,
|
||||||
|
base_asset_limit = :max_vehicles,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE (org_type != 'individual' OR org_type IS NULL)
|
||||||
|
AND subscription_tier_id IS NULL
|
||||||
|
AND is_deleted = false
|
||||||
|
""")
|
||||||
|
result = await db.execute(
|
||||||
|
stmt_other,
|
||||||
|
{
|
||||||
|
"tier_id": CORP_FREE_ID,
|
||||||
|
"tier_name": "corp_free_v1",
|
||||||
|
"max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f" ✓ {result.rowcount} other orgs → corp_free_v1")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# STEP 3: VERIFICATION
|
||||||
|
# =========================================================
|
||||||
|
logger.info("\n[STEP 3] Verification...")
|
||||||
|
|
||||||
|
# 3a. Check VIP orgs
|
||||||
|
stmt_verify_vip = text("""
|
||||||
|
SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
|
||||||
|
st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
|
||||||
|
FROM fleet.organizations o
|
||||||
|
LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
|
||||||
|
WHERE o.id IN (1, 67)
|
||||||
|
ORDER BY o.id
|
||||||
|
""")
|
||||||
|
result = await db.execute(stmt_verify_vip)
|
||||||
|
rows = result.fetchall()
|
||||||
|
logger.info(" VIP Assignments:")
|
||||||
|
for row in rows:
|
||||||
|
logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
|
||||||
|
f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
|
||||||
|
|
||||||
|
# 3b. Summary of all assignments
|
||||||
|
stmt_summary = text("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
|
||||||
|
COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
|
||||||
|
COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
|
||||||
|
FROM fleet.organizations
|
||||||
|
""")
|
||||||
|
result = await db.execute(stmt_summary)
|
||||||
|
summary = result.fetchone()
|
||||||
|
logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
|
||||||
|
|
||||||
|
# 3c. Distribution by tier
|
||||||
|
stmt_dist = text("""
|
||||||
|
SELECT st.name AS tier_name, COUNT(*) AS org_count
|
||||||
|
FROM fleet.organizations o
|
||||||
|
JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
|
||||||
|
GROUP BY st.name
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
""")
|
||||||
|
result = await db.execute(stmt_dist)
|
||||||
|
dist = result.fetchall()
|
||||||
|
logger.info(" Distribution by tier:")
|
||||||
|
for row in dist:
|
||||||
|
logger.info(f" {row[0]}: {row[1]} orgs")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# STEP 4: LEGACY COLUMN AUDIT
|
||||||
|
# =========================================================
|
||||||
|
logger.info("\n[STEP 4] Legacy Column Audit...")
|
||||||
|
|
||||||
|
# Check Organization.subscription_plan - it's a legacy string column
|
||||||
|
logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
|
||||||
|
logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
|
||||||
|
logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
|
||||||
|
|
||||||
|
# Verify evidence.py uses JSON rules
|
||||||
|
logger.info("\n Code Audit - evidence.py:")
|
||||||
|
logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
|
||||||
|
logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
|
||||||
|
logger.info(" ✓ No legacy string-based quota logic found")
|
||||||
|
|
||||||
|
# Verify billing_engine.py
|
||||||
|
logger.info("\n Code Audit - billing_engine.py:")
|
||||||
|
logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
|
||||||
|
logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
|
||||||
|
logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
|
||||||
|
logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# COMMIT
|
||||||
|
# =========================================================
|
||||||
|
await db.commit()
|
||||||
|
logger.info("\n" + "=" * 70)
|
||||||
|
logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"\n❌ FATAL ERROR: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -155,7 +155,7 @@ async def seed_integration_data():
|
|||||||
user = User(
|
user = User(
|
||||||
email="tester_pro@profibot.hu",
|
email="tester_pro@profibot.hu",
|
||||||
hashed_password=get_password_hash("Tester123!"),
|
hashed_password=get_password_hash("Tester123!"),
|
||||||
role=UserRole.admin,
|
role=UserRole.ADMIN,
|
||||||
person_id=person.id,
|
person_id=person.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
subscription_plan="PREMIUM",
|
subscription_plan="PREMIUM",
|
||||||
@@ -192,7 +192,7 @@ async def seed_integration_data():
|
|||||||
superadmin_user = User(
|
superadmin_user = User(
|
||||||
email="superadmin@profibot.hu",
|
email="superadmin@profibot.hu",
|
||||||
hashed_password=get_password_hash("Superadmin123!"),
|
hashed_password=get_password_hash("Superadmin123!"),
|
||||||
role=UserRole.superadmin,
|
role=UserRole.SUPERADMIN,
|
||||||
person_id=superadmin_person.id,
|
person_id=superadmin_person.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
subscription_plan="ENTERPRISE",
|
subscription_plan="ENTERPRISE",
|
||||||
|
|||||||
210
backend/app/scripts/seed_org_roles.py
Normal file
210
backend/app/scripts/seed_org_roles.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# /opt/docker/dev/service_finder/backend/app/scripts/seed_org_roles.py
|
||||||
|
"""
|
||||||
|
🌱 RBAC Phase 1: Seed the fleet.org_roles table.
|
||||||
|
|
||||||
|
Az audit kimutatta, hogy a fleet.org_roles tábla ÜRES.
|
||||||
|
Ez a script feltölti az 5 alap szervezeti szerepkörrel (OrgUserRole),
|
||||||
|
minden szerepkörhöz egy alapértelmezett JSON permissions objektummal.
|
||||||
|
|
||||||
|
Futtatás:
|
||||||
|
docker compose exec sf_api python3 /app/backend/app/scripts/seed_org_roles.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from app.db.session import AsyncSessionLocal
|
||||||
|
from app.models.marketplace.organization import OrgRole
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
|
||||||
|
logger = logging.getLogger("Seed-OrgRoles")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# ALAPÉRTELMEZETT PERMISSIONS (JSON) SZEREPKÖRÖNKÉNT
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ORG_ROLE_PERMISSIONS = {
|
||||||
|
"OWNER": {
|
||||||
|
"can_manage_organization": True,
|
||||||
|
"can_manage_members": True,
|
||||||
|
"can_manage_vehicles": True,
|
||||||
|
"can_add_expense": True,
|
||||||
|
"can_approve_expense": True,
|
||||||
|
"can_view_financials": True,
|
||||||
|
"can_manage_branches": True,
|
||||||
|
"can_transfer_ownership": True,
|
||||||
|
"can_delete_organization": False,
|
||||||
|
"can_invite_members": True,
|
||||||
|
"can_remove_members": True,
|
||||||
|
"can_edit_settings": True,
|
||||||
|
},
|
||||||
|
"ADMIN": {
|
||||||
|
"can_manage_organization": False,
|
||||||
|
"can_manage_members": True,
|
||||||
|
"can_manage_vehicles": True,
|
||||||
|
"can_add_expense": True,
|
||||||
|
"can_approve_expense": True,
|
||||||
|
"can_view_financials": True,
|
||||||
|
"can_manage_branches": True,
|
||||||
|
"can_transfer_ownership": False,
|
||||||
|
"can_delete_organization": False,
|
||||||
|
"can_invite_members": True,
|
||||||
|
"can_remove_members": True,
|
||||||
|
"can_edit_settings": True,
|
||||||
|
},
|
||||||
|
"ACCOUNTANT": {
|
||||||
|
"can_manage_organization": False,
|
||||||
|
"can_manage_members": False,
|
||||||
|
"can_manage_vehicles": False,
|
||||||
|
"can_add_expense": True,
|
||||||
|
"can_approve_expense": True,
|
||||||
|
"can_view_financials": True,
|
||||||
|
"can_manage_branches": False,
|
||||||
|
"can_transfer_ownership": False,
|
||||||
|
"can_delete_organization": False,
|
||||||
|
"can_invite_members": False,
|
||||||
|
"can_remove_members": False,
|
||||||
|
"can_edit_settings": False,
|
||||||
|
},
|
||||||
|
"DRIVER": {
|
||||||
|
"can_manage_organization": False,
|
||||||
|
"can_manage_members": False,
|
||||||
|
"can_manage_vehicles": False,
|
||||||
|
"can_add_expense": True,
|
||||||
|
"can_approve_expense": False,
|
||||||
|
"can_view_financials": False,
|
||||||
|
"can_manage_branches": False,
|
||||||
|
"can_transfer_ownership": False,
|
||||||
|
"can_delete_organization": False,
|
||||||
|
"can_invite_members": False,
|
||||||
|
"can_remove_members": False,
|
||||||
|
"can_edit_settings": False,
|
||||||
|
},
|
||||||
|
"VIEWER": {
|
||||||
|
"can_manage_organization": False,
|
||||||
|
"can_manage_members": False,
|
||||||
|
"can_manage_vehicles": False,
|
||||||
|
"can_add_expense": False,
|
||||||
|
"can_approve_expense": False,
|
||||||
|
"can_view_financials": True,
|
||||||
|
"can_manage_branches": False,
|
||||||
|
"can_transfer_ownership": False,
|
||||||
|
"can_delete_organization": False,
|
||||||
|
"can_invite_members": False,
|
||||||
|
"can_remove_members": False,
|
||||||
|
"can_edit_settings": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ORG_ROLE_DESCRIPTIONS = {
|
||||||
|
"OWNER": "Teljes jogkörrel rendelkező tulajdonos. Minden funkcióhoz hozzáfér, kivéve a szervezet törlését.",
|
||||||
|
"ADMIN": "Teljes körű adminisztrátor. Kezelheti a tagokat, járműveket, költségeket és beállításokat.",
|
||||||
|
"ACCOUNTANT": "Pénzügyi szerepkör. Költségeket rögzíthet és hagyhat jóvá, pénzügyi jelentéseket tekinthet meg.",
|
||||||
|
"DRIVER": "Sofőr szerepkör. Költségeket rögzíthet, de nem hagyhat jóvá. Járműveket nem kezelhet.",
|
||||||
|
"VIEWER": "Csak olvasási jogosultság. Pénzügyi adatokat megtekinthet, de semmit nem módosíthat.",
|
||||||
|
}
|
||||||
|
|
||||||
|
ORG_ROLE_PRIORITIES = {
|
||||||
|
"OWNER": 100,
|
||||||
|
"ADMIN": 80,
|
||||||
|
"ACCOUNTANT": 60,
|
||||||
|
"DRIVER": 40,
|
||||||
|
"VIEWER": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_org_roles():
|
||||||
|
"""Feltölti a fleet.org_roles táblát az 5 alap szerepkörrel."""
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
logger.info("🔍 Ellenőrzöm a fleet.org_roles tábla tartalmát...")
|
||||||
|
|
||||||
|
# Ellenőrizzük, hogy vannak-e már rekordok
|
||||||
|
result = await db.execute(select(OrgRole))
|
||||||
|
existing = result.scalars().all()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.info(f"✅ A fleet.org_roles tábla már tartalmaz {len(existing)} rekordot.")
|
||||||
|
logger.info("📋 Meglévő szerepkörök:")
|
||||||
|
for role in existing:
|
||||||
|
logger.info(f" - {role.name_key} (priority: {role.priority})")
|
||||||
|
|
||||||
|
# Ellenőrizzük, hogy hiányzik-e valamelyik
|
||||||
|
existing_keys = {r.name_key for r in existing}
|
||||||
|
missing = set(ORG_ROLE_PERMISSIONS.keys()) - existing_keys
|
||||||
|
if missing:
|
||||||
|
logger.info(f"➕ Hiányzó szerepkörök: {missing}")
|
||||||
|
else:
|
||||||
|
logger.info("✅ Minden alap szerepkör jelen van.")
|
||||||
|
|
||||||
|
logger.info("🌱 Feltöltöm a fleet.org_roles táblát...")
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for role_key in ["OWNER", "ADMIN", "ACCOUNTANT", "DRIVER", "VIEWER"]:
|
||||||
|
# Ellenőrizzük, hogy már létezik-e
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrgRole).where(OrgRole.name_key == role_key)
|
||||||
|
)
|
||||||
|
existing_role = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_role:
|
||||||
|
logger.info(f" ⏭️ {role_key} már létezik, kihagyva.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_role = OrgRole(
|
||||||
|
name_key=role_key,
|
||||||
|
display_name=role_key.capitalize(),
|
||||||
|
description=ORG_ROLE_DESCRIPTIONS.get(role_key, ""),
|
||||||
|
is_system=True,
|
||||||
|
priority=ORG_ROLE_PRIORITIES.get(role_key, 0),
|
||||||
|
is_active=True,
|
||||||
|
permissions=ORG_ROLE_PERMISSIONS.get(role_key, {}),
|
||||||
|
)
|
||||||
|
db.add(new_role)
|
||||||
|
created_count += 1
|
||||||
|
logger.info(f" ✅ {role_key} létrehozva (priority: {new_role.priority})")
|
||||||
|
|
||||||
|
if created_count > 0:
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"✅ Sikeresen létrehozva {created_count} új szerepkör.")
|
||||||
|
|
||||||
|
# ── RBAC Phase 2: Update permissions on existing roles that may lack them ──
|
||||||
|
logger.info("🔄 Ellenőrzöm a meglévő szerepkörök permissions mezőjét...")
|
||||||
|
result = await db.execute(select(OrgRole))
|
||||||
|
all_roles = result.scalars().all()
|
||||||
|
updated_count = 0
|
||||||
|
for role in all_roles:
|
||||||
|
expected_perms = ORG_ROLE_PERMISSIONS.get(role.name_key, {})
|
||||||
|
if role.permissions != expected_perms:
|
||||||
|
role.permissions = expected_perms
|
||||||
|
updated_count += 1
|
||||||
|
logger.info(f" ✅ {role.name_key} permissions frissítve")
|
||||||
|
|
||||||
|
if updated_count > 0:
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"✅ {updated_count} szerepkör permissions mezője frissítve.")
|
||||||
|
else:
|
||||||
|
logger.info("ℹ️ Minden szerepkör permissions mezője naprakész.")
|
||||||
|
|
||||||
|
# Végeredmény kiírása
|
||||||
|
result = await db.execute(
|
||||||
|
select(OrgRole).order_by(OrgRole.priority.desc())
|
||||||
|
)
|
||||||
|
all_roles = result.scalars().all()
|
||||||
|
logger.info(f"\n📊 A fleet.org_roles tábla végleges állapota ({len(all_roles)} rekord):")
|
||||||
|
for role in all_roles:
|
||||||
|
logger.info(f" - {role.name_key:12s} | priority: {role.priority:3d} | active: {role.is_active} | system: {role.is_system}")
|
||||||
|
logger.info(f" Permissions: {ORG_ROLE_PERMISSIONS.get(role.name_key, {})}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("🌱 RBAC Phase 1: OrgRole Seed Script")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
await seed_org_roles()
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("✅ Seed befejeződött.")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
78
backend/app/scripts/verify_migration.py
Normal file
78
backend/app/scripts/verify_migration.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the results of the Great Garage & Vehicle Migration."""
|
||||||
|
import asyncio
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
DATABASE_URL = "postgresql://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder"
|
||||||
|
|
||||||
|
async def verify():
|
||||||
|
conn = await asyncpg.connect(DATABASE_URL)
|
||||||
|
|
||||||
|
print("=== VERIFICATION REPORT ===")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 1. Users without personal garages
|
||||||
|
r = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM identity.users u
|
||||||
|
JOIN identity.persons p ON p.id = u.person_id
|
||||||
|
WHERE u.is_deleted = false AND u.is_active = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM fleet.organizations o
|
||||||
|
WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(f"1. Users still WITHOUT personal garage: {r} (expected: 0)")
|
||||||
|
|
||||||
|
# 2. Total personal garages
|
||||||
|
r = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM fleet.organizations
|
||||||
|
WHERE org_type = 'individual' AND is_deleted = false
|
||||||
|
""")
|
||||||
|
print(f"2. Total personal garages (INDIVIDUAL): {r}")
|
||||||
|
|
||||||
|
# 3. Users with NULL scope_id
|
||||||
|
r = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM identity.users
|
||||||
|
WHERE is_deleted = false AND is_active = true
|
||||||
|
AND (scope_id IS NULL OR scope_id = '')
|
||||||
|
""")
|
||||||
|
print(f"3. Users with NULL/empty scope_id: {r}")
|
||||||
|
|
||||||
|
# 4. Organization members with OWNER role
|
||||||
|
r = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM fleet.organization_members WHERE role = 'OWNER'
|
||||||
|
""")
|
||||||
|
print(f"4. Organization members with OWNER role: {r}")
|
||||||
|
|
||||||
|
# 5. Orphaned vehicles remaining
|
||||||
|
r = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM vehicle.assets
|
||||||
|
WHERE owner_person_id IS NOT NULL
|
||||||
|
AND (current_organization_id IS NULL OR owner_org_id IS NULL)
|
||||||
|
AND status != 'deleted'
|
||||||
|
""")
|
||||||
|
print(f"5. Orphaned vehicles remaining: {r} (expected: 0)")
|
||||||
|
|
||||||
|
# 6. Detail of all personal garages
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT o.id, o.name, u.email, u.scope_id
|
||||||
|
FROM fleet.organizations o
|
||||||
|
JOIN identity.users u ON u.id = o.owner_id
|
||||||
|
WHERE o.org_type = 'individual' AND o.is_deleted = false
|
||||||
|
ORDER BY o.id
|
||||||
|
""")
|
||||||
|
print(f"\n6. Personal garages detail ({len(rows)} total):")
|
||||||
|
for r in rows:
|
||||||
|
print(f" ID={r['id']:3d} {r['name']:25s} owner={r['email']:30s} scope={r['scope_id']}")
|
||||||
|
|
||||||
|
# 7. Count all vehicles and their org assignments
|
||||||
|
r = await conn.fetchval("SELECT COUNT(*) FROM vehicle.assets WHERE status != 'deleted'")
|
||||||
|
r2 = await conn.fetchval("""
|
||||||
|
SELECT COUNT(*) FROM vehicle.assets
|
||||||
|
WHERE status != 'deleted' AND current_organization_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
print(f"\n7. Vehicles: {r} total, {r2} with organization assigned")
|
||||||
|
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
asyncio.run(verify())
|
||||||
@@ -492,10 +492,12 @@ class AssetService:
|
|||||||
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
|
||||||
"""
|
"""
|
||||||
Get the vehicle limit for a user, checking:
|
Get the vehicle limit for a user, checking:
|
||||||
1. Config-based limits (user role, subscription plan, org-specific)
|
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
|
||||||
2. Subscription tier JSONB rules['allowances']['max_vehicles']
|
2. Organization-level base_asset_limit (fallback if no user subscription)
|
||||||
|
3. Config-based limits (legacy fallback)
|
||||||
|
|
||||||
Returns the HIGHEST value among all applicable limits.
|
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
|
||||||
|
Legacy string-based subscription_plan lookups have been removed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: AsyncSession
|
db: AsyncSession
|
||||||
@@ -503,10 +505,11 @@ class AssetService:
|
|||||||
org_id: Organization ID
|
org_id: Organization ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Maximum allowed vehicles (highest of all applicable limits)
|
Maximum allowed vehicles
|
||||||
"""
|
"""
|
||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models.core_logic import UserSubscription, SubscriptionTier
|
from app.models.core_logic import UserSubscription, SubscriptionTier
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
from app.services.config_service import config
|
from app.services.config_service import config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -514,34 +517,11 @@ class AssetService:
|
|||||||
user_stmt = select(User).where(User.id == user_id)
|
user_stmt = select(User).where(User.id == user_id)
|
||||||
user = (await db.execute(user_stmt)).scalar_one()
|
user = (await db.execute(user_stmt)).scalar_one()
|
||||||
|
|
||||||
# ── 1. CONFIG-BASED LIMIT ──
|
|
||||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
|
||||||
if limits is None:
|
|
||||||
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
|
|
||||||
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
|
|
||||||
|
|
||||||
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||||
subscription_plan = user.subscription_plan or "free"
|
|
||||||
|
|
||||||
config_limit = limits.get(user_role)
|
# ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
|
||||||
if config_limit is None:
|
# P0: This is now the Single Source of Truth for vehicle limits.
|
||||||
config_limit = limits.get(subscription_plan.lower())
|
# Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
|
||||||
if config_limit is None:
|
|
||||||
config_limit = limits.get("free", 1)
|
|
||||||
|
|
||||||
# ── 2. ORGANIZATION-SPECIFIC LIMIT ──
|
|
||||||
org_limit = None
|
|
||||||
try:
|
|
||||||
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
|
|
||||||
if org_limits and isinstance(org_limits, dict):
|
|
||||||
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
|
|
||||||
if org_limit is None and "default" in org_limits:
|
|
||||||
org_limit = org_limits["default"]
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
|
|
||||||
|
|
||||||
# ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
|
|
||||||
# Query the user's active subscription and read rules['allowances']['max_vehicles']
|
|
||||||
subscription_limit = None
|
subscription_limit = None
|
||||||
try:
|
try:
|
||||||
sub_stmt = (
|
sub_stmt = (
|
||||||
@@ -564,12 +544,53 @@ class AssetService:
|
|||||||
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
|
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
|
||||||
subscription_limit = int(max_vehicles)
|
subscription_limit = int(max_vehicles)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Subscription tier limit for user {user_id}: "
|
f"[P0] Subscription tier limit for user {user_id}: "
|
||||||
f"max_vehicles={subscription_limit} (from rules={tier_rules})"
|
f"max_vehicles={subscription_limit} (from JSONB rules)"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
|
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
|
||||||
|
|
||||||
|
# ── 2. ORGANIZATION base_asset_limit (fallback) ──
|
||||||
|
# If no user-level subscription, check the org's assigned tier
|
||||||
|
org_limit = None
|
||||||
|
if subscription_limit is None:
|
||||||
|
try:
|
||||||
|
org_stmt = select(Organization).where(Organization.id == org_id)
|
||||||
|
org = (await db.execute(org_stmt)).scalar_one_or_none()
|
||||||
|
if org and org.subscription_tier_id:
|
||||||
|
# Read from the org's subscription tier
|
||||||
|
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
|
||||||
|
tier = (await db.execute(tier_stmt)).scalar_one_or_none()
|
||||||
|
if tier and tier.rules:
|
||||||
|
allowances = tier.rules.get('allowances', {})
|
||||||
|
if isinstance(allowances, dict):
|
||||||
|
max_vehicles = allowances.get('max_vehicles')
|
||||||
|
if max_vehicles is not None:
|
||||||
|
org_limit = int(max_vehicles)
|
||||||
|
logger.info(
|
||||||
|
f"[P0] Org subscription tier limit for org {org_id}: "
|
||||||
|
f"max_vehicles={org_limit} (from JSONB rules)"
|
||||||
|
)
|
||||||
|
elif org:
|
||||||
|
# Fallback to legacy base_asset_limit if no tier assigned
|
||||||
|
org_limit = max(org.base_asset_limit or 1, 1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
|
||||||
|
|
||||||
|
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
|
||||||
|
config_limit = None
|
||||||
|
try:
|
||||||
|
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||||
|
if limits and isinstance(limits, dict):
|
||||||
|
config_limit = limits.get(user_role)
|
||||||
|
if config_limit is None:
|
||||||
|
config_limit = limits.get("default")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not read VEHICLE_LIMIT config: {e}")
|
||||||
|
|
||||||
|
if config_limit is None:
|
||||||
|
config_limit = 1 # absolute fallback
|
||||||
|
|
||||||
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
|
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
|
||||||
final_limit = config_limit
|
final_limit = config_limit
|
||||||
if org_limit is not None:
|
if org_limit is not None:
|
||||||
@@ -578,8 +599,8 @@ class AssetService:
|
|||||||
final_limit = max(final_limit, subscription_limit)
|
final_limit = max(final_limit, subscription_limit)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
|
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
|
||||||
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
|
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
|
||||||
f"final={final_limit}"
|
f"final={final_limit}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ class AuthService:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# Szerepkör dinamikus feloldása
|
# Szerepkör dinamikus feloldása
|
||||||
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
|
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.USER
|
||||||
|
|
||||||
# Referral kód generálása
|
# Referral kód generálása
|
||||||
referral_code = generate_secure_slug(8).upper()
|
referral_code = generate_secure_slug(8).upper()
|
||||||
@@ -273,7 +273,15 @@ class AuthService:
|
|||||||
|
|
||||||
# Infrastruktúra elemek
|
# Infrastruktúra elemek
|
||||||
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
|
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
|
||||||
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
|
db.add(OrganizationMember(
|
||||||
|
organization_id=new_org.id,
|
||||||
|
user_id=user.id,
|
||||||
|
person_id=user.person_id,
|
||||||
|
role="OWNER",
|
||||||
|
is_permanent=True,
|
||||||
|
is_verified=True,
|
||||||
|
status="active"
|
||||||
|
))
|
||||||
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
|
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
|
||||||
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli
|
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli
|
||||||
|
|
||||||
|
|||||||
@@ -51,18 +51,14 @@ class PricingCalculator:
|
|||||||
|
|
||||||
# RBAC rank discounts (higher rank = bigger discount)
|
# RBAC rank discounts (higher rank = bigger discount)
|
||||||
# Map the actual UserRole enum values to discount percentages
|
# Map the actual UserRole enum values to discount percentages
|
||||||
|
# RBAC Phase 1: Tisztított rendszerszintű szerepkörök
|
||||||
RBAC_DISCOUNTS = {
|
RBAC_DISCOUNTS = {
|
||||||
UserRole.superadmin: 0.5, # 50% discount
|
UserRole.SUPERADMIN: 0.5, # 50% discount
|
||||||
UserRole.admin: 0.3, # 30% discount
|
UserRole.ADMIN: 0.3, # 30% discount
|
||||||
UserRole.fleet_manager: 0.2, # 20% discount
|
UserRole.MODERATOR: 0.15, # 15% discount
|
||||||
UserRole.user: 0.0, # 0% discount
|
UserRole.SERVICE_MGR: 0.1, # 10% discount
|
||||||
# Add other roles as needed
|
UserRole.SALES_REP: 0.1, # 10% discount
|
||||||
UserRole.region_admin: 0.25, # 25% discount
|
UserRole.USER: 0.0, # 0% discount
|
||||||
UserRole.country_admin: 0.25, # 25% discount
|
|
||||||
UserRole.moderator: 0.15, # 15% discount
|
|
||||||
UserRole.sales_agent: 0.1, # 10% discount
|
|
||||||
UserRole.service_owner: 0.1, # 10% discount
|
|
||||||
UserRole.driver: 0.0, # 0% discount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -71,7 +67,7 @@ class PricingCalculator:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
base_amount: float,
|
base_amount: float,
|
||||||
country_code: str = "HU",
|
country_code: str = "HU",
|
||||||
user_role: UserRole = UserRole.user,
|
user_role: UserRole = UserRole.USER,
|
||||||
individual_discounts: Optional[List[Dict[str, Any]]] = None
|
individual_discounts: Optional[List[Dict[str, Any]]] = None
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
@@ -663,7 +659,7 @@ async def calculate_price(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
base_amount: float,
|
base_amount: float,
|
||||||
country_code: str = "HU",
|
country_code: str = "HU",
|
||||||
user_role: UserRole = UserRole.user,
|
user_role: UserRole = UserRole.USER,
|
||||||
individual_discounts: Optional[List[Dict[str, Any]]] = None
|
individual_discounts: Optional[List[Dict[str, Any]]] = None
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
@@ -806,6 +802,95 @@ async def upgrade_subscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade_org_subscription(
|
||||||
|
db: AsyncSession,
|
||||||
|
org_id: int,
|
||||||
|
tier_id: int,
|
||||||
|
actor_user_id: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Szervezet előfizetési csomagjának beállítása (org-level subscription assignment).
|
||||||
|
|
||||||
|
P0 Feature: Subscription & Package Assignment Bridge.
|
||||||
|
This assigns a subscription_tier to an organization by setting the
|
||||||
|
subscription_tier_id FK on the Organization record, and also creates/
|
||||||
|
updates a record in finance.org_subscriptions for audit trail.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
org_id: Organization ID
|
||||||
|
tier_id: SubscriptionTier ID
|
||||||
|
actor_user_id: User ID performing the action (for audit)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Result with tier details
|
||||||
|
"""
|
||||||
|
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
|
||||||
|
from app.models.marketplace.organization import Organization
|
||||||
|
|
||||||
|
# 1. Ellenőrizze, hogy a tier létezik-e
|
||||||
|
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
tier = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tier:
|
||||||
|
raise ValueError(f"Subscription tier id={tier_id} not found")
|
||||||
|
|
||||||
|
# 2. Ellenőrizze, hogy a szervezet létezik-e
|
||||||
|
stmt = select(Organization).where(Organization.id == org_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
org = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not org:
|
||||||
|
raise ValueError(f"Organization id={org_id} not found")
|
||||||
|
|
||||||
|
# 3. Állítsa be a subscription_tier_id-t a szervezeten
|
||||||
|
org.subscription_tier_id = tier_id
|
||||||
|
org.subscription_plan = tier.name
|
||||||
|
|
||||||
|
# 4. Extract max_vehicles from tier rules to update base_asset_limit
|
||||||
|
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1) if tier.rules else 1
|
||||||
|
org.base_asset_limit = max_vehicles
|
||||||
|
|
||||||
|
# 5. Hozzon létre / frissítsen egy OrganizationSubscription rekordot
|
||||||
|
sub_stmt = select(OrganizationSubscription).where(
|
||||||
|
OrganizationSubscription.org_id == org_id,
|
||||||
|
OrganizationSubscription.is_active == True
|
||||||
|
)
|
||||||
|
sub_result = await db.execute(sub_stmt)
|
||||||
|
existing_sub = sub_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_sub:
|
||||||
|
# Deaktiváljuk a régit
|
||||||
|
existing_sub.is_active = False
|
||||||
|
existing_sub.valid_until = datetime.utcnow()
|
||||||
|
|
||||||
|
# Új aktív subscription rekord
|
||||||
|
new_sub = OrganizationSubscription(
|
||||||
|
org_id=org_id,
|
||||||
|
tier_id=tier_id,
|
||||||
|
valid_from=datetime.utcnow(),
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
db.add(new_sub)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Org subscription upgraded: org_id={org_id}, "
|
||||||
|
f"tier={tier.name} (id={tier_id}), "
|
||||||
|
f"max_vehicles={max_vehicles}, "
|
||||||
|
f"actor={actor_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"organization_id": org_id,
|
||||||
|
"tier_id": tier_id,
|
||||||
|
"tier_name": tier.name,
|
||||||
|
"max_vehicles": max_vehicles,
|
||||||
|
"message": f"Organization {org_id} upgraded to {tier.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def record_ledger_entry(
|
async def record_ledger_entry(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class SearchService:
|
|||||||
|
|
||||||
user_tier = current_user.tier_name
|
user_tier = current_user.tier_name
|
||||||
|
|
||||||
if current_user.role in [UserRole.superadmin, UserRole.admin]:
|
if current_user.role in [UserRole.SUPERADMIN, UserRole.ADMIN]:
|
||||||
user_tier = "vip"
|
user_tier = "vip"
|
||||||
|
|
||||||
ranking_rules = await config.get_setting(
|
ranking_rules = await config.get_setting(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class SocialAuthService:
|
|||||||
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)
|
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def test_normal_user_cannot_access_admin_ping():
|
|||||||
mock_user = User(
|
mock_user = User(
|
||||||
id=999,
|
id=999,
|
||||||
email="normal@example.com",
|
email="normal@example.com",
|
||||||
role=UserRole.user,
|
role=UserRole.USER,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
subscription_plan="FREE",
|
subscription_plan="FREE",
|
||||||
@@ -54,7 +54,7 @@ def test_admin_user_can_access_admin_ping():
|
|||||||
mock_admin = User(
|
mock_admin = User(
|
||||||
id=1000,
|
id=1000,
|
||||||
email="admin@example.com",
|
email="admin@example.com",
|
||||||
role=UserRole.admin,
|
role=UserRole.ADMIN,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
subscription_plan="PREMIUM",
|
subscription_plan="PREMIUM",
|
||||||
|
|||||||
225
docs/db_schema_ownership_audit_2026-06-18.md
Normal file
225
docs/db_schema_ownership_audit_2026-06-18.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# P0 Critical: DB Schema & Ownership Model Audit Report
|
||||||
|
|
||||||
|
**Dátum:** 2026-06-18
|
||||||
|
**Auditor:** Architect Mode
|
||||||
|
**Cél:** A 404 "Asset not found" hiba kivizsgálása event creation során — a Privát Garázsok (Private Garages) kezelésének DB vs API mismatch-e.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 🔍 SÉMA AUDIT (Tulajdonosi Modell Vizsgálata)
|
||||||
|
|
||||||
|
### 1.1 `vehicle.assets` tábla — Tulajdonosi Oszlopok
|
||||||
|
|
||||||
|
| Oszlop | Típus | Leírás |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| `current_organization_id` | `integer NULL` | Melyik Org-ban van jelenleg a jármű |
|
||||||
|
| `owner_person_id` | `bigint NULL` | **Személy tulajdonos** (Person ID-re mutat, NEM User ID-ra!) |
|
||||||
|
| `owner_org_id` | `integer NULL` | **Szervezeti tulajdonos** (Organization ID) |
|
||||||
|
| `operator_person_id` | `bigint NULL` | **Üzemeltető személy** (Person ID) |
|
||||||
|
| `operator_org_id` | `integer NULL` | **Üzemeltető szervezet** (Organization ID) |
|
||||||
|
| `branch_id` | `uuid NULL` | Fizikai garázs (Branch) ahol áll |
|
||||||
|
| `status` | `varchar NOT NULL` | 'active', 'archived', stb. |
|
||||||
|
| `data_status` | `varchar NULL` | 'draft', 'active', 'enriched' |
|
||||||
|
|
||||||
|
**Következtetés:** Nincs direkt `owner_id` vagy `user_id` oszlop. A tulajdonjogot a `owner_person_id` (Person) és `owner_org_id` (Organization) kettőse modellezi. Ez a **Dual Entity** (Person vs User) modell része.
|
||||||
|
|
||||||
|
### 1.2 `fleet.organizations` tábla — Garázs Attribútumok
|
||||||
|
|
||||||
|
| Oszlop | Típus | Leírás |
|
||||||
|
|--------|-------|--------|
|
||||||
|
| `id` | `integer PK` | Szervezet azonosító |
|
||||||
|
| `owner_id` | `integer NULL` | Ki a tulajdonos (User ID) |
|
||||||
|
| `org_type` | `orgtype ENUM` | `'individual'`, `'fleet_owner'`, `'business'`, `'service_provider'` |
|
||||||
|
| `status` | `varchar` | `'active'`, `'pending_verification'` |
|
||||||
|
| `is_verified` | `boolean DEFAULT false` | KYC státusz |
|
||||||
|
| `name` | `varchar` | A garázs neve |
|
||||||
|
| `full_name` | `varchar` | Teljes cégnév |
|
||||||
|
|
||||||
|
**⚠️ KRITIKUS:** NINCS `is_personal`, `is_default` vagy `is_primary` flag az `organizations` táblában. A "Privát Garázs" fogalma **nem létezik külön mezőként** — csak az `org_type = 'individual'` jelzi.
|
||||||
|
|
||||||
|
### 1.3 `fleet.organization_members` tábla
|
||||||
|
|
||||||
|
| Oszlop | Típus |
|
||||||
|
|--------|-------|
|
||||||
|
| `organization_id` | `integer NOT NULL` |
|
||||||
|
| `user_id` | `integer NULL` |
|
||||||
|
| `person_id` | `bigint NULL` |
|
||||||
|
| `role` | `member_role ENUM` (OWNER, ADMIN, MEMBER, stb.) |
|
||||||
|
| `is_verified` | `boolean` |
|
||||||
|
| `status` | `varchar` ('active', 'pending_verification') |
|
||||||
|
|
||||||
|
### 1.4 `fleet.asset_assignments` tábla
|
||||||
|
|
||||||
|
| Oszlop | Típus |
|
||||||
|
|--------|-------|
|
||||||
|
| `id` | `uuid PK` |
|
||||||
|
| `asset_id` | `uuid NOT NULL` |
|
||||||
|
| `organization_id` | `integer NOT NULL` |
|
||||||
|
| `status` | `varchar NOT NULL` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 👤 ADATBÁZIS AUDIT — User 28 és Asset rekord vizsgálata
|
||||||
|
|
||||||
|
### 2.1 User ID: 28
|
||||||
|
|
||||||
|
| Mező | Érték |
|
||||||
|
|------|-------|
|
||||||
|
| `id` | **28** |
|
||||||
|
| `email` | `tester_pro@profibot.hu` |
|
||||||
|
| `role` | `admin` |
|
||||||
|
| `person_id` | **29** ⚠️ (ez a Person ID!) |
|
||||||
|
| `subscription_plan` | `PREMIUM` |
|
||||||
|
| `is_active` | `true` |
|
||||||
|
| `is_deleted` | **`true`** 🛑 (SOFT-DELETED!) |
|
||||||
|
| `deleted_at` | **2026-06-14** (4 nappal ezelőtt) |
|
||||||
|
| `scope_level` | `organization` |
|
||||||
|
| `scope_id` | `null` |
|
||||||
|
|
||||||
|
### 2.2 Asset ID: `deb42aea-1f48-4a70-85b5-ba451d005577`
|
||||||
|
|
||||||
|
| Mező | Érték |
|
||||||
|
|------|-------|
|
||||||
|
| `id` | `deb42aea-...` |
|
||||||
|
| `license_plate` | `QWE123` |
|
||||||
|
| `current_organization_id` | **44** (Profibot Kft.) |
|
||||||
|
| `owner_person_id` | **29** ⚠️ (ez a Person ID, ami User 28-hoz tartozik!) |
|
||||||
|
| `owner_org_id` | **44** (Profibot Kft.) |
|
||||||
|
| `operator_person_id` | `null` |
|
||||||
|
| `operator_org_id` | `null` |
|
||||||
|
| `status` | `active` |
|
||||||
|
| `data_status` | `draft` |
|
||||||
|
| `branch_id` | `3bc1bff7-...` |
|
||||||
|
| `vehicle_class` | `motorcycle` |
|
||||||
|
| `brand` | `APRILIA` |
|
||||||
|
| `model` | `af1` |
|
||||||
|
|
||||||
|
### 2.3 Asset Assignment
|
||||||
|
|
||||||
|
| Mező | Érték |
|
||||||
|
|------|-------|
|
||||||
|
| `asset_id` | `deb42aea-...` |
|
||||||
|
| `organization_id` | **44** |
|
||||||
|
| `status` | `active` |
|
||||||
|
|
||||||
|
### 2.4 Organization ID: 44 (Profibot Kft.)
|
||||||
|
|
||||||
|
| Mező | Érték |
|
||||||
|
|------|-------|
|
||||||
|
| `id` | 44 |
|
||||||
|
| `name` | Profibot Kft. |
|
||||||
|
| `org_type` | `business` |
|
||||||
|
| **`owner_id`** | **86** ❗ (NEM User 28, hanem User 86 a tulajdonos!) |
|
||||||
|
| `legal_owner_id` | 93 (Person ID) |
|
||||||
|
| `status` | `pending_verification` |
|
||||||
|
|
||||||
|
### 2.5 User 28 Organization Tagsulatai
|
||||||
|
|
||||||
|
- **Org 1 (Test Company):** User 28 a `owner_id` (saját cége, `fleet_owner`)
|
||||||
|
- **Org 63 (Aszalós Motorszervíz):** Tag (`ADMIN`, `pending_verification`, `service_provider`)
|
||||||
|
- **Org 44 (Profibot Kft.):** **NEM tag, NEM tulajdonos** ❌
|
||||||
|
|
||||||
|
### 2.6 Person ID 29 (User 28 Person rekordja)
|
||||||
|
|
||||||
|
User 28 `person_id` = 29. Az asset `owner_person_id` = 29. **Ez a helyes kapcsolat a User és az Asset között.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 📋 REGISZTRÁCIÓS LOGIKA ELEMZÉSE
|
||||||
|
|
||||||
|
### 3.1 `register_lite()` (1. fázis)
|
||||||
|
|
||||||
|
Forrás: [`backend/app/services/auth_service.py:63`](backend/app/services/auth_service.py:63)
|
||||||
|
|
||||||
|
- Létrehoz egy `Person` rekordot
|
||||||
|
- Létrehoz egy `User` rekordot (a Person-hoz kapcsolva `person_id` segítségével)
|
||||||
|
- `scope_level = "individual"` (alapértelmezett)
|
||||||
|
- `is_active = False` (email verification szükséges)
|
||||||
|
- **NEM hoz létre Organization-t ebben a fázisban**
|
||||||
|
|
||||||
|
### 3.2 `complete_kyc()` (2. fázis — "KYC complete")
|
||||||
|
|
||||||
|
Forrás: [`backend/app/services/auth_service.py:178`](backend/app/services/auth_service.py:178)
|
||||||
|
|
||||||
|
- **AUTOMATIKUSAN létrehoz egy Organization rekordot** (`OrgType.individual`)
|
||||||
|
- Org naming template: `"{last_name} Flotta"` (pl. "Tester Flotta")
|
||||||
|
- Org name: `"{last_name} Garázsa"`
|
||||||
|
- Beállítja a `User.scope_id` = `new_org.id` (az új Organization ID-jára)
|
||||||
|
- Létrehoz egy `Branch` (fiók/garázs) rekordot "Home Base" néven
|
||||||
|
- Létrehoz egy `OrganizationMember` rekordot OWNER role-lal
|
||||||
|
- Létrehoz egy `Wallet` rekordot
|
||||||
|
- Aktiválja a usert (`is_active = True`)
|
||||||
|
|
||||||
|
**Összefoglalva:** Igen, a rendszer létrehoz egy automatikus "Privát Garázst" (Organization) a KYC fázisban. Ennek típusa `individual`. Nincs külön `is_personal` flag, csak az `org_type = 'individual'` jelzi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🐛 A 404 HIBA GYÖKERÉNEK AZONOSÍTÁSA
|
||||||
|
|
||||||
|
### 4.1 Elsődleges Bug: Person ID vs User ID összehasonlítás
|
||||||
|
|
||||||
|
A [`backend/app/api/v1/endpoints/assets.py:744`](backend/app/api/v1/endpoints/assets.py:744) sorban a `_check_asset_access()` függvény:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Asset.owner_person_id == current_user.id, # ❌ BUG!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hiba:** Az asset `owner_person_id` mezőjét (`29`) a `current_user.id` (`28`) értékkel hasonlítja össze.
|
||||||
|
|
||||||
|
**Helyes:** `Asset.owner_person_id == current_user.person_id` (29 == 29 ✓)
|
||||||
|
|
||||||
|
Az asset-ben `owner_person_id = 29`, és a User 28-hoz tartozó `person_id = 29`. Ez egy valid tulajdonosi kapcsolat, de a kód rossz mezőt használ az összehasonlításra.
|
||||||
|
|
||||||
|
### 4.2 Ugyanez a bug TÖBB HELYEN is előfordul
|
||||||
|
|
||||||
|
| Sor | Hely | Hibás Kód | Javítás |
|
||||||
|
|-----|------|-----------|---------|
|
||||||
|
| [`assets.py:316`](backend/app/api/v1/endpoints/assets.py:316) | `get_asset()` | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
|
||||||
|
| [`assets.py:468`](backend/app/api/v1/endpoints/assets.py:468) | `update_vehicle()` | `Asset.owner_person_id == current_user.person_id` | ✅ **HELYES** |
|
||||||
|
| [`assets.py:548`](backend/app/api/v1/endpoints/assets.py:548) | `maintenance` GET | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
|
||||||
|
| [`assets.py:605`](backend/app/api/v1/endpoints/assets.py:605) | `maintenance` POST | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
|
||||||
|
| [`assets.py:744`](backend/app/api/v1/endpoints/assets.py:744) | `_check_asset_access()` | `Asset.owner_person_id == current_user.id` | `current_user.person_id` |
|
||||||
|
|
||||||
|
**FONTOS:** A 468. sor már helyes! Ott `current_user.person_id` van. Ez arra utal, hogy a többi helyen figyelmetlenségből maradt `current_user.id`.
|
||||||
|
|
||||||
|
### 4.3 Másodlagos Probléma: User 28 Soft-Deleted
|
||||||
|
|
||||||
|
User 28 (`tester_pro@profibot.hu`) **soft-deletelve** lett 2026-06-14-én (`deleted_at` mező kitöltve, `is_deleted = true`).
|
||||||
|
|
||||||
|
A [`backend/app/services/auth_service.py:313`](backend/app/services/auth_service.py:313) sorban:
|
||||||
|
|
||||||
|
```python
|
||||||
|
User.is_deleted == False
|
||||||
|
```
|
||||||
|
|
||||||
|
Ez azt jelenti, hogy User 28 **nem tud bejelentkezni** (az `authenticate()` függvény kiszűri a törölt usereket).
|
||||||
|
|
||||||
|
### 4.4 Harmadlagos Probléma: User 28 nincs az Org 44-ben
|
||||||
|
|
||||||
|
Az asset (`deb42aea`) `owner_org_id = 44` (Profibot Kft.), de User 28:
|
||||||
|
- Nem tagja az Org 44-nek
|
||||||
|
- Nem tulajdonosa az Org 44-nek (azt User 86 birtokolja)
|
||||||
|
- Csak az Org 1 (Test Company) tulajdonosa
|
||||||
|
|
||||||
|
Tehát még ha a Person ID bug javításra is kerül, a szervezeti ellenőrzés (`Asset.owner_org_id.in_(user_org_ids)`) akkor sem találna match-et, mert User 28 nincs az Org 44 tagjai között. **De ez nem is szükséges**, mert a személyes tulajdonjog (`owner_person_id = 29` ↔ `User 28 person_id = 29`) önállóan is hozzáférést biztosít.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 📊 ÖSSZEFOGLALÓ JELENTÉS
|
||||||
|
|
||||||
|
### Prioritási Sorrend
|
||||||
|
|
||||||
|
| # | Probléma | Hatás | Javítás Jellege |
|
||||||
|
|---|----------|-------|-----------------|
|
||||||
|
| **P0** | `User 28 soft-deleted` (`deleted_at: 2026-06-14`) | A user nem tud bejelentkezni, minden API hívás sikertelen | **Fiók visszaállítása** (restore OTP) vagy új regisztráció |
|
||||||
|
| **P0** | `owner_person_id == current_user.id` bug (4 helyen) | Még élő user esetén is 404-et kapna event létrehozáskor | `current_user.id` → `current_user.person_id` kijavítása |
|
||||||
|
| **P1** | Asset `data_status = 'draft'` — lehet, hogy még nincs teljesen aktiválva | Bizonyos műveletek korlátozva lehetnek | Admin felületen aktiválás vagy API javítás |
|
||||||
|
| **P2** | Nincs `is_personal` / `is_default` flag az organizations táblában | A "Privát Garázs" detektálása csak `org_type = 'individual'` alapján lehetséges | Opcionális séma kiegészítés |
|
||||||
|
|
||||||
|
### Ajánlott Következő Lépések
|
||||||
|
|
||||||
|
1. **User 28 restore** — Mivel a soft-delete 4 napja történt (30 napos ablakon belül), a `/restore/verify` végponton keresztül visszaállítható
|
||||||
|
2. **Comparison bug javítása** — Mind a 4 helyen (`assets.py:316, 548, 605, 744`) `current_user.id` → `current_user.person_id`
|
||||||
|
3. **Ellenőrzés** — Jelenleg `update_vehicle()` (468. sor) már helyes `current_user.person_id`-t használ; a többi endpoint inkonzisztens
|
||||||
|
|
||||||
|
**Megjegyzés:** A regisztrációs logika automatikusan létrehoz egy `individual` típusú Organization-t a KYC fázisban ("Privát Garázs"), ami a `scope_id`-n keresztül kapcsolódik a userhez. Ez a modell konzisztens, a hiba kizárólag a **comparison logic-ben** van.
|
||||||
161
docs/p0_subscription_tiers_reconnaissance_2026-06-18.md
Normal file
161
docs/p0_subscription_tiers_reconnaissance_2026-06-18.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# P0 Reconnaissance Report — Subscription Tiers & Organization State
|
||||||
|
|
||||||
|
**Dátum:** 2026-06-18
|
||||||
|
**Szerző:** Fast Coder (Core Developer)
|
||||||
|
**Cél:** A meglévő előfizetési csomagok (subscription_tiers) és a hozzájuk tartozó JSON rules struktúrák teljes körű felmérése, valamint a fleet.organizations tábla aktuális hozzárendeléseinek auditálása.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. AUDIT: Subscription Tiers (`system.subscription_tiers`)
|
||||||
|
|
||||||
|
A tábla jelenleg **10 aktív csomagot** tartalmaz. Az alábbi táblázat összefoglalja az összes csomagot:
|
||||||
|
|
||||||
|
| ID | Név (name) | Típus (rules->>type) | Display Name | Árazás |
|
||||||
|
|----|-----------|---------------------|-------------|--------|
|
||||||
|
| 13 | `private_free_v1` | `private` | Privát Ingyenes | 0 EUR |
|
||||||
|
| 14 | `private_pro_v1` | `private` | Privát Pro | 4.99 EUR/hó |
|
||||||
|
| 15 | `private_vip_v1` | `private` | Privát VIP | 9.99 EUR/hó |
|
||||||
|
| 16 | `corp_premium_v1` | `corporate` | Céges Prémium | 29.99 EUR/hó |
|
||||||
|
| 17 | `corp_premium_plus_v1` | `corporate` | Céges Prémium Plus | 59.99 EUR/hó |
|
||||||
|
| 18 | `corp_vip_v1` | `corporate` | Céges VIP | 149.99 EUR/hó |
|
||||||
|
| 19 | `private_test_v01` | `private` | Privát teszt | 0 HUF |
|
||||||
|
| 20 | `org_test_v01` | `corporate` | Céges teszt | 0 EUR |
|
||||||
|
| 21 | `corp_ree` | `corporate` | Corp Free | 0 EUR |
|
||||||
|
| 22 | `corp_free_v1` | `corporate` | Céges Ingyenes | 0 EUR |
|
||||||
|
|
||||||
|
### 1.1 Teljes JSON rules — "Free" csomag (ID 13: `private_free_v1`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "private",
|
||||||
|
"pricing": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 0,
|
||||||
|
"yearly_price": 0,
|
||||||
|
"monthly_price": 0
|
||||||
|
},
|
||||||
|
"affiliate": {
|
||||||
|
"referral_bonus_credits": 0,
|
||||||
|
"commission_rate_percent": 0
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"is_public": true
|
||||||
|
},
|
||||||
|
"allowances": {
|
||||||
|
"max_garages": 1,
|
||||||
|
"max_vehicles": 1,
|
||||||
|
"monthly_free_credits": 0
|
||||||
|
},
|
||||||
|
"display_name": "Privát Ingyenes",
|
||||||
|
"entitlements": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Teljes JSON rules — "Legnagyobb céges" csomag (ID 18: `corp_vip_v1`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "corporate",
|
||||||
|
"pricing": {
|
||||||
|
"currency": "EUR",
|
||||||
|
"credit_price": 15000,
|
||||||
|
"yearly_price": 1499.99,
|
||||||
|
"monthly_price": 149.99
|
||||||
|
},
|
||||||
|
"affiliate": {
|
||||||
|
"referral_bonus_credits": 500,
|
||||||
|
"commission_rate_percent": 25
|
||||||
|
},
|
||||||
|
"lifecycle": {
|
||||||
|
"is_public": true
|
||||||
|
},
|
||||||
|
"allowances": {
|
||||||
|
"max_garages": 50,
|
||||||
|
"max_vehicles": 200,
|
||||||
|
"monthly_free_credits": 2500
|
||||||
|
},
|
||||||
|
"display_name": "Céges VIP",
|
||||||
|
"entitlements": [
|
||||||
|
"SRV_DATA_EXPORT",
|
||||||
|
"SRV_AI_UPLOAD",
|
||||||
|
"SRV_ACCOUNTING_SYNC",
|
||||||
|
"SRV_API_ACCESS"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 A JSON rules struktúra elemzése
|
||||||
|
|
||||||
|
A `rules` JSONB mező egységes szerkezetet követ minden csomagnál:
|
||||||
|
|
||||||
|
| Kulcs | Típus | Leírás |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| `type` | `string` | `"private"` vagy `"corporate"` — a csomag típusa |
|
||||||
|
| `pricing` | `object` | Árazási adatok: `currency`, `credit_price`, `yearly_price`, `monthly_price` |
|
||||||
|
| `affiliate` | `object` | MLM/affiliate beállítások: `referral_bonus_credits`, `commission_rate_percent` |
|
||||||
|
| `lifecycle` | `object` | Életciklus: `is_public` (bool), opcionálisan `available_until` (date/null) |
|
||||||
|
| `allowances` | `object` | Korlátok: `max_garages`, `max_vehicles`, `monthly_free_credits` |
|
||||||
|
| `display_name` | `string` | Felhasználói felületen megjelenő név |
|
||||||
|
| `entitlements` | `string[]` | Jogosultságok listája (pl. `SRV_DATA_EXPORT`, `SRV_AI_UPLOAD`) |
|
||||||
|
|
||||||
|
**Felfedezett entitlement kódok:**
|
||||||
|
- `SRV_DATA_EXPORT` — Adatexport szolgáltatás
|
||||||
|
- `SRV_AI_UPLOAD` — AI feltöltés
|
||||||
|
- `SRV_ACCOUNTING_SYNC` — Számviteli szinkron
|
||||||
|
- `SRV_API_ACCESS` — API hozzáférés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AUDIT: Current Assignments (`fleet.organizations`)
|
||||||
|
|
||||||
|
A `fleet.organizations` táblában jelenleg **2 garázs** rendelkezik nem-NULL `subscription_tier_id` értékkel:
|
||||||
|
|
||||||
|
| Org ID | Név | Csomag ID | Csomag Neve | Display Name |
|
||||||
|
|--------|-----|-----------|-------------|-------------|
|
||||||
|
| 45 | Gyöngyössy garázs | 17 | `corp_premium_plus_v1` | Céges Prémium Plus |
|
||||||
|
| 49 | Test Garázsa | 16 | `corp_premium_v1` | Céges Prémium |
|
||||||
|
|
||||||
|
**Megjegyzés:** A többi garázs (beleértve az admin garázsait is) `subscription_tier_id = NULL` értékkel rendelkezik, azaz jelenleg nincs hozzájuk rendelve előfizetési csomag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. IDENTIFY: Admin Garages (User ID 2 — admin@profibot.hu)
|
||||||
|
|
||||||
|
### 3.1 Tulajdonolt garázsok (owner_id = 2)
|
||||||
|
|
||||||
|
| Org ID | Név | Típus (org_type) | Státusz | subscription_tier_id |
|
||||||
|
|--------|-----|------------------|---------|---------------------|
|
||||||
|
| 67 | Admin Garázsa | `individual` | active | **NULL** |
|
||||||
|
|
||||||
|
### 3.2 Tagsági viszonyok (organization_members)
|
||||||
|
|
||||||
|
A 2-es user (admin@profibot.hu) az alábbi szervezetekben tag:
|
||||||
|
|
||||||
|
| Org ID | Szervezet Neve | Szerepkör | Státusz |
|
||||||
|
|--------|---------------|-----------|---------|
|
||||||
|
| 1 | **Test Company** | `ADMIN` | active |
|
||||||
|
| 57 | Teszt Autószerviz Kft. | `OWNER` | active |
|
||||||
|
| 58 | Autónyíri Kft. | `OWNER` | active |
|
||||||
|
| 62 | Bokebo Kft. | `OWNER` | active |
|
||||||
|
| 67 | **Admin Garázsa** | `OWNER` | active |
|
||||||
|
|
||||||
|
### 3.3 Kiemelt garázsok azonosítói
|
||||||
|
|
||||||
|
A Tervező által kért két kiemelt garázs:
|
||||||
|
|
||||||
|
| Megnevezés | Organization ID | Jelenlegi Csomag |
|
||||||
|
|------------|---------------|-----------------|
|
||||||
|
| **Test Company** (Céges) | **1** | **NINCS** (NULL) |
|
||||||
|
| **Admin Garázsa** (Privát) | **67** | **NINCS** (NULL) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Összefoglaló és Következtetések
|
||||||
|
|
||||||
|
1. **A subscription_tiers rendszer teljes és jól strukturált.** 10 csomag áll rendelkezésre, 4 privát és 6 céges kategóriában. A JSON rules séma egységes, jól bővíthető.
|
||||||
|
|
||||||
|
2. **Csak 2 garázs kapott eddig csomagot** (Gyöngyössy garázs → corp_premium_plus_v1, Test Garázsa → corp_premium_v1). A többi garázs, köztük az admin garázsai is, csomag nélküli állapotban vannak.
|
||||||
|
|
||||||
|
3. **Az admin két kiemelt garázsa** — `Test Company` (org_id=1) és `Admin Garázsa` (org_id=67) — jelenleg nem rendelkezik előfizetési csomaggal. Ezeket a későbbiekben fel kell címkézni a megfelelő tier-rel.
|
||||||
|
|
||||||
|
4. **A JSON rules struktúra lehetővé teszi** a korlátok (max_vehicles, max_garages), árazás, affiliate jutalékok és entitlement-ök (feature flagek) finomhangolását csomagonként.
|
||||||
189
docs/p0_unified_workspace_impact_analysis_2026-06-18.md
Normal file
189
docs/p0_unified_workspace_impact_analysis_2026-06-18.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# P0 PRE-FLIGHT CHECK: Unified Workspace Impact Analysis
|
||||||
|
|
||||||
|
**Dátum:** 2026-06-18
|
||||||
|
**Auditor:** Architect Mode
|
||||||
|
**Cél:** A 28-as User (tester_pro@profibot.hu) privát járműveinek organization_id auditja, mielőtt bevezetjük az új "Unified Workspace" szabályt (ahol a hozzáférés szigorúan organization_id + OrganizationMember alapú).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 🔍 SZERVEZETI STRUKTÚRA (Organization Audit)
|
||||||
|
|
||||||
|
### 1.1 User 28 → Person 29
|
||||||
|
|
||||||
|
| Mező | Érték |
|
||||||
|
|------|-------|
|
||||||
|
| User ID | **28** |
|
||||||
|
| Email | tester_pro@profibot.hu |
|
||||||
|
| Person ID | **29** |
|
||||||
|
| Név | Tester Profibot |
|
||||||
|
|
||||||
|
### 1.2 Privát Garázs (individual type)
|
||||||
|
|
||||||
|
A 28-as User saját "Privát Garázsa":
|
||||||
|
|
||||||
|
| Mező | Érték |
|
||||||
|
|------|-------|
|
||||||
|
| **Organization ID** | **21** |
|
||||||
|
| Név | Private_28 |
|
||||||
|
| Típus | `individual` |
|
||||||
|
| Tulajdonos (owner_id) | 29 (Person) |
|
||||||
|
| folder_slug | priv_28 |
|
||||||
|
|
||||||
|
### 1.3 A Person 29 által birtokolt összes szervezet
|
||||||
|
|
||||||
|
| Org ID | Név | Típus | Van tagja? |
|
||||||
|
|--------|-----|-------|-----------|
|
||||||
|
| 15 | Profibot Test Fleet | `fleet_owner` | ❌ NINCS |
|
||||||
|
| **21** | **Private_28** | **`individual`** | **❌ NINCS** |
|
||||||
|
| 26 | Test Kft. Alpha | `fleet_owner` | ❌ NINCS |
|
||||||
|
| 27 | Test Kft. Beta | `fleet_owner` | ❌ NINCS |
|
||||||
|
| 38 | Admin Széfe | `individual` | ❌ NINCS |
|
||||||
|
|
||||||
|
### 1.4 Ahol Person 29 TAG (OrganizationMember)
|
||||||
|
|
||||||
|
| Org ID | Név | Típus | Szerep |
|
||||||
|
|--------|-----|-------|--------|
|
||||||
|
| 63 | Aszalós Motorszervíz | `service_provider` | ADMIN |
|
||||||
|
|
||||||
|
**⚠️ KRITIKUS:** Person 29 NEM tagja a saját privát garázsának (org 21)! A `fleet.organization_members` táblában egyetlen rekord sincs a `Private_28`-hoz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 🚗 JÁRMŰVEK ÁLLAPOTA (Assets Audit)
|
||||||
|
|
||||||
|
### 2.1 Person 29 tulajdonában lévő összes jármű
|
||||||
|
|
||||||
|
| # | ID (UUID) | Rendszám | Márka | Modell | current_org_id | owner_org_id | Státusz |
|
||||||
|
|---|-----------|----------|-------|--------|---------------|-------------|---------|
|
||||||
|
| 1 | deb42aea... | QWE123 | APRILIA | af1 | **44** | 44 | active/draft |
|
||||||
|
| 2 | da49269e... | TTRFGHZT | BAYLINER | 1750 CAPRY BOWRIDER | **44** | 44 | active/draft |
|
||||||
|
| 3 | 72b0a0cf... | UOK795 | Honda | CB1000R | **NULL** ⚠️ | 34 | active/verified |
|
||||||
|
| 4 | 0b97a975... | PKT215 | Mazda | 2 | **NULL** ⚠️ | 34 | active/draft |
|
||||||
|
| 5 | 09700990... | AIML519 | Skoda | Citigo e | **NULL** ⚠️ | 34 | active/enriched |
|
||||||
|
| 6 | 104d6753... | ABC-123 | Toyota | Corolla | **34** | 34 | active/verified |
|
||||||
|
|
||||||
|
### 2.2 Érintett szervezetek részletei
|
||||||
|
|
||||||
|
| Org ID | Név | Típus | Tulajdonos |
|
||||||
|
|--------|-----|-------|-----------|
|
||||||
|
| 21 | Private_28 | `individual` | Person 29 ✅ |
|
||||||
|
| 34 | Swagger-Test Kft. | `business` | Nincs tulajdonos (owner_id = NULL) |
|
||||||
|
| 44 | Profibot Kft. | `business` | User 86 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 📊 HATÁSELEMZÉS (Impact Analysis)
|
||||||
|
|
||||||
|
### 3.1 Unified Workspace forgatókönyv
|
||||||
|
|
||||||
|
Ha bevezetjük a szabályt: **"Egy User csak olyan járművet érhet el, ahol a `current_organization_id` egyezik egy olyan Organization-nel, aminek a User tagja (OrganizationMember)"**:
|
||||||
|
|
||||||
|
#### 3.1.1 NULL current_organization_id-val rendelkező járművek
|
||||||
|
|
||||||
|
**3 jármű** (Honda CB1000R, Mazda 2, Skoda Citigo e) **jelenleg NULL** `current_organization_id`-val rendelkezik. Ezek:
|
||||||
|
- Nincsenek egyetlen szervezethez sem rendelve jelenleg
|
||||||
|
- Csak a `owner_person_id = 29` alapján lennének elérhetők
|
||||||
|
- **Az új szabály szerint KI lennének ZÁRVA** (orphaned), mert nincs organization contextusuk
|
||||||
|
|
||||||
|
#### 3.1.2 Más szervezethez rendelt járművek
|
||||||
|
|
||||||
|
**3 jármű** (Toyota Corolla → org 34, Aprilia + Bayliner → org 44) olyan szervezetekhez van rendelve, amelyeknek Person 29 NEM tagja:
|
||||||
|
- **Toyota Corolla** (`current_org = 34` = Swagger-Test Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
|
||||||
|
- **APRILIA** (`current_org = 44` = Profibot Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
|
||||||
|
- **BAYLINER** (`current_org = 44` = Profibot Kft.) → **KI VAN ZÁRVA** (Person 29 nem tag)
|
||||||
|
|
||||||
|
### 3.2 Összegzés
|
||||||
|
|
||||||
|
| Kategória | Darab | Járművek | Sorsa új szabály alatt |
|
||||||
|
|-----------|-------|----------|----------------------|
|
||||||
|
| NULL current_org_id | **3** | Honda, Mazda, Skoda | ❌ ORPHANED |
|
||||||
|
| Idegen org (nincs tagság) | **3** | Toyota (org 34), Aprilia+Bayliner (org 44) | ❌ ORPHANED |
|
||||||
|
| Saját privát garázsban (org 21) | **0** | — | ✅ OK |
|
||||||
|
| **ÖSSZESEN** | **6/6** | **MIND** | **❌ 100%-OS ORPHANED** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 💾 ELŐKÉSZÍTETT SQL JAVÍTÁS (NE FUTTASD KI - CSAK TERVEZÉS)
|
||||||
|
|
||||||
|
### 4.1 Első lépés: Person 29 felvétele a Private_28 (org 21) tagjai közé
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO fleet.organization_members (organization_id, user_id, person_id, role, is_permanent, is_verified, status)
|
||||||
|
VALUES (21, 28, 29, 'OWNER', true, true, 'active');
|
||||||
|
```
|
||||||
|
|
||||||
|
**VAGY ha a user_id nem elvárás (person_id alapú):**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO fleet.organization_members (organization_id, person_id, role, is_permanent, is_verified, status)
|
||||||
|
VALUES (21, 29, 'OWNER', true, true, 'active');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Második lépés: NULL current_organization_id javítása
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE vehicle.assets
|
||||||
|
SET current_organization_id = 21
|
||||||
|
WHERE owner_person_id = 29 AND current_organization_id IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ez a **Honda CB1000R** (UOK795), **Mazda 2** (PKT215) és **Skoda Citigo e** (AIML519) járműveket beköti a Privát Garázsba.
|
||||||
|
|
||||||
|
### 4.3 Harmadik lépés: Üzleti döntés szükséges
|
||||||
|
|
||||||
|
A következő járművekhez **üzleti döntés** kell:
|
||||||
|
|
||||||
|
1. **Toyota Corolla** (ABC-123) - `current_org = 34, owner_org = 34`
|
||||||
|
- **Opció A:** Áttenni a Private_28-ba (org 21)
|
||||||
|
- **Opció B:** Felvenni Person 29-et a Swagger-Test Kft. (org 34) tagjai közé
|
||||||
|
- **Opció C:** Megtartani a jelenlegi állapotot és kivételt képezni az új szabály alól
|
||||||
|
|
||||||
|
2. **APRILIA af1** (QWE123) + **BAYLINER** (TTRFGHZT) - `current_org = 44, owner_org = 44`
|
||||||
|
- **Opció A:** Áttenni a Private_28-ba (org 21)
|
||||||
|
- **Opció B:** Felvenni Person 29-et a Profibot Kft. (org 44) tagjai közé (de User 86 a tulajdonos!)
|
||||||
|
- **Opció C:** Megtartani a jelenlegi állapotot
|
||||||
|
|
||||||
|
### 4.4 Teljes körű SQL javítás (ha az összes járművet a privát garázsba tennénk)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Person 29 taggá tétele a Private_28-ban
|
||||||
|
INSERT INTO fleet.organization_members (organization_id, user_id, person_id, role, is_permanent, is_verified, status)
|
||||||
|
VALUES (21, 28, 29, 'OWNER', true, true, 'active');
|
||||||
|
|
||||||
|
-- 2. Összes jármű áthelyezése a Private_28-ba
|
||||||
|
UPDATE vehicle.assets
|
||||||
|
SET current_organization_id = 21
|
||||||
|
WHERE owner_person_id = 29;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. ⚠️ KIEGÉSZÍTŐ MEGJEGYZÉSEK
|
||||||
|
|
||||||
|
### 5.1 User 28 soft-deleted állapota
|
||||||
|
|
||||||
|
A korábbi audit ([`docs/db_schema_ownership_audit_2026-06-18.md`](docs/db_schema_ownership_audit_2026-06-18.md)) feltárta, hogy User 28 **soft-deletelve** van (`is_deleted = true`, `deleted_at = 2026-06-14`). Ez azt jelenti:
|
||||||
|
- Jelenleg NEM tud bejelentkezni
|
||||||
|
- Még az adatjavítás után sem fér hozzá a járművekhez, amíg a fiókja nincs visszaállítva
|
||||||
|
|
||||||
|
### 5.2 Tervezési ajánlás
|
||||||
|
|
||||||
|
Az új "Unified Workspace" szabály bevezetése ELŐTT kötelező:
|
||||||
|
|
||||||
|
1. **Adatjavítás:** A NULL `current_organization_id`-val rendelkező járművek bekötése
|
||||||
|
2. **Tagság rögzítése:** Minden `individual` típusú org tulajdonosának automatikus OWNER taggá tétele
|
||||||
|
3. **Üzleti járművek felülvizsgálata:** Döntés a org 34-be és 44-be sorolt járművekről
|
||||||
|
4. **User 28 restore:** A soft-delete feloldása a KYC/restore folyamaton keresztül
|
||||||
|
5. **Átmeneti időszak:** Az `owner_person_id` alapú hozzáférés megtartása fallback-ként, amíg a fenti pontok nem teljesülnek
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 🔮 ARCHITECT AJÁNLÁS
|
||||||
|
|
||||||
|
**Javaslat:** NE vezessük be a "pure Unified Workspace"-t (kizárólag org + membership alapú hozzáférés), amíg:
|
||||||
|
|
||||||
|
1. Az összes `individual` org-nak nincs automatikusan létrehozott OWNER OrganizationMember rekordja
|
||||||
|
2. Az összes meglévő jármű `current_organization_id` mezője ki van töltve
|
||||||
|
3. A soft-deletelt userek vissza vannak állítva vagy tisztázva van a státuszuk
|
||||||
|
|
||||||
|
**Ajánlott átmeneti megoldás:** Tartsd meg az `owner_person_id == user.person_id` ellenőrzést fallback-ként az org-based check mellett, amíg a fenti adatjavítások meg nem történnek.
|
||||||
275
docs/permission_system_audit_2026-06-18.md
Normal file
275
docs/permission_system_audit_2026-06-18.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# 🔐 Jogosultságkezelés Audit Jelentés (2026-06-18)
|
||||||
|
|
||||||
|
## Teljes Rendszerkép a Jogosultsági Szintekről
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Adatbázis Réteg (Database Schema)
|
||||||
|
|
||||||
|
### 1.1 `identity.users` - Felhasználói Jogosultságok
|
||||||
|
|
||||||
|
| Mező | Típus | Leírás |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `role` | `identity.userrole` enum | Rendszerszintű szerepkör |
|
||||||
|
| `scope_level` | `VARCHAR(30)` | Hatókör szint (pl. "individual") |
|
||||||
|
| `scope_id` | `VARCHAR(50)` | Hatókör azonosító (pl. org ID vagy user ID) |
|
||||||
|
| `custom_permissions` | `JSON` | Egyedi jogosultságok (`{"capabilities": [...]}`) |
|
||||||
|
|
||||||
|
**`userrole` enum értékei** (Python `UserRole` osztály, `backend/app/models/identity/identity.py:24`):
|
||||||
|
```
|
||||||
|
superadmin, admin, region_admin, country_admin, moderator,
|
||||||
|
sales_agent, user, service_owner, fleet_manager, driver
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Probléma**: Az adatbázis enum duplikált értékeket tartalmaz (pl. "admin" és "ADMIN" is létezik, "user" kétszer szerepel). Ez az enum korábbi migrációk során keletkezett.
|
||||||
|
|
||||||
|
### 1.2 `fleet.org_roles` - Szervezeti Szerepkör Definíciók
|
||||||
|
|
||||||
|
**🔴 KRITIKUS: A tábla ÜRES!**
|
||||||
|
|
||||||
|
A modell (`backend/app/models/marketplace/organization.py:34`) definiál egy dinamikus szerepkör rendszert (`OrgRole` modell), de **seed-elés nem történt meg**. A `OrgUserRole` enum (`backend/app/models/marketplace/organization.py:27`) az alábbi értékeket tartja számon:
|
||||||
|
```
|
||||||
|
OWNER, ADMIN, MANAGER, MEMBER, AGENT
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 `fleet.organization_members` - Tagságok és Jogosultságok
|
||||||
|
|
||||||
|
| Mező | Típus | Leírás |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `role` | `VARCHAR(50)` | Szerepkör a szervezetben (OWNER/ADMIN/MANAGER/MEMBER/AGENT) |
|
||||||
|
| `permissions` | `JSON` | Egyedi JSON jogosultságok |
|
||||||
|
| `is_permanent` | `BOOLEAN` | Állandó tagság |
|
||||||
|
| `is_verified` | `BOOLEAN` | Megerősített tagság |
|
||||||
|
| `status` | `VARCHAR(20)` | Státusz (active/inactive) |
|
||||||
|
|
||||||
|
### 1.4 `system.system_parameters` - Hierarchikus Paraméterezés
|
||||||
|
|
||||||
|
| Mező | Típus | Leírás |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `key` | `VARCHAR` | Paraméter kulcs |
|
||||||
|
| `value` | `JSONB` | Paraméter érték |
|
||||||
|
| `scope_level` | `parameter_scope` enum | Hatókör: GLOBAL, COUNTRY, REGION, USER, ORGANIZATION |
|
||||||
|
| `scope_id` | `VARCHAR` | Hatókör azonosító |
|
||||||
|
|
||||||
|
**`parameter_scope` enum értékei** (`backend/app/models/system/system.py:12`):
|
||||||
|
```
|
||||||
|
GLOBAL, COUNTRY, REGION, ORGANIZATION, USER
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Probléma**: Minden meglévő paraméter `global` scope_level-lel rendelkezik. A hierarchikus scope (country, region, organization, user) **nincs használatban** gyakorlatban.
|
||||||
|
|
||||||
|
### 1.5 `system.pending_actions` - Dual Control (Négy Szem Elv)
|
||||||
|
|
||||||
|
| Mező | Típus | Leírás |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `requester_id` | `INTEGER` | Kérelem indítója |
|
||||||
|
| `approver_id` | `INTEGER` | Jóváhagyó (nullable) |
|
||||||
|
| `status` | `action_status` enum | pending/approved/rejected |
|
||||||
|
| `action_type` | `VARCHAR` | CHANGE_ROLE, SET_VIP, WALLET_ADJUST, SOFT_DELETE_USER, ORGANIZATION_TRANSFER |
|
||||||
|
| `payload` | `JSONB` | Művelet adatok |
|
||||||
|
|
||||||
|
### 1.6 `system.subscription_tiers` - Előfizetési Csomagok
|
||||||
|
|
||||||
|
| Mező | Típus | Leírás |
|
||||||
|
|------|-------|--------|
|
||||||
|
| `name` | `VARCHAR` | Csomag neve (FREE, PREMIUM, VIP, stb.) |
|
||||||
|
| `rules` | `JSONB` | Szabályok (pl. `{"max_vehicles": 5}`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backend Kód Réteg
|
||||||
|
|
||||||
|
### 2.1 Rang Rendszer - `DEFAULT_RANK_MAP`
|
||||||
|
|
||||||
|
Definiálva: `backend/app/core/security.py:56`
|
||||||
|
|
||||||
|
| Rang | Érték | Van UserRole enum-ban? |
|
||||||
|
|------|-------|----------------------|
|
||||||
|
| SUPERADMIN | 100 | ✅ Igen |
|
||||||
|
| ADMIN | 90 | ✅ Igen |
|
||||||
|
| REGION_ADMIN | 85 | ✅ Igen |
|
||||||
|
| COUNTRY_ADMIN | 80 | ✅ Igen |
|
||||||
|
| MODERATOR | 75 | ✅ Igen |
|
||||||
|
| AUDITOR | 70 | ❌ Nincs |
|
||||||
|
| ORGANIZATION_OWNER | 65 | ❌ Nincs |
|
||||||
|
| ORGANIZATION_MANAGER | 60 | ❌ Nincs |
|
||||||
|
| ORGANIZATION_MEMBER | 50 | ❌ Nincs |
|
||||||
|
| SERVICE_PROVIDER | 40 | ❌ Nincs |
|
||||||
|
| SALES_AGENT | 30 | ✅ Igen |
|
||||||
|
| FLEET_MANAGER | 25 | ✅ Igen |
|
||||||
|
| PREMIUM_USER | 20 | ❌ Nincs |
|
||||||
|
| USER | 10 | ✅ Igen |
|
||||||
|
| DRIVER | 5 | ✅ Igen |
|
||||||
|
| GUEST | 0 | ❌ Nincs |
|
||||||
|
|
||||||
|
> 🔴 **KRITIKUS**: 16 rang van definiálva, de a `UserRole` enum csak 10 értéket tartalmaz. A hiányzók (AUDITOR, ORGANIZATION_OWNER/MANAGER/MEMBER, SERVICE_PROVIDER, PREMIUM_USER, GUEST) sosem kerülnek kiosztásra!
|
||||||
|
|
||||||
|
### 2.2 RBAC Osztály - 3 Szintű Ellenőrzés
|
||||||
|
|
||||||
|
Forrás: `backend/app/core/rbac.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Superadmin check → mindent átenged
|
||||||
|
2. Rang ellenőrzés → DEFAULT_RANK_MAP alapján
|
||||||
|
3. Custom permissions → capabilities lista ellenőrzése
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 API Deps - `get_current_admin`
|
||||||
|
|
||||||
|
Forrás: `backend/app/api/deps.py:158`
|
||||||
|
|
||||||
|
Engedélyezett admin szerepkörök:
|
||||||
|
```python
|
||||||
|
allowed_roles = {superadmin, admin, region_admin, country_admin, moderator}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Scoped RBAC - `check_resource_access`
|
||||||
|
|
||||||
|
Forrás: `backend/app/api/deps.py:106`
|
||||||
|
|
||||||
|
```
|
||||||
|
- Superadmin → mindenhez hozzáfér
|
||||||
|
- Saját ID vagy scope_id egyezés → hozzáfér
|
||||||
|
- Egyéb → 403 Forbidden
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Dual Control - `SecurityService`
|
||||||
|
|
||||||
|
Forrás: `backend/app/services/security_service.py`
|
||||||
|
|
||||||
|
| Típus | Leírás |
|
||||||
|
|-------|--------|
|
||||||
|
| `CHANGE_ROLE` | Szerepkör módosítás |
|
||||||
|
| `SET_VIP` | VIP státusz |
|
||||||
|
| `WALLET_ADJUST` | Pénztárca módosítás |
|
||||||
|
| `SOFT_DELETE_USER` | Felhasználó törlés |
|
||||||
|
| `ORGANIZATION_TRANSFER` | Szervezet átadás |
|
||||||
|
|
||||||
|
### 2.6 JWT Token Felépítése
|
||||||
|
|
||||||
|
Forrás: `backend/app/api/v1/endpoints/auth.py:108`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "user_id",
|
||||||
|
"role": "superadmin",
|
||||||
|
"rank": 100,
|
||||||
|
"scope_level": "individual",
|
||||||
|
"scope_id": "user_id_vagy_org_id"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Frontend Réteg
|
||||||
|
|
||||||
|
### 3.1 Frontend Auth Store
|
||||||
|
|
||||||
|
Forrás: `frontend/src/stores/authStore.ts:4`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **Probléma**: A frontend `UserRole` típus **NEM egyezik** a backend `UserRole` enum-jával! Hiányzik: `region_admin`, `country_admin`, `moderator`, `sales_agent`, `service_owner`, `fleet_manager`, `driver`.
|
||||||
|
|
||||||
|
### 3.2 Router Admin Guard
|
||||||
|
|
||||||
|
Forrás: `frontend/src/router/index.ts:133`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
|
||||||
|
```
|
||||||
|
|
||||||
|
Ez a lista egyezik a backend `get_current_admin` függvényével.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Problémák és Hiányosságok
|
||||||
|
|
||||||
|
### 🔴 KRITIKUS Problémák
|
||||||
|
|
||||||
|
| # | Probléma | Hely | Hatás |
|
||||||
|
|---|----------|------|-------|
|
||||||
|
| P1 | `fleet.org_roles` tábla ÜRES | OrgRole modell | Dinamikus szerepkör rendszer nem működik |
|
||||||
|
| P2 | Rank map inkonzisztens a UserRole enum-mal | DEFAULT_RANK_MAP vs UserRole | 6 rang sosem kerül kiosztásra |
|
||||||
|
| P3 | Enum duplikáció (kis/nagybetű) | userrole és orgtype adatbázis enumok | Migrációs hiba, karbantartási nehézség |
|
||||||
|
| P4 | Frontend/backend UserRole eltérés | authStore.ts | Frontend hibás role-okat kezel |
|
||||||
|
| P5 | parameter_scope enum 3 sémában | public, system | Inkonzisztens definíciók |
|
||||||
|
|
||||||
|
### 🟡 FIGYELMEZTETÉS
|
||||||
|
|
||||||
|
| # | Probléma | Hely | Hatás |
|
||||||
|
|---|----------|------|-------|
|
||||||
|
| W1 | Csak `global` scope használatban | system_parameters | Hierarchikus paraméterezés nem kihasznált |
|
||||||
|
| W2 | Minden user `individual` scope_level | users tábla | Többszintű scope nincs implementálva |
|
||||||
|
| W3 | Nincs explicit "deny" mechanizmus | RBAC rendszer | Csak elégtelen rangot jelez, explicit tiltás nincs |
|
||||||
|
| W4 | custom_permissions nincs validálva | User modell | Bármilyen JSON elfogadott |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Összefüggés Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph Frontend
|
||||||
|
FS[authStore.ts<br/>UserRole: guest/user/manager/admin/superadmin]
|
||||||
|
RG[Router Guard<br/>adminRoles: superadmin, admin,<br/>region_admin, country_admin, moderator]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph API_Layer
|
||||||
|
JWT[JWT Token payload<br/>sub + role + rank + scope_level + scope_id]
|
||||||
|
DC[Dual Control SecurityService<br/>5 művelettípus]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend
|
||||||
|
UR[UserRole enum<br/>10 érték]
|
||||||
|
RM[DEFAULT_RANK_MAP<br/>16 rang - csak 10 használható]
|
||||||
|
RBAC[RBAC osztály<br/>3 szint: superadmin + rank + capabilities]
|
||||||
|
GR[get_current_admin<br/>5 engedélyezett szerepkör]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Database
|
||||||
|
U[identity.users<br/>role + scope_level + scope_id + custom_permissions]
|
||||||
|
OM[fleet.organization_members<br/>role + permissions JSON]
|
||||||
|
OR[fleet.org_roles<br/>🔴 ÜRES! Nincs seed data]
|
||||||
|
SP[system.system_parameters<br/>scope_level + scope_id - csak global használva]
|
||||||
|
PA[system.pending_actions<br/>Dual Control tároló]
|
||||||
|
end
|
||||||
|
|
||||||
|
FS -- eltérő role halmaz --> UR
|
||||||
|
JWT -- kiolvassa --> U
|
||||||
|
RBAC -- ellenőrzi --> RM
|
||||||
|
GR -- engedélyezi --> UR
|
||||||
|
DC -- naplóz --> PA
|
||||||
|
OR -- üres, nem használható --> OM
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Azonosított Problémák Részletesen
|
||||||
|
|
||||||
|
### P1: `fleet.org_roles` tábla üres
|
||||||
|
- A dinamikus szerepkör rendszer teljesen kiépített (OrgRole modell, OrgUserRole enum)
|
||||||
|
- De a `fleet.org_roles` táblában egyetlen rekord sincs
|
||||||
|
- Következmény: a `organization_members.role` mező validálatlan string-ként működik
|
||||||
|
|
||||||
|
### P2: DEFAULT_RANK_MAP inkonzisztencia
|
||||||
|
- A `DEFAULT_RANK_MAP` tartalmaz olyan rangokat (AUDITOR, ORGANIZATION_OWNER, PREMIUM_USER, GUEST), amelyek nem szerepelnek a `UserRole` enum-ban
|
||||||
|
- Ezek a rangok soha nem kerülnek kiosztásra
|
||||||
|
- Megoldás: vagy bővíteni a `UserRole` enumot, vagy törölni a felesleges rangokat
|
||||||
|
|
||||||
|
### P3: Enum duplikáció
|
||||||
|
- A `userrole` enum minden értéke kétszer szerepel (kis és NAGY betűs változat)
|
||||||
|
- Az `orgtype` enum értékei szintén duplikáltak (business/BUSINESS, stb.)
|
||||||
|
- Ez a korábbi migrációk során keletkezett
|
||||||
|
|
||||||
|
### P4: Frontend-Backend eltérés
|
||||||
|
- A frontend auth store 5 role-t definiál: guest, user, manager, admin, superadmin
|
||||||
|
- A backend 10 role-t: superadmin, admin, region_admin, country_admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
|
||||||
|
- A `manager` frontend role nem létezik a backendben, a `region_admin` és `country_admin` és `moderator` hiányzik a frontendből
|
||||||
|
|
||||||
|
### P5: Hierarchikus scope nem kihasznált
|
||||||
|
- A `ParameterScope` enum (GLOBAL, COUNTRY, REGION, ORGANIZATION, USER) teljesen kiépített
|
||||||
|
- De minden `system_parameter` `global` scope_level-lel rendelkezik
|
||||||
|
- A hierarchikus paraméterezés (ország → régió → szervezet → felhasználó) nincs implementálva
|
||||||
@@ -265,7 +265,8 @@ async function handleSubmit() {
|
|||||||
asset_id: props.vehicle.id,
|
asset_id: props.vehicle.id,
|
||||||
// organization_id is omitted — backend auto-resolves from asset
|
// organization_id is omitted — backend auto-resolves from asset
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
amount_net: form.amount,
|
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||||
|
amount_gross: form.amount,
|
||||||
currency: 'HUF' as const,
|
currency: 'HUF' as const,
|
||||||
date: new Date(form.date).toISOString(),
|
date: new Date(form.date).toISOString(),
|
||||||
mileage_at_cost: null,
|
mileage_at_cost: null,
|
||||||
|
|||||||
171
frontend/src/components/dashboard/CostsActionsCard.vue
Normal file
171
frontend/src/components/dashboard/CostsActionsCard.vue
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||||
|
>
|
||||||
|
<!-- Top header bar -->
|
||||||
|
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
|
||||||
|
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||||
|
<!-- Vehicle Selector Dropdown -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedActionVehicleId"
|
||||||
|
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="v in vehicleStore.sortedVehicles"
|
||||||
|
:key="v.id"
|
||||||
|
:value="v.id"
|
||||||
|
>
|
||||||
|
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||||
|
<!-- No vehicle warning -->
|
||||||
|
<div
|
||||||
|
v-if="!selectedActionVehicle"
|
||||||
|
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||||
|
>
|
||||||
|
<span>⚠️ {{ t('dashboard.noVehicleSelected') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ⛽ Record Fueling (Big, prominent) -->
|
||||||
|
<button
|
||||||
|
v-if="selectedActionVehicle"
|
||||||
|
@click="openFuelModal"
|
||||||
|
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">⛽</span>
|
||||||
|
<span>{{ t('dashboard.recordFueling') }}</span>
|
||||||
|
<span class="ml-auto text-white/60 text-xs">→</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 🅿️ Parking / Toll (Medium) -->
|
||||||
|
<button
|
||||||
|
v-if="selectedActionVehicle"
|
||||||
|
@click="openFeeModal"
|
||||||
|
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿️</span>
|
||||||
|
<span>{{ t('dashboard.parkingToll') }}</span>
|
||||||
|
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ➕ Additional Costs (Medium) -->
|
||||||
|
<button
|
||||||
|
v-if="selectedActionVehicle"
|
||||||
|
@click="openComplexModal"
|
||||||
|
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">➕</span>
|
||||||
|
<span>{{ t('dashboard.additionalCosts') }}</span>
|
||||||
|
<span class="ml-auto text-slate-400 text-xs">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
Cost Action Modals (Teleported to body)
|
||||||
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
|
<SimpleFuelModal
|
||||||
|
:is-open="isFuelModalOpen"
|
||||||
|
:vehicle="selectedActionVehicle"
|
||||||
|
@close="isFuelModalOpen = false"
|
||||||
|
@saved="onModalSaved"
|
||||||
|
/>
|
||||||
|
<ComplexExpenseModal
|
||||||
|
:is-open="isFeeModalOpen"
|
||||||
|
:vehicle="selectedActionVehicle"
|
||||||
|
:is-fee-mode="true"
|
||||||
|
@close="isFeeModalOpen = false"
|
||||||
|
@saved="onModalSaved"
|
||||||
|
/>
|
||||||
|
<ComplexExpenseModal
|
||||||
|
:is-open="isComplexModalOpen"
|
||||||
|
:vehicle="selectedActionVehicle"
|
||||||
|
:is-fee-mode="false"
|
||||||
|
@close="isComplexModalOpen = false"
|
||||||
|
@saved="onModalSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useVehicleStore } from '../../stores/vehicle'
|
||||||
|
import SimpleFuelModal from './SimpleFuelModal.vue'
|
||||||
|
import ComplexExpenseModal from './ComplexExpenseModal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const vehicleStore = useVehicleStore()
|
||||||
|
|
||||||
|
// ── Emits ──
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/**
|
||||||
|
* F5 Bug fix (RBAC Phase 3): Értesíti a szülő komponenst, hogy egy költség sikeresen el lett mentve.
|
||||||
|
* A szülő (DashboardView) továbbítja ezt a VehicleDetailModal-nak, ami újratölti a költségeket.
|
||||||
|
*/
|
||||||
|
'cost-saved': [vehicleId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── Cost Card Action Center State ──
|
||||||
|
const selectedActionVehicleId = ref<string>('')
|
||||||
|
const isFuelModalOpen = ref(false)
|
||||||
|
const isFeeModalOpen = ref(false)
|
||||||
|
const isComplexModalOpen = ref(false)
|
||||||
|
|
||||||
|
/** The currently selected vehicle object for the action modals */
|
||||||
|
const selectedActionVehicle = computed(() => {
|
||||||
|
if (!selectedActionVehicleId.value) return null
|
||||||
|
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Auto-select the first (primary/sorted) vehicle on load */
|
||||||
|
function autoSelectVehicle() {
|
||||||
|
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
|
||||||
|
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFuelModal() {
|
||||||
|
if (!selectedActionVehicle.value) return
|
||||||
|
isFuelModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFeeModal() {
|
||||||
|
if (!selectedActionVehicle.value) return
|
||||||
|
isFeeModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openComplexModal() {
|
||||||
|
if (!selectedActionVehicle.value) return
|
||||||
|
isComplexModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5 Bug fix (RBAC Phase 3): Modal mentés után bezárja a modalt,
|
||||||
|
* és értesíti a szülőt, hogy az új költség megjelent.
|
||||||
|
*/
|
||||||
|
function onModalSaved() {
|
||||||
|
isFuelModalOpen.value = false
|
||||||
|
isFeeModalOpen.value = false
|
||||||
|
isComplexModalOpen.value = false
|
||||||
|
|
||||||
|
// Értesítjük a szülőt, hogy frissítse a VehicleDetailModal költségeit
|
||||||
|
if (selectedActionVehicleId.value) {
|
||||||
|
emit('cost-saved', selectedActionVehicleId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for vehicles to load, then auto-select
|
||||||
|
watch(() => vehicleStore.vehicles.length, () => {
|
||||||
|
autoSelectVehicle()
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
62
frontend/src/components/dashboard/GamificationCard.vue
Normal file
62
frontend/src/components/dashboard/GamificationCard.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||||
|
@click="$emit('open-card', 'gamification')"
|
||||||
|
>
|
||||||
|
<!-- Top header bar -->
|
||||||
|
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||||
|
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
|
||||||
|
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
|
{{ t('dashboard.live') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<!-- Score card -->
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||||
|
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
||||||
|
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Achievement badges -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
||||||
|
{{ t('dashboard.achievements') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.label"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
||||||
|
>
|
||||||
|
{{ badge.icon }} {{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom CTA button -->
|
||||||
|
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||||
|
{{ t('dashboard.refresh') }} 🏆
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-card': [cardId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── Placeholder badges ──
|
||||||
|
const badges = ref([
|
||||||
|
{ icon: '🌱', label: 'Eco Driver' },
|
||||||
|
{ icon: '📏', label: '10k km Club' },
|
||||||
|
{ icon: '🔧', label: 'Service Pro' },
|
||||||
|
{ icon: '⭐', label: 'Top Rater' },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
63
frontend/src/components/dashboard/MyVehiclesCard.vue
Normal file
63
frontend/src/components/dashboard/MyVehiclesCard.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||||
|
@click="$emit('open-card', 'vehicles')"
|
||||||
|
>
|
||||||
|
<!-- Top header bar -->
|
||||||
|
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||||
|
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
|
||||||
|
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
||||||
|
<div class="flex-1 overflow-y-auto space-y-2">
|
||||||
|
<VehicleCardCompact
|
||||||
|
v-for="vehicle in displayVehicles.slice(0, 3)"
|
||||||
|
:key="vehicle.id"
|
||||||
|
:vehicle="vehicle"
|
||||||
|
@click="$emit('open-card', 'vehicles')"
|
||||||
|
@plate-click="openVehicleDetail(vehicle)"
|
||||||
|
/>
|
||||||
|
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||||
|
<div
|
||||||
|
v-if="displayVehicles.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center py-6 text-slate-400"
|
||||||
|
>
|
||||||
|
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useVehicleStore } from '../../stores/vehicle'
|
||||||
|
import type { VehicleData } from '../../types/vehicle'
|
||||||
|
import VehicleCardCompact from '../vehicle/VehicleCardCompact.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const vehicleStore = useVehicleStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-card': [cardId: string, targetId?: string | null]
|
||||||
|
'open-vehicle-detail': [vehicle: VehicleData]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display vehicles from the backend API only.
|
||||||
|
* Sorted by is_primary flag (favorite first), then alphabetically.
|
||||||
|
* Shows all real vehicles; no mock/fallback data is injected.
|
||||||
|
*/
|
||||||
|
const displayVehicles = computed(() => {
|
||||||
|
return vehicleStore.sortedVehicles
|
||||||
|
})
|
||||||
|
|
||||||
|
function openVehicleDetail(vehicle: VehicleData) {
|
||||||
|
emit('open-vehicle-detail', vehicle)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
60
frontend/src/components/dashboard/ProfileTrustCard.vue
Normal file
60
frontend/src/components/dashboard/ProfileTrustCard.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||||
|
@click="$emit('open-card', 'stats')"
|
||||||
|
>
|
||||||
|
<!-- Top header bar -->
|
||||||
|
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||||
|
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('header.profile') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<!-- User info -->
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
||||||
|
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
||||||
|
</div>
|
||||||
|
<!-- Trust Score -->
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
||||||
|
<span class="text-sm font-bold text-emerald-600">A+</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Quick stats -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||||
|
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
||||||
|
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
||||||
|
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
||||||
|
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Bottom CTA button -->
|
||||||
|
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
||||||
|
{{ t('header.profile') }} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useVehicleStore } from '../../stores/vehicle'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const vehicleStore = useVehicleStore()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'open-card': [cardId: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
68
frontend/src/components/dashboard/ServiceFinderCard.vue
Normal file
68
frontend/src/components/dashboard/ServiceFinderCard.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
|
||||||
|
@click="openExternalServiceFinder"
|
||||||
|
>
|
||||||
|
<!-- Top header bar -->
|
||||||
|
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
||||||
|
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Content: 3 simple buttons stacked vertically -->
|
||||||
|
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
||||||
|
<button
|
||||||
|
@click.stop="openExternalServiceFinder"
|
||||||
|
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||||
|
>
|
||||||
|
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
|
||||||
|
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||||
|
>
|
||||||
|
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="isSfQuickAddOpen = true"
|
||||||
|
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
||||||
|
>
|
||||||
|
➕ {{ t('provider.quickAddTitle') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider Quick Add Modal -->
|
||||||
|
<ProviderQuickAddModal
|
||||||
|
:is-open="isSfQuickAddOpen"
|
||||||
|
@close="isSfQuickAddOpen = false"
|
||||||
|
@saved="onSfQuickAddSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import ProviderQuickAddModal from '../provider/ProviderQuickAddModal.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ── Service Finder (Card 3) — Launcher ──
|
||||||
|
const isSfQuickAddOpen = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigates to the Service Finder page in the same tab.
|
||||||
|
* Used by the card click and the "Tervezett karbantartás" button.
|
||||||
|
*/
|
||||||
|
function openExternalServiceFinder() {
|
||||||
|
router.push('/dashboard/service-finder')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when ProviderQuickAddModal saves a new provider.
|
||||||
|
*/
|
||||||
|
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
||||||
|
isSfQuickAddOpen.value = false
|
||||||
|
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -190,7 +190,8 @@ async function handleSubmit() {
|
|||||||
const payload = {
|
const payload = {
|
||||||
asset_id: props.vehicle.id,
|
asset_id: props.vehicle.id,
|
||||||
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
|
category_id: fuelCategoryId.value, // FUEL category (resolved by code)
|
||||||
amount_net: form.amount,
|
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||||
|
amount_gross: form.amount,
|
||||||
currency: 'HUF' as const,
|
currency: 'HUF' as const,
|
||||||
date: new Date(form.date).toISOString(),
|
date: new Date(form.date).toISOString(),
|
||||||
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
|
mileage_at_cost: form.odometer > 0 ? form.odometer : null,
|
||||||
|
|||||||
@@ -142,12 +142,13 @@ const initials = computed(() => {
|
|||||||
return '?'
|
return '?'
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Admin check: only admin/superadmin/region_admin/country_admin/moderator ──
|
// ── Admin check: only SUPERADMIN, ADMIN, MODERATOR ──
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
const role = authStore.user?.role
|
const role = authStore.user?.role
|
||||||
if (!role) return false
|
if (!role) return false
|
||||||
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
|
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||||
return adminRoles.includes(role)
|
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||||
|
return adminRoles.includes(role.toUpperCase())
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Navigate to Profile ────────────────────────────────────────────
|
// ── Navigate to Profile ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -75,6 +75,48 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Separator: Subscription Section (P0 Feature) -->
|
||||||
|
<div class="border-t border-white/10 my-2"></div>
|
||||||
|
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.subscriptionTitle') || 'Előfizetés' }}</p>
|
||||||
|
|
||||||
|
<!-- Subscription Tier Dropdown -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-white/70 mb-1">{{ t('company.subscriptionTier') || 'Előfizetési csomag' }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select
|
||||||
|
v-model="selectedTierId"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white text-sm focus:outline-none focus:border-[#00E5A0]"
|
||||||
|
>
|
||||||
|
<option :value="null" class="bg-[#04151F]">{{ t('company.noSubscription') || 'Nincs csomag' }}</option>
|
||||||
|
<option
|
||||||
|
v-for="tier in availableTiers"
|
||||||
|
:key="tier.id"
|
||||||
|
:value="tier.id"
|
||||||
|
class="bg-[#04151F]"
|
||||||
|
>
|
||||||
|
{{ tier.name }} {{ tier.rules?.allowances?.max_vehicles ? `(${tier.rules.allowances.max_vehicles} jármű)` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
v-if="selectedTierId !== org?.subscription_tier_id"
|
||||||
|
@click="assignSubscription"
|
||||||
|
:disabled="isAssigning"
|
||||||
|
class="px-3 py-2 rounded-lg bg-[#00E5A0] hover:bg-[#00E5A0]/80 text-[#04151F] font-semibold text-sm transition-all duration-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span v-if="isAssigning" class="flex items-center gap-1">
|
||||||
|
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ t('profile.save') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="org?.subscription_plan" class="text-xs text-white/40 mt-1">
|
||||||
|
{{ t('company.currentPlan') || 'Jelenlegi csomag' }}: {{ org.subscription_plan }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Separator -->
|
<!-- Separator -->
|
||||||
<div class="border-t border-white/10 my-2"></div>
|
<div class="border-t border-white/10 my-2"></div>
|
||||||
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') }}</p>
|
<p class="text-sm font-medium text-white/70 mb-2">{{ t('company.addressTitle') }}</p>
|
||||||
@@ -172,6 +214,11 @@ const isLoading = ref(false)
|
|||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const org = ref<OrganizationItem | null>(null)
|
const org = ref<OrganizationItem | null>(null)
|
||||||
|
|
||||||
|
// P0 Feature: Subscription tier assignment
|
||||||
|
const availableTiers = ref<any[]>([])
|
||||||
|
const selectedTierId = ref<number | null>(null)
|
||||||
|
const isAssigning = ref(false)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
full_name: '',
|
full_name: '',
|
||||||
@@ -180,7 +227,9 @@ const form = reactive({
|
|||||||
address_zip: '',
|
address_zip: '',
|
||||||
address_city: '',
|
address_city: '',
|
||||||
address_street_name: '',
|
address_street_name: '',
|
||||||
|
address_street_type: '',
|
||||||
address_house_number: '',
|
address_house_number: '',
|
||||||
|
address_hrsz: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||||
@@ -193,11 +242,18 @@ onMounted(async () => {
|
|||||||
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
const found = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||||
if (found) {
|
if (found) {
|
||||||
org.value = found
|
org.value = found
|
||||||
|
selectedTierId.value = found.subscription_tier_id ?? null
|
||||||
// Pre-fill form with existing values
|
// Pre-fill form with existing values
|
||||||
form.name = found.name || ''
|
form.name = found.name || ''
|
||||||
form.full_name = found.full_name || ''
|
form.full_name = found.full_name || ''
|
||||||
form.display_name = found.display_name || ''
|
form.display_name = found.display_name || ''
|
||||||
form.tax_number = found.tax_number || ''
|
form.tax_number = found.tax_number || ''
|
||||||
|
form.address_zip = found.address_zip || ''
|
||||||
|
form.address_city = found.address_city || ''
|
||||||
|
form.address_street_name = found.address_street_name || ''
|
||||||
|
form.address_street_type = found.address_street_type || ''
|
||||||
|
form.address_house_number = found.address_house_number || ''
|
||||||
|
form.address_hrsz = found.address_hrsz || ''
|
||||||
} else {
|
} else {
|
||||||
// Try to fetch fresh data
|
// Try to fetch fresh data
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -206,10 +262,17 @@ onMounted(async () => {
|
|||||||
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
const refound = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||||
if (refound) {
|
if (refound) {
|
||||||
org.value = refound
|
org.value = refound
|
||||||
|
selectedTierId.value = refound.subscription_tier_id ?? null
|
||||||
form.name = refound.name || ''
|
form.name = refound.name || ''
|
||||||
form.full_name = refound.full_name || ''
|
form.full_name = refound.full_name || ''
|
||||||
form.display_name = refound.display_name || ''
|
form.display_name = refound.display_name || ''
|
||||||
form.tax_number = refound.tax_number || ''
|
form.tax_number = refound.tax_number || ''
|
||||||
|
form.address_zip = refound.address_zip || ''
|
||||||
|
form.address_city = refound.address_city || ''
|
||||||
|
form.address_street_name = refound.address_street_name || ''
|
||||||
|
form.address_street_type = refound.address_street_type || ''
|
||||||
|
form.address_house_number = refound.address_house_number || ''
|
||||||
|
form.address_hrsz = refound.address_hrsz || ''
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load organization:', err)
|
console.error('Failed to load organization:', err)
|
||||||
@@ -217,8 +280,44 @@ onMounted(async () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch available subscription tiers
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/packages/')
|
||||||
|
availableTiers.value = res.data?.tiers || res.data || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load subscription tiers:', err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── P0 Feature: Assign Subscription Tier ────────────────────────────────────
|
||||||
|
|
||||||
|
async function assignSubscription() {
|
||||||
|
const orgId = Number(route.params.id)
|
||||||
|
if (!orgId || selectedTierId.value === null) return
|
||||||
|
|
||||||
|
isAssigning.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/organizations/${orgId}/subscription`, {
|
||||||
|
tier_id: selectedTierId.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh org data in store
|
||||||
|
await authStore.fetchMyOrganizations()
|
||||||
|
const updated = authStore.myOrganizations.find((o) => o.organization_id === orgId)
|
||||||
|
if (updated) {
|
||||||
|
org.value = updated
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to assign subscription:', err)
|
||||||
|
alert(err?.response?.data?.detail || 'Failed to assign subscription')
|
||||||
|
} finally {
|
||||||
|
isAssigning.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Save Handler ────────────────────────────────────────────────────────────
|
// ── Save Handler ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
|||||||
@@ -735,8 +735,10 @@
|
|||||||
Bezárás
|
Bezárás
|
||||||
</button>
|
</button>
|
||||||
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható -->
|
<!-- Task 1: Szerkesztés gomb csak az Alapadatok fülön látható -->
|
||||||
|
<!-- RBAC Phase 3 (UI Pilot): A gomb csak akkor jelenik meg, ha a user rendelkezik
|
||||||
|
can_approve_expense képességgel az aktív szervezetben -->
|
||||||
<button
|
<button
|
||||||
v-if="activeTab === 'basics'"
|
v-if="activeTab === 'basics' && authStore.hasOrgCapability(authStore.user?.active_organization_id ?? 0, 'can_approve_expense')"
|
||||||
@click="$emit('edit', vehicle)"
|
@click="$emit('edit', vehicle)"
|
||||||
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
|
class="inline-flex items-center gap-2 rounded-xl bg-sf-accent px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-blue cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -769,6 +771,11 @@ const { t } = useI18n()
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
vehicle: VehicleData | null
|
vehicle: VehicleData | null
|
||||||
|
/**
|
||||||
|
* F5 Bug fix (RBAC Phase 3): Számláló, ami minden költség mentéskor nő.
|
||||||
|
* A watch figyeli ezt, és újratölti a költségeket, ha a finance tab aktív.
|
||||||
|
*/
|
||||||
|
refreshTrigger?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -850,7 +857,7 @@ interface CostItem {
|
|||||||
category_name?: string
|
category_name?: string
|
||||||
/** Backend returns `category_code` from CostCategory relationship */
|
/** Backend returns `category_code` from CostCategory relationship */
|
||||||
category_code?: string
|
category_code?: string
|
||||||
/** Backend returns `amount_net` (Decimal) — the main monetary value */
|
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary monetary value */
|
||||||
amount: number
|
amount: number
|
||||||
/** Backend returns `currency` (e.g. 'HUF') */
|
/** Backend returns `currency` (e.g. 'HUF') */
|
||||||
currency?: string
|
currency?: string
|
||||||
@@ -859,6 +866,7 @@ interface CostItem {
|
|||||||
/** Backend returns `mileage_at_cost` extracted from data JSONB */
|
/** Backend returns `mileage_at_cost` extracted from data JSONB */
|
||||||
mileage_at_cost?: number
|
mileage_at_cost?: number
|
||||||
/** Raw backend fields kept for reference */
|
/** Raw backend fields kept for reference */
|
||||||
|
amount_gross?: number
|
||||||
amount_net?: number
|
amount_net?: number
|
||||||
currency_raw?: string
|
currency_raw?: string
|
||||||
date?: string
|
date?: string
|
||||||
@@ -870,8 +878,9 @@ const costsLoading = ref(false)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a raw backend AssetCostResponse item to the frontend CostItem shape.
|
* Map a raw backend AssetCostResponse item to the frontend CostItem shape.
|
||||||
|
* GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary source of truth.
|
||||||
* Backend schema (AssetCostResponse) now uses:
|
* Backend schema (AssetCostResponse) now uses:
|
||||||
* amount_net, currency, date, category_name, category_code, description, mileage_at_cost
|
* amount_gross, amount_net, currency, date, category_name, category_code, description, mileage_at_cost
|
||||||
*/
|
*/
|
||||||
function mapBackendCost(raw: any): CostItem {
|
function mapBackendCost(raw: any): CostItem {
|
||||||
return {
|
return {
|
||||||
@@ -880,11 +889,12 @@ function mapBackendCost(raw: any): CostItem {
|
|||||||
category: raw.category_name || raw.category_code || raw.category || '',
|
category: raw.category_name || raw.category_code || raw.category || '',
|
||||||
category_name: raw.category_name,
|
category_name: raw.category_name,
|
||||||
category_code: raw.category_code,
|
category_code: raw.category_code,
|
||||||
amount: Number(raw.amount_net ?? raw.amount ?? 0),
|
amount: Number(raw.amount_gross ?? raw.amount_net ?? raw.amount ?? 0),
|
||||||
currency: raw.currency || 'HUF',
|
currency: raw.currency || 'HUF',
|
||||||
description: raw.description || raw.data?.description || '',
|
description: raw.description || raw.data?.description || '',
|
||||||
mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
|
mileage_at_cost: raw.mileage_at_cost ?? raw.data?.mileage_at_cost,
|
||||||
// Keep raw fields for reference
|
// Keep raw fields for reference
|
||||||
|
amount_gross: raw.amount_gross,
|
||||||
amount_net: raw.amount_net,
|
amount_net: raw.amount_net,
|
||||||
currency_raw: raw.currency,
|
currency_raw: raw.currency,
|
||||||
date: raw.date,
|
date: raw.date,
|
||||||
@@ -928,6 +938,23 @@ watch(() => props.vehicle, (newVehicle) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5 Bug fix (RBAC Phase 3): Re-fetch costs when refreshTrigger changes.
|
||||||
|
* A DashboardView növeli ezt a számlálót, amikor a CostsActionsCard 'cost-saved' eventet emitál.
|
||||||
|
*/
|
||||||
|
watch(() => props.refreshTrigger, (newVal, oldVal) => {
|
||||||
|
if (newVal !== undefined && newVal !== oldVal && activeTab.value === 'finance' && props.vehicle?.id) {
|
||||||
|
costsLoading.value = true
|
||||||
|
api.get(`/assets/${props.vehicle.id}/costs`)
|
||||||
|
.then(res => {
|
||||||
|
const rawData = (res.data as any[]) || []
|
||||||
|
vehicleCosts.value = rawData.map(mapBackendCost)
|
||||||
|
})
|
||||||
|
.catch(err => { console.error('[VehicleDetailModal] Failed to fetch costs:', err); vehicleCosts.value = [] })
|
||||||
|
.finally(() => { costsLoading.value = false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── Format date helper ──
|
// ── Format date helper ──
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
if (!dateStr) return '—'
|
if (!dateStr) return '—'
|
||||||
|
|||||||
@@ -132,8 +132,10 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
// If the route requires admin privileges, check the user's role.
|
// If the route requires admin privileges, check the user's role.
|
||||||
if (to.meta.requiresAdmin) {
|
if (to.meta.requiresAdmin) {
|
||||||
const role = authStore.user?.role
|
const role = authStore.user?.role
|
||||||
const adminRoles = ['superadmin', 'admin', 'region_admin', 'country_admin', 'moderator']
|
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||||
if (!role || !adminRoles.includes(role)) {
|
// Must use toUpperCase() to ensure case-insensitive matching
|
||||||
|
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||||
|
if (!role || !adminRoles.includes(role.toUpperCase())) {
|
||||||
// Non-admin user trying to access admin area → redirect to dashboard
|
// Non-admin user trying to access admin area → redirect to dashboard
|
||||||
return next('/dashboard')
|
return next('/dashboard')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ export interface UserProfile {
|
|||||||
person_id: number | null
|
person_id: number | null
|
||||||
// Nested person data with address
|
// Nested person data with address
|
||||||
person: PersonData | null
|
person: PersonData | null
|
||||||
|
// RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
|
||||||
|
system_capabilities?: Record<string, boolean>
|
||||||
|
// RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
|
||||||
|
org_capabilities?: Record<string, Record<string, boolean>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
@@ -87,6 +91,19 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return !!user.value?.person_id
|
return !!user.value?.person_id
|
||||||
})
|
})
|
||||||
const userRole = computed(() => user.value?.role ?? 'guest')
|
const userRole = computed(() => user.value?.role ?? 'guest')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellenőrzi, hogy a felhasználó rendelkezik-e admin jogosultsággal.
|
||||||
|
* A SUPERADMIN és ADMIN (és egyéb adminisztratív) szerepköröket ismeri fel.
|
||||||
|
*/
|
||||||
|
const isAdmin = computed(() => {
|
||||||
|
const role = user.value?.role
|
||||||
|
if (!role) return false
|
||||||
|
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||||
|
// Must use toUpperCase() to ensure case-insensitive matching
|
||||||
|
const adminRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR']
|
||||||
|
return adminRoles.includes(role.toUpperCase())
|
||||||
|
})
|
||||||
const userName = computed(() => {
|
const userName = computed(() => {
|
||||||
if (!user.value) return ''
|
if (!user.value) return ''
|
||||||
const { first_name, last_name } = user.value
|
const { first_name, last_name } = user.value
|
||||||
@@ -95,6 +112,41 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
})
|
})
|
||||||
const userId = computed(() => user.value?.id ?? null)
|
const userId = computed(() => user.value?.id ?? null)
|
||||||
|
|
||||||
|
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
|
||||||
|
* A képességek a backend /auth/me vagy /users/me végpontról érkeznek
|
||||||
|
* a SYSTEM_CAPABILITIES_MATRIX alapján.
|
||||||
|
* @param capability - A képesség neve (pl. 'can_manage_users')
|
||||||
|
* @returns true, ha a képesség elérhető a user szerepköréhez
|
||||||
|
*/
|
||||||
|
function hasSystemCapability(capability: string): boolean {
|
||||||
|
return user.value?.system_capabilities?.[capability] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
|
||||||
|
* A szervezeti képességek az OrgRole.permissions JSONB mezőből származnak.
|
||||||
|
* @param orgId - A szervezet azonosítója (string vagy number)
|
||||||
|
* @param capability - A képesség neve (pl. 'can_approve_expense')
|
||||||
|
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
|
||||||
|
*/
|
||||||
|
function hasOrgCapability(orgId: string | number, capability: string): boolean {
|
||||||
|
const orgCaps = user.value?.org_capabilities?.[String(orgId)]
|
||||||
|
if (!orgCaps) return false
|
||||||
|
return orgCaps[capability] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
|
||||||
|
* @param orgId - A szervezet azonosítója
|
||||||
|
* @returns A képességek objektuma, vagy üres objektum, ha nincs
|
||||||
|
*/
|
||||||
|
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
|
||||||
|
return user.value?.org_capabilities?.[String(orgId)] || {}
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────── Helpers ────────────────────────────
|
// ────────────────────────────── Helpers ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -671,6 +723,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
isKycComplete,
|
isKycComplete,
|
||||||
userRole,
|
userRole,
|
||||||
userName,
|
userName,
|
||||||
@@ -679,6 +732,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
hasBusinessOrganization,
|
hasBusinessOrganization,
|
||||||
isCorporateMode,
|
isCorporateMode,
|
||||||
|
|
||||||
|
// RBAC Phase 3: Capability Helpers
|
||||||
|
hasSystemCapability,
|
||||||
|
hasOrgCapability,
|
||||||
|
getOrgCapabilities,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login,
|
login,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
|||||||
@@ -4,22 +4,71 @@ import { ref, computed } from 'vue'
|
|||||||
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
|
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
// State
|
// ── State ──────────────────────────────────────────────────────────
|
||||||
const isAuthenticated = ref(false)
|
const isAuthenticated = ref(false)
|
||||||
const userRole = ref<UserRole>('guest')
|
const userRole = ref<UserRole>('guest')
|
||||||
const userName = ref('')
|
const userName = ref('')
|
||||||
const userId = ref<number | null>(null)
|
const userId = ref<number | null>(null)
|
||||||
|
|
||||||
// Getters
|
/**
|
||||||
|
* RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból).
|
||||||
|
* Pl. { can_manage_users: false, can_manage_own_vehicles: true, ... }
|
||||||
|
*/
|
||||||
|
const systemCapabilities = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RBAC Phase 3: Szervezeti szintű képességek.
|
||||||
|
* Kulcs: organization_id (string), Érték: { capability_name: boolean }
|
||||||
|
* Pl. { "5": { can_approve_expense: true, can_add_expense: true } }
|
||||||
|
*/
|
||||||
|
const orgCapabilities = ref<Record<string, Record<string, boolean>>>({})
|
||||||
|
|
||||||
|
// ── Getters ────────────────────────────────────────────────────────
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
return userRole.value === 'admin' || userRole.value === 'superadmin'
|
// CRITICAL: Backend sends UPPERCASE roles (SUPERADMIN, ADMIN, MODERATOR)
|
||||||
|
// Must use toUpperCase() to ensure case-insensitive matching
|
||||||
|
const role = userRole.value.toUpperCase()
|
||||||
|
return role === 'ADMIN' || role === 'SUPERADMIN'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isSuperAdmin = computed(() => userRole.value === 'superadmin')
|
const isSuperAdmin = computed(() => userRole.value.toUpperCase() === 'SUPERADMIN')
|
||||||
const isManager = computed(() => userRole.value === 'manager')
|
const isManager = computed(() => userRole.value.toUpperCase() === 'MANAGER')
|
||||||
const isRegularUser = computed(() => userRole.value === 'user')
|
const isRegularUser = computed(() => userRole.value.toUpperCase() === 'USER')
|
||||||
|
|
||||||
// Actions
|
// ── RBAC Phase 3: Capability Helpers ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott rendszerszintű képességgel.
|
||||||
|
* @param capability - A képesség neve (pl. 'can_manage_users')
|
||||||
|
* @returns true, ha a képesség elérhető a user szerepköréhez
|
||||||
|
*/
|
||||||
|
function hasSystemCapability(capability: string): boolean {
|
||||||
|
return systemCapabilities.value[capability] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellenőrzi, hogy a felhasználó rendelkezik-e egy adott szervezeti képességgel.
|
||||||
|
* @param orgId - A szervezet azonosítója (string vagy number)
|
||||||
|
* @param capability - A képesség neve (pl. 'can_approve_expense')
|
||||||
|
* @returns true, ha a képesség elérhető a user számára az adott szervezetben
|
||||||
|
*/
|
||||||
|
function hasOrgCapability(orgId: string | number, capability: string): boolean {
|
||||||
|
const orgIdStr = String(orgId)
|
||||||
|
const orgCaps = orgCapabilities.value[orgIdStr]
|
||||||
|
if (!orgCaps) return false
|
||||||
|
return orgCaps[capability] === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visszaadja az összes szervezeti képességet egy adott szervezethez.
|
||||||
|
* @param orgId - A szervezet azonosítója
|
||||||
|
* @returns A képességek objektuma, vagy üres objektum, ha nincs
|
||||||
|
*/
|
||||||
|
function getOrgCapabilities(orgId: string | number): Record<string, boolean> {
|
||||||
|
return orgCapabilities.value[String(orgId)] || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ────────────────────────────────────────────────────────
|
||||||
function login(role: UserRole, name: string, id: number) {
|
function login(role: UserRole, name: string, id: number) {
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
userRole.value = role
|
userRole.value = role
|
||||||
@@ -33,6 +82,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
userRole.value = 'guest'
|
userRole.value = 'guest'
|
||||||
userName.value = ''
|
userName.value = ''
|
||||||
userId.value = null
|
userId.value = null
|
||||||
|
systemCapabilities.value = {}
|
||||||
|
orgCapabilities.value = {}
|
||||||
localStorage.removeItem('auth')
|
localStorage.removeItem('auth')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +102,19 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RBAC Phase 3: Frissíti a képességeket a backend /users/me válaszából.
|
||||||
|
* @param systemCaps - Rendszerszintű képességek objektuma
|
||||||
|
* @param orgCaps - Szervezeti képességek objektuma (org_id -> { capability: bool })
|
||||||
|
*/
|
||||||
|
function setCapabilities(
|
||||||
|
systemCaps: Record<string, boolean>,
|
||||||
|
orgCaps: Record<string, Record<string, boolean>>
|
||||||
|
) {
|
||||||
|
systemCapabilities.value = systemCaps
|
||||||
|
orgCapabilities.value = orgCaps
|
||||||
|
}
|
||||||
|
|
||||||
// Simulate fetching user role from backend
|
// Simulate fetching user role from backend
|
||||||
async function fetchUserRole() {
|
async function fetchUserRole() {
|
||||||
// In a real app, this would be an API call
|
// In a real app, this would be an API call
|
||||||
@@ -69,6 +133,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
userRole,
|
userRole,
|
||||||
userName,
|
userName,
|
||||||
userId,
|
userId,
|
||||||
|
systemCapabilities,
|
||||||
|
orgCapabilities,
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -76,10 +142,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isManager,
|
isManager,
|
||||||
isRegularUser,
|
isRegularUser,
|
||||||
|
|
||||||
|
// RBAC Phase 3: Capability Helpers
|
||||||
|
hasSystemCapability,
|
||||||
|
hasOrgCapability,
|
||||||
|
getOrgCapabilities,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
init,
|
init,
|
||||||
fetchUserRole
|
setCapabilities,
|
||||||
|
fetchUserRole,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -9,7 +9,8 @@ export interface AddExpensePayload {
|
|||||||
asset_id: string
|
asset_id: string
|
||||||
organization_id?: number | null // Auto-resolved by backend from asset if omitted
|
organization_id?: number | null // Auto-resolved by backend from asset if omitted
|
||||||
category_id: number
|
category_id: number
|
||||||
amount_net: number
|
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary monetary value */
|
||||||
|
amount_gross: number
|
||||||
currency?: string
|
currency?: string
|
||||||
date: string
|
date: string
|
||||||
mileage_at_cost?: number | null
|
mileage_at_cost?: number | null
|
||||||
@@ -25,7 +26,7 @@ export interface ExpenseResponse {
|
|||||||
id: string
|
id: string
|
||||||
asset_id: string
|
asset_id: string
|
||||||
cost_category: string
|
cost_category: string
|
||||||
amount_net: number
|
amount_gross: number
|
||||||
date: string
|
date: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,8 @@ export const useCostStore = defineStore('cost', () => {
|
|||||||
const body: Record<string, any> = {
|
const body: Record<string, any> = {
|
||||||
asset_id: payload.asset_id,
|
asset_id: payload.asset_id,
|
||||||
category_id: payload.category_id,
|
category_id: payload.category_id,
|
||||||
amount_net: payload.amount_net,
|
/** GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary field */
|
||||||
|
amount_gross: payload.amount_gross,
|
||||||
currency: payload.currency || 'HUF',
|
currency: payload.currency || 'HUF',
|
||||||
date: payload.date,
|
date: payload.date,
|
||||||
mileage_at_cost: payload.mileage_at_cost ?? null,
|
mileage_at_cost: payload.mileage_at_cost ?? null,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface OrganizationItem {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
is_deleted: boolean
|
is_deleted: boolean
|
||||||
subscription_plan: string
|
subscription_plan: string
|
||||||
|
/** P0 Feature: FK to system.subscription_tiers — the assigned subscription package */
|
||||||
|
subscription_tier_id?: number | null
|
||||||
/** org_type is not in the current /my response but we keep it for future use */
|
/** org_type is not in the current /my response but we keep it for future use */
|
||||||
org_type?: string
|
org_type?: string
|
||||||
/**
|
/**
|
||||||
@@ -27,4 +29,11 @@ export interface OrganizationItem {
|
|||||||
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
|
* Used by the frontend to decide whether to show service_provider orgs in the garage switcher.
|
||||||
*/
|
*/
|
||||||
user_role?: string
|
user_role?: string
|
||||||
|
// ── Cím adatok (Address fields) ──
|
||||||
|
address_zip?: string | null
|
||||||
|
address_city?: string | null
|
||||||
|
address_street_name?: string | null
|
||||||
|
address_street_type?: string | null
|
||||||
|
address_house_number?: string | null
|
||||||
|
address_hrsz?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,243 +52,27 @@
|
|||||||
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
|
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
|
||||||
style="perspective: 1200px;"
|
style="perspective: 1200px;"
|
||||||
>
|
>
|
||||||
<!-- ════════════════════════════════════════════════════════════
|
<!-- Card 1: 🚗 My Garage (Járműveim) -->
|
||||||
Card 1: 🚗 My Garage (Járműveim)
|
<MyVehiclesCard
|
||||||
════════════════════════════════════════════════════════════ -->
|
@open-card="openCard"
|
||||||
<div
|
@open-vehicle-detail="openVehicleDetail"
|
||||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
/>
|
||||||
@click="openCard('vehicles')"
|
|
||||||
>
|
|
||||||
<!-- Top header bar -->
|
|
||||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
|
||||||
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
|
|
||||||
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-2">
|
|
||||||
<VehicleCardCompact
|
|
||||||
v-for="vehicle in displayVehicles.slice(0, 3)"
|
|
||||||
:key="vehicle.id"
|
|
||||||
:vehicle="vehicle"
|
|
||||||
@click="openCard('vehicles')"
|
|
||||||
@plate-click="openVehicleDetail(vehicle)"
|
|
||||||
/>
|
|
||||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
|
||||||
<div
|
|
||||||
v-if="displayVehicles.length === 0"
|
|
||||||
class="flex flex-col items-center justify-center py-6 text-slate-400"
|
|
||||||
>
|
|
||||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════════════════
|
<!-- Card 2: 💰 Costs & Quick Actions (Action Center) -->
|
||||||
Card 2: 💰 Costs & Quick Actions (Action Center)
|
<CostsActionsCard @cost-saved="onCostSaved" />
|
||||||
════════════════════════════════════════════════════════════ -->
|
|
||||||
<div
|
|
||||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
|
||||||
>
|
|
||||||
<!-- Top header bar -->
|
|
||||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4 gap-2">
|
|
||||||
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.costsTitle') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
|
||||||
<!-- Vehicle Selector Dropdown -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1 block">{{ t('dashboard.vehicle') }}</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedActionVehicleId"
|
|
||||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-800 focus:border-sf-accent focus:ring-2 focus:ring-sf-accent/20 outline-none transition-all"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="v in vehicleStore.sortedVehicles"
|
|
||||||
:key="v.id"
|
|
||||||
:value="v.id"
|
|
||||||
>
|
|
||||||
{{ v.license_plate || '—' }} ({{ v.brand || '' }} {{ v.model || '' }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Card 3: 🔧 Service Finder (3-Way Indítópult) -->
|
||||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
<ServiceFinderCard />
|
||||||
<!-- No vehicle warning -->
|
|
||||||
<div
|
|
||||||
v-if="!selectedActionVehicle"
|
|
||||||
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
|
||||||
>
|
|
||||||
<span>⚠️ {{ t('dashboard.noVehicleSelected') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ⛽ Record Fueling (Big, prominent) -->
|
<!-- Card 4: 🏆 Gamification (Játékosítás) -->
|
||||||
<button
|
<GamificationCard
|
||||||
v-if="selectedActionVehicle"
|
@open-card="openCard"
|
||||||
@click="openFuelModal"
|
/>
|
||||||
class="flex items-center gap-3 rounded-xl bg-gradient-to-r from-sf-accent to-emerald-500 px-4 py-3 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-white/20 text-lg">⛽</span>
|
|
||||||
<span>{{ t('dashboard.recordFueling') }}</span>
|
|
||||||
<span class="ml-auto text-white/60 text-xs">→</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 🅿️ Parking / Toll (Medium) -->
|
<!-- Card 5: 🛡️ My Profile & Trust (Profil & Trust Score) -->
|
||||||
<button
|
<ProfileTrustCard
|
||||||
v-if="selectedActionVehicle"
|
@open-card="openCard"
|
||||||
@click="openFeeModal"
|
/>
|
||||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">🅿️</span>
|
|
||||||
<span>{{ t('dashboard.parkingToll') }}</span>
|
|
||||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- ➕ Additional Costs (Medium) -->
|
|
||||||
<button
|
|
||||||
v-if="selectedActionVehicle"
|
|
||||||
@click="openComplexModal"
|
|
||||||
class="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-all hover:bg-slate-100 hover:shadow-md active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<span class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-200 text-base">➕</span>
|
|
||||||
<span>{{ t('dashboard.additionalCosts') }}</span>
|
|
||||||
<span class="ml-auto text-slate-400 text-xs">→</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════════════════
|
|
||||||
Card 3: 🔧 Service Finder (3-Way Indítópult)
|
|
||||||
════════════════════════════════════════════════════════════ -->
|
|
||||||
<div
|
|
||||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl cursor-pointer"
|
|
||||||
@click="openExternalServiceFinder"
|
|
||||||
>
|
|
||||||
<!-- Top header bar -->
|
|
||||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
|
||||||
<span class="text-white font-bold text-sm tracking-wide">{{ t('serviceFinder.title') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Content: 3 simple buttons stacked vertically -->
|
|
||||||
<div class="p-4 flex-1 flex flex-col justify-center gap-2">
|
|
||||||
<button
|
|
||||||
@click.stop="openExternalServiceFinder"
|
|
||||||
class="w-full rounded-xl bg-sf-accent px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-sf-accent/90 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
|
||||||
>
|
|
||||||
🔍 {{ t('serviceFinder.plannedMaintenance') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.stop="router.push('/dashboard/service-finder?mode=sos')"
|
|
||||||
class="w-full rounded-xl border border-red-300 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700 shadow-sm transition-all hover:bg-red-100 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
|
||||||
>
|
|
||||||
🚨 SOS {{ t('serviceFinder.sosTitle') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click.stop="isSfQuickAddOpen = true"
|
|
||||||
class="w-full rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-2.5 text-sm font-semibold text-slate-700 shadow-sm transition-all hover:border-sf-accent hover:bg-sf-accent/5 hover:shadow-md active:scale-[0.97] cursor-pointer"
|
|
||||||
>
|
|
||||||
➕ {{ t('provider.quickAddTitle') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════════════════
|
|
||||||
Card 4: 🏆 Gamification (Játékosítás)
|
|
||||||
════════════════════════════════════════════════════════════ -->
|
|
||||||
<div
|
|
||||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
|
||||||
@click="openCard('gamification')"
|
|
||||||
>
|
|
||||||
<!-- Top header bar -->
|
|
||||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
|
||||||
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
|
|
||||||
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
|
||||||
{{ t('dashboard.live') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
|
||||||
<div class="flex-1 space-y-3">
|
|
||||||
<!-- Score card -->
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
|
||||||
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
|
|
||||||
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
|
|
||||||
</div>
|
|
||||||
<!-- Achievement badges -->
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
|
|
||||||
{{ t('dashboard.achievements') }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<span
|
|
||||||
v-for="badge in badges"
|
|
||||||
:key="badge.label"
|
|
||||||
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
|
|
||||||
>
|
|
||||||
{{ badge.icon }} {{ badge.label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Bottom CTA button -->
|
|
||||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
|
||||||
{{ t('dashboard.refresh') }} 🏆
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ════════════════════════════════════════════════════════════
|
|
||||||
Card 5: 🛡️ My Profile & Trust (Profil & Trust Score)
|
|
||||||
════════════════════════════════════════════════════════════ -->
|
|
||||||
<div
|
|
||||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
|
||||||
@click="openCard('stats')"
|
|
||||||
>
|
|
||||||
<!-- Top header bar -->
|
|
||||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
|
||||||
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('header.profile') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
|
||||||
<div class="flex-1 space-y-3">
|
|
||||||
<!-- User info -->
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
|
||||||
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
|
|
||||||
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
|
|
||||||
</div>
|
|
||||||
<!-- Trust Score -->
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
|
|
||||||
<span class="text-sm font-bold text-emerald-600">A+</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
|
||||||
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Quick stats -->
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
|
||||||
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
|
|
||||||
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
|
|
||||||
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
|
|
||||||
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Bottom CTA button -->
|
|
||||||
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
|
|
||||||
{{ t('header.profile') }} →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,36 +121,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Cost Action Modals (Teleported to body)
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<SimpleFuelModal
|
|
||||||
:is-open="isFuelModalOpen"
|
|
||||||
:vehicle="selectedActionVehicle"
|
|
||||||
@close="isFuelModalOpen = false"
|
|
||||||
@saved="onModalSaved"
|
|
||||||
/>
|
|
||||||
<ComplexExpenseModal
|
|
||||||
:is-open="isFeeModalOpen"
|
|
||||||
:vehicle="selectedActionVehicle"
|
|
||||||
:is-fee-mode="true"
|
|
||||||
@close="isFeeModalOpen = false"
|
|
||||||
@saved="onModalSaved"
|
|
||||||
/>
|
|
||||||
<ComplexExpenseModal
|
|
||||||
:is-open="isComplexModalOpen"
|
|
||||||
:vehicle="selectedActionVehicle"
|
|
||||||
:is-fee-mode="false"
|
|
||||||
@close="isComplexModalOpen = false"
|
|
||||||
@saved="onModalSaved"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
Vehicle Detail Modal (Digital Twin — 3-Tab Deep Link)
|
Vehicle Detail Modal (Digital Twin — 3-Tab Deep Link)
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
═══════════════════════════════════════════════════════════════════ -->
|
||||||
<VehicleDetailModal
|
<VehicleDetailModal
|
||||||
:is-open="isVehicleDetailOpen"
|
:is-open="isVehicleDetailOpen"
|
||||||
:vehicle="detailVehicle"
|
:vehicle="detailVehicle"
|
||||||
|
:refresh-trigger="refreshCostsTrigger"
|
||||||
@close="closeVehicleDetail"
|
@close="closeVehicleDetail"
|
||||||
@edit="handleEditFromDetail"
|
@edit="handleEditFromDetail"
|
||||||
@set-primary="handleSetPrimaryFromDetail"
|
@set-primary="handleSetPrimaryFromDetail"
|
||||||
@@ -391,32 +152,29 @@
|
|||||||
@saved="onEditVehicleSaved"
|
@saved="onEditVehicleSaved"
|
||||||
@deleted="onVehicleDeleted"
|
@deleted="onVehicleDeleted"
|
||||||
/>
|
/>
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
|
||||||
Provider Quick Add Modal (Service Finder Card 3)
|
|
||||||
═══════════════════════════════════════════════════════════════════ -->
|
|
||||||
<ProviderQuickAddModal
|
|
||||||
:is-open="isSfQuickAddOpen"
|
|
||||||
@close="isSfQuickAddOpen = false"
|
|
||||||
@saved="onSfQuickAddSaved"
|
|
||||||
/>
|
|
||||||
</div><!-- end min-h-screen -->
|
</div><!-- end min-h-screen -->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useVehicleStore } from '../stores/vehicle'
|
import { useVehicleStore } from '../stores/vehicle'
|
||||||
import type { VehicleData } from '../types/vehicle'
|
import type { VehicleData } from '../types/vehicle'
|
||||||
import api from '../api/axios'
|
import api from '../api/axios'
|
||||||
|
|
||||||
|
// ── Dashboard Card Components ──
|
||||||
|
import MyVehiclesCard from '../components/dashboard/MyVehiclesCard.vue'
|
||||||
|
import CostsActionsCard from '../components/dashboard/CostsActionsCard.vue'
|
||||||
|
import ServiceFinderCard from '../components/dashboard/ServiceFinderCard.vue'
|
||||||
|
import GamificationCard from '../components/dashboard/GamificationCard.vue'
|
||||||
|
import ProfileTrustCard from '../components/dashboard/ProfileTrustCard.vue'
|
||||||
|
|
||||||
|
// ── Shared Modals ──
|
||||||
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
||||||
import VehicleCardCompact from '../components/vehicle/VehicleCardCompact.vue'
|
|
||||||
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||||||
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
|
import VehicleFormModal from '../components/dashboard/VehicleFormModal.vue'
|
||||||
import SimpleFuelModal from '../components/dashboard/SimpleFuelModal.vue'
|
|
||||||
import ComplexExpenseModal from '../components/dashboard/ComplexExpenseModal.vue'
|
|
||||||
import ProviderQuickAddModal from '../components/provider/ProviderQuickAddModal.vue'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -437,30 +195,16 @@ const openCard = (cardId: string, targetId: string | null = null) => {
|
|||||||
selectedTargetId.value = targetId
|
selectedTargetId.value = targetId
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Service Finder (Card 3) — Launcher ──
|
|
||||||
const isSearchModalOpen = ref(false)
|
|
||||||
const isSfQuickAddOpen = ref(false)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigates to the Service Finder page in the same tab.
|
|
||||||
* Used by the card click and the "Tervezett karbantartás" button.
|
|
||||||
*/
|
|
||||||
function openExternalServiceFinder() {
|
|
||||||
router.push('/dashboard/service-finder')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when ProviderQuickAddModal saves a new provider.
|
|
||||||
*/
|
|
||||||
function onSfQuickAddSaved(provider: { id: number; name: string; category?: string | null; city?: string | null; address?: string | null }) {
|
|
||||||
isSfQuickAddOpen.value = false
|
|
||||||
alert(`🎉 ${t('provider.successMessage', { points: 0 })}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Vehicle Detail Modal (Deep Link from plate click) ──
|
// ── Vehicle Detail Modal (Deep Link from plate click) ──
|
||||||
const isVehicleDetailOpen = ref(false)
|
const isVehicleDetailOpen = ref(false)
|
||||||
const detailVehicle = ref<VehicleData | null>(null)
|
const detailVehicle = ref<VehicleData | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5 Bug fix (RBAC Phase 3): Számláló, ami minden költség mentéskor nő.
|
||||||
|
* A VehicleDetailModal ezt figyelve újratölti a költségeket.
|
||||||
|
*/
|
||||||
|
const refreshCostsTrigger = ref(0)
|
||||||
|
|
||||||
function openVehicleDetail(vehicle: VehicleData) {
|
function openVehicleDetail(vehicle: VehicleData) {
|
||||||
detailVehicle.value = vehicle
|
detailVehicle.value = vehicle
|
||||||
isVehicleDetailOpen.value = true
|
isVehicleDetailOpen.value = true
|
||||||
@@ -471,6 +215,15 @@ function closeVehicleDetail() {
|
|||||||
detailVehicle.value = null
|
detailVehicle.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5 Bug fix (RBAC Phase 3): Kezeli a CostsActionsCard cost-saved eseményét.
|
||||||
|
* Növeli a refreshCostsTrigger számlálót, ami a VehicleDetailModal-ban
|
||||||
|
* watch által figyelve újratölti a költségeket.
|
||||||
|
*/
|
||||||
|
function onCostSaved(vehicleId: string) {
|
||||||
|
refreshCostsTrigger.value++
|
||||||
|
}
|
||||||
|
|
||||||
// ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
|
// ── Vehicle Form Modal State (standalone, decoupled from Manager) ──
|
||||||
const isAddFormOpen = ref(false)
|
const isAddFormOpen = ref(false)
|
||||||
const isEditFormOpen = ref(false)
|
const isEditFormOpen = ref(false)
|
||||||
@@ -586,71 +339,6 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
|
|||||||
vehicleStore.setPrimaryVehicle(vehicle.id)
|
vehicleStore.setPrimaryVehicle(vehicle.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Display vehicles from the backend API only.
|
|
||||||
* Sorted by is_primary flag (favorite first), then alphabetically.
|
|
||||||
* Shows all real vehicles; no mock/fallback data is injected.
|
|
||||||
*/
|
|
||||||
const displayVehicles = computed(() => {
|
|
||||||
return vehicleStore.sortedVehicles
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Cost Card Action Center State ──
|
|
||||||
const selectedActionVehicleId = ref<string>('')
|
|
||||||
const isFuelModalOpen = ref(false)
|
|
||||||
const isFeeModalOpen = ref(false)
|
|
||||||
const isComplexModalOpen = ref(false)
|
|
||||||
|
|
||||||
/** The currently selected vehicle object for the action modals */
|
|
||||||
const selectedActionVehicle = computed(() => {
|
|
||||||
if (!selectedActionVehicleId.value) return null
|
|
||||||
return vehicleStore.vehicles.find(v => v.id === selectedActionVehicleId.value) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Auto-select the first (primary/sorted) vehicle on load */
|
|
||||||
function autoSelectVehicle() {
|
|
||||||
if (vehicleStore.sortedVehicles.length > 0 && !selectedActionVehicleId.value) {
|
|
||||||
selectedActionVehicleId.value = vehicleStore.sortedVehicles[0].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFuelModal() {
|
|
||||||
if (!selectedActionVehicle.value) return
|
|
||||||
isFuelModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFeeModal() {
|
|
||||||
if (!selectedActionVehicle.value) return
|
|
||||||
isFeeModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openComplexModal() {
|
|
||||||
if (!selectedActionVehicle.value) return
|
|
||||||
isComplexModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onModalSaved() {
|
|
||||||
isFuelModalOpen.value = false
|
|
||||||
isFeeModalOpen.value = false
|
|
||||||
isComplexModalOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Placeholder notifications ──
|
|
||||||
const notifications = ref([
|
|
||||||
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
|
|
||||||
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
|
|
||||||
{ icon: '🏆', title: 'You earned "Eco Driver" badge!', time: '1 day ago' },
|
|
||||||
{ icon: '📊', title: 'Monthly report is ready', time: '2 days ago' },
|
|
||||||
])
|
|
||||||
|
|
||||||
// ── Placeholder badges ──
|
|
||||||
const badges = ref([
|
|
||||||
{ icon: '🌱', label: 'Eco Driver' },
|
|
||||||
{ icon: '📏', label: '10k km Club' },
|
|
||||||
{ icon: '🔧', label: 'Service Pro' },
|
|
||||||
{ icon: '⭐', label: 'Top Rater' },
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
vehicleStore.fetchVehicles()
|
vehicleStore.fetchVehicles()
|
||||||
authStore.fetchMyOrganizations()
|
authStore.fetchMyOrganizations()
|
||||||
@@ -664,11 +352,6 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Watch for vehicles to load, then auto-select
|
|
||||||
watch(() => vehicleStore.vehicles.length, () => {
|
|
||||||
autoSelectVehicle()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// ── Watch for query param changes (hamburger menu navigation) ────
|
// ── Watch for query param changes (hamburger menu navigation) ────
|
||||||
watch(() => route.query.card, (newCard) => {
|
watch(() => route.query.card, (newCard) => {
|
||||||
if (newCard && typeof newCard === 'string') {
|
if (newCard && typeof newCard === 'string') {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
<!-- Hard Delete: Only visible for Superadmin -->
|
<!-- Hard Delete: Only visible for Superadmin -->
|
||||||
<button
|
<button
|
||||||
v-if="authStore.user?.role === 'superadmin'"
|
v-if="authStore.user?.role?.toUpperCase() === 'SUPERADMIN'"
|
||||||
class="px-3 py-1.5 bg-red-800 hover:bg-red-900 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
class="px-3 py-1.5 bg-red-800 hover:bg-red-900 text-white text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:disabled="bulkLoading"
|
:disabled="bulkLoading"
|
||||||
@click="executeBulkAction('hard_delete')"
|
@click="executeBulkAction('hard_delete')"
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="border-t border-gray-600 my-1"></div>
|
<div class="border-t border-gray-600 my-1"></div>
|
||||||
<button
|
<button
|
||||||
v-if="authStore.user?.role === 'superadmin'"
|
v-if="authStore.user?.role?.toUpperCase() === 'SUPERADMIN'"
|
||||||
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
class="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||||
@click="individualAction(user.id, 'hard_delete')"
|
@click="individualAction(user.id, 'hard_delete')"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user