frontend költség beállítások

This commit is contained in:
Roo
2026-06-23 21:11:21 +00:00
parent 5b437b220d
commit 71ef33bb85
90 changed files with 11850 additions and 1053 deletions

View File

@@ -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"
)
)
# =============================================================================
# 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()

View File

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

View File

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

View File

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

View File

@@ -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
}
"trend_percentage": -5.2,
}

View File

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

View File

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

View File

@@ -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
from app.models import PendingAction # noqa

View File

@@ -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",
]
]

View File

@@ -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",
]

View File

@@ -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}')"
)

View File

@@ -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",
]

View File

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

View File

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

View File

@@ -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",
]
]

View File

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

View File

@@ -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())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

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

View File

@@ -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ű")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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