From 71ef33bb8508b4e89707c6ef3f19279dcbbc3fe1 Mon Sep 17 00:00:00 2001 From: Roo Date: Tue, 23 Jun 2026 21:11:21 +0000 Subject: [PATCH] =?UTF-8?q?frontend=20k=C3=B6lts=C3=A9g=20be=C3=A1ll=C3=AD?= =?UTF-8?q?t=C3=A1sok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .roo/history.md | 238 +++- backend/app/api/v1/endpoints/assets.py | 367 +++++- backend/app/api/v1/endpoints/dictionaries.py | 181 ++- backend/app/api/v1/endpoints/expenses.py | 250 +++- backend/app/api/v1/endpoints/organizations.py | 34 + backend/app/api/v1/endpoints/reports.py | 115 +- backend/app/api/v1/endpoints/users.py | 63 +- backend/app/constants/vehicle_features.py | 72 +- backend/app/db/base.py | 17 +- backend/app/models/__init__.py | 23 +- backend/app/models/fleet/__init__.py | 13 + backend/app/models/fleet/organization.py | 146 +++ backend/app/models/fleet_finance/__init__.py | 25 + backend/app/models/fleet_finance/models.py | 316 +++++ .../app/models/marketplace/organization.py | 3 +- backend/app/models/vehicle/__init__.py | 21 +- backend/app/models/vehicle/asset.py | 141 +- backend/app/models/vehicle/vehicle.py | 99 +- .../app/models/vehicle/vehicle_definitions.py | 3 +- backend/app/schemas/asset.py | 97 +- backend/app/schemas/asset_cost.py | 15 +- backend/app/schemas/fleet_finance.py | 242 ++++ backend/app/schemas/organization.py | 5 + backend/app/schemas/user.py | 4 + backend/app/services/analytics_service.py | 60 +- backend/app/services/asset_service.py | 13 + backend/app/services/subscription_service.py | 2 +- backend/scripts/seed_finance_dictionaries.py | 245 ++++ backend/static/locales/en.json | 17 +- backend/static/locales/hu.json | 10 +- ...vehicle_data_flow_bug_report_2026-06-20.md | 214 ++++ docs/cost_pipeline_audit.md | 158 +++ docs/cost_ui_audit.md | 177 +++ docs/dashboard_ui_audit.md | 271 ++++ docs/db_consistency_and_zombie_api_report.md | 227 ++++ docs/db_schema_audit.md | 774 +++++++++++ docs/finance_api_gap_analysis.md | 278 ++++ docs/full_db_schema_blueprint.md | 1139 +++++++++++++++++ ...nd_api_audit_vehicle_finance_2026-06-21.md | 204 +++ ...inancial_data_modeling_audit_2026-06-20.md | 189 +++ ..._vehicle_details_ui_ux_audit_2026-06-19.md | 199 +++ docs/vehicle_dataflow_audit_2026-06-21.md | 180 +++ ...registration_documents_audit_2026-06-19.md | 142 ++ frontend/admin/Dockerfile.dev | 7 + frontend/admin/app.vue | 6 + frontend/admin/nuxt.config.ts | 3 + frontend/admin/package.json | 16 + .../src/components/admin/PackageEditModal.vue | 4 +- .../src/components/cost/CostEntryWizard.vue | 834 ++++++++++++ .../src/components/cost/CostManagerModal.vue | 125 +- .../dashboard/AdPlacementWidget.vue | 6 + .../components/dashboard/CostsActionsCard.vue | 48 +- .../components/dashboard/FinancialCard.vue | 2 +- .../components/dashboard/ProfileTrustCard.vue | 4 +- .../components/dashboard/VehicleFormModal.vue | 292 ++++- .../dashboard/WalletDetailsModal.vue | 2 +- .../header/HeaderCompanySwitcher.vue | 2 +- .../src/components/header/HeaderProfile.vue | 37 +- .../organization/CompanyDataModal.vue | 4 +- .../OrganizationSettingsModal.vue | 6 +- .../components/provider/ProviderEditModal.vue | 6 +- .../provider/ProviderQuickAddModal.vue | 6 +- .../subscription/SubscriptionInfoModal.vue | 278 ++++ .../vehicle/VehicleCardStandard.vue | 20 +- .../components/vehicle/VehicleDetailModal.vue | 359 ++++-- .../vehicles/tabs/FinancialsTab.vue | 707 +++++++++- .../components/vehicles/tabs/OverviewTab.vue | 305 +++-- .../components/vehicles/tabs/TechDataTab.vue | 279 +++- frontend/src/i18n/en.ts | 315 +++-- frontend/src/i18n/hu.ts | 322 +++-- frontend/src/layouts/PrivateLayout.vue | 7 +- frontend/src/router/index.ts | 9 +- frontend/src/stores/auth.ts | 4 + frontend/src/stores/expense.ts | 149 +++ frontend/src/stores/vehicle.ts | 150 +++ frontend/src/types/organization.ts | 5 + frontend/src/types/vehicle.ts | 67 + frontend/src/views/CompleteKycView.vue | 14 +- frontend/src/views/DashboardView.vue | 15 +- frontend/src/views/ProfileView.vue | 44 +- .../src/views/admin/AdminServicesView.vue | 4 +- frontend/src/views/admin/AdminUsersView.vue | 2 +- frontend/src/views/costs/CostsView.vue | 566 ++++++++ .../views/organization/CompanyGarageView.vue | 2 +- .../organization/CompanyOnboardingView.vue | 28 +- frontend/src/views/vehicles/FleetView.vue | 102 +- .../src/views/vehicles/VehicleDetailsView.vue | 64 +- ...gic_spec_vehicle_registration_documents.md | 264 ++++ ...p0_subscription_profile_menu_modal_plan.md | 148 +++ plans/p0_vehicle_card_fix_plan.md | 275 ++++ 90 files changed, 11850 insertions(+), 1053 deletions(-) create mode 100644 backend/app/models/fleet/__init__.py create mode 100644 backend/app/models/fleet/organization.py create mode 100644 backend/app/models/fleet_finance/__init__.py create mode 100644 backend/app/models/fleet_finance/models.py create mode 100644 backend/app/schemas/fleet_finance.py create mode 100644 backend/scripts/seed_finance_dictionaries.py create mode 100644 docs/bmw123_vehicle_data_flow_bug_report_2026-06-20.md create mode 100644 docs/cost_pipeline_audit.md create mode 100644 docs/cost_ui_audit.md create mode 100644 docs/dashboard_ui_audit.md create mode 100644 docs/db_consistency_and_zombie_api_report.md create mode 100644 docs/db_schema_audit.md create mode 100644 docs/finance_api_gap_analysis.md create mode 100644 docs/full_db_schema_blueprint.md create mode 100644 docs/p0_backend_api_audit_vehicle_finance_2026-06-21.md create mode 100644 docs/p0_financial_data_modeling_audit_2026-06-20.md create mode 100644 docs/p0_vehicle_details_ui_ux_audit_2026-06-19.md create mode 100644 docs/vehicle_dataflow_audit_2026-06-21.md create mode 100644 docs/vehicle_registration_documents_audit_2026-06-19.md create mode 100644 frontend/admin/Dockerfile.dev create mode 100644 frontend/admin/app.vue create mode 100644 frontend/admin/nuxt.config.ts create mode 100644 frontend/admin/package.json create mode 100644 frontend/src/components/cost/CostEntryWizard.vue create mode 100644 frontend/src/components/subscription/SubscriptionInfoModal.vue create mode 100644 frontend/src/stores/expense.ts create mode 100644 frontend/src/views/costs/CostsView.vue create mode 100644 plans/logic_spec_vehicle_registration_documents.md create mode 100644 plans/p0_subscription_profile_menu_modal_plan.md create mode 100644 plans/p0_vehicle_card_fix_plan.md diff --git a/.roo/history.md b/.roo/history.md index 83ca433..8c85a4f 100644 --- a/.roo/history.md +++ b/.roo/history.md @@ -1,74 +1,204 @@ # Service Finder Fejlesztési Történet -## 2026-06-14 - Admin Search Bugs Fix & Sticky UX +## 2026-06-21 - P0 Deep Audit: Database Consistency & Zombie API Hunt ### 🎯 Cél -Backend SQL hibák javítása a GET /admin/users végponton (Address outerjoin, phone search), valamint frontend UX fejlesztések (sticky bulk action bar, clear/X gomb, üres állapot üzenet, HeaderProfile az AdminLayout-ban). +Teljes körű adatbázis konzisztencia ellenőrzés és zombie API végpontok felderítése a `vehicle`, `finance`, `fleet_finance` sémákban, mielőtt a frontend fejlesztés elkezdődik. -### 🔧 Változtatások +### 🔧 Eredmények -**1. BACKEND - [`admin.py`](backend/app/api/v1/endpoints/admin.py:502):** -- `GET /admin/users` SQL javítás: `outerjoin(Person).outerjoin(Address)` → `outerjoin(Person).outerjoin(Address, ...)` (explicit ON clause a person_id-n) -- `phone` keresés hozzáadva a `search_category`-hez (LIKE `person.phone`) -- `is_active` és `is_deleted` filterek külön kezelése (nem zárják ki egymást) +**1. ADATBÁZIS TISZTASÁG:** ✅ PASS +- API modul import: ✅ Sikeres (2 route: GET, POST) +- Sync Engine: ✅ 1210 OK, 0 Fixed, 0 Extra +- E2E test: ⚠️ Pre-existing conftest hiba (verification token timeout - nem kapcsolódó) -**2. FRONTEND - [`AdminUsersView.vue`](frontend/src/views/admin/AdminUsersView.vue):** -- Bulk action bar: `sticky` pozícionálás (top-0, z-30) -- Clear/X gomb a keresőmezőben -- Üres állapot kezelés: "No users match your search" / "No users in the database yet." -- HeaderProfile komponens hozzáadva az AdminLayout-hoz - -### ✅ Verifikáció -- `docker compose exec sf_api python3 -m pytest backend/tests/test_04_business_onboarding.py -v` → 7 passed -- Manuális teszt: GET /admin/users?search_term=...&search_category=phone működik - -## 2026-06-19 - P0 BUG FIX: VehicleDetails Blank Page & OverviewTab +## 2026-06-23 - Cost Entry Wizard (CostEntryWizard.vue) - 4-lépéses Számla Űrlap ### 🎯 Cél -A VehicleDetailsView üres oldalt (blank page) mutatott, amikor a Dashboardról vagy FleetView-ról navigáltak rá. A hiba oka a nem-reaktív tab definíciók és a hiányzó loading/error állapotkezelés volt. Emellett az OverviewTab csak egy placeholdert tartalmazott. +4-lépéses stepper wizard építése részletes számla/invoice költségek rögzítésére a CostsActionsCard-ból indíthatóan. -### 🔧 Változtatások +### 🔧 Módosított fájlok +- **`frontend/src/components/cost/CostEntryWizard.vue`** (ÚJ) - 4-step stepper wizard komponens +- **`frontend/src/components/dashboard/CostsActionsCard.vue`** - "🧾 Részletes Számla" gomb hozzáadva, CostEntryWizard import és modal state +- **`frontend/src/i18n/en.ts`** - costWizard i18n szekció hozzáadva +- **`frontend/src/i18n/hu.ts`** - costWizard i18n szekció hozzáadva -**1. [`VehicleDetailsView.vue`](frontend/src/views/vehicles/VehicleDetailsView.vue) - Blank Page Fix:** -- **Root Cause:** A `tabs` tömb `const`-ként volt definiálva, benne `computed()` ref-ekkel, amelyek sosem frissültek. A `v-for="tab in tabs"` nem volt reaktív. -- **Fix:** A `tabs` tömböt `computed(() => [...])`-re cseréltük, így a teljes tömb reaktív. -- **Loading State:** `v-if="isLoading"` spinner hozzáadva, ami akkor jelenik meg, ha `vehicleStore.isLoading` true VAGY a vehicles tömb üres és a vehicle még nincs betöltve. -- **Error/Empty State:** `v-else-if="!vehicle"` üres állapot ikonnal és `t('vehicleDetail.notFound')` üzenettel. -- **Vehicle Data Flow:** `provide('vehicle', vehicle)` használatával a jármű adatokat átadjuk a child tab komponenseknek. -- **Async onMounted:** `await vehicleStore.fetchVehicles()` hívás, hogy direkt URL-es navigációnál (F5) is betöltődjenek az adatok. +### 🧩 CostEntryWizard lépések +1. **Alapadatok**: Jármű (read-only), kategória cascade (fő+alkategória), dátum +2. **Pénzügyek**: Nettó összeg, ÁFA kulcs (0/5/10/20/27%), Bruttó összeg (GROSS-FIRST), Pénznem +3. **Admin & Szállító**: Dinamikus vendor autocomplete (GET /organizations?org_type=SERVICE_PROVIDER) szabad szöveges fallbackkel, számlaszám, számla dátuma, fizetési határidő +4. **Fájlok**: Drag & drop fájl feltöltő UI (csak UI szintű file picker) -**2. [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue) - Full Layout:** -- **Photo Section:** 16:9 / négyzet alakú placeholder szürke háttérrel, kamera ikonnal és "Photo upload coming soon" felirattal. -- **Basic Info:** Nagy, hangsúlyos rendszám (4xl), alatta brand/model (2xl), évjárat, majd elválasztó után VIN, üzemanyag típus, futásteljesítmény. -- **Vital Signs (3 kártya):** - - **MOT Expiry:** Naptár ikon, dátum megjelenítés, színkódolás (piros=lejárt, sárga=<30 nap, zöld=rendben). - - **Current Mileage:** Villám ikon, formázott km érték. - - **Next Service:** Dokumentum ikon, "Coming soon" placeholder (később a service book-hoz kapcsolva). -- **Data Flow:** `inject>('vehicle')` a szülő komponensből. - -**3. [`en.ts`](frontend/src/i18n/en.ts) - New i18n keys:** -- `vehicleDetail.notFound`, `vehicleDetail.noPlate`, `vehicleDetail.photoPlaceholder`, `vehicleDetail.vin`, `vehicleDetail.fuelType`, `vehicleDetail.mileage`, `vehicleDetail.motExpiry`, `vehicleDetail.nextService`, `vehicleDetail.noData`, `vehicleDetail.comingSoon` +### 🔌 API Integráció +- POST `/expenses/` a `costStore.addExpense()`-on keresztül AssetCostBase payload-dal +- Payload tartalmazza: amount_gross (GROSS-FIRST), category_id, currency, date, data JSONB (net_amount, vat_rate, invoice_number, invoice_date, payment_deadline, vendor_id, vendor_name, file_names) ### ✅ Verifikáció -- A tab navigáció most már reaktív (`computed`-be csomagolt `tabDefinitions`) -- A loading spinner megjelenik adatok betöltése közben -- Az OverviewTab teljes layout-ja renderelődik: fotó placeholder + alapadatok + 3 vital signs kártya -- A jármű adatok `provide`/`inject` mechanizmussal jutnak el a tab komponensekhez +- Vite build: ✅ Sikeres (253 modules, 6.94s) +- Gitea kártya: #277 -## 2026-06-19 - FleetView jármű részletek navigációs hiba javítása +## 2026-06-23 - Dashboard Cost Card Navigation Fix & Dedicated Costs Page ### 🎯 Cél -A privát jármű oldalon (FleetView) a kártyákra kattintva nem nyílt meg a jármű teljes adatlapja az 5 aloldallal (Overview, TechData, ServiceBook, Financials, Documents). +A dashboard költség kártya üres területére kattintva és a "További költségek" gombbal is a költségek oldalra navigálás. Dedikált CostsView oldal létrehozása vissza gombbal és garázs háttérrel. -### 🔧 Változtatások - -**1. [`FleetView.vue`](frontend/src/views/vehicles/FleetView.vue:62):** A `@click` handler `router.push(\`/vehicles/\${vehicle.id}\`)` útvonalat használt, ami nem egyezik egyetlen regisztrált route-pal sem. A helyes útvonalak: - - Privát mód: `/dashboard/vehicles/:id` - - Céges mód: `/organization/:id/vehicles/:id` - - Javítás: Az `openVehicleDetail()` függvény az `authStore.isCorporateMode` és `active_organization_id` alapján dinamikusan választja ki a megfelelő útvonalat, megegyező logikával, mint a [`MyVehiclesCard.vue`](frontend/src/components/dashboard/MyVehiclesCard.vue:72)-ben lévő `openVehicleDetail()`. +### 🔧 Módosított fájlok +- **`frontend/src/views/costs/CostsView.vue`** (ÚJ) - Teljes költség oldal garázs háttérrel, vissza gombbal, költség táblázattal és paginációval +- **`frontend/src/router/index.ts`** - `/dashboard/costs` route hozzáadva a CostsView-hoz +- **`frontend/src/components/dashboard/CostsActionsCard.vue`** - Header bar kattinthatóvá téve + "További költségek" gomb átirányítva a `/dashboard/costs` oldalra +- **`frontend/src/layouts/PrivateLayout.vue`** - Hamburger menü "Költségek" navigáció `/dashboard/costs`-ra módosítva +- **`frontend/src/i18n/hu.ts`** - `costs.backToDashboard` kulcs hozzáadva +- **`frontend/src/i18n/en.ts`** - `costs.backToDashboard` kulcs hozzáadva ### ✅ Verifikáció -- A kártyára kattintás után a böngésző a `/dashboard/vehicles/:id` (privát) vagy `/organization/:id/vehicles/:id` (céges) útvonalra navigál -- A `VehicleDetailsView.vue` betöltődik és megjeleníti az 5 tabot -- A TypeScript fordítás hibátlan (csak pre-existing `.js` fájl warningok) +- Vite build: ✅ Sikeres (CostsView-DaFsqPnD.js chunk generálva) + +## 2026-06-23 - Hotfix: Backend Expenses GET / endpoint & AssetCost.organization FK fix + +### 🎯 Cél +Két kritikus backend hiba javítása: +1. A `GET /api/v1/expenses/` hívás 404-et adott (nem létezett `GET /` végpont), amit a reverse proxy 307-es HTTPS→HTTP átirányítással válaszolt, ami Mixed Content blokkolást okozott a böngészőben. +2. A backend indításkor `InvalidRequestError`-ral összeomlott az `AssetCost.organization` relationship hibás konfigurációja miatt (két FK ugyanarra a táblára, de a `foreign_keys` paraméter hiányzott). + +### 🔧 Módosított fájlok +- **`backend/app/api/v1/endpoints/expenses.py`** - `GET /` végpont hozzáadva: list_all_expenses() - szervezet összes költségének listázása paginációval, Asset, CostCategory, Organization JOIN-okkal +- **`backend/app/models/fleet_finance/models.py`** - `AssetCost.organization` relationship javítva: `foreign_keys=[organization_id]` paraméter hozzáadva a kétértelmű FK kapcsolat feloldásához + +### 🔧 Eredmények +- Backend konténer: ✅ Tisztán indul, nincs SQLAlchemy hiba +- Login: ✅ Működik (nem omlik össze) +- `GET /api/v1/expenses/`: ✅ Végpont elérhető, paginált költség listát ad vissza + +## 2026-06-23 - Hotfix #2: Expenses API 307 redirect / Mixed Content - trailing slash fix + +### 🎯 Cél +A `GET /api/v1/expenses` hívás 307-es HTTPS→HTTP átirányítást kapott, mert a backend route `@router.get("/")` (perjellel) volt definiálva, de a frontend `/expenses` (perjel nélkül) hívta. A Starlette automatikus 307 redirect-je a külső openresty proxy miatt `http://` címmé alakult → Mixed Content blokk. + +### 🔧 Módosított fájlok +- **`backend/app/api/v1/endpoints/expenses.py`** - `@router.get("/")` → `@router.get("")` (perjel eltávolítva a route-ból) + +### 🔧 Eredmények +- Backend konténer: ✅ Tisztán indul +- `GET /api/v1/expenses` most direkt egyezik a route-tal → nincs 307 redirect → nincs Mixed Content + +## 2026-06-23 - Hotfix #3: Expenses API 500 Internal Server Error - AssetCost.cost_date → AssetCost.date + +### 🎯 Cél +A `GET /api/v1/expenses` végpont 500-as hibát dobott: `AttributeError: type object 'AssetCost' has no attribute 'cost_date'`. Az `AssetCost` modellben a mező neve `date` (110. sor), de a kód `cost_date`-t használt. + +### 🔧 Módosított fájlok +- **`backend/app/api/v1/endpoints/expenses.py`** - 2 helyen javítva: + - 157. sor: `AssetCost.cost_date` → `AssetCost.date` + - 193. sor: `cost.cost_date.isoformat()` → `cost.date.isoformat()` + +### 🔧 Eredmények +- Backend konténer: ✅ Tisztán indul +- `GET /api/v1/expenses`: ✅ Várhatóan 200 OK (nem 500) + +--- + +## 2026-06-23: Dashboard üres AdPlacementWidget elrejtése + +### 🎯 Cél +A felhasználó kérésére: a dashboardon a "PRO" feliratú `SubscriptionStatusWidget` mellett egy üres csempe volt látható (`AdPlacementWidget`), mert nincs hirdetés beállítva a rendszerben. A kérés az volt, hogy rejtsük el ezt az üres csempét. + +### 🔧 Módosított fájlok +- **`frontend/src/components/dashboard/AdPlacementWidget.vue`** - `defineEmits` hozzáadva: `ads-loaded` esemény, amely jelzi a szülőnek, hogy van-e hirdetés (`hasAds: boolean`). Az esemény a `fetchAds` siker és hiba ágában is meghívásra kerül. +- **`frontend/src/views/DashboardView.vue`** - `adWidgetHasAds` ref hozzáadva (alapértelmezett: `false`). A hirdetési csempe wrapper `
`-je `v-if="adWidgetHasAds"` feltételt kapott, így csak akkor jelenik meg, ha az API ténylegesen adott vissza hirdetéseket. Az `@ads-loaded` esemény frissíti a ref értékét. + +### ✅ Verifikáció +- Frontend build: ✅ Sikeres (Vite build, 0 hiba) + +## 2026-06-23 - P0 Fix: cost_date vs date mezőnév mismatch + CostsView.vue upgrade + +### 🎯 Cél +Két produkciós hiba javítása a cost rögzítésben: +1. **P0 - 500 Internal Server Error** POST `/api/v1/expenses/` végponton: négyrétegű mezőnév-eltérés (`cost_date` vs `date`) a frontend, Pydantic séma, endpoint és SQLAlchemy model között. +2. **P1 - `/dashboard/costs` oldal** nem mutatott semmilyen információt és nem volt költség hozzáadási lehetőség. + +### 🔧 Módosított fájlok + +**Fix 1a - Backend endpoint** (`backend/app/api/v1/endpoints/expenses.py:464`): +- `cost_date=expense.cost_date` → `date=expense.cost_date` (SQLAlchemy model mezőneve `date`) + +**Fix 1b - Backend Pydantic schema** (`backend/app/schemas/asset_cost.py:37`): +- `validation_alias="date"` hozzáadva a `cost_date` mezőhöz, hogy mind a `date`, mind a `cost_date` kulcsot elfogadja + +**Fix 2 - Frontend CostsView.vue** (`frontend/src/views/costs/CostsView.vue`): +- Vezérlősáv hozzáadva járműszűrő dropdown-nal +- "➕ Új Költség" gomb hozzáadva +- CostEntryWizard integráció (modal formában) +- Mezőnevek javítva: `cost_type` → `category_name`, `amount` → `amount_gross`, `vehicle_label` → `vehicle_name` +- `useVehicleStore` importálva a járművek listájához + +### ✅ Verifikáció +- Python szintaxis: ✅ asset_cost.py OK, expenses.py OK +- Backend indítás: ✅ Uvicorn fut, nincs hiba +- Frontend build: ✅ Sikeres (Vite build 6.77s, 0 hiba) +- Gitea kártyák: #278 (P0) és #279 (P1) lezárva + +## 2026-06-23 - #280 Vizsgálat: Miért nem jelennek meg költségek? + +### 🎯 Cél +Annak kivizsgálása, hogy az admin@profibot.hu teszt felhasználó miért nem lát költségeket. + +### 🔍 Vizsgálat +- `fleet_finance.asset_costs` táblában összesen **1 rekord** van (org_id=43, asset=PTB162 Mazda 2, 18118 HUF, "Finanszírozás") +- admin@profibot.hu (user_id=2) tagja: org 1 (Test Company), 57, 58, 62, 67 +- admin **NEM tagja** org 43-nak ("Accipe garage"), ahova az egyetlen költség tartozik +- admin szervezeteiben (1, 57, 58, 62, 67) összesen **0 db költség** van + +### ✅ Következtetés +**NINCS backend/frontend hiba.** A probléma egyszerű **adathiány**: +1. Nincs rögzítve költség admin egyetlen járművéhez sem +2. Az egyetlen DB-ben lévő költség egy másik szervezethez tartozik +3. A #278 és #279 javítások után most már lehetővé vált a költségrögzítés +- Dokumentáció: `/opt/docker/docs/cost_visibility_investigation_280.md` + +## 2026-06-23 - #281 Hotfix: Hiányos #278 javítás - expenses.py:533 new_cost.cost_date → new_cost.date + +### 🎯 Cél +A #278-as javítás hiányos volt: a konstruktor paramétert kijavítottuk (expenses.py:464: cost_date= → date=), de a response dict-ben (expenses.py:533) továbbra is `new_cost.cost_date`-et használtunk az SQLAlchemy AssetCost modellen, aminek a mezőneve `date`. Ez `AttributeError: 'AssetCost' object has no attribute 'cost_date'` hibát okozott minden POST /api/v1/expenses/ híváskor. + +### 🔧 Módosított fájlok +- **`backend/app/api/v1/endpoints/expenses.py:533`** - `new_cost.cost_date.isoformat()` → `new_cost.date.isoformat()` + +### ✅ Verifikáció +- Python szintaxis: ✅ OK +- Backend újraindítás: ✅ OK (Application startup complete) +- POST /api/v1/expenses/ teszt: ✅ **201 Created** - sikeres költség rögzítés a QWE432 járműre +- Response tartalmazza: `"date":"2026-06-23T13:07:34.785065+00:00"` (helyes) +- Gitea kártya: #281 lezárva + +## 2026-06-23 - P0 Fix: User 28 Data Anomaly Resolution + +### 🎯 Cél +User 28 (tester_pro@profibot.hu) adat-anomália javítása ORM szkript segítségével. + +### 🔧 Végrehajtott műveletek + +**A) Org 1 javítása:** `fleet.organizations` ID=1 org_type átállítva `fleet_owner` → `individual` + +**B) Tag hozzáadása:** `fleet.organization_members` rekord létrehozva: org=1, user=28, role=OWNER, status=active. Org 1 owner_id=28 beállítva. + +**C) Scope törlése:** User 28 `scope_level` visszaállítva `individual`-ra (NOT NULL constraint miatt), `scope_id` None-ra. + +**D) Fantom garázs törlése:** Org 68 ("Tester Garázsa") és hozzá tartozó OrganizationMember rekordok törlése. + +### ✅ Verifikáció +- Org 1: org_type=individual, owner_id=28 +- Org 1 Member: role=OWNER, status=active, is_verified=True +- User 28: scope_level=individual, scope_id=None +- Org 68: nem létezik (deleted) +- Ideiglenes szkript törölve: `backend/scripts/fix_user_28.py` + +## 2026-06-23 - P0 Cost Pipeline Visibility Audit (#282) + +### 🔍 Felfedezés +A `/dashboard/costs` oldal üres User 28 számára, annak ellenére, hogy Org 1-ben 5 db asset_cost rekord van (BMW123, QWE432). + +### 🐛 Gyökér Ok +A `GET /expenses/` endpoint (`backend/app/api/v1/endpoints/expenses.py:130-139`) a user első aktív tagságát használja org feloldáshoz (`.limit(1)` ORDER BY nélkül). User 28-nak 3 tagsága van: org 63 (ADMIN, nincs költség), org 21 (OWNER), org 1 (OWNER, 5 költség). Az első tagság org 63-ra mutat, ami üres. + +### 📄 Dokumentáció +`docs/cost_pipeline_audit.md` - Teljes pipeline audit a frontendtől a DB-ig. diff --git a/backend/app/api/v1/endpoints/assets.py b/backend/app/api/v1/endpoints/assets.py index 552c09b..04afb26 100755 --- a/backend/app/api/v1/endpoints/assets.py +++ b/backend/app/api/v1/endpoints/assets.py @@ -12,11 +12,17 @@ from sqlalchemy.orm import selectinload from app.db.session import get_db from app.api.deps import get_current_user from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole +from app.models.fleet_finance import AssetFinancials, VehicleInsurancePolicy, VehicleTaxObligation from app.models.identity import User from app.services.cost_service import cost_service from app.services.asset_service import AssetService from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEventCreate, AssetEventResponse +from app.schemas.fleet_finance import ( + AssetFinancialsUpdate, AssetFinancialsResponse, + VehicleInsurancePolicyCreate, VehicleInsurancePolicyUpdate, VehicleInsurancePolicyResponse, + VehicleTaxObligationCreate, VehicleTaxObligationUpdate, VehicleTaxObligationResponse, +) logger = logging.getLogger(__name__) @@ -1228,4 +1234,363 @@ async def archive_vehicle( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Belső szerverhiba a jármű archiválásakor" - ) \ No newline at end of file + ) + + +# ============================================================================= +# Fleet Finance endpoints +# ============================================================================= + + +@router.get( + "/vehicles/{asset_id}/financials", + response_model=AssetFinancialsResponse, +) +async def get_vehicle_financials( + asset_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Jármű pénzügyi adatainak lekérése. + + GET /api/v1/assets/vehicles/{asset_id}/financials + + Visszaadja a jármű beszerzési, finanszírozási és lízing adatait. + """ + asset = await _check_asset_access(db, asset_id, current_user) + + stmt = select(AssetFinancials).where(AssetFinancials.asset_id == asset_id) + result = await db.execute(stmt) + financials = result.scalar_one_or_none() + + if not financials: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="A járműhöz nem található pénzügyi adat" + ) + return financials + + +@router.patch( + "/vehicles/{asset_id}/financials", + response_model=AssetFinancialsResponse, +) +async def update_vehicle_financials( + asset_id: uuid.UUID, + payload: AssetFinancialsUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Jármű pénzügyi adatainak frissítése. + + PATCH /api/v1/assets/vehicles/{asset_id}/financials + + Csak a megadott mezőket frissíti (partial update). + """ + asset = await _check_asset_access(db, asset_id, current_user) + + stmt = select(AssetFinancials).where(AssetFinancials.asset_id == asset_id) + result = await db.execute(stmt) + financials = result.scalar_one_or_none() + + if not financials: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="A járműhöz nem található pénzügyi adat" + ) + + # Partial update: csak a megadott mezőket frissítjük + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(financials, field, value) + + await db.commit() + await db.refresh(financials) + return financials + + +@router.get( + "/vehicles/{asset_id}/insurance", + response_model=List[VehicleInsurancePolicyResponse], +) +async def list_vehicle_insurance_policies( + asset_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Jármű biztosítási kötvényeinek listázása. + + GET /api/v1/assets/vehicles/{asset_id}/insurance + + Visszaadja a járműhöz tartozó összes biztosítási kötvényt. + """ + asset = await _check_asset_access(db, asset_id, current_user) + + stmt = ( + select(VehicleInsurancePolicy) + .where(VehicleInsurancePolicy.asset_id == asset_id) + .order_by(VehicleInsurancePolicy.start_date.desc()) + ) + result = await db.execute(stmt) + policies = result.scalars().all() + return policies + + +@router.post( + "/vehicles/{asset_id}/insurance", + response_model=VehicleInsurancePolicyResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_vehicle_insurance_policy( + asset_id: uuid.UUID, + payload: VehicleInsurancePolicyCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Új biztosítási kötvény létrehozása a járműhöz. + + POST /api/v1/assets/vehicles/{asset_id}/insurance + """ + asset = await _check_asset_access(db, asset_id, current_user) + + policy = VehicleInsurancePolicy( + asset_id=asset_id, + provider_id=payload.provider_id, + insurance_type=payload.insurance_type, + policy_number=payload.policy_number, + start_date=payload.start_date, + expiry_date=payload.expiry_date, + premium_amount=payload.premium_amount, + currency=payload.currency, + ) + db.add(policy) + await db.commit() + await db.refresh(policy) + return policy + + +@router.get( + "/vehicles/{asset_id}/tax", + response_model=List[VehicleTaxObligationResponse], +) +async def list_vehicle_tax_obligations( + asset_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Jármű adókötelezettségeinek listázása. + + GET /api/v1/assets/vehicles/{asset_id}/tax + + Visszaadja a járműhöz tartozó összes adókötelezettséget. + """ + asset = await _check_asset_access(db, asset_id, current_user) + + stmt = ( + select(VehicleTaxObligation) + .where(VehicleTaxObligation.asset_id == asset_id) + .order_by(VehicleTaxObligation.tax_year.desc()) + ) + result = await db.execute(stmt) + obligations = result.scalars().all() + return obligations + + +@router.post( + "/vehicles/{asset_id}/tax", + response_model=VehicleTaxObligationResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_vehicle_tax_obligation( + asset_id: uuid.UUID, + payload: VehicleTaxObligationCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Új adókötelezettség létrehozása a járműhöz. + + POST /api/v1/assets/vehicles/{asset_id}/tax + """ + asset = await _check_asset_access(db, asset_id, current_user) + + obligation = VehicleTaxObligation( + asset_id=asset_id, + tax_type=payload.tax_type, + tax_year=payload.tax_year, + amount=payload.amount, + currency=payload.currency, + due_date=payload.due_date, + payment_status=payload.payment_status, + ) + db.add(obligation) + await db.commit() + await db.refresh(obligation) + return obligation + + +# ============================================================================= +# VehicleInsurancePolicy PUT / DELETE +# ============================================================================= + + +@router.put( + "/vehicles/{asset_id}/insurance/{policy_id}", + response_model=VehicleInsurancePolicyResponse, +) +async def update_vehicle_insurance_policy( + asset_id: uuid.UUID, + policy_id: uuid.UUID, + payload: VehicleInsurancePolicyUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Biztosítási kötvény módosítása. + + PUT /api/v1/assets/vehicles/{asset_id}/insurance/{policy_id} + + Csak a megadott mezőket frissíti (partial update). + """ + await _check_asset_access(db, asset_id, current_user) + + stmt = select(VehicleInsurancePolicy).where( + VehicleInsurancePolicy.id == policy_id, + VehicleInsurancePolicy.asset_id == asset_id, + ) + result = await db.execute(stmt) + policy = result.scalar_one_or_none() + + if not policy: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Biztosítási kötvény nem található", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(policy, field, value) + + await db.commit() + await db.refresh(policy) + return policy + + +@router.delete( + "/vehicles/{asset_id}/insurance/{policy_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_vehicle_insurance_policy( + asset_id: uuid.UUID, + policy_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Biztosítási kötvény törlése. + + DELETE /api/v1/assets/vehicles/{asset_id}/insurance/{policy_id} + """ + await _check_asset_access(db, asset_id, current_user) + + stmt = select(VehicleInsurancePolicy).where( + VehicleInsurancePolicy.id == policy_id, + VehicleInsurancePolicy.asset_id == asset_id, + ) + result = await db.execute(stmt) + policy = result.scalar_one_or_none() + + if not policy: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Biztosítási kötvény nem található", + ) + + await db.delete(policy) + await db.commit() + + +# ============================================================================= +# VehicleTaxObligation PUT / DELETE +# ============================================================================= + + +@router.put( + "/vehicles/{asset_id}/tax/{tax_id}", + response_model=VehicleTaxObligationResponse, +) +async def update_vehicle_tax_obligation( + asset_id: uuid.UUID, + tax_id: uuid.UUID, + payload: VehicleTaxObligationUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Adókötelezettség módosítása. + + PUT /api/v1/assets/vehicles/{asset_id}/tax/{tax_id} + + Csak a megadott mezőket frissíti (partial update). + """ + await _check_asset_access(db, asset_id, current_user) + + stmt = select(VehicleTaxObligation).where( + VehicleTaxObligation.id == tax_id, + VehicleTaxObligation.asset_id == asset_id, + ) + result = await db.execute(stmt) + obligation = result.scalar_one_or_none() + + if not obligation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Adókötelezettség nem található", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(obligation, field, value) + + await db.commit() + await db.refresh(obligation) + return obligation + + +@router.delete( + "/vehicles/{asset_id}/tax/{tax_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_vehicle_tax_obligation( + asset_id: uuid.UUID, + tax_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Adókötelezettség törlése. + + DELETE /api/v1/assets/vehicles/{asset_id}/tax/{tax_id} + """ + await _check_asset_access(db, asset_id, current_user) + + stmt = select(VehicleTaxObligation).where( + VehicleTaxObligation.id == tax_id, + VehicleTaxObligation.asset_id == asset_id, + ) + result = await db.execute(stmt) + obligation = result.scalar_one_or_none() + + if not obligation: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Adókötelezettség nem található", + ) + + await db.delete(obligation) + await db.commit() \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/dictionaries.py b/backend/app/api/v1/endpoints/dictionaries.py index 150cd3e..2eaddfa 100644 --- a/backend/app/api/v1/endpoints/dictionaries.py +++ b/backend/app/api/v1/endpoints/dictionaries.py @@ -2,15 +2,21 @@ """ Szótárak és katalógusok végpontjai. - GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve +- CRUD /dictionaries/insurance-providers: Biztosítók katalógusa """ import logging from typing import List, Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, desc from app.db.session import get_db from app.api import deps -from app.models.vehicle import CostCategory +from app.models.fleet_finance import CostCategory, InsuranceProvider +from app.schemas.fleet_finance import ( + InsuranceProviderResponse, + InsuranceProviderCreate, + InsuranceProviderUpdate, +) router = APIRouter() logger = logging.getLogger(__name__) @@ -66,3 +72,172 @@ async def list_cost_categories( root_categories.append(cat_dict) return root_categories + + +# ============================================================================= +# InsuranceProvider CRUD +# ============================================================================= + + +@router.get( + "/insurance-providers", + response_model=List[InsuranceProviderResponse], +) +async def list_insurance_providers( + active_only: bool = Query(True, description="Csak az aktív biztosítók listázása"), + country_code: Optional[str] = Query(None, max_length=2, description="ISO 3166-1 alpha-2 országkód szerinti szűrés (pl. 'HU'). Visszaadja az adott ország és a globális (NULL) szolgáltatókat."), + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_user), +): + """ + Biztosítók listázása. + Alapértelmezés szerint csak az aktív biztosítókat adja vissza (frontend dropdown számára). + Opcionálisan szűrhető country_code alapján: ha meg van adva, visszaadja az adott ország + szolgáltatóit ÉS a globális (country_code IS NULL) szolgáltatókat is. + """ + stmt = select(InsuranceProvider).order_by(InsuranceProvider.name) + + if active_only: + stmt = stmt.where(InsuranceProvider.is_active == True) + + if country_code: + stmt = stmt.where( + (InsuranceProvider.country_code == country_code) | + (InsuranceProvider.country_code.is_(None)) + ) + + result = await db.execute(stmt) + providers = result.scalars().all() + return providers + + +@router.get( + "/insurance-providers/{provider_id}", + response_model=InsuranceProviderResponse, +) +async def get_insurance_provider( + provider_id: int, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_user), +): + """ + Egy biztosító adatainak lekérése ID alapján. + """ + stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id) + result = await db.execute(stmt) + provider = result.scalar_one_or_none() + + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Biztosító nem található", + ) + return provider + + +@router.post( + "/insurance-providers", + response_model=InsuranceProviderResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_insurance_provider( + payload: InsuranceProviderCreate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_user), +): + """ + Új biztosító hozzáadása a katalógushoz. + """ + # Check for duplicate name + existing_stmt = select(InsuranceProvider).where( + InsuranceProvider.name == payload.name + ) + existing_result = await db.execute(existing_stmt) + if existing_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Már létezik biztosító ezzel a névvel: '{payload.name}'", + ) + + provider = InsuranceProvider( + name=payload.name, + claim_phone=payload.claim_phone, + claim_url=payload.claim_url, + services_offered=payload.services_offered or {}, + is_active=payload.is_active, + ) + db.add(provider) + await db.commit() + await db.refresh(provider) + return provider + + +@router.put( + "/insurance-providers/{provider_id}", + response_model=InsuranceProviderResponse, +) +async def update_insurance_provider( + provider_id: int, + payload: InsuranceProviderUpdate, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_user), +): + """ + Biztosító adatainak módosítása. + """ + stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id) + result = await db.execute(stmt) + provider = result.scalar_one_or_none() + + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Biztosító nem található", + ) + + # Check for duplicate name if name is being changed + if payload.name is not None and payload.name != provider.name: + dup_stmt = select(InsuranceProvider).where( + InsuranceProvider.name == payload.name, + InsuranceProvider.id != provider_id, + ) + dup_result = await db.execute(dup_stmt) + if dup_result.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Már létezik biztosító ezzel a névvel: '{payload.name}'", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(provider, field, value) + + await db.commit() + await db.refresh(provider) + return provider + + +@router.delete( + "/insurance-providers/{provider_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_insurance_provider( + provider_id: int, + db: AsyncSession = Depends(get_db), + current_user = Depends(deps.get_current_user), +): + """ + Biztosító törlése a katalógusból. + """ + stmt = select(InsuranceProvider).where(InsuranceProvider.id == provider_id) + result = await db.execute(stmt) + provider = result.scalar_one_or_none() + + if not provider: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Biztosító nem található", + ) + + await db.delete(provider) + await db.commit() diff --git a/backend/app/api/v1/endpoints/expenses.py b/backend/app/api/v1/endpoints/expenses.py index 9c0e0d2..3b89a8a 100755 --- a/backend/app/api/v1/endpoints/expenses.py +++ b/backend/app/api/v1/endpoints/expenses.py @@ -1,11 +1,13 @@ # /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py import logging from decimal import Decimal -from fastapi import APIRouter, Depends, HTTPException, status +from typing import Optional +from uuid import UUID +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func +from sqlalchemy import select, func, desc from app.api.deps import get_db, get_current_user, RequireOrgCapability -from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole +from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole, CostCategory, Organization from app.schemas.asset_cost import AssetCostCreate from datetime import datetime, timezone @@ -110,6 +112,236 @@ def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decim return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01")) +@router.get("") +async def list_all_expenses( + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_user), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page"), + asset_id: Optional[str] = Query(None, description="Filter by asset UUID"), + organization_id: Optional[int] = Query(None, description="Filter by organization ID. If provided, user must be a member."), +): + """ + List all expenses across all assets for the current user's organization, + ordered by date descending with pagination. + Supports optional asset_id filter for vehicle-specific cost views. + Supports optional organization_id filter for cross-org views (user must be a member). + Returns enriched expense data including vehicle info, category name, and vendor. + """ + # Resolve organization: use explicit organization_id if provided, otherwise fallback to user's active org + if organization_id is not None: + # Verify user is a member of the requested organization + org_stmt = select(OrganizationMember).where( + OrganizationMember.user_id == current_user.id, + OrganizationMember.organization_id == organization_id, + OrganizationMember.status == "active" + ).limit(1) + org_result = await db.execute(org_stmt) + membership = org_result.scalar_one_or_none() + if not membership: + raise HTTPException( + status_code=403, + detail="You are not an active member of the requested organization." + ) + org_id = organization_id + else: + # Fallback: resolve user's first active organization + org_stmt = select(OrganizationMember).where( + OrganizationMember.user_id == current_user.id, + OrganizationMember.status == "active" + ).limit(1) + org_result = await db.execute(org_stmt) + membership = org_result.scalar_one_or_none() + if not membership: + raise HTTPException(status_code=403, detail="No active organization membership found.") + org_id = membership.organization_id + + # Calculate offset + offset = (page - 1) * page_size + + # Build base query with joins + base_select = ( + select( + AssetCost, + CostCategory.code, + CostCategory.name, + Organization.name, + Asset.license_plate, + Asset.brand, + Asset.model, + ) + .outerjoin(CostCategory, AssetCost.category_id == CostCategory.id) + .outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id) + .join(Asset, AssetCost.asset_id == Asset.id) + ) + + # Apply filters + filters = [AssetCost.organization_id == org_id] + if asset_id: + filters.append(AssetCost.asset_id == asset_id) + + expense_stmt = ( + base_select + .where(*filters) + .order_by(desc(AssetCost.date)) + .offset(offset) + .limit(page_size) + ) + expense_result = await db.execute(expense_stmt) + rows = expense_result.all() + + # Build enriched response + expenses = [] + for row in rows: + cost = row[0] + cat_code = row[1] + cat_name = row[2] + vendor_name = row[3] + license_plate = row[4] + brand = row[5] + model = row[6] + + data = cost.data or {} + description = data.get("description") + mileage_at_cost = data.get("mileage_at_cost") + + # Build vehicle display name + vehicle_name = license_plate or f"{brand or ''} {model or ''}".strip() or "Unknown" + + expenses.append({ + "id": str(cost.id), + "asset_id": str(cost.asset_id), + "organization_id": cost.organization_id, + "category_id": cost.category_id, + "category_code": cat_code, + "category_name": cat_name, + "amount_gross": str(cost.amount_gross) if cost.amount_gross else None, + "amount_net": str(cost.amount_net), + "vat_rate": str(cost.vat_rate) if cost.vat_rate else None, + "currency": cost.currency, + "date": cost.date.isoformat() if cost.date else None, + "status": cost.status, + "description": description, + "mileage_at_cost": mileage_at_cost, + "invoice_number": cost.invoice_number, + "linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None, + "vendor_organization_id": cost.vendor_organization_id, + "external_vendor_name": cost.external_vendor_name, + "vendor_name": vendor_name, + "invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None, + "fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None, + # Vehicle info + "vehicle_name": vehicle_name, + "license_plate": license_plate, + }) + + # Count total for pagination (respect asset_id filter) + count_filters = [AssetCost.organization_id == org_id] + if asset_id: + count_filters.append(AssetCost.asset_id == asset_id) + count_stmt = ( + select(func.count(AssetCost.id)) + .where(*count_filters) + ) + count_result = await db.execute(count_stmt) + total = count_result.scalar() + + total_pages = max(1, (total + page_size - 1) // page_size) + + return { + "data": expenses, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } + + +@router.get("/{asset_id}") +async def list_asset_expenses( + asset_id: UUID, + db: AsyncSession = Depends(get_db), + current_user = Depends(get_current_user), + limit: int = Query(50, ge=1, le=200, description="Maximum number of expenses to return"), + offset: int = Query(0, ge=0, description="Number of expenses to skip"), +): + """ + List all expenses for a specific asset, ordered by date descending. + Returns enriched expense data including category name, code, and vendor info. + Used by the OverviewTab and CostManagerModal to display real expense data. + """ + # Validate asset exists + stmt = select(Asset).where(Asset.id == asset_id) + result = await db.execute(stmt) + asset = result.scalar_one_or_none() + if not asset: + raise HTTPException(status_code=404, detail="Asset not found.") + + # Fetch expenses with category join and vendor organization join + expense_stmt = ( + select(AssetCost, CostCategory.code, CostCategory.name, Organization.name) + .outerjoin(CostCategory, AssetCost.category_id == CostCategory.id) + .outerjoin(Organization, AssetCost.vendor_organization_id == Organization.id) + .where(AssetCost.asset_id == asset_id) + .order_by(desc(AssetCost.date)) + .offset(offset) + .limit(limit) + ) + expense_result = await db.execute(expense_stmt) + rows = expense_result.all() + + # Build enriched response + expenses = [] + for row in rows: + cost = row[0] # AssetCost instance + cat_code = row[1] # CostCategory.code + cat_name = row[2] # CostCategory.name + vendor_name = row[3] # Organization.name (resolved from vendor_organization_id) + + # Extract description and mileage from data JSONB + data = cost.data or {} + description = data.get("description") + mileage_at_cost = data.get("mileage_at_cost") + + expenses.append({ + "id": str(cost.id), + "asset_id": str(cost.asset_id), + "organization_id": cost.organization_id, + "category_id": cost.category_id, + "category_code": cat_code, + "category_name": cat_name, + "amount_gross": str(cost.amount_gross) if cost.amount_gross else None, + "amount_net": str(cost.amount_net), + "vat_rate": str(cost.vat_rate) if cost.vat_rate else None, + "currency": cost.currency, + "date": cost.date.isoformat() if cost.date else None, + "status": cost.status, + "description": description, + "mileage_at_cost": mileage_at_cost, + "invoice_number": cost.invoice_number, + "linked_asset_event_id": str(cost.linked_asset_event_id) if cost.linked_asset_event_id else None, + # === B2B VENDOR FIELDS === + "vendor_organization_id": cost.vendor_organization_id, + "external_vendor_name": cost.external_vendor_name, + "vendor_name": vendor_name, # Enriched from fleet.organizations.name + # === INVOICE DATES === + "invoice_date": cost.invoice_date.isoformat() if cost.invoice_date else None, + "fulfillment_date": cost.fulfillment_date.isoformat() if cost.fulfillment_date else None, + }) + + # Count total for pagination + count_stmt = select(func.count(AssetCost.id)).where(AssetCost.asset_id == asset_id) + count_result = await db.execute(count_stmt) + total = count_result.scalar() + + return { + "data": expenses, + "total": total, + "limit": limit, + "offset": offset, + } + + @router.post("/", status_code=201) async def create_expense( expense: AssetCostCreate, @@ -247,10 +479,16 @@ async def create_expense( amount_gross=amount_gross, vat_rate=vat_rate, currency=expense.currency, - date=expense.date, + date=expense.cost_date, invoice_number=data.get("invoice_number"), status=expense_status, - data=data + data=data, + # === B2B VENDOR FIELDS === + vendor_organization_id=expense.vendor_organization_id, + external_vendor_name=expense.external_vendor_name, + # === INVOICE DATES === + invoice_date=expense.invoice_date, + fulfillment_date=expense.fulfillment_date, ) db.add(new_cost) @@ -279,7 +517,7 @@ async def create_expense( 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.cost_date or datetime.now(timezone.utc), ) db.add(new_event) await db.flush() # Flush to get new_event.id diff --git a/backend/app/api/v1/endpoints/organizations.py b/backend/app/api/v1/endpoints/organizations.py index de2846e..218d114 100755 --- a/backend/app/api/v1/endpoints/organizations.py +++ b/backend/app/api/v1/endpoints/organizations.py @@ -184,7 +184,11 @@ async def get_my_organizations( ): """ A bejelentkezett felhasználóhoz tartozó szervezetek listázása. + P0: Minden szervezethez visszaadja a valós max_vehicles és max_garages limiteket + a subscription_tier JSONB rules alapján. """ + from app.models.core_logic import SubscriptionTier, OrganizationSubscription + subq = ( select(Organization.id) .outerjoin(OrganizationMember, OrganizationMember.organization_id == Organization.id) @@ -206,6 +210,31 @@ async def get_my_organizations( ) result = await db.execute(stmt) orgs = result.scalars().all() + + # ── P0: Pre-fetch all org subscription tiers for limit resolution ── + org_ids = [o.id for o in orgs] + org_tier_map: dict[int, tuple[int, int]] = {} + if org_ids: + org_subs_stmt = ( + select(OrganizationSubscription.org_id, SubscriptionTier) + .join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id) + .where( + OrganizationSubscription.org_id.in_(org_ids), + OrganizationSubscription.is_active == True + ) + .distinct(OrganizationSubscription.org_id) + .order_by(OrganizationSubscription.org_id, OrganizationSubscription.valid_from.desc()) + ) + org_subs_result = await db.execute(org_subs_stmt) + for row in org_subs_result: + o_id = row.org_id + tier = row[1] + if tier and tier.rules: + allowances = tier.rules.get("allowances", {}) + org_tier_map[o_id] = ( + int(allowances.get("max_vehicles", 1)), + int(allowances.get("max_garages", 1)), + ) return [ { @@ -220,6 +249,11 @@ async def get_my_organizations( "is_active": o.is_active, "is_deleted": o.is_deleted, "subscription_plan": o.subscription_plan, + "subscription_expires_at": o.subscription_expires_at.isoformat() if o.subscription_expires_at else None, + "subscription_tier_id": o.subscription_tier_id, + # P0: Real limits from subscription tier + "max_vehicles": org_tier_map.get(o.id, (o.base_asset_limit or 1, 1))[0], + "max_garages": org_tier_map.get(o.id, (1, 1))[1], "org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type), "visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None, "user_role": _get_user_role(o, current_user.id), diff --git a/backend/app/api/v1/endpoints/reports.py b/backend/app/api/v1/endpoints/reports.py index 1d240bd..a0d0fe1 100755 --- a/backend/app/api/v1/endpoints/reports.py +++ b/backend/app/api/v1/endpoints/reports.py @@ -1,56 +1,97 @@ -from fastapi import APIRouter, Depends +# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/reports.py +""" +Reports endpoints — Zombie API Cleanup. +Replaced raw SQL (FROM vehicle.vehicle_expenses) with SQLAlchemy ORM queries +using fleet_finance.asset_costs (AssetCost) + CostCategory. +""" +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import text +from sqlalchemy import select, func, desc +from sqlalchemy.orm import selectinload from app.api.deps import get_db, get_current_user +from app.models.fleet_finance import AssetCost, CostCategory +from app.models.identity import User + +router = APIRouter() -router = APIRouter() # EZ HIÁNYZOTT! @router.get("/summary/{vehicle_id}") -async def get_vehicle_summary(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)): +async def get_vehicle_summary( + vehicle_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """ Összesített jelentés egy járműhöz: kategóriánkénti költségek. + Uses fleet_finance.asset_costs (AssetCost) joined with CostCategory. """ - query = text(""" - SELECT - category, - SUM(amount) as total_amount, - COUNT(*) as transaction_count - FROM vehicle.vehicle_expenses - WHERE vehicle_id = :v_id - GROUP BY category - """) - - result = await db.execute(query, {"v_id": vehicle_id}) - rows = result.fetchall() - - total_cost = sum(row.total_amount for row in rows) if rows else 0 - + # Aggregate costs by category using ORM + stmt = ( + select( + CostCategory.name.label("category"), + func.sum(AssetCost.amount_gross).label("total_amount"), + func.count(AssetCost.id).label("transaction_count"), + ) + .join(CostCategory, AssetCost.category_id == CostCategory.id) + .where(AssetCost.asset_id == vehicle_id) + .group_by(CostCategory.name) + ) + result = await db.execute(stmt) + rows = result.all() + + total_cost = sum(float(row.total_amount) for row in rows) if rows else 0.0 + return { "vehicle_id": vehicle_id, - "total_cost": float(total_cost), - "breakdown": [dict(row._mapping) for row in rows] + "total_cost": total_cost, + "breakdown": [ + { + "category": row.category, + "total_amount": float(row.total_amount), + "transaction_count": row.transaction_count, + } + for row in rows + ], } + @router.get("/trends/{vehicle_id}") -async def get_monthly_trends(vehicle_id: str, db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)): +async def get_monthly_trends( + vehicle_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """ Visszaadja az utolsó 6 hónap költéseit havi bontásban. + Uses fleet_finance.asset_costs (AssetCost) with date truncation. """ - query = text(""" - SELECT - TO_CHAR(date, 'YYYY-MM') as month, - SUM(amount) as monthly_total - FROM vehicle.vehicle_expenses - WHERE vehicle_id = :v_id - GROUP BY month - ORDER BY month DESC - LIMIT 6 - """) - result = await db.execute(query, {"v_id": vehicle_id}) - return [dict(row._mapping) for row in result.fetchall()] + # Monthly aggregation using ORM-compatible date truncation + from sqlalchemy import text as sql_text + + stmt = ( + select( + func.to_char(AssetCost.date, "YYYY-MM").label("month"), + func.sum(AssetCost.amount_gross).label("monthly_total"), + ) + .where(AssetCost.asset_id == vehicle_id) + .group_by(sql_text("month")) + .order_by(desc(sql_text("month"))) + .limit(6) + ) + result = await db.execute(stmt) + rows = result.all() + + return [ + {"month": row.month, "monthly_total": float(row.monthly_total)} + for row in rows + ] + @router.get("/summary/latest") -async def get_latest_summary(db: AsyncSession = Depends(get_db), current_user = Depends(get_current_user)): +async def get_latest_summary( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): """ Returns a simple summary for the dashboard (mock data for now). This endpoint is called by the frontend dashboard. @@ -61,5 +102,5 @@ async def get_latest_summary(db: AsyncSession = Depends(get_db), current_user = "total_cost_this_month": 1250.50, "most_expensive_category": "Fuel", "trend": "down", - "trend_percentage": -5.2 - } \ No newline at end of file + "trend_percentage": -5.2, + } diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 6c08550..4c1d331 100755 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -46,6 +46,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op Segédfüggvény a UserResponse dict előállításához. 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. + P0: max_vehicles és max_garages a subscription_tier JSONB rules-ból. """ from app.models.marketplace.organization import Organization, OrganizationMember from app.models.marketplace.organization import OrgUserRole @@ -60,8 +61,6 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op active_org_id = None if active_org_id is None: - # 1. Check if user is a member of any organization with ADMIN/OWNER role - # (This is a helper - in real usage the caller should pass active_org_id) pass person = user.person @@ -110,10 +109,13 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op # ── 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 + # ── P0: Resolve subscription limits ── + # Default fallback values + max_vehicles = 1 + max_garages = 1 + return { "id": user.id, "email": user.email, @@ -124,6 +126,8 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Op "person_id": user.person_id, "role": role_key, "subscription_plan": user.subscription_plan, + "max_vehicles": max_vehicles, + "max_garages": max_garages, "scope_level": user.scope_level or "individual", "scope_id": str(active_org_id) if active_org_id else None, "ui_mode": user.ui_mode or "personal", @@ -198,9 +202,60 @@ async def read_users_me( # Check if user is the last admin in any organization is_last_admin = await AuthService.check_is_last_admin(db, current_user.id) + # ── P0: Resolve real subscription limits from the assigned tier ── + from app.models.core_logic import SubscriptionTier, OrganizationSubscription, UserSubscription + max_vehicles = 1 + max_garages = 1 + + if active_org_id is not None: + # Try org-level subscription first + org_sub_stmt = ( + select(SubscriptionTier) + .select_from(OrganizationSubscription) + .join(SubscriptionTier, OrganizationSubscription.tier_id == SubscriptionTier.id) + .where( + OrganizationSubscription.org_id == active_org_id, + OrganizationSubscription.is_active == True + ) + .order_by(OrganizationSubscription.valid_from.desc()) + .limit(1) + ) + tier = (await db.execute(org_sub_stmt)).scalar_one_or_none() + if tier and tier.rules: + allowances = tier.rules.get("allowances", {}) + max_vehicles = int(allowances.get("max_vehicles", 1)) + max_garages = int(allowances.get("max_garages", 1)) + else: + # Fallback: read base_asset_limit from Organization record + org_stmt = select(Organization).where(Organization.id == active_org_id) + org = (await db.execute(org_stmt)).scalar_one_or_none() + if org: + max_vehicles = org.base_asset_limit or 1 + max_garages = 1 # No base_garage_limit column, default to 1 + else: + # Personal mode: try user-level subscription + user_sub_stmt = ( + select(SubscriptionTier) + .select_from(UserSubscription) + .join(SubscriptionTier, UserSubscription.tier_id == SubscriptionTier.id) + .where( + UserSubscription.user_id == current_user.id, + UserSubscription.is_active == True + ) + .order_by(UserSubscription.valid_from.desc()) + .limit(1) + ) + tier = (await db.execute(user_sub_stmt)).scalar_one_or_none() + if tier and tier.rules: + allowances = tier.rules.get("allowances", {}) + max_vehicles = int(allowances.get("max_vehicles", 1)) + max_garages = int(allowances.get("max_garages", 1)) + # ── RBAC Phase 3: Build base response ── response_data = _build_user_response(current_user, active_org_id) response_data["is_last_admin"] = is_last_admin + response_data["max_vehicles"] = max_vehicles + response_data["max_garages"] = max_garages # ── RBAC Phase 3: Resolve org_capabilities ── # Get all organizations the user is a member of diff --git a/backend/app/constants/vehicle_features.py b/backend/app/constants/vehicle_features.py index 21c6c89..2c96505 100644 --- a/backend/app/constants/vehicle_features.py +++ b/backend/app/constants/vehicle_features.py @@ -214,24 +214,16 @@ MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = { "catalyst": "Katalizátor", "electric_starter": "Önindító", "all_wheel_drive": "Összkerékhajtás", - "alarm": "Riasztó", "sport_exhaust": "Sport kipufogó", "sport_air_filter": "Sport légszűrő", - "cruise_control": "Tempomat", "turbo": "Turbó", "12v_system": "12 V rendszer", "heated_grip": "Markolat fűtés", - "abs": "ABS (blokkolásgátló)", "seat_belt": "Biztonsági öv", "dtc": "DTC", - "fog_light": "Ködlámpa", - "airbag": "Légzsák", - "xenon_headlight": "Xenon fényszóró", }, "frame_body": { - "full_extra": "Full extra", "leather_seat": "Bőrülés", - "heated_seat": "Fűthető ülés", "backrest": "Háttámla", "center_stand": "Középsztender", "footrest": "Lábtartó", @@ -243,7 +235,6 @@ MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = { "crash_bar": "Bukócső / bukógomba", "hand_guards": "Kézvédők", "heated_mirror": "Fűthető tükör", - "tow_hitch": "Vonóhorog", }, "luggage": { "factory_cases": "Gyári dobozok", @@ -269,15 +260,12 @@ MOTORCYCLE_FEATURES: dict[str, dict[str, str]] = { "showroom_vehicle": "Bemutató jármű", "orderable": "Rendelhető", "car_trade_in_possible": "Autóbeszámítás lehetséges", - "first_owner": "Első tulajdonostól", - "garage_kept": "Garázsban tartott", "female_owner": "Hölgy tulajdonostól", "low_mileage": "Keveset futott", "second_owner": "Második tulajdonostól", "motorcycle_trade_in_possible": "Motorbeszámítás lehetséges", "track_fairing": "Pályaidom", "regularly_maintained": "Rendszeresen karbantartott", - "service_book": "Szervizkönyv", "title_certificate": "Törzskönyv", }, } @@ -309,53 +297,22 @@ LCV_BODY_TYPES: dict[str, str] = { LCV_FEATURES: dict[str, dict[str, str]] = { "technical": { - "abs": "ABS", "asr": "ASR (Kipörgésgátló)", - "gps_tracker": "GPS nyomkövető", "immobiliser": "Immobiliser", - "alarm": "Riasztó", - "cruise_control": "Tempomat", - "parking_sensors_rear": "Hátsó parkolóérzékelők", }, "interior": { - "curtain_airbag": "Függönylégzsák", - "rear_side_airbag": "Hátsó oldallégzsák", - "switchable_airbag": "Kikapcsolható utaslégzsák", - "side_airbag": "Oldallégzsák", - "passenger_airbag": "Utaslégzsák", - "driver_airbag": "Vezető légzsák", "roll_bar": "Bukókeret", "cargo_tie_down": "Rögzítő pontok a raktérben", - "isofix": "Isofix gyerekülés rögzítés", - "full_extra": "Full extra", "auxiliary_heating": "Kiegészítő fűtés (Webasto)", "leather_interior": "Bőr belső", - "heated_seat": "Fűthető ülés", "partition_wall": "Válaszfal", "seat_height_adjustment": "Ülésmagasság állítás", "adjustable_steering_wheel": "Állítható kormány", - "central_locking": "Központi zár", - "trip_computer": "Fedélzeti számítógép", - "power_steering": "Szervokormány", }, "exterior": { - "power_windows": "Elektromos ablakemelő", "power_mirrors": "Elektromos tükör állítás", - "heated_mirrors": "Fűthető tükör", - "alloy_wheels": "Könnyűfém felni", - "tinted_glass": "Sötétített üvegek", - "tow_bar": "Vontatóhorog", - "sunroof": "Napfénytető", - "fog_lights": "Ködlámpa", - "xenon_headlights": "Xenon fényszóró", - }, - "multimedia": { - "cd_changer": "CD váltó", - "cd_radio": "CD rádió", - "gps": "GPS navigáció", - "hifi": "Hi-Fi hangrendszer", - "radio_cassette": "Rádiós kazettás magnó", }, + "multimedia": {}, } # ────────────────────────────────────────────────────────────────────── @@ -529,20 +486,16 @@ HGV_FEATURES: dict[str, dict[str, str]] = { "engine_brake": "Motorfék (Engine Brake)", "exhaust_brake": "Kipufogófék (Exhaust Brake)", "auxiliary_brake": "Segédfék", - "abs": "ABS (Blokkolásgátló)", "ebs": "EBS (Elektronikus fékrendszer)", "esp": "ESP (Menetstabilizáló)", - "traction_control": "Kipörgésgátló (ASR/TCS)", "roll_stability": "Borulásgátló (RSP)", "stability_control": "Stabilitás szabályzó (ESP)", "hill_holder": "Hegymeneti tartó (Hill Holder)", "hill_descent": "Lejtmenetvezérlő (Hill Descent)", - "hill_descent_control": "Lejtmenetvezérlő", # ═══════════════════════════════════════════════ # 2. Vezetéstámogató rendszerek (ADAS) # ═══════════════════════════════════════════════ - "adaptive_cruise": "Adaptív tempomat (ACC)", "lane_departure": "Sávelhagyás figyelő (LDWS)", "lane_assist": "Sávtartó asszisztens", "blind_spot": "Holtpont-figyelő (Blind Spot)", @@ -552,7 +505,6 @@ HGV_FEATURES: dict[str, dict[str, str]] = { "emergency_brake_assist": "Vészfék asszisztens", "traffic_sign": "Táblafelismerő rendszer", "traffic_sign_recognition": "Táblafelismerő", - "driver_alert": "Fáradtságfigyelő (Driver Alert)", "crosswind_assist": "Keresztszél asszisztens", "tyre_pressure_monitor": "Guminyomás monitor (TPMS)", "tire_pressure": "Guminyomás-figyelő (TPMS)", @@ -563,7 +515,6 @@ HGV_FEATURES: dict[str, dict[str, str]] = { "tachograph": "Menetíró (Tachograph)", "digital_tachograph": "Digitális menetíró", "smart_tachograph": "Smart Tachográf (G2V2)", - "gps_tracker": "GPS nyomkövető", "fleet_management": "Flottamenedzsment rendszer", "fuel_monitoring": "Üzemanyag monitorozás", "fuel_card_reader": "Üzemanyagkártya-olvasó", @@ -576,10 +527,8 @@ HGV_FEATURES: dict[str, dict[str, str]] = { # 4. Látás & Kamerák # ═══════════════════════════════════════════════ "reverse_camera": "Tolatókamera", - "rear_camera": "Hátsó kamera", "front_camera": "Első kamera", "side_camera": "Oldalsó kamera", - "360_camera": "360°-os kamera", "parking_sensors": "Parkolóérzékelők", "front_parking_sensors": "Első parkolóérzékelők", "rear_parking_sensors": "Hátsó parkolóérzékelők", @@ -589,14 +538,9 @@ HGV_FEATURES: dict[str, dict[str, str]] = { # 5. Kötelező tartozékok & Egyéb # ═══════════════════════════════════════════════ - "fire_extinguisher": "Tűzoltó készülék", - "first_aid_kit": "Elsősegély csomag", - "warning_triangle": "Figyelmeztető háromszög", "wheel_chocks": "Ékek (kerékrögzítő)", - "reflective_vest": "Reflexiós mellény", "roof_hatch": "Tetőablak", "roof_ladder": "Tetőlétra", - "mudguards": "Sárvédők", "spare_wheel": "Pótkerék", "snow_chains": "Hólánc", "toolbox": "Szerszámos láda", @@ -653,9 +597,6 @@ HGV_FEATURES: dict[str, dict[str, str]] = { "seat_heating": "Ülésfűtés", "seat_ventilation": "Ülésszellőzés", "armrest": "Karfák", - "adjustable_steering": "Állítható kormány", - "electric_windows": "Elektromos ablakok", - "central_locking": "Központi zár", "storage_box": "Tároló doboz", "wardrobe": "Gardrób / ruhatároló", "bed_extension": "Ágy kihúzó panel", @@ -667,22 +608,11 @@ HGV_FEATURES: dict[str, dict[str, str]] = { # MULTIMEDIA (Multimédia) # ═══════════════════════════════════════════════════════════════ "multimedia": { - "navigation": "Navigáció", - "apple_carplay": "Apple CarPlay", - "android_auto": "Android Auto", - "bluetooth": "Bluetooth", - "usb_charging": "USB töltő", - "wireless_charging": "Vezeték nélküli töltő", - "dab_radio": "DAB+ digitális rádió", "cb_radio": "CB rádió", "satellite_radio": "Műholdas rádió", "digital_tv": "Digitális TV", "dvb_t": "DVB-T (földi digitális TV)", "dvb_s": "DVB-S (műholdas TV)", - "premium_sound": "Prémium hangrendszer", - "subwoofer": "Mélysugárzó", - "rear_entertainment": "Hátsó szórakoztató rendszer", - "wifi_hotspot": "WiFi hotspot", }, } diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 8e20c74..28bbc05 100755 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -15,8 +15,17 @@ from app.models import VehicleType, VehicleModelDefinition, FeatureDefinition # from app.models import SecurityAuditLog, OperationalLog, FinancialLedger # noqa <--- KRITIKUS! from app.models import ( # noqa - Asset, AssetCatalog, AssetCost, AssetEvent, - AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate + Asset, AssetCatalog, AssetEvent, + AssetTelemetry, AssetReview, ExchangeRate +) + +from app.models.fleet_finance import ( # noqa + AssetCost, CostCategory, AssetFinancials, + InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation +) + +from app.models.fleet import ( # noqa + OrganizationRelationship, ContactPerson ) from app.models import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger # noqa @@ -30,6 +39,6 @@ from app.models import Document # noqa from app.models import Translation # noqa from app.models.core_logic import ( # noqa - SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty + SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty ) -from app.models import PendingAction # noqa \ No newline at end of file +from app.models import PendingAction # noqa diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bd6a246..67449cb 100755 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,18 +8,24 @@ from .identity.identity import Person, User, Wallet, VerificationToken, SocialAc # 2. Szervezeti felépítés (MUST be before Address/Rating due to FK references) from .marketplace.organization import Organization, OrganizationMember, OrganizationFinancials, OrganizationSalesAssignment, OrgType, OrgUserRole, OrgRole, Branch +# 2b. B2B kapcsolatok és kapcsolattartók (fleet schema) +from .fleet import OrganizationRelationship, ContactPerson + # 3. Földrajzi adatok és címek from .identity.address import Address, GeoPostalCode, GeoStreet, GeoStreetType, Rating # 4. Jármű definíciók from .vehicle.vehicle_definitions import VehicleModelDefinition, VehicleType, FeatureDefinition, ModelFeatureMap from .reference_data import ReferenceLookup -from .vehicle.vehicle import CostCategory, VehicleCost, GbCatalogDiscovery +from .vehicle.vehicle import VehicleUserRating, GbCatalogDiscovery from .vehicle.external_reference import ExternalReferenceLibrary from .vehicle.external_reference_queue import ExternalReferenceQueue # 5. Eszközök és katalógusok -from .vehicle.asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetAssignment, AssetFinancials, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading +from .vehicle.asset import Asset, AssetCatalog, AssetEvent, AssetAssignment, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading + +# 5b. Flotta pénzügyek (fleet_finance schema - áthelyezve vehicle-ból) +from .fleet_finance import AssetCost, CostCategory, AssetFinancials, InsuranceProvider, VehicleInsurancePolicy, VehicleTaxObligation # 6. Üzleti logika és előfizetések from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty @@ -27,7 +33,6 @@ from .marketplace.payment import PaymentIntent, PaymentIntentStatus from .marketplace.finance import Issuer, IssuerType # 7. Szolgáltatások és staging -# JAVÍTVA: ServiceStaging és társai a staged_data-ból jönnek! from .marketplace.service import ServiceProfile, ExpertiseTag, ServiceExpertise from .marketplace.staged_data import ServiceStaging, DiscoveryParameter, StagedVehicleData from .marketplace.service_request import ServiceRequest @@ -38,12 +43,10 @@ from .identity.social import ServiceProvider, Vote, Competition, UserScore, Serv # 9. Rendszer, Gamification és egyebek from .gamification.gamification import PointRule, LevelConfig, UserStats, Badge, UserBadge, PointsLedger, UserContribution, Season -# --- 2.2 ÚJDONSÁG: InternalNotification hozzáadása --- from .system.system import SystemParameter, ParameterScope, InternalNotification, SystemServiceStaging from .system.document import Document from .system.translation import Translation -# Direct import from audit module from .system.audit import SecurityAuditLog, OperationalLog, ProcessLog, FinancialLedger, WalletType, LedgerStatus, LedgerEntryType from .vehicle.history import AuditLog, LogSeverity from .identity.security import PendingAction, ActionStatus @@ -67,30 +70,30 @@ ServiceRecord = AssetEvent __all__ = [ "Base", "User", "Person", "Wallet", "UserRole", "VerificationToken", "SocialAccount", "Organization", "OrganizationMember", "OrganizationSalesAssignment", "OrgType", "OrgUserRole", "OrgRole", + "OrganizationRelationship", "ContactPerson", "Asset", "AssetCatalog", "AssetCost", "AssetEvent", "AssetAssignment", "AssetFinancials", "AssetTelemetry", "AssetReview", "ExchangeRate", "CatalogDiscovery", "OdometerReading", "Address", "GeoPostalCode", "GeoStreet", "GeoStreetType", "Branch", "PointRule", "LevelConfig", "UserStats", "Badge", "UserBadge", "Rating", "PointsLedger", "UserContribution", - # --- 2.2 ÚJDONSÁG KIEGÉSZÍTÉS --- "SystemParameter", "ParameterScope", "InternalNotification", - # Social models (Social 3) "ServiceProvider", "Vote", "Competition", "UserScore", "ServiceReview", "ModerationStatus", "SourceType", "Document", "Translation", "PendingAction", "ActionStatus", "SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty", "PaymentIntent", "PaymentIntentStatus", "AuditLog", "VehicleOwnership", "LogSeverity", - "SecurityAuditLog", "OperationalLog", "ProcessLog", + "SecurityAuditLog", "OperationalLog", "ProcessLog", "FinancialLedger", "WalletType", "LedgerStatus", "LedgerEntryType", "ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter", "ServiceRequest", "Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup", "VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance", - "Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue", + "Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "ExternalReferenceLibrary", "ExternalReferenceQueue", "GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword", + "InsuranceProvider", "VehicleInsurancePolicy", "VehicleTaxObligation", # Marketing / Ad Engine "Campaign", "Creative", "Placement", "CampaignCreative", "CampaignPlacement", "AdImpression", "AdClick", "CampaignStatus", "CreativeType", "PlacementType", -] \ No newline at end of file +] diff --git a/backend/app/models/fleet/__init__.py b/backend/app/models/fleet/__init__.py new file mode 100644 index 0000000..5322d00 --- /dev/null +++ b/backend/app/models/fleet/__init__.py @@ -0,0 +1,13 @@ +# /opt/docker/dev/service_finder/backend/app/models/fleet/__init__.py +""" +fleet schema: B2B kapcsolatok és kapcsolattartók. +- OrganizationRelationship: Két szervezet közötti B2B kapcsolat (vevő, beszállító, partner) +- ContactPerson: Szervezethez tartozó kapcsolattartó személy +""" + +from .organization import OrganizationRelationship, ContactPerson + +__all__ = [ + "OrganizationRelationship", + "ContactPerson", +] diff --git a/backend/app/models/fleet/organization.py b/backend/app/models/fleet/organization.py new file mode 100644 index 0000000..406f828 --- /dev/null +++ b/backend/app/models/fleet/organization.py @@ -0,0 +1,146 @@ +# /opt/docker/dev/service_finder/backend/app/models/fleet/organization.py +""" +fleet schema: B2B kapcsolatok és kapcsolattartók. +- OrganizationRelationship: Két szervezet közötti B2B kapcsolat (vevő, beszállító, partner) +- ContactPerson: Szervezethez tartozó kapcsolattartó személy +""" + +from __future__ import annotations +from datetime import datetime +from typing import Optional, TYPE_CHECKING +from sqlalchemy import ( + String, Boolean, DateTime, ForeignKey, Numeric, Text, Integer, BigInteger, text +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from app.database import Base + +if TYPE_CHECKING: + from app.models.marketplace.organization import Organization + from app.models.identity.identity import Person + + +# ============================================================================= +# OrganizationRelationship +# ============================================================================= + +class OrganizationRelationship(Base): + """ + Két szervezet közötti B2B kapcsolat. + Lehetővé teszi a vevő-beszállító, partneri kapcsolatok nyilvántartását + anélkül, hogy a szervezet rekordokat duplikálni kellene. + """ + __tablename__ = "org_relationships" + __table_args__ = {"schema": "fleet"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + # A kapcsolat két oldala + source_org_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fleet.organizations.id"), nullable=False, index=True + ) + target_org_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fleet.organizations.id"), nullable=False, index=True + ) + + # Kapcsolat típusa + relationship_type: Mapped[str] = mapped_column( + String(30), nullable=False, comment="CUSTOMER, SUPPLIER, PARTNER" + ) + + # Státusz + status: Mapped[str] = mapped_column( + String(20), default="ACTIVE", server_default=text("'ACTIVE'") + ) + + # Szerződéses adatok + contract_ref: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + # Pénzügyi feltételek + credit_limit: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True) + payment_terms: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + + # Megjegyzések + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Időbélyegek + 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(), server_default=func.now() + ) + + # Kapcsolatok + source_org: Mapped["Organization"] = relationship( + "Organization", foreign_keys=[source_org_id] + ) + target_org: Mapped["Organization"] = relationship( + "Organization", foreign_keys=[target_org_id] + ) + + def __repr__(self) -> str: + return ( + f"OrganizationRelationship(id={self.id}, " + f"source={self.source_org_id}, target={self.target_org_id}, " + f"type='{self.relationship_type}')" + ) + + +# ============================================================================= +# ContactPerson +# ============================================================================= + +class ContactPerson(Base): + """ + Kapcsolattartó személy egy szervezethez. + A személy rekord az identity.persons táblában él, + ez a modell csak a szervezeti kapcsolatot és szerepkört tárolja. + """ + __tablename__ = "contact_persons" + __table_args__ = {"schema": "fleet"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + # Melyik szervezethez tartozik + organization_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fleet.organizations.id"), nullable=False, index=True + ) + + # Hivatkozás a személy rekordra (identity sémában) + person_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("identity.persons.id"), nullable=False, index=True + ) + + # Szerepkör a szervezetben (pl. "CEO", "Fleet Manager", "Accountant") + role: Mapped[str] = mapped_column(String(50), nullable=False) + + # Elsődleges kapcsolattartó? + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text("false")) + + # Részleg + department: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + + # Megjegyzések + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Időbélyegek + 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(), server_default=func.now() + ) + + # Kapcsolatok + organization: Mapped["Organization"] = relationship( + "Organization", foreign_keys=[organization_id] + ) + person: Mapped["Person"] = relationship( + "Person", foreign_keys=[person_id] + ) + + def __repr__(self) -> str: + return ( + f"ContactPerson(id={self.id}, org_id={self.organization_id}, " + f"person_id={self.person_id}, role='{self.role}')" + ) diff --git a/backend/app/models/fleet_finance/__init__.py b/backend/app/models/fleet_finance/__init__.py new file mode 100644 index 0000000..0234209 --- /dev/null +++ b/backend/app/models/fleet_finance/__init__.py @@ -0,0 +1,25 @@ +# /opt/docker/dev/service_finder/backend/app/models/fleet_finance/__init__.py +""" +fleet_finance schema: Flotta pénzügyi adatok (költségek, finanszírozás, biztosítás, adók). +Áthelyezve a vehicle sémából: AssetCost, CostCategory, AssetFinancials. +""" + +from .models import ( + AssetCost, + AssetCostStatusEnum, + CostCategory, + AssetFinancials, + InsuranceProvider, + VehicleInsurancePolicy, + VehicleTaxObligation, +) + +__all__ = [ + "AssetCost", + "AssetCostStatusEnum", + "CostCategory", + "AssetFinancials", + "InsuranceProvider", + "VehicleInsurancePolicy", + "VehicleTaxObligation", +] diff --git a/backend/app/models/fleet_finance/models.py b/backend/app/models/fleet_finance/models.py new file mode 100644 index 0000000..9d12b87 --- /dev/null +++ b/backend/app/models/fleet_finance/models.py @@ -0,0 +1,316 @@ +# /opt/docker/dev/service_finder/backend/app/models/fleet_finance/models.py +""" +fleet_finance schema: Flotta pénzügyi adatok. +- CostCategory: Standardizált költségkategóriák hierarchiája (áthelyezve vehicle -> fleet_finance) +- AssetCost: Eszközhöz kapcsolódó tényleges költségnapló (áthelyezve vehicle -> fleet_finance) +- AssetFinancials: Beszerzés és értékcsökkenés (áthelyezve vehicle -> fleet_finance, kibővítve finanszírozási adatokkal) +- InsuranceProvider: Biztosítók katalógusa (ÚJ) +- VehicleInsurancePolicy: Jármű biztosítási kötvények (ÚJ) +- VehicleTaxObligation: Jármű adókötelezettségek (ÚJ) +""" + +from __future__ import annotations +import uuid +import enum +from datetime import datetime, date +from typing import Optional, List, TYPE_CHECKING +from sqlalchemy import ( + String, Boolean, DateTime, ForeignKey, Numeric, text, Text, + UniqueConstraint, Integer, Date +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB +from sqlalchemy.sql import func +from app.database import Base + +if TYPE_CHECKING: + from app.models.vehicle.asset import Asset + from app.models.marketplace.organization import Organization + from app.models.system.document import Document + + +# ============================================================================= +# CostCategory (áthelyezve vehicle -> fleet_finance) +# ============================================================================= + +class CostCategory(Base): + """ + Standardizált költségkategóriák hierarchikus fája. + Rendszerkategóriák (is_system=True) nem törölhetők, csak felhasználói kategóriák. + Áthelyezve a vehicle sémából a fleet_finance sémába. + """ + __tablename__ = "cost_categories" + __table_args__ = {"schema": "fleet_finance", "extend_existing": True} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + parent_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("fleet_finance.cost_categories.id", ondelete="SET NULL"), + nullable=True, + index=True + ) + code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'") + min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'") + accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) + + # Hierarchikus kapcsolatok + parent: Mapped[Optional["CostCategory"]] = relationship( + "CostCategory", + remote_side=[id], + back_populates="children", + foreign_keys=[parent_id] + ) + children: Mapped[list["CostCategory"]] = relationship( + "CostCategory", + back_populates="parent", + foreign_keys=[parent_id] + ) + + # Kapcsolódó költségek (AssetCost) + asset_costs: Mapped[list["AssetCost"]] = relationship("AssetCost", back_populates="category") + + def __repr__(self) -> str: + return f"CostCategory(id={self.id}, code='{self.code}', name='{self.name}')" + + +# ============================================================================= +# AssetCostStatusEnum +# ============================================================================= + +class AssetCostStatusEnum(str, enum.Enum): + """Költség státuszok a jóváhagyási munkafolyamathoz.""" + DRAFT = "DRAFT" + PENDING_APPROVAL = "PENDING_APPROVAL" + APPROVED = "APPROVED" + + +# ============================================================================= +# AssetCost (áthelyezve vehicle -> fleet_finance) +# ============================================================================= + +class AssetCost(Base): + """ II. Üzemeltetés és TCO kimutatás. Áthelyezve a vehicle sémából. """ + __tablename__ = "asset_costs" + __table_args__ = {"schema": "fleet_finance"} + id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False) + organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), nullable=False) + + category_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet_finance.cost_categories.id"), index=True, 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") + date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + 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) + + # 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 + ) + + # === BESZÁLLÍTÓI HIVATKOZÁSOK (B2B expansion) === + # Hivatkozás a fleet.organizations táblában lévő beszállítóra + vendor_organization_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("fleet.organizations.id"), nullable=True, index=True + ) + # Szabadon gépelhető beszállító név (ha nincs a rendszerben) + external_vendor_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + # === KÖNYVELÉSI DÁTUMOK === + invoice_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + fulfillment_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + + data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) + asset: Mapped["Asset"] = relationship("Asset", back_populates="costs") + organization: Mapped["Organization"] = relationship("Organization", foreign_keys=[organization_id]) + category: Mapped["CostCategory"] = relationship("CostCategory", back_populates="asset_costs") + + # Kapcsolat a hivatkozott eseményhez + linked_event: Mapped[Optional["AssetEvent"]] = relationship( + "AssetEvent", + foreign_keys=[linked_asset_event_id], + post_update=True + ) + + # Beszállító kapcsolat + vendor_organization: Mapped[Optional["Organization"]] = relationship( + "Organization", foreign_keys=[vendor_organization_id] + ) + + +# ============================================================================= +# AssetFinancials (áthelyezve vehicle -> fleet_finance, kibővítve) +# ============================================================================= + +class AssetFinancials(Base): + """ + I. Beszerzés és IV. Értékcsökkenés (Amortizáció). + Áthelyezve a vehicle sémából a fleet_finance sémába. + Kibővítve finanszírozási adatokkal (lízing, hitel). + """ + __tablename__ = "asset_financials" + __table_args__ = {"schema": "fleet_finance"} + id: Mapped[int] = mapped_column(Integer, primary_key=True) + asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), unique=True) + + # === BESZERZÉS === + purchase_price_net: Mapped[float] = mapped_column(Numeric(18, 2)) + purchase_price_gross: Mapped[float] = mapped_column(Numeric(18, 2)) + vat_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=27.00) + activation_date: Mapped[Optional[datetime]] = mapped_column(DateTime) + verified_purchase_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + financing_type: Mapped[str] = mapped_column(String(50)) + accounting_details: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) + + # === FINANSZÍROZÁS (ÚJ) === + down_payment: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True) + monthly_installment: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True) + residual_value: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True) + contract_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + financing_provider: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + interest_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True) + total_contract_value: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True) + lease_start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + lease_end_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + + # === KAPCSOLATOK === + asset: Mapped["Asset"] = relationship("Asset", back_populates="financials") + + +# ============================================================================= +# InsuranceProvider (ÚJ) +# ============================================================================= + +class InsuranceProvider(Base): + """ + Biztosítók katalógusa. + Tárolja a biztosítók elérhetőségeit és szolgáltatásait. + """ + __tablename__ = "insurance_providers" + __table_args__ = {"schema": "fleet_finance"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(150), nullable=False, index=True) + country_code: Mapped[Optional[str]] = mapped_column(String(2), index=True, nullable=True, comment="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha NULL, akkor globális.") + claim_phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + claim_url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + services_offered: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default=text("true")) + + # Kapcsolatok + policies: Mapped[List["VehicleInsurancePolicy"]] = relationship( + "VehicleInsurancePolicy", back_populates="provider" + ) + + def __repr__(self) -> str: + return f"InsuranceProvider(id={self.id}, name='{self.name}')" + + +# ============================================================================= +# VehicleInsurancePolicy (ÚJ) +# ============================================================================= + +class VehicleInsurancePolicy(Base): + """ + Jármű biztosítási kötvények. + Minden kötvény egy járműhöz (asset) és egy biztosítóhoz (provider) tartozik. + """ + __tablename__ = "vehicle_insurance_policies" + __table_args__ = {"schema": "fleet_finance"} + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + asset_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True + ) + provider_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fleet_finance.insurance_providers.id"), nullable=False, index=True + ) + + insurance_type: Mapped[str] = mapped_column( + String(20), nullable=False, comment="KGFB, CASCO" + ) + policy_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + start_date: Mapped[date] = mapped_column(Date, nullable=False) + expiry_date: Mapped[date] = mapped_column(Date, nullable=False) + premium_amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="HUF") + + # Dokumentum kapcsolat (PDF kötvény) + document_id: Mapped[Optional[uuid.UUID]] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True + ) + + # Kapcsolatok + asset: Mapped["Asset"] = relationship("Asset") + provider: Mapped["InsuranceProvider"] = relationship( + "InsuranceProvider", back_populates="policies" + ) + document: Mapped[Optional["Document"]] = relationship("Document") + + def __repr__(self) -> str: + return ( + f"VehicleInsurancePolicy(id={self.id}, asset_id={self.asset_id}, " + f"type='{self.insurance_type}', policy='{self.policy_number}')" + ) + + +# ============================================================================= +# VehicleTaxObligation (ÚJ) +# ============================================================================= + +class VehicleTaxObligation(Base): + """ + Jármű adókötelezettségek. + Tárolja a különböző típusú adókat (súlyadó, cégautóadó, stb.). + """ + __tablename__ = "vehicle_tax_obligations" + __table_args__ = {"schema": "fleet_finance"} + + id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + asset_id: Mapped[uuid.UUID] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True + ) + + tax_type: Mapped[str] = mapped_column( + String(30), nullable=False, comment="WEIGHT_TAX, COMPANY_CAR_TAX" + ) + tax_year: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="HUF") + due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + payment_status: Mapped[str] = mapped_column( + String(20), default="pending", server_default=text("'pending'") + ) + + # Dokumentum kapcsolat (PDF adóbevallás) + document_id: Mapped[Optional[uuid.UUID]] = mapped_column( + PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True + ) + + # Kapcsolatok + asset: Mapped["Asset"] = relationship("Asset") + document: Mapped[Optional["Document"]] = relationship("Document") + + def __repr__(self) -> str: + return ( + f"VehicleTaxObligation(id={self.id}, asset_id={self.asset_id}, " + f"type='{self.tax_type}', year={self.tax_year})" + ) diff --git a/backend/app/models/marketplace/organization.py b/backend/app/models/marketplace/organization.py index 52efde9..180cc4e 100755 --- a/backend/app/models/marketplace/organization.py +++ b/backend/app/models/marketplace/organization.py @@ -139,6 +139,7 @@ class Organization(Base): # ── 📦 ELŐFIZETÉS / SUBSCRIPTION ── subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True) + subscription_expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1")) purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0")) @@ -208,8 +209,6 @@ class Organization(Base): # Kapcsolat az örök személy rekordhoz legal_owner: Mapped[Optional["Person"]] = relationship("Person", back_populates="owned_business_entities") - # Kapcsolat a jármű költségekhez (TCO rendszer) - vehicle_costs: Mapped[List["VehicleCost"]] = relationship("VehicleCost", back_populates="organization") class OrganizationFinancials(Base): __tablename__ = "organization_financials" diff --git a/backend/app/models/vehicle/__init__.py b/backend/app/models/vehicle/__init__.py index 798f3c4..d01343d 100644 --- a/backend/app/models/vehicle/__init__.py +++ b/backend/app/models/vehicle/__init__.py @@ -8,8 +8,6 @@ from .vehicle_definitions import ( ) from .vehicle import ( - CostCategory, - VehicleCost, VehicleUserRating, GbCatalogDiscovery, ) @@ -19,15 +17,17 @@ from .external_reference_queue import ExternalReferenceQueue from .asset import ( Asset, AssetCatalog, - AssetCost, AssetEvent, - AssetFinancials, + AssetAssignment, AssetTelemetry, AssetReview, ExchangeRate, CatalogDiscovery, VehicleOwnership, OdometerReading, + AssetEventTypeEnum, + AssetEventStatusEnum, + VehicleTransferRequest, ) from .history import AuditLog, LogSeverity @@ -40,17 +40,14 @@ __all__ = [ "VehicleType", "FeatureDefinition", "ModelFeatureMap", - "CostCategory", - "VehicleCost", "VehicleUserRating", "GbCatalogDiscovery", "ExternalReferenceLibrary", "ExternalReferenceQueue", "Asset", "AssetCatalog", - "AssetCost", "AssetEvent", - "AssetFinancials", + "AssetAssignment", "AssetTelemetry", "AssetReview", "ExchangeRate", @@ -58,8 +55,10 @@ __all__ = [ "VehicleOwnership", "AuditLog", "LogSeverity", - # --- EXPORT LISTA KIEGÉSZÍTÉSE --- + "OdometerReading", + "AssetEventTypeEnum", + "AssetEventStatusEnum", + "VehicleTransferRequest", "MotorcycleSpecs", "BodyTypeDictionary", - "OdometerReading", -] \ No newline at end of file +] diff --git a/backend/app/models/vehicle/asset.py b/backend/app/models/vehicle/asset.py index 7e5af36..caf3850 100644 --- a/backend/app/models/vehicle/asset.py +++ b/backend/app/models/vehicle/asset.py @@ -2,9 +2,9 @@ from __future__ import annotations import uuid import enum -from datetime import datetime +from datetime import datetime, date from typing import List, Optional, TYPE_CHECKING -from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float +from sqlalchemy import String, Boolean, DateTime, ForeignKey, Numeric, text, Text, UniqueConstraint, CheckConstraint, BigInteger, Integer, Float, Date from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB from sqlalchemy.sql import func @@ -129,6 +129,46 @@ class Asset(Base): is_under_warranty: Mapped[bool] = mapped_column(Boolean, default=False, server_default=text('false')) warranty_expiry_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + # === REGISTRATION DOCUMENTS === + registration_certificate_number: Mapped[Optional[str]] = mapped_column( + String(50), nullable=True, default=None, + comment="Forgalmi engedély száma" + ) + registration_certificate_validity: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, default=None, + comment="Forgalmi engedély érvényességi ideje" + ) + vehicle_registration_document_number: Mapped[Optional[str]] = mapped_column( + String(50), nullable=True, default=None, + comment="Törzskönyv száma" + ) + + # === INTERNATIONALIZATION (ÚJ) === + registration_country: Mapped[Optional[str]] = mapped_column( + String(2), nullable=True, index=True, + comment="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')" + ) + first_domestic_registration_date: Mapped[Optional[date]] = mapped_column( + Date, nullable=True, + comment="Első belföldi forgalomba helyezés dátuma" + ) + import_country: Mapped[Optional[str]] = mapped_column( + String(2), nullable=True, + comment="Importország (ISO 3166-1 alpha-2), ha importált jármű" + ) + title_document_number: Mapped[Optional[str]] = mapped_column( + String(50), nullable=True, + comment="Törzskönyv szám (title document)" + ) + engine_number: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, + comment="Motorszám" + ) + number_of_previous_owners: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, + comment="Előző tulajdonosok száma" + ) + # === SALES MODULE === is_for_sale: Mapped[bool] = mapped_column(Boolean, default=False, index=True) price: Mapped[Optional[float]] = mapped_column(Numeric(15, 2)) @@ -147,7 +187,9 @@ class Asset(Base): # --- KAPCSOLATOK --- catalog: Mapped["AssetCatalog"] = relationship("AssetCatalog", back_populates="assets") - financials: Mapped[Optional["AssetFinancials"]] = relationship("AssetFinancials", back_populates="asset", uselist=False) + financials: Mapped[Optional["AssetFinancials"]] = relationship( + "AssetFinancials", back_populates="asset", uselist=False + ) costs: Mapped[List["AssetCost"]] = relationship("AssetCost", back_populates="asset") events: Mapped[List["AssetEvent"]] = relationship("AssetEvent", back_populates="asset") logbook: Mapped[List["VehicleLogbook"]] = relationship("VehicleLogbook", back_populates="asset") @@ -229,71 +271,6 @@ class Asset(Base): return min(total_score, 100) -class AssetFinancials(Base): - """ I. Beszerzés és IV. Értékcsökkenés (Amortizáció). """ - __tablename__ = "asset_financials" - __table_args__ = {"schema": "vehicle"} - id: Mapped[int] = mapped_column(Integer, primary_key=True) - asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), unique=True) - - purchase_price_net: Mapped[float] = mapped_column(Numeric(18, 2)) - purchase_price_gross: Mapped[float] = mapped_column(Numeric(18, 2)) - vat_rate: Mapped[float] = mapped_column(Numeric(5, 2), default=27.00) - activation_date: Mapped[Optional[datetime]] = mapped_column(DateTime) - verified_purchase_date: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - financing_type: Mapped[str] = mapped_column(String(50)) - accounting_details: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb")) - - 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): - """ II. Üzemeltetés és TCO kimutatás. """ - __tablename__ = "asset_costs" - __table_args__ = {"schema": "vehicle"} - id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - asset_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False) - organization_id: Mapped[int] = mapped_column(Integer, ForeignKey("fleet.organizations.id"), 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_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") - date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - 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) - - # 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")) - asset: Mapped["Asset"] = relationship("Asset", back_populates="costs") - organization: Mapped["Organization"] = relationship("Organization") - 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): """ Útnyilvántartás (NAV, Kiküldetés, Munkábajárás). """ @@ -391,11 +368,14 @@ class OdometerReading(Base): reading: Mapped[int] = mapped_column(Integer, nullable=False) # km óra állás recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) source: Mapped[str] = mapped_column(String(30), default="manual") # manual, api, telemetry - 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("fleet_finance.asset_costs.id"), nullable=True) # Relationships asset: Mapped["Asset"] = relationship("Asset", back_populates="odometer_readings") - cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost") + cost: Mapped[Optional["AssetCost"]] = relationship( + "AssetCost", + primaryjoin="OdometerReading.cost_id == AssetCost.id" + ) class AssetAssignment(Base): @@ -443,7 +423,7 @@ class AssetEvent(Base): event_type: Mapped[str] = mapped_column(String(50), nullable=False) # AssetEventTypeEnum értékek odometer_reading: Mapped[Optional[int]] = mapped_column(Integer) # Km óra állás az eseménykor 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("fleet_finance.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) @@ -451,7 +431,7 @@ class AssetEvent(Base): # 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"), + ForeignKey("fleet_finance.asset_costs.id"), nullable=True, index=True ) @@ -511,23 +491,6 @@ class CatalogDiscovery(Base): updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) -class VehicleExpenses(Base): - """ Jármű költségek a jelentésekhez. """ - __tablename__ = "vehicle_expenses" - __table_args__ = {"schema": "vehicle"} - - id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - vehicle_id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.assets.id"), nullable=False, index=True) - category: Mapped[str] = mapped_column(String(50), nullable=False, index=True) - amount: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False) - date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) - description: Mapped[Optional[str]] = mapped_column(Text) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - # Relationship - asset: Mapped["Asset"] = relationship("Asset") - class VehicleTransferRequest(Base): """Járműátadási kérelem - asset átruházás másik tulajdonosnak vagy szervezetnek.""" diff --git a/backend/app/models/vehicle/vehicle.py b/backend/app/models/vehicle/vehicle.py index a6689cf..1f42f0d 100644 --- a/backend/app/models/vehicle/vehicle.py +++ b/backend/app/models/vehicle/vehicle.py @@ -1,8 +1,8 @@ # /opt/docker/dev/service_finder/backend/app/models/vehicle/vehicle.py """ TCO (Total Cost of Ownership) alapmodelljei a 'vehicle' sémában. -- CostCategory: Standardizált költségkategóriák hierarchiája -- VehicleCost: Járműhöz kapcsolódó tényleges költségnapló +- CostCategory és VehicleCost áthelyezve a fleet_finance sémába. +- Itt marad: VehicleUserRating, GbCatalogDiscovery """ from __future__ import annotations @@ -16,99 +16,6 @@ from sqlalchemy.sql import func from app.database import Base -class CostCategory(Base): - """ - Standardizált költségkategóriák hierarchikus fája. - Rendszerkategóriák (is_system=True) nem törölhetők, csak felhasználói kategóriák. - """ - __tablename__ = "cost_categories" - __table_args__ = {"schema": "vehicle", "extend_existing": True} - - id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) - parent_id: Mapped[Optional[int]] = mapped_column( - Integer, - ForeignKey("vehicle.cost_categories.id", ondelete="SET NULL"), - nullable=True, - index=True - ) - code: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False) - name: Mapped[str] = mapped_column(String(100), nullable=False) - description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") - visibility: Mapped[str] = mapped_column(String(20), default="both", server_default="'both'") - min_tier: Mapped[str] = mapped_column(String(20), default="free", server_default="'free'") # free, premium, enterprise - accounting_code: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) - - # Hierarchikus kapcsolatok - parent: Mapped[Optional["CostCategory"]] = relationship( - "CostCategory", - remote_side=[id], - back_populates="children", - foreign_keys=[parent_id] - ) - children: Mapped[list["CostCategory"]] = relationship( - "CostCategory", - back_populates="parent", - foreign_keys=[parent_id] - ) - - # Kapcsolódó költségek - costs: Mapped[list["VehicleCost"]] = relationship("VehicleCost", back_populates="category") - - def __repr__(self) -> str: - return f"CostCategory(id={self.id}, code='{self.code}', name='{self.name}')" - - -class VehicleCost(Base): - """ - Járműhöz kapcsolódó tényleges költségnapló. - Minden költséghez kötelező az odometer állás (km) és a dátum. - Az organization_id az Univerzális Flotta hivatkozás (fleet.organizations). - """ - __tablename__ = "costs" - __table_args__ = ( - UniqueConstraint("vehicle_id", "category_id", "date", "odometer", name="uq_cost_unique_entry"), - {"schema": "vehicle"} - ) - - id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) - vehicle_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("vehicle.vehicle_model_definitions.id", ondelete="CASCADE"), - nullable=False, - index=True - ) - organization_id: Mapped[Optional[int]] = mapped_column( - Integer, - ForeignKey("fleet.organizations.id", ondelete="SET NULL"), - nullable=True, - index=True - ) - category_id: Mapped[int] = mapped_column( - Integer, - ForeignKey("vehicle.cost_categories.id", ondelete="RESTRICT"), - nullable=False, - index=True - ) - amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False) # Összeg - currency: Mapped[str] = mapped_column(String(3), default="HUF", server_default="'HUF'") # ISO valutakód - odometer: Mapped[int] = mapped_column(Integer, nullable=False) # Kilométeróra állás (km) - date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) - notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) - - # Kapcsolatok - vehicle: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="costs") - organization: Mapped[Optional["Organization"]] = relationship("Organization", back_populates="vehicle_costs") - category: Mapped["CostCategory"] = relationship("CostCategory", back_populates="costs") - - def __repr__(self) -> str: - return f"VehicleCost(id={self.id}, vehicle_id={self.vehicle_id}, amount={self.amount} {self.currency})" - - class VehicleUserRating(Base): """ Jármű értékelési rendszer - User -> Vehicle kapcsolat. @@ -173,4 +80,4 @@ class GbCatalogDiscovery(Base): make: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) model: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) status: Mapped[str] = mapped_column(String(20), default='pending') - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) \ No newline at end of file + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/models/vehicle/vehicle_definitions.py b/backend/app/models/vehicle/vehicle_definitions.py index af93953..5d80896 100755 --- a/backend/app/models/vehicle/vehicle_definitions.py +++ b/backend/app/models/vehicle/vehicle_definitions.py @@ -12,7 +12,7 @@ from app.database import Base # Típus ellenőrzés a körkörös importok elkerülésére if TYPE_CHECKING: from .asset import AssetCatalog - from .vehicle import VehicleCost, VehicleUserRating + from .vehicle import VehicleUserRating class VehicleType(Base): """ Jármű kategóriák (pl. Személyautó, Motorkerékpár, Teherautó, Hajó) """ @@ -142,7 +142,6 @@ class VehicleModelDefinition(Base): # JAVÍTÁS: Ez a sor hiányzott az API indításához! ratings: Mapped[List["VehicleUserRating"]] = relationship("VehicleUserRating", back_populates="vehicle", cascade="all, delete-orphan") - costs: Mapped[List["VehicleCost"]] = relationship("VehicleCost", back_populates="vehicle", cascade="all, delete-orphan") class ModelFeatureMap(Base): diff --git a/backend/app/schemas/asset.py b/backend/app/schemas/asset.py index 2b1034a..94caca8 100755 --- a/backend/app/schemas/asset.py +++ b/backend/app/schemas/asset.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field, validator, root_validator, model_validator, field_validator from typing import Optional, Dict, Any, List from uuid import UUID -from datetime import datetime +from datetime import datetime, date class AssetCatalogResponse(BaseModel): """ A technikai katalógus (Master Data) teljes adattartalma. """ @@ -86,6 +86,19 @@ class AssetResponse(BaseModel): is_under_warranty: bool = Field(default=False, description="Érvényes jótállás") warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") + # === REGISTRATION DOCUMENTS === + registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma") + registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje") + vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma") + + # === INTERNATIONALIZATION (ÚJ) === + registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')") + first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma") + import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű") + title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)") + engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám") + number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma") + # === SALES MODULE === is_for_sale: bool = Field(default=False) price: Optional[float] = None @@ -228,12 +241,53 @@ class AssetCreate(BaseModel): is_under_warranty: bool = Field(default=False, description="Érvényes jótállás") warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") + # === REGISTRATION DOCUMENTS === + registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma") + registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje") + vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma") + + # === INTERNATIONALIZATION (ÚJ) === + registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')") + first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma") + import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű") + title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)") + engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám") + number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma") + # === PRIMARY VEHICLE FLAG === is_primary: bool = Field(default=False, description="Elsődleges jármű (tárolva: individual_equipment JSONB)") # === DATA STATUS (for transfer/duplicate scenarios) === data_status: Optional[str] = Field(None, description="Adat státusz (pl. 'draft' tulajdonosváltáskor)") + # ── Internationalization validators ── + @field_validator('registration_country', 'import_country') + @classmethod + def validate_iso_country(cls, v: Optional[str]) -> Optional[str]: + """registration_country és import_country: pontosan 2 karakteres nagybetűs string (ISO 3166-1 alpha-2).""" + if v is None or not isinstance(v, str): + return v + cleaned = v.strip().upper() + if len(cleaned) != 2: + raise ValueError(f'Az országkódnak pontosan 2 karakter hosszúnak kell lennie (ISO 3166-1 alpha-2), kapott: "{v}"') + return cleaned + + @field_validator('number_of_previous_owners') + @classmethod + def validate_previous_owners(cls, v: Optional[int]) -> Optional[int]: + """number_of_previous_owners >= 0.""" + if v is not None and v < 0: + raise ValueError('Az előző tulajdonosok száma nem lehet negatív') + return v + + @field_validator('first_domestic_registration_date') + @classmethod + def validate_not_future_date(cls, v: Optional[date]) -> Optional[date]: + """first_domestic_registration_date nem lehet jövőbeli dátum.""" + if v is not None and v > date.today(): + raise ValueError('Az első belföldi forgalomba helyezés dátuma nem lehet jövőbeli') + return v + # === STATUS VALIDATION === @validator('status', pre=True, always=True) def determine_status(cls, v, values): @@ -370,6 +424,47 @@ class AssetUpdate(BaseModel): is_under_warranty: Optional[bool] = Field(None, description="Érvényes jótállás") warranty_expiry_date: Optional[datetime] = Field(None, description="Jótállás érvényességi dátuma") + # === REGISTRATION DOCUMENTS === + registration_certificate_number: Optional[str] = Field(None, max_length=50, description="Forgalmi engedély száma") + registration_certificate_validity: Optional[datetime] = Field(None, description="Forgalmi engedély érvényességi ideje") + vehicle_registration_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv száma") + + # === INTERNATIONALIZATION (ÚJ) === + registration_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Regisztráció országa (ISO 3166-1 alpha-2, pl. 'HU', 'SK', 'DE')") + first_domestic_registration_date: Optional[date] = Field(None, description="Első belföldi forgalomba helyezés dátuma") + import_country: Optional[str] = Field(None, min_length=2, max_length=2, description="Importország (ISO 3166-1 alpha-2), ha importált jármű") + title_document_number: Optional[str] = Field(None, max_length=50, description="Törzskönyv szám (title document)") + engine_number: Optional[str] = Field(None, max_length=100, description="Motorszám") + number_of_previous_owners: Optional[int] = Field(None, ge=0, description="Előző tulajdonosok száma") + + # ── Internationalization validators ── + @field_validator('registration_country', 'import_country') + @classmethod + def validate_iso_country(cls, v: Optional[str]) -> Optional[str]: + """registration_country és import_country: pontosan 2 karakteres nagybetűs string (ISO 3166-1 alpha-2).""" + if v is None or not isinstance(v, str): + return v + cleaned = v.strip().upper() + if len(cleaned) != 2: + raise ValueError(f'Az országkódnak pontosan 2 karakter hosszúnak kell lennie (ISO 3166-1 alpha-2), kapott: "{v}"') + return cleaned + + @field_validator('number_of_previous_owners') + @classmethod + def validate_previous_owners(cls, v: Optional[int]) -> Optional[int]: + """number_of_previous_owners >= 0.""" + if v is not None and v < 0: + raise ValueError('Az előző tulajdonosok száma nem lehet negatív') + return v + + @field_validator('first_domestic_registration_date') + @classmethod + def validate_not_future_date(cls, v: Optional[date]) -> Optional[date]: + """first_domestic_registration_date nem lehet jövőbeli dátum.""" + if v is not None and v > date.today(): + raise ValueError('Az első belföldi forgalomba helyezés dátuma nem lehet jövőbeli') + return v + # === STATUS === current_mileage: Optional[int] = Field(None, ge=0, description="Aktuális km óra állás") is_primary: Optional[bool] = Field(None, description="Elsődleges jármű") diff --git a/backend/app/schemas/asset_cost.py b/backend/app/schemas/asset_cost.py index 9be283b..b1b05c2 100755 --- a/backend/app/schemas/asset_cost.py +++ b/backend/app/schemas/asset_cost.py @@ -1,7 +1,7 @@ # /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional, Dict, Any -from datetime import datetime +from datetime import datetime, date from decimal import Decimal from uuid import UUID @@ -34,13 +34,21 @@ class AssetCostBase(BaseModel): return data vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)") currency: str = "HUF" - date: datetime = Field(default_factory=datetime.now) + cost_date: datetime = Field(default_factory=lambda: datetime.now(), validation_alias="date", description="Költség rögzítésének dátuma/időpontja") invoice_number: Optional[str] = None data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták category_id: int # FK a CostCategory táblához mileage_at_cost: Optional[int] = None # Stored inside data JSONB description: Optional[str] = None # Stored inside data JSONB + # === BESZÁLLÍTÓI ADATOK (B2B expansion) === + vendor_organization_id: Optional[int] = Field(None, description="Beszállító szervezet ID (FK fleet.organizations)") + external_vendor_name: Optional[str] = Field(None, max_length=200, description="Szabadon gépelhető beszállító név (ha nincs a rendszerben)") + + # === KÖNYVELÉSI DÁTUMOK === + invoice_date: Optional[date] = Field(None, description="Számla kiállításának dátuma") + fulfillment_date: Optional[date] = Field(None, description="Teljesítés dátuma") + @model_validator(mode='after') def validate_gross_first(self): """Gross-First validation: ensure amount_gross is the primary source. @@ -86,5 +94,8 @@ class AssetCostResponse(AssetCostBase): category_code: Optional[str] = None # Resolved from CostCategory relationship description: Optional[str] = None # Extracted from data['description'] mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost'] + + # Enriched vendor name (resolved from vendor_organization_id relationship) + vendor_name: Optional[str] = None # Resolved from Organization.name via vendor_organization_id model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/app/schemas/fleet_finance.py b/backend/app/schemas/fleet_finance.py new file mode 100644 index 0000000..f3e21da --- /dev/null +++ b/backend/app/schemas/fleet_finance.py @@ -0,0 +1,242 @@ +# /opt/docker/dev/service_finder/backend/app/schemas/fleet_finance.py +""" +Pydantic sémák a flotta pénzügyi modelljeihez. +- AssetFinancials: Beszerzés, finanszírozás, értékcsökkenés +- InsuranceProvider: Biztosítók katalógusa +- VehicleInsurancePolicy: Jármű biztosítási kötvények +- VehicleTaxObligation: Jármű adókötelezettségek +""" +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Optional, Dict, Any, List +from uuid import UUID +from datetime import datetime, date + + +# ============================================================================= +# AssetFinancials sémák +# ============================================================================= + +class AssetFinancialsUpdate(BaseModel): + """AssetFinancials frissítése - minden mező opcionális.""" + purchase_price_net: Optional[float] = Field(None, ge=0, description="Nettó vételár") + purchase_price_gross: Optional[float] = Field(None, ge=0, description="Bruttó vételár") + vat_rate: Optional[float] = Field(None, ge=0, le=100, description="ÁFA kulcs (%)") + activation_date: Optional[datetime] = Field(None, description="Aktiválás dátuma") + verified_purchase_date: Optional[datetime] = Field(None, description="Ellenőrzött vásárlási dátum") + financing_type: Optional[str] = Field(None, max_length=50, description="Finanszírozás típusa (cash, loan, lease)") + accounting_details: Optional[Dict[str, Any]] = Field(None, description="Számviteli részletek (JSONB)") + + # Finanszírozás + down_payment: Optional[float] = Field(None, ge=0, description="Önerő") + monthly_installment: Optional[float] = Field(None, ge=0, description="Havi törlesztő") + residual_value: Optional[float] = Field(None, ge=0, description="Maradványérték") + contract_number: Optional[str] = Field(None, max_length=100, description="Szerződésszám") + financing_provider: Optional[str] = Field(None, max_length=200, description="Finanszírozó") + interest_rate: Optional[float] = Field(None, ge=0, le=100, description="Kamatláb (%)") + total_contract_value: Optional[float] = Field(None, ge=0, description="Teljes szerződéses érték") + lease_start_date: Optional[date] = Field(None, description="Lízing kezdete") + lease_end_date: Optional[date] = Field(None, description="Lízing vége") + + model_config = ConfigDict(from_attributes=True) + + +class AssetFinancialsResponse(BaseModel): + """AssetFinancials teljes válaszmodell.""" + id: int + asset_id: UUID + + # Beszerzés + purchase_price_net: float + purchase_price_gross: float + vat_rate: float + activation_date: Optional[datetime] = None + verified_purchase_date: Optional[datetime] = None + financing_type: str + accounting_details: Dict[str, Any] = Field(default_factory=dict) + + # Finanszírozás + down_payment: Optional[float] = None + monthly_installment: Optional[float] = None + residual_value: Optional[float] = None + contract_number: Optional[str] = None + financing_provider: Optional[str] = None + interest_rate: Optional[float] = None + total_contract_value: Optional[float] = None + lease_start_date: Optional[date] = None + lease_end_date: Optional[date] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================= +# InsuranceProvider sémák +# ============================================================================= + +class InsuranceProviderResponse(BaseModel): + """Biztosító teljes válaszmodell.""" + id: int + name: str + country_code: Optional[str] = Field(None, max_length=2, description="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha None, akkor globális.") + claim_phone: Optional[str] = None + claim_url: Optional[str] = None + services_offered: Dict[str, Any] = Field(default_factory=dict) + is_active: bool = True + + model_config = ConfigDict(from_attributes=True) + + +class InsuranceProviderCreate(BaseModel): + """Új biztosító létrehozása.""" + name: str = Field(..., min_length=1, max_length=150, description="Biztosító neve") + country_code: Optional[str] = Field(None, max_length=2, description="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha None, akkor globális.") + claim_phone: Optional[str] = Field(None, max_length=50, description="Kárbejelentő telefonszám") + claim_url: Optional[str] = Field(None, max_length=255, description="Kárbejelentő URL") + services_offered: Optional[Dict[str, Any]] = Field(None, description="Kínált szolgáltatások (JSONB)") + is_active: bool = Field(default=True, description="Aktív-e") + + model_config = ConfigDict(from_attributes=True) + + +class InsuranceProviderUpdate(BaseModel): + """Biztosító frissítése - minden mező opcionális.""" + name: Optional[str] = Field(None, min_length=1, max_length=150, description="Biztosító neve") + country_code: Optional[str] = Field(None, max_length=2, description="ISO 3166-1 alpha-2 országkód (pl. 'HU'). Ha None, akkor globális.") + claim_phone: Optional[str] = Field(None, max_length=50, description="Kárbejelentő telefonszám") + claim_url: Optional[str] = Field(None, max_length=255, description="Kárbejelentő URL") + services_offered: Optional[Dict[str, Any]] = Field(None, description="Kínált szolgáltatások (JSONB)") + is_active: Optional[bool] = Field(None, description="Aktív-e") + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================= +# VehicleInsurancePolicy sémák +# ============================================================================= + +class VehicleInsurancePolicyCreate(BaseModel): + """Új biztosítási kötvény létrehozása.""" + provider_id: int = Field(..., description="Biztosító ID (insurance_providers tábla)") + insurance_type: str = Field(..., max_length=20, description="Biztosítás típusa (KGFB, CASCO)") + policy_number: str = Field(..., max_length=100, description="Kötvényszám") + start_date: date = Field(..., description="Biztosítás kezdete") + expiry_date: date = Field(..., description="Biztosítás lejárata") + premium_amount: float = Field(..., ge=0, description="Díj összege") + currency: str = Field(default="HUF", max_length=3, description="Pénznem") + document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF kötvény)") + + @field_validator('insurance_type') + @classmethod + def validate_insurance_type(cls, v: str) -> str: + """insurance_type csak KGFB vagy CASCO lehet.""" + allowed = {"KGFB", "CASCO"} + cleaned = v.strip().upper() + if cleaned not in allowed: + raise ValueError(f'A biztosítás típusa csak {allowed} lehet, kapott: "{v}"') + return cleaned + + @field_validator('expiry_date') + @classmethod + def validate_expiry_after_start(cls, v: date, info): + """expiry_date nem lehet a start_date előtt.""" + values = info.data + start = values.get('start_date') + if start and v <= start: + raise ValueError('A biztosítás lejárati dátuma nem lehet a kezdő dátum előtt vagy azzal egyenlő') + return v + + model_config = ConfigDict(from_attributes=True) + + +class VehicleInsurancePolicyUpdate(BaseModel): + """Biztosítási kötvény frissítése - minden mező opcionális.""" + provider_id: Optional[int] = Field(None, description="Biztosító ID") + insurance_type: Optional[str] = Field(None, max_length=20, description="Biztosítás típusa") + policy_number: Optional[str] = Field(None, max_length=100, description="Kötvényszám") + start_date: Optional[date] = Field(None, description="Biztosítás kezdete") + expiry_date: Optional[date] = Field(None, description="Biztosítás lejárata") + premium_amount: Optional[float] = Field(None, ge=0, description="Díj összege") + currency: Optional[str] = Field(None, max_length=3, description="Pénznem") + document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF kötvény)") + + model_config = ConfigDict(from_attributes=True) + + +class VehicleInsurancePolicyResponse(BaseModel): + """Biztosítási kötvény teljes válaszmodell.""" + id: UUID + asset_id: UUID + provider_id: int + insurance_type: str + policy_number: str + start_date: date + expiry_date: date + premium_amount: float + currency: str + document_id: Optional[UUID] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================= +# VehicleTaxObligation sémák +# ============================================================================= + +class VehicleTaxObligationCreate(BaseModel): + """Új adókötelezettség létrehozása.""" + tax_type: str = Field(..., max_length=30, description="Adó típusa (WEIGHT_TAX, COMPANY_CAR_TAX)") + tax_year: int = Field(..., ge=2000, le=2100, description="Adóév") + amount: float = Field(..., ge=0, description="Adó összege") + currency: str = Field(default="HUF", max_length=3, description="Pénznem") + due_date: Optional[date] = Field(None, description="Fizetési határidő") + payment_status: str = Field(default="pending", max_length=20, description="Fizetési státusz") + document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF adóbevallás)") + + @field_validator('tax_type') + @classmethod + def validate_tax_type(cls, v: str) -> str: + """tax_type csak WEIGHT_TAX vagy COMPANY_CAR_TAX lehet.""" + allowed = {"WEIGHT_TAX", "COMPANY_CAR_TAX"} + cleaned = v.strip().upper() + if cleaned not in allowed: + raise ValueError(f'Az adó típusa csak {allowed} lehet, kapott: "{v}"') + return cleaned + + @field_validator('payment_status') + @classmethod + def validate_payment_status(cls, v: str) -> str: + """payment_status csak pending, paid, overdue lehet.""" + allowed = {"pending", "paid", "overdue"} + cleaned = v.strip().lower() + if cleaned not in allowed: + raise ValueError(f'A fizetési státusz csak {allowed} lehet, kapott: "{v}"') + return cleaned + + model_config = ConfigDict(from_attributes=True) + + +class VehicleTaxObligationUpdate(BaseModel): + """Adókötelezettség frissítése - minden mező opcionális.""" + tax_type: Optional[str] = Field(None, max_length=30, description="Adó típusa") + tax_year: Optional[int] = Field(None, ge=2000, le=2100, description="Adóév") + amount: Optional[float] = Field(None, ge=0, description="Adó összege") + currency: Optional[str] = Field(None, max_length=3, description="Pénznem") + due_date: Optional[date] = Field(None, description="Fizetési határidő") + payment_status: Optional[str] = Field(None, max_length=20, description="Fizetési státusz") + document_id: Optional[UUID] = Field(None, description="Kapcsolódó dokumentum ID (PDF adóbevallás)") + + model_config = ConfigDict(from_attributes=True) + + +class VehicleTaxObligationResponse(BaseModel): + """Adókötelezettség teljes válaszmodell.""" + id: UUID + asset_id: UUID + tax_type: str + tax_year: int + amount: float + currency: str + due_date: Optional[date] = None + payment_status: str + document_id: Optional[UUID] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/organization.py b/backend/app/schemas/organization.py index d90ebe9..f485946 100755 --- a/backend/app/schemas/organization.py +++ b/backend/app/schemas/organization.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field, ConfigDict from typing import Optional, List, Any +from datetime import datetime class ContactCreate(BaseModel): full_name: str @@ -78,7 +79,11 @@ class OrganizationResponse(BaseModel): is_active: bool = True is_deleted: bool = False subscription_plan: str = "FREE" + subscription_expires_at: Optional[datetime] = None subscription_tier_id: Optional[int] = None + # P0: Real subscription limits from the assigned subscription_tier JSONB rules + max_vehicles: int = 1 + max_garages: int = 1 visual_settings: Optional[dict] = None notification_settings: Optional[Any] = None external_integration_config: Optional[Any] = None diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index be24d66..77ad8e2 100755 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -56,6 +56,10 @@ class UserResponse(UserBase): person_id: Optional[int] = None role: str subscription_plan: str + subscription_expires_at: Optional[datetime] = None + # P0: Real subscription limits from the assigned subscription_tier JSONB rules + max_vehicles: int = 1 + max_garages: int = 1 scope_level: str scope_id: Optional[str] = None ui_mode: str = "personal" diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index 122e60e..d020830 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -1,7 +1,7 @@ # /opt/docker/dev/service_finder/backend/app/services/analytics_service.py """ TCO (Total Cost of Ownership) Analytics Service. -Számítások a vehicle.costs tábla alapján, árfolyam-átváltással a system_service segítségével. +Számítások a fleet_finance.asset_costs tábla alapján, árfolyam-átváltással a system_service segítségével. """ import logging @@ -10,7 +10,7 @@ from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models.vehicle import VehicleCost, CostCategory +from app.models.fleet_finance import AssetCost, CostCategory from app.models import VehicleModelDefinition from app.models.marketplace.organization import Organization from app.services.system_service import SystemService @@ -56,22 +56,22 @@ class TCOAnalytics: """ # Alap lekérdezés: organization_id szűrés stmt = select( - VehicleCost.amount, - VehicleCost.currency, - VehicleCost.category_id, + AssetCost.amount_net, + AssetCost.currency, + AssetCost.category_id, CostCategory.code, CostCategory.name ).join( - CostCategory, VehicleCost.category_id == CostCategory.id + CostCategory, AssetCost.category_id == CostCategory.id ).where( - VehicleCost.organization_id == organization_id + AssetCost.organization_id == organization_id ) # Dátum szűrés if start_date: - stmt = stmt.where(VehicleCost.date >= start_date) + stmt = stmt.where(AssetCost.date >= start_date) if end_date: - stmt = stmt.where(VehicleCost.date <= end_date) + stmt = stmt.where(AssetCost.date <= end_date) # Kategória szűrés if include_categories: @@ -87,7 +87,7 @@ class TCOAnalytics: category_totals = {} for row in rows: - amount = float(row.amount) + amount = float(row.amount_net) source_currency = row.currency # Átváltás célvalutára @@ -145,14 +145,14 @@ class TCOAnalytics: """ # Összes költség lekérdezése a járműhöz stmt = select( - VehicleCost.amount, - VehicleCost.currency, - VehicleCost.organization_id, + AssetCost.amount_net, + AssetCost.currency, + AssetCost.organization_id, Organization.name.label("org_name") ).outerjoin( - Organization, VehicleCost.organization_id == Organization.id + Organization, AssetCost.organization_id == Organization.id ).where( - VehicleCost.vehicle_id == vehicle_model_id + AssetCost.asset_id == vehicle_model_id ) result = await db.execute(stmt) @@ -166,7 +166,7 @@ class TCOAnalytics: owner_totals = {} for row in rows: - amount = float(row.amount) + amount = float(row.amount_net) source_currency = row.currency # Átváltás célvalutára @@ -238,23 +238,22 @@ class TCOAnalytics: """ # Alap lekérdezés: vehicle és cost összekapcsolása stmt = select( - VehicleCost.amount, - VehicleCost.currency, - VehicleCost.vehicle_id, - VehicleCost.odometer, + AssetCost.amount_net, + AssetCost.currency, + AssetCost.asset_id, CostCategory.code, VehicleModelDefinition.make, VehicleModelDefinition.model, VehicleModelDefinition.fuel_type ).join( - VehicleModelDefinition, VehicleCost.vehicle_id == VehicleModelDefinition.id + VehicleModelDefinition, AssetCost.asset_id == VehicleModelDefinition.id ).join( - CostCategory, VehicleCost.category_id == CostCategory.id + CostCategory, AssetCost.category_id == CostCategory.id ) # Szűrés if vehicle_model_id: - stmt = stmt.where(VehicleCost.vehicle_id == vehicle_model_id) + stmt = stmt.where(AssetCost.asset_id == vehicle_model_id) benchmark_type = "specific_model" else: conditions = [] @@ -295,7 +294,7 @@ class TCOAnalytics: category_counts = {} for row in rows: - amount = float(row.amount) + amount = float(row.amount_net) source_currency = row.currency # Átváltás @@ -304,11 +303,7 @@ class TCOAnalytics: ) total_cost_sum += converted_amount - vehicle_ids.add(row.vehicle_id) - - # Odometer összegzés (ha van) - if row.odometer: - total_odometer_sum += row.odometer + vehicle_ids.add(row.asset_id) # Kategória összesítés category_code = row.code @@ -322,11 +317,6 @@ class TCOAnalytics: vehicle_count = len(vehicle_ids) average_cost_per_vehicle = round(total_cost_sum / vehicle_count, 2) - # Kilométerenkénti átlag számítása - average_cost_per_km = None - if total_odometer_sum > 0: - average_cost_per_km = round(total_cost_sum / total_odometer_sum, 4) - # Kategóriánkénti átlagok category_averages = {} for code, total in category_totals.items(): @@ -342,7 +332,7 @@ class TCOAnalytics: "vehicle_count": vehicle_count, "total_cost_sum": round(total_cost_sum, 2), "average_cost_per_vehicle": average_cost_per_vehicle, - "average_cost_per_km": average_cost_per_km, + "average_cost_per_km": None, "by_category": category_averages, "currency": currency_target, "criteria": { diff --git a/backend/app/services/asset_service.py b/backend/app/services/asset_service.py index d8b6432..309b8a2 100755 --- a/backend/app/services/asset_service.py +++ b/backend/app/services/asset_service.py @@ -216,6 +216,19 @@ class AssetService: # Timeline 'year_of_manufacture': asset_data.year_of_manufacture, 'first_registration_date': asset_data.first_registration_date, + + # Registration Documents + 'registration_certificate_number': asset_data.registration_certificate_number, + 'registration_certificate_validity': asset_data.registration_certificate_validity, + 'vehicle_registration_document_number': asset_data.vehicle_registration_document_number, + + # Internationalization (ÚJ) + 'registration_country': asset_data.registration_country, + 'first_domestic_registration_date': asset_data.first_domestic_registration_date, + 'import_country': asset_data.import_country, + 'title_document_number': asset_data.title_document_number, + 'engine_number': asset_data.engine_number, + 'number_of_previous_owners': asset_data.number_of_previous_owners, } # Remove None values from the dictionary diff --git a/backend/app/services/subscription_service.py b/backend/app/services/subscription_service.py index 77ec06b..cef996e 100644 --- a/backend/app/services/subscription_service.py +++ b/backend/app/services/subscription_service.py @@ -16,7 +16,7 @@ import logging from typing import Optional, List from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from app.models.vehicle.vehicle import CostCategory +from app.models.fleet_finance import CostCategory logger = logging.getLogger(__name__) diff --git a/backend/scripts/seed_finance_dictionaries.py b/backend/scripts/seed_finance_dictionaries.py new file mode 100644 index 0000000..c738561 --- /dev/null +++ b/backend/scripts/seed_finance_dictionaries.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +""" +Seed script: fleet_finance dictionary tables (CostCategory & InsuranceProvider). + +Idempotent seeder that populates: + 1. fleet_finance.cost_categories - Hierarchical cost categories (5 parents + children) + 2. fleet_finance.insurance_providers - Base insurance providers (HU/EU) + +Usage: + docker compose exec sf_api python -m backend.scripts.seed_finance_dictionaries +""" +import asyncio +import logging +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select, text + +from app.core.config import settings +from app.models.fleet_finance.models import CostCategory, InsuranceProvider + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# ============================================================================= +# Cost Categories - Hierarchical structure +# ============================================================================= +# Format: (code, name, description, children) +# Children format: (code, name, description) + +COST_CATEGORY_TREE = [ + { + "code": "FINANCING", + "name": "Finanszírozás", + "description": "Jármű finanszírozásával kapcsolatos költségek", + "children": [ + ("FINANCING_LEASE", "Lízingdíj", "Lízingdíj fizetés"), + ("FINANCING_LOAN", "Hiteltörlesztő", "Hiteltörlesztő részlet"), + ], + }, + { + "code": "INSURANCE", + "name": "Biztosítás", + "description": "Biztosítási díjak és kötvények", + "children": [ + ("INSURANCE_KGFB", "KGFB", "Kötelező gépjármű-felelősségbiztosítás"), + ("INSURANCE_CASCO", "Casco", "Casco biztosítás"), + ("INSURANCE_ASSISTANCE", "Assistance", "Assistance szolgáltatás"), + ], + }, + { + "code": "ADMIN_FEES", + "name": "Hatósági díjak", + "description": "Hatósági és adminisztratív költségek", + "children": [ + ("ADMIN_FEES_VEHICLE_TAX", "Gépjárműadó", "Gépjárműadó fizetés"), + ("ADMIN_FEES_MOT", "Műszaki vizsga", "Műszaki vizsgadíj"), + ("ADMIN_FEES_DOCS", "Okmányok", "Okmányokkal kapcsolatos díjak"), + ], + }, + { + "code": "OPERATION", + "name": "Üzemeltetés", + "description": "Jármű üzemeltetésével kapcsolatos költségek", + "children": [ + ("OPERATION_FUEL", "Üzemanyag", "Üzemanyag költség"), + ("OPERATION_ELECTRIC", "Elektromos töltés", "Elektromos töltési költség"), + ("OPERATION_TOLL", "Autópálya", "Autópálya használati díj"), + ("OPERATION_PARKING", "Parkolás", "Parkolási díj"), + ("OPERATION_WASH", "Mosás", "Járműmosás és ápolás"), + ], + }, + { + "code": "SERVICE", + "name": "Szerviz", + "description": "Szerviz és karbantartási költségek", + "children": [ + ("SERVICE_SCHEDULED", "Kötelező szerviz", "Kötelező időszakos szerviz"), + ("SERVICE_REPAIR", "Eseti javítás", "Eseti javítási költség"), + ("SERVICE_TIRES", "Gumiabroncs", "Gumiabroncs csere és tárolás"), + ], + }, +] + +# ============================================================================= +# Insurance Providers +# ============================================================================= + +INSURANCE_PROVIDERS = [ + {"name": "Allianz Hungária Zrt.", "claim_phone": "+36-1-301-7100", "claim_url": "https://www.allianz.hu/karterites"}, + {"name": "Generali Biztosító Zrt.", "claim_phone": "+36-1-452-3200", "claim_url": "https://www.generali.hu/karterites"}, + {"name": "K&H Biztosító Zrt.", "claim_phone": "+36-1-335-3300", "claim_url": "https://www.kh.hu/biztositas/karterites"}, + {"name": "Groupama Biztosító Zrt.", "claim_phone": "+36-1-477-4800", "claim_url": "https://www.groupama.hu/karterites"}, + {"name": "Uniqa Biztosító Zrt.", "claim_phone": "+36-1-301-7300", "claim_url": "https://www.uniqa.hu/karterites"}, + {"name": "Aegon Magyarország / Alfa Vienna Insurance Group", "claim_phone": "+36-1-477-4700", "claim_url": "https://www.alfabiztosito.hu/karterites"}, + {"name": "Signal Biztosító Zrt.", "claim_phone": "+36-1-457-4400", "claim_url": "https://www.signal.hu/karterites"}, + {"name": "Wáberer Hungária Biztosító Zrt.", "claim_phone": "+36-1-237-7000", "claim_url": "https://www.whb.hu/karterites"}, + {"name": "Posta Biztosító Zrt.", "claim_phone": "+36-1-477-4900", "claim_url": "https://www.postabiztosito.hu/karterites"}, + {"name": "Magyar Posta Életbiztosító Zrt.", "claim_phone": "+36-1-477-5000", "claim_url": "https://www.mpelb.hu/karterites"}, +] + + +async def seed_cost_categories(db: AsyncSession) -> int: + """Seed cost_categories table with hierarchical data. Returns count of inserted parents.""" + inserted_count = 0 + + for parent_def in COST_CATEGORY_TREE: + # Check if parent already exists by code + existing_parent = await db.execute( + select(CostCategory).where(CostCategory.code == parent_def["code"]) + ) + parent = existing_parent.scalar_one_or_none() + + if not parent: + parent = CostCategory( + code=parent_def["code"], + name=parent_def["name"], + description=parent_def["description"], + is_system=True, + visibility="both", + min_tier="free", + parent_id=None, + ) + db.add(parent) + await db.flush() # Get the parent ID + inserted_count += 1 + logger.info(f" ✅ PARENT [{parent.code:20s}] {parent.name}") + + # Process children + for child_code, child_name, child_desc in parent_def["children"]: + existing_child = await db.execute( + select(CostCategory).where(CostCategory.code == child_code) + ) + child = existing_child.scalar_one_or_none() + + if not child: + child = CostCategory( + code=child_code, + name=child_name, + description=child_desc, + is_system=True, + visibility="both", + min_tier="free", + parent_id=parent.id, + ) + db.add(child) + await db.flush() + logger.info(f" └─ CHILD [{child.code:20s}] {child.name}") + else: + logger.info(f" └─ EXISTS [{child.code:20s}] {child.name}") + + return inserted_count + + +async def seed_insurance_providers(db: AsyncSession) -> int: + """Seed insurance_providers table. Returns count of inserted providers.""" + inserted_count = 0 + + for prov_def in INSURANCE_PROVIDERS: + existing = await db.execute( + select(InsuranceProvider).where(InsuranceProvider.name == prov_def["name"]) + ) + provider = existing.scalar_one_or_none() + + if not provider: + provider = InsuranceProvider( + name=prov_def["name"], + country_code="HU", + claim_phone=prov_def["claim_phone"], + claim_url=prov_def["claim_url"], + services_offered={"kgfb": True, "casco": True}, + is_active=True, + ) + db.add(provider) + inserted_count += 1 + logger.info(f" ✅ {provider.name} (HU)") + else: + # Update existing provider's country_code if not set + if not provider.country_code: + provider.country_code = "HU" + logger.info(f" UPDATED {provider.name} → country_code='HU'") + else: + logger.info(f" EXISTS {provider.name} (country_code='{provider.country_code}')") + + return inserted_count + + +async def main(): + """Main seeder entry point.""" + engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as db: + try: + logger.info("=" * 60) + logger.info("📦 fleet_finance dictionary seeder") + logger.info("=" * 60) + + # --- Cost Categories --- + logger.info("\n📁 Cost Categories (hierarchical):") + cat_count = await seed_cost_categories(db) + await db.commit() + logger.info(f" → {cat_count} new parent categories inserted") + + # --- Insurance Providers --- + logger.info("\n🏢 Insurance Providers:") + prov_count = await seed_insurance_providers(db) + await db.commit() + logger.info(f" → {prov_count} new providers inserted") + + # --- Summary --- + total_cats = await db.execute( + select(CostCategory).where(CostCategory.parent_id.is_(None)) + ) + parents = total_cats.scalars().all() + logger.info("\n" + "=" * 60) + logger.info("📊 SEED SUMMARY") + logger.info("=" * 60) + logger.info(f" CostCategory parents: {len(parents)}") + for p in parents: + child_count = await db.execute( + select(CostCategory).where(CostCategory.parent_id == p.id) + ) + children = child_count.scalars().all() + logger.info(f" ├─ {p.code:20s} ({p.name}) → {len(children)} children") + + total_providers = await db.execute( + select(InsuranceProvider).where(InsuranceProvider.is_active.is_(True)) + ) + providers = total_providers.scalars().all() + logger.info(f" InsuranceProviders (active): {len(providers)}") + for p in providers: + logger.info(f" ├─ {p.name}") + + logger.info("\n✅ Seeding completed successfully!") + + except Exception as e: + await db.rollback() + logger.error(f"❌ Seeding failed: {e}") + raise + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/static/locales/en.json b/backend/static/locales/en.json index bd9f93d..eb7165e 100755 --- a/backend/static/locales/en.json +++ b/backend/static/locales/en.json @@ -4,14 +4,21 @@ "SUCCESS": "Login successful. Welcome back!" }, "ERROR": { - "EMAIL_EXISTS": "This email is already registered." + "EMAIL_EXISTS": "This email is already registered.", + "UNAUTHORIZED": "Unauthorized access." } }, "SENTINEL": { "LOCK": { "MSG": "Account locked for security reasons." + }, + "APPROVAL": { + "REQUIRED": "Approval required" } }, + "COMMON": { + "SAVE_SUCCESS": "Saved successfully!" + }, "EMAIL": { "REG": { "SUBJECT": "Confirm your registration - Service Finder", @@ -72,5 +79,11 @@ "OTP_SENT": "A 6-digit verification code has been sent to the organization owner's email.", "SUCCESS": "You have successfully taken over the organization. You are now the admin." } + }, + "REGISTRATION_DOCUMENTS": { + "CERTIFICATE_NUMBER": "Registration Certificate Number", + "CERTIFICATE_VALIDITY": "Registration Certificate Validity", + "DOCUMENT_NUMBER": "Vehicle Registration Document Number", + "TITLE": "Registration Certificate & Vehicle Document" } -} \ No newline at end of file +} diff --git a/backend/static/locales/hu.json b/backend/static/locales/hu.json index 71f8bf7..a4a2769 100755 --- a/backend/static/locales/hu.json +++ b/backend/static/locales/hu.json @@ -5,7 +5,7 @@ }, "ERROR": { "EMAIL_EXISTS": "Ez az e-mail cím már foglalt.", - "UNAUTHORIZED": "Nincs jogosultságod a művelethez." + "UNAUTHORIZED": "Jogosulatlan hozzáférés" } }, "SENTINEL": { @@ -79,5 +79,11 @@ "OTP_SENT": "Egy 6-jegyű megerősítő kódot küldtünk a cég tulajdonosának email címére.", "SUCCESS": "Sikeresen átvetted a szervezet irányítását. Mostantól te vagy az admin." } + }, + "REGISTRATION_DOCUMENTS": { + "CERTIFICATE_NUMBER": "Forgalmi engedély száma", + "CERTIFICATE_VALIDITY": "Forgalmi engedély érvényessége", + "DOCUMENT_NUMBER": "Törzskönyv száma", + "TITLE": "Forgalmi engedély és Törzskönyv" } -} \ No newline at end of file +} diff --git a/docs/bmw123_vehicle_data_flow_bug_report_2026-06-20.md b/docs/bmw123_vehicle_data_flow_bug_report_2026-06-20.md new file mode 100644 index 0000000..6ddb6cc --- /dev/null +++ b/docs/bmw123_vehicle_data_flow_bug_report_2026-06-20.md @@ -0,0 +1,214 @@ +# 🐛 BMW123 Jármű Adatmegjelenítési Hibajelentés + +**Dátum:** 2026-06-20 +**Vizsgált entitás:** `bcfed768-c8c2-4867-a7bb-38a6517dce36` (BMW123 / BMW / BMWM3) +**Scope:** Backend (`asset` modell + `AssetUpdate` séma) → Frontend (`OverviewTab`, `VehicleFormModal`) +**Hibák száma:** 4 + +--- + +## 🔍 Összefoglaló + +A BMW123 rendszámú jármű adatbázisban tárolt `registration_certificate_validity` (2026-08-08) adata nem jelenik meg a frontend járműáttekintő oldalon. Emellett a `VehicleFormModal` adminisztrációs fülön beállítható mezők (`insurance_expiry_date`, `purchase_date`) értékei elvesznek a mentés során, mivel a backend Pydantic sémái és adatbázis modellje nem tartalmazza ezeket a mezőket. + +--- + +## 🐛 BUG #1 — KRITIKUS: MOT/Regisztrációs Érvényesség Eltérés + +### Adatbázis oldal +A `vehicle.assets` táblában a `registration_certificate_validity` mező **létezik** és **helyes értéket** tárol: +``` +registration_certificate_validity = 2026-08-08 00:00:00+00 +``` +Ez a mező egy **top-level DateTime** oszlop az [`Asset`](backend/app/models/vehicle/asset.py:137-140) modellben: +```python +registration_certificate_validity: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True +) +``` + +### Frontend hiba +Az [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:273-277) a `registration_certificate_validity` helyett tévesen a `individual_equipment.dates.mot_expiry` JSONB mezőből olvassa ki a műszaki érvényességet: +```typescript +const motExpiryLabel = computed(() => { + const dates = vehicle?.value?.individual_equipment?.dates + if (!dates?.mot_expiry) return t('common.noData') + return formatDate(dates.mot_expiry) +}) +``` + +### Gyökérok +A **JSONB `individual_equipment.dates`** objektum és a **top-level `registration_certificate_validity`** oszlop két különböző tárolási hely. A `VehicleFormModal` a `registration_certificate_validity` mezőt használja (helyesen), de az `OverviewTab` a JSONB-ből próbál olvasni. Mivel a `dates` objektum nem létezik a BMW123 adataiban (`individual_equipment` = `{"features": [...], "car_specs": {...}}`), mindig "Nincs adat" jelenik meg. + +### Érintett fájlok +- [`frontend/src/components/vehicles/tabs/OverviewTab.vue:273`](frontend/src/components/vehicles/tabs/OverviewTab.vue:273) — hibás olvasás +- [`frontend/src/stores/vehicle.ts:89-92`](frontend/src/stores/vehicle.ts:89) — `dates` interfész definíció +- [`backend/app/models/vehicle/asset.py:137-140`](backend/app/models/vehicle/asset.py:137) — helyes adatbázis mező + +### Javasolt javítás +Az `OverviewTab.vue`-ban a `motExpiryLabel` computed property-t át kell írni, hogy a `vehicle.value?.registration_certificate_validity`-ből olvasson, vagy alternatívaként a `VehicleFormModal` `handleSave()` függvényében a `registration_certificate_validity` értékét szinkronizálni kell a `individual_equipment.dates.mot_expiry`-be is. + +--- + +## 🐛 BUG #2 — KRITIKUS: Biztosítási és Vásárlási Adatok Elvesznek + +### Adatbázis modell hiányosság +Az [`Asset`](backend/app/models/vehicle/asset.py:69) modellben **NEM** léteznek az alábbi mezők: +- `insurance_expiry_date` (biztosítás lejárta) +- `purchase_date` (vásárlás dátuma) + +Ezek a mezők teljesen hiányoznak az adatbázis sémából. + +### Pydantic séma hiányosság +Az [`AssetUpdate`](backend/app/schemas/asset.py:269) és [`AssetCreate`](backend/app/schemas/asset.py:122) sémákban **NEM** szerepelnek: +- `insurance_expiry_date` +- `purchase_date` + +### Frontend helyzet +A [`VehicleFormModal.vue`](frontend/src/components/dashboard/VehicleFormModal.vue) adminisztrációs fül (Tab 2) tartalmazza ezeket a mezőket: +- `form.insurance_expiry_date` — "Biztosítás lejárta" dátumválasztó +- `form.purchase_date` — "Vásárlás dátuma" dátumválasztó +- `form.registration_certificate_validity` — "Forgalmi engedély érvényessége" dátumválasztó + +A [`handleSave()`](frontend/src/components/dashboard/VehicleFormModal.vue:507) függvény a payload-ba teszi ezeket: +```typescript +const payload: Record = { + ...form.value, + // ... +} +``` + +A [`populateForm()`](frontend/src/components/dashboard/VehicleFormModal.vue:440) függvény pedig a vehicle objektumból várja ezeket: +```typescript +form.value.insurance_expiry_date = vehicle.insurance_expiry_date || '' +form.value.purchase_date = vehicle.purchase_date || '' +``` + +### Gyökérok +Az [`update_vehicle`](backend/app/api/v1/endpoints/assets.py:660) endpoint `payload.model_dump(exclude_unset=True)`-t használ, ami **kizárólag** az `AssetUpdate` Pydantic sémában definiált mezőket adja át az adatbázisnak. Mivel `insurance_expiry_date` és `purchase_date` nem szerepelnek az `AssetUpdate`-ben, ezek az értékek **csendben elvesznek** a PUT kérés során. + +### Érintett fájlok +- [`backend/app/models/vehicle/asset.py:69`](backend/app/models/vehicle/asset.py:69) — hiányzó modell mezők +- [`backend/app/schemas/asset.py:269`](backend/app/schemas/asset.py:269) — hiányzó séma mezők +- [`backend/app/schemas/asset.py:122`](backend/app/schemas/asset.py:122) — hiányzó séma mezők (Create) +- [`frontend/src/components/dashboard/VehicleFormModal.vue:440-460`](frontend/src/components/dashboard/VehicleFormModal.vue:440) — frontend várja ezeket +- [`frontend/src/components/dashboard/VehicleFormModal.vue:507`](frontend/src/components/dashboard/VehicleFormModal.vue:507) — frontend küldi ezeket + +### Javasolt javítás +1. Hozzáadni az `Asset` modellhez: `insurance_expiry_date: Mapped[Optional[datetime]]` és `purchase_date: Mapped[Optional[datetime]]` +2. Hozzáadni az `AssetUpdate` és `AssetCreate` sémákhoz ezeket a mezőket +3. Futtatni a `sync_engine`-t a séma frissítéséhez + +--- + +## 🐛 BUG #3 — HIÁNYZÓ FUNKCIÓ: Nincs Dedikált Biztosítási Adat Struktúra + +### Hiányzó mezők +Sem az [`Asset`](backend/app/models/vehicle/asset.py:69) modellben, sem más kapcsolódó táblában (`asset_financials`, `asset_events`) **NEM** léteznek dedikált mezők az alábbiakra: +- **Kötelező felelősségbiztosítás** (liability insurance) + - Kötvény száma + - Lejárati dátuma + - Biztosító neve +- **CASCO biztosítás** + - Kötvény száma + - Lejárati dátuma + - Önrész összege + - Biztosító neve + +### Javasolt megoldás +Új tábla létrehozása: `vehicle.insurance_policies`: +```sql +CREATE TABLE vehicle.insurance_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + asset_id UUID NOT NULL REFERENCES vehicle.assets(id) ON DELETE CASCADE, + insurance_type VARCHAR(20) NOT NULL CHECK (insurance_type IN ('liability', 'casco')), + policy_number VARCHAR(100), + insurer_name VARCHAR(255), + start_date DATE, + expiry_date DATE, + deductible_amount DECIMAL(12,2), + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); +``` + +--- + +## 🐛 BUG #4 — KISEBB: "Következő Szerviz" Helyettesítő Szöveg + +### Hiba +Az [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:291-294) "Következő szerviz" mezője mindig "Coming Soon" szöveget mutat: + +```typescript +const nextServiceLabel = computed(() => { + // Placeholder — will be connected to the service book / maintenance schedule later + return t('vehicleDetail.comingSoon') +}) +``` + +### Érintett fájl +- [`frontend/src/components/vehicles/tabs/OverviewTab.vue:291`](frontend/src/components/vehicles/tabs/OverviewTab.vue:291) + +### Javasolt javítás +Össze kell kötni a szervizkönyv modullal. A következő szerviz esedékességét az `asset_events` táblában rögzített szerviz előzményekből kellene kiszámolni (pl. utolsó olajcsere + 15.000 km vagy + 1 év). + +--- + +## 📊 Adatfolyam Diagram (Jelenlegi Hibás Állapot) + +```mermaid +flowchart TD + DB[(PostgreSQL\nvehicle.assets)] + DB -->|registration_certificate_validity\n2026-08-08| API + + API[GET /api/v1/vehicles/{id}] + API -->|AssetResponse\nregistration_certificate_validity| FE_Store[Pinia Store\nvehicle.ts] + + FE_Store -->|vehicle.registration_certificate_validity| OverviewTab + FE_Store -->|vehicle.individual_equipment.dates.mot_expiry| OverviewTab + + subgraph OverviewTab [OverviewTab.vue - HIBA] + MOT[MOT label\nolvas: individual_equipment.dates.mot_expiry] + Service[Next Service\nComing Soon placeholder] + end + + subgraph VehicleFormModal [VehicleFormModal.vue - VESZÉLYES] + Form[Adminisztráció fül] + Form --> insurance[insurance_expiry_date] + Form --> purchase[purchase_date] + Form --> regCert[registration_certificate_validity] + + insurance -->|ELVESZ| PUT[PUT /vehicles/{id}\nexclude_unset=True] + purchase -->|ELVESZ| PUT + regCert -->|ELTÁROLVA| PUT + end + + PUT -->|AssetUpdate.model_dump\ncsak definiált mezők| DB +``` + +--- + +## ✅ Javítási Terv (Jóváhagyásra Vár) + +| # | Prioritás | Feladat | Érintett fájlok | +|---|-----------|---------|-----------------| +| 1 | **KRITIKUS** | `OverviewTab.vue` MOT olvasás javítása `registration_certificate_validity`-ből | [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:273) | +| 2 | **KRITIKUS** | `insurance_expiry_date` és `purchase_date` hozzáadása az `Asset` modellhez | [`asset.py`](backend/app/models/vehicle/asset.py:69) | +| 3 | **KRITIKUS** | `insurance_expiry_date` és `purchase_date` hozzáadása az `AssetUpdate`/`AssetCreate` sémákhoz | [`asset.py schema`](backend/app/schemas/asset.py:269) | +| 4 | **MAGAS** | Szinkronizációs logika: `registration_certificate_validity` másolása `individual_equipment.dates.mot_expiry`-be | [`assets.py endpoint`](backend/app/api/v1/endpoints/assets.py:660) | +| 5 | **KÖZEPES** | Dedikált `insurance_policies` tábla létrehozása | Új SQLAlchemy modell | +| 6 | **KÖZEPES** | Következő szerviz összekötése a szervizkönyv adatokkal | [`OverviewTab.vue`](frontend/src/components/vehicles/tabs/OverviewTab.vue:291) | + +--- + +## 📝 Technikai Megjegyzések + +- A **Sync Engine** használata kötelező az új mezők adatbázisba vezetéséhez: `docker exec sf_api python3 -m app.scripts.sync_engine` +- A JSONB `individual_equipment.dates` opcionális, nem minden járműnél van kitöltve +- A frontend `Vehicle` interfész frissítése is szükséges a [`vehicle.ts`](frontend/src/stores/vehicle.ts:9) store-ban +- A `populateForm` függvényben a hiányzó mezők miatt TypeScript hiba léphet fel, ha szigorú típusellenőrzés van + +--- + +*Jelentés készült: 2026-06-20 | Auditor: Rendszer-Architect* diff --git a/docs/cost_pipeline_audit.md b/docs/cost_pipeline_audit.md new file mode 100644 index 0000000..2a6369e --- /dev/null +++ b/docs/cost_pipeline_audit.md @@ -0,0 +1,158 @@ +# P0 Cost Pipeline Visibility Audit Report + +**Date:** 2026-06-23 +**Author:** Fast Coder (Core Developer) +**Status:** Complete +**Gitea Card:** #282 + +--- + +## Executive Summary + +The `/dashboard/costs` page is **empty** for User 28 (tester_pro@profibot.hu) despite Org 1 having **5 confirmed records** in `fleet_finance.asset_costs` (for vehicles BMW123 and QWE432). The root cause is a **backend organization resolution bug** in the `GET /expenses/` endpoint. + +--- + +## 1. Frontend Audit + +### CostsView.vue +- **File:** [`frontend/src/views/costs/CostsView.vue`](frontend/src/views/costs/CostsView.vue:299) +- Calls `api.get('/expenses', { params })` at line 299 +- **Does NOT pass any `organization_id` parameter** — only sends `page`, `page_size`, and optionally `asset_id` +- The frontend relies entirely on the backend to resolve the correct organization + +### Pinia Stores +- **`cost.ts`** ([`frontend/src/stores/cost.ts`](frontend/src/stores/cost.ts)) — Only handles POST (create expense), not fetching +- **`expense.ts`** ([`frontend/src/stores/expense.ts`](frontend/src/stores/expense.ts)) — Fetches by asset ID (`/expenses/{asset_id}`), but is NOT used by CostsView.vue + +### Data Mapping +The frontend template reads these keys from the API response: +- `cost.created_at` or `cost.date` (line 127) +- `cost.category_code` or `cost.cost_type` (line 134) +- `cost.category_name` or `cost.cost_type` (line 136) +- `cost.amount_gross` (line 143) +- `cost.currency` (line 143) +- `cost.vehicle_name` or `cost.license_plate` (line 149) +- `cost.description` (line 155) + +The backend API returns all these keys correctly (see lines 193-218 of expenses.py). **No mapping mismatch found.** + +--- + +## 2. Backend API Audit + +### Endpoint: `GET /expenses/` (list_all_expenses) +- **File:** [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py:115-239) + +### 🔴 ROOT CAUSE: Organization Resolution Bug + +At lines 130-139, the endpoint resolves the user's organization: + +```python +org_stmt = select(OrganizationMember).where( + OrganizationMember.user_id == current_user.id, + OrganizationMember.status == "active" +).limit(1) +org_result = await db.execute(org_stmt) +membership = org_result.scalar_one_or_none() +if not membership: + raise HTTPException(status_code=403, detail="No active organization membership found.") +org_id = membership.organization_id +``` + +**Problem:** This query uses `.limit(1)` without an `ORDER BY`, which means PostgreSQL returns the **first matching row by physical storage order**. For User 28, this resolves to **organization_id = 63** (membership ID 26, ADMIN role), which has **ZERO asset_costs**. + +### User 28's Memberships (in DB order): +| Membership ID | Organization ID | Role | Status | Has Costs? | +|---------------|----------------|-------|--------|------------| +| 26 | 63 | ADMIN | active | **NO** | +| 27 | 21 | OWNER | active | **NO** | +| 49 | 1 | OWNER | active | **YES (5 records)** | + +### The Fix Should Be: +The endpoint should use the user's **`active_organization_id`** (from `identity.users` table) instead of blindly picking the first membership. The `active_organization_id` is already tracked on the user profile (see [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:70) — `active_organization_id: number | null`). + +--- + +## 3. Database Verification + +### Org 1 Asset Costs (Confirmed) +| Asset ID | License Plate | Amount | Category | Status | +|----------|--------------|--------|----------|--------| +| bcfed768... | BMW123 | 69,850 | 4 (INSURANCE) | APPROVED | +| bcfed768... | BMW123 | 69,850 | 4 (INSURANCE) | APPROVED | +| c6afc130... | QWE432 | 10,000 | 1 (FUEL) | APPROVED | +| c6afc130... | QWE432 | 10,000 | 1 (FUEL) | APPROVED | +| c6afc130... | QWE432 | 5,000 | 1 (FUEL) | APPROVED | + +**Total: 5 records, all APPROVED, all in Org 1.** + +### Backend Query Test +When the exact same SQLAlchemy query is run with `organization_id = 1` explicitly, it returns **all 5 rows** with correct vehicle info (license_plate, brand, model). The data pipeline from DB → ORM → API response is **fully functional**. + +--- + +## 4. Data Flow Summary + +``` +CostsView.vue + └─ GET /expenses (no org_id param) + └─ Backend resolves org via first active membership + └─ Finds org_id = 63 (User 28's first membership) + └─ Query: AssetCost.organization_id == 63 + └─ Returns 0 rows → Empty table +``` + +**Expected flow:** +``` +CostsView.vue + └─ GET /expenses (no org_id param) + └─ Backend resolves org via active_organization_id + └─ Finds org_id = 1 (User 28's active org) + └─ Query: AssetCost.organization_id == 1 + └─ Returns 5 rows → Table populated +``` + +--- + +## 5. Recommended Fix + +### Immediate Fix (Backend) +Modify [`list_all_expenses()`](backend/app/api/v1/endpoints/expenses.py:130-139) to use the user's `active_organization_id` instead of the first active membership: + +```python +# Use active_organization_id from user profile +org_id = current_user.active_organization_id +if not org_id: + # Fallback: try to find any active membership + org_stmt = select(OrganizationMember).where( + OrganizationMember.user_id == current_user.id, + OrganizationMember.status == "active" + ).limit(1) + org_result = await db.execute(org_stmt) + membership = org_result.scalar_one_or_none() + if not membership: + raise HTTPException(status_code=403, detail="No active organization membership found.") + org_id = membership.organization_id +``` + +### Alternative Fix (Frontend) +Pass `organization_id` explicitly in the API call from CostsView.vue, reading it from the auth store's `active_organization_id`. However, this is less robust as it requires every API consumer to know about org switching. + +### Recommended Approach +**Fix the backend** — it's the single source of truth for organization resolution and will fix the issue for ALL frontend components that call `GET /expenses/`. + +--- + +## 6. Files Touched During Audit + +| File | Purpose | +|------|---------| +| [`frontend/src/views/costs/CostsView.vue`](frontend/src/views/costs/CostsView.vue) | Frontend costs page — calls GET /expenses | +| [`frontend/src/stores/cost.ts`](frontend/src/stores/cost.ts) | Pinia store for creating expenses | +| [`frontend/src/stores/expense.ts`](frontend/src/stores/expense.ts) | Pinia store for per-asset expense fetching | +| [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts) | Auth store — has `active_organization_id` | +| [`backend/app/api/v1/endpoints/expenses.py`](backend/app/api/v1/endpoints/expenses.py) | **Backend endpoint with the bug** (lines 130-139) | +| `fleet_finance.asset_costs` | Database table — 5 records confirmed for Org 1 | +| `fleet.organization_members` | User 28 has 3 memberships (org 63, 21, 1) | +| `identity.users` | User 28 has `scope_id = None`, `scope_level = 'individual'` | diff --git a/docs/cost_ui_audit.md b/docs/cost_ui_audit.md new file mode 100644 index 0000000..975b81e --- /dev/null +++ b/docs/cost_ui_audit.md @@ -0,0 +1,177 @@ +# Cost UI Audit Report — Dashboard Cost Actions & Existing Cost Forms + +**Date:** 2026-06-21 +**Author:** Fast Coder (Core Developer) +**Scope:** P0 READ & PLAN — Dual-Entry Cost System UX Audit +**Status:** Audit Complete — No code modified + +--- + +## 1. Dashboard Cost Actions Card (`CostsActionsCard.vue`) + +**File:** [`frontend/src/components/dashboard/CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue) + +### Current Template Structure + +The card is a 350px-tall bento-grid tile with: + +| Element | Description | +|---------|-------------| +| **Header** | Dark slate bar with `💰` icon + `t('dashboard.costsTitle')` label | +| **Vehicle Selector** | `` cascade | ✅ (if not fee mode) | +| Sub Category | `