egyedi jármű szerkesztés előtti mentés
This commit is contained in:
@@ -4,7 +4,7 @@ from app.api.v1.endpoints import (
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||
gamification, translations, users, reports
|
||||
gamification, translations, users, reports, dictionaries
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -28,4 +28,5 @@ api_router.include_router(system_parameters.router, prefix="/system/parameters",
|
||||
api_router.include_router(gamification.router, prefix="/gamification", tags=["Gamification"])
|
||||
api_router.include_router(translations.router, prefix="/translations", tags=["i18n"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["Users"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
||||
api_router.include_router(reports.router, prefix="/reports", tags=["Reports"])
|
||||
api_router.include_router(dictionaries.router, prefix="/dictionaries", tags=["Dictionaries"])
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, text, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -17,11 +18,81 @@ from app.services.asset_service import AssetService
|
||||
from app.schemas.asset_cost import AssetCostCreate, AssetCostResponse
|
||||
from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate
|
||||
|
||||
|
||||
class ArchiveVehicleRequest(BaseModel):
|
||||
"""Payload a jármű archiválásához (Soft Delete)."""
|
||||
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
|
||||
archive_reason: str = Field(..., min_length=1, max_length=100, description="Eltávolítás oka")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/vehicles/quota-status")
|
||||
async def get_vehicle_quota_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Preemptive quota check endpoint for the frontend.
|
||||
Returns whether the user can add a new vehicle BEFORE they start the wizard.
|
||||
|
||||
GET /api/v1/assets/vehicles/quota-status
|
||||
|
||||
Returns:
|
||||
{ "can_add": bool, "current_count": int, "limit": int }
|
||||
"""
|
||||
try:
|
||||
# Determine organization ID based on user's active scope (garage isolation)
|
||||
org_id = None
|
||||
if current_user.scope_id is not None:
|
||||
try:
|
||||
org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Get the user's vehicle limit
|
||||
allowed_limit = await AssetService.get_user_vehicle_limit(
|
||||
db, current_user.id, org_id
|
||||
)
|
||||
# SAFETY: Ensure limit is at least 1
|
||||
allowed_limit = max(allowed_limit or 1, 1)
|
||||
|
||||
# Count only active vehicles (draft vehicles don't count toward the limit)
|
||||
from sqlalchemy import func
|
||||
if org_id is not None:
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id == org_id,
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
else:
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id.is_(None),
|
||||
Asset.owner_person_id == current_user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
current_count = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
can_add = current_count < allowed_limit
|
||||
|
||||
logger.info(
|
||||
f"Quota check for user {current_user.id} (org={org_id}): "
|
||||
f"current_count={current_count}, allowed_limit={allowed_limit}, can_add={can_add}"
|
||||
)
|
||||
|
||||
return {
|
||||
"can_add": can_add,
|
||||
"current_count": current_count,
|
||||
"limit": allowed_limit
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Quota status check error for user {current_user.id}: {e}")
|
||||
# Fail open — if we can't check quota, allow the user to proceed
|
||||
return {"can_add": True, "current_count": 0, "limit": 9999}
|
||||
|
||||
|
||||
@router.get("/vehicles/check")
|
||||
async def check_vehicle_exists(
|
||||
license_plate: str,
|
||||
@@ -286,14 +357,42 @@ async def create_or_claim_vehicle(
|
||||
f"Setting data_status='draft' for transfer scenario."
|
||||
)
|
||||
|
||||
# Determine organization ID based on user's active scope (garage isolation)
|
||||
org_id = None
|
||||
if current_user.scope_id is not None:
|
||||
# ── 🛡️ KETTŐS VÉDELEM: Szervezet hozzárendelés ──
|
||||
# 1. PRIORITÁS: A frontend által küldött organization_id (ha van)
|
||||
# 2. FALLBACK: A felhasználó aktív scope-ja (scope_id)
|
||||
# 3. VASÁJTÓ: Ha egyik sincs, lekérdezzük a fleet.organization_members táblából
|
||||
org_id = payload.organization_id
|
||||
|
||||
if org_id is None and current_user.scope_id is not None:
|
||||
try:
|
||||
org_id = int(current_user.scope_id)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# VASÁJTÓ FALLBACK: Ha még mindig nincs org_id, keressük meg a felhasználó
|
||||
# első elérhető szervezetét a fleet.organization_members táblában
|
||||
if org_id is None:
|
||||
from app.models.marketplace.organization import OrganizationMember
|
||||
from sqlalchemy import select
|
||||
member_stmt = (
|
||||
select(OrganizationMember.organization_id)
|
||||
.where(OrganizationMember.user_id == current_user.id)
|
||||
.limit(1)
|
||||
)
|
||||
member_result = await db.execute(member_stmt)
|
||||
member_row = member_result.first()
|
||||
if member_row:
|
||||
org_id = member_row[0]
|
||||
logger.info(
|
||||
f"Iron Door fallback: assigned org_id={org_id} from "
|
||||
f"organization_members for user {current_user.id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Iron Door fallback: user {current_user.id} has no "
|
||||
f"organization memberships — asset will be created without org"
|
||||
)
|
||||
|
||||
asset = await AssetService.create_or_claim_vehicle(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
@@ -591,4 +690,46 @@ async def create_maintenance_record(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal server error while creating maintenance record"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/vehicles/{vehicle_id}/archive", response_model=AssetResponse)
|
||||
async def archive_vehicle(
|
||||
vehicle_id: uuid.UUID,
|
||||
payload: ArchiveVehicleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Jármű biztonságos kivezetése (Strict Soft Delete).
|
||||
|
||||
POST /api/v1/assets/vehicles/{vehicle_id}/archive
|
||||
|
||||
A végpont:
|
||||
1. Frissíti a jármű current_mileage értékét a megadott final_mileage-ra
|
||||
2. Átállítja a státuszt 'archived'-ra
|
||||
3. Nullázza a tulajdonosi mezőket (owner_person_id, owner_org_id)
|
||||
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
|
||||
5. Naplózza a biztonsági auditba
|
||||
|
||||
Payload:
|
||||
- final_mileage: int (utolsó km óra állás)
|
||||
- archive_reason: string (Eladás, Gazdasági totálkár / Bontás, Lízing/Bérlet lejárta, Téves rögzítés, Egyéb)
|
||||
"""
|
||||
try:
|
||||
asset = await AssetService.archive_vehicle(
|
||||
db=db,
|
||||
asset_id=vehicle_id,
|
||||
user_id=current_user.id,
|
||||
final_mileage=payload.final_mileage,
|
||||
archive_reason=payload.archive_reason,
|
||||
)
|
||||
return asset
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vehicle archive error for {vehicle_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Belső szerverhiba a jármű archiválásakor"
|
||||
)
|
||||
@@ -1,8 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.db.session import get_db
|
||||
from app.services.asset_service import AssetService
|
||||
from app.api import deps
|
||||
from app.models.vehicle import BodyTypeDictionary
|
||||
from typing import List, Optional
|
||||
|
||||
router = APIRouter()
|
||||
@@ -74,4 +76,28 @@ async def list_engines(
|
||||
"fuel_type": e.fuel_type,
|
||||
"factory_data": e.factory_data
|
||||
} for e in engines
|
||||
]
|
||||
|
||||
|
||||
# Secured endpoint: Closed premium ecosystem
|
||||
@router.get("/body-types")
|
||||
async def list_body_types(
|
||||
vehicle_class: Optional[str] = Query(None, description="Szűrés járműosztályra (pl. personal, motorcycle, light_commercial)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""Karosszéria-típus szótár lekérése. Opcionálisan szűrhető vehicle_class alapján."""
|
||||
stmt = select(BodyTypeDictionary).order_by(BodyTypeDictionary.name_hu)
|
||||
if vehicle_class:
|
||||
stmt = stmt.where(BodyTypeDictionary.vehicle_class == vehicle_class)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"vehicle_class": r.vehicle_class,
|
||||
"code": r.code,
|
||||
"name_hu": r.name_hu,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
68
backend/app/api/v1/endpoints/dictionaries.py
Normal file
68
backend/app/api/v1/endpoints/dictionaries.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/dictionaries.py
|
||||
"""
|
||||
Szótárak és katalógusok végpontjai.
|
||||
- GET /dictionaries/cost-categories: Költségkategóriák lekérése visibility szerint szűrve
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.db.session import get_db
|
||||
from app.api import deps
|
||||
from app.models.vehicle import CostCategory
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/cost-categories")
|
||||
async def list_cost_categories(
|
||||
visibility: Optional[str] = Query(None, description="Szűrés láthatóságra: b2c, b2b, both, internal"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user = Depends(deps.get_current_user)
|
||||
):
|
||||
"""
|
||||
Költségkategóriák lekérése hierarchikus struktúrában.
|
||||
Opcionálisan szűrhető visibility alapján (pl. ha a frontend csak a 'b2c' és 'both' kategóriákat akarja).
|
||||
"""
|
||||
stmt = select(CostCategory).order_by(CostCategory.name)
|
||||
|
||||
if visibility:
|
||||
# Ha 'b2c' van megadva, akkor a 'b2c' ÉS 'both' láthatóságú kategóriákat adjuk vissza
|
||||
if visibility == "b2c":
|
||||
stmt = stmt.where(CostCategory.visibility.in_(["b2c", "both"]))
|
||||
elif visibility == "b2b":
|
||||
stmt = stmt.where(CostCategory.visibility.in_(["b2b", "both"]))
|
||||
else:
|
||||
stmt = stmt.where(CostCategory.visibility == visibility)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
categories = result.scalars().all()
|
||||
|
||||
# Hierarchikus struktúra építése
|
||||
category_map = {}
|
||||
root_categories = []
|
||||
|
||||
for cat in categories:
|
||||
cat_dict = {
|
||||
"id": cat.id,
|
||||
"code": cat.code,
|
||||
"name": cat.name,
|
||||
"description": cat.description,
|
||||
"parent_id": cat.parent_id,
|
||||
"visibility": cat.visibility,
|
||||
"accounting_code": cat.accounting_code,
|
||||
"is_system": cat.is_system,
|
||||
"children": [],
|
||||
}
|
||||
category_map[cat.id] = cat_dict
|
||||
|
||||
for cat in categories:
|
||||
cat_dict = category_map[cat.id]
|
||||
if cat.parent_id and cat.parent_id in category_map:
|
||||
category_map[cat.parent_id]["children"].append(cat_dict)
|
||||
else:
|
||||
root_categories.append(cat_dict)
|
||||
|
||||
return root_categories
|
||||
@@ -1,4 +1,5 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/evidence.py
|
||||
import logging
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, text
|
||||
@@ -6,18 +7,25 @@ from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User
|
||||
from app.models import Asset # JAVÍTVA: Asset modell
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/scan-registration")
|
||||
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
|
||||
res = await db.execute(stmt_limit, {"plan": current_user.subscription_plan or "free"})
|
||||
max_allowed = res.scalar() or 1
|
||||
plan_key = (current_user.subscription_plan or "free").lower()
|
||||
res = await db.execute(stmt_limit, {"plan": plan_key})
|
||||
max_allowed = max(res.scalar() or 1, 1)
|
||||
|
||||
stmt_count = select(func.count(Asset.id)).where(Asset.owner_organization_id == current_user.scope_id)
|
||||
stmt_count = select(func.count(Asset.id)).where(
|
||||
Asset.owner_org_id == current_user.scope_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
count = (await db.execute(stmt_count)).scalar() or 0
|
||||
|
||||
if count >= max_allowed:
|
||||
logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {current_user.person_id}, Active Count: {count}, Limit: {max_allowed}")
|
||||
raise HTTPException(status_code=403, detail=f"Limit túllépés: {max_allowed} jármű engedélyezett.")
|
||||
|
||||
# OCR hívás helye...
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models import Asset, AssetCost, SystemParameter
|
||||
from app.models import Asset, AssetCost, OrganizationMember, SystemParameter
|
||||
from app.schemas.asset_cost import AssetCostCreate
|
||||
from datetime import datetime
|
||||
|
||||
@@ -53,13 +53,30 @@ async def create_expense(
|
||||
detail=f"DRAFT_LIMIT_REACHED: Draft vehicles are limited to {limit} expenses. This asset already has {expense_count} expenses."
|
||||
)
|
||||
|
||||
# Determine organization_id from asset (required by AssetCost model)
|
||||
organization_id = asset.current_organization_id or asset.owner_org_id
|
||||
# Determine organization_id: prefer explicit from payload, fallback to asset fields
|
||||
organization_id = expense.organization_id or asset.current_organization_id or asset.owner_org_id
|
||||
if not organization_id:
|
||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||
|
||||
# Map cost_type to cost_category (AssetCost uses cost_category)
|
||||
cost_category = expense.cost_type
|
||||
# B2C fallback: if the asset is owned by a person (not an org),
|
||||
# try to find the current user's default organization
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id,
|
||||
OrganizationMember.is_verified == True
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
# Last resort: any membership (even unverified)
|
||||
org_stmt = select(OrganizationMember).where(
|
||||
OrganizationMember.user_id == current_user.id
|
||||
).limit(1)
|
||||
org_result = await db.execute(org_stmt)
|
||||
org_member = org_result.scalar_one_or_none()
|
||||
if org_member:
|
||||
organization_id = org_member.organization_id
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
|
||||
|
||||
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
|
||||
data = expense.data.copy() if expense.data else {}
|
||||
@@ -72,7 +89,7 @@ async def create_expense(
|
||||
new_cost = AssetCost(
|
||||
asset_id=expense.asset_id,
|
||||
organization_id=organization_id,
|
||||
cost_category=cost_category,
|
||||
category_id=expense.category_id,
|
||||
amount_net=expense.amount_local,
|
||||
currency=expense.currency_local,
|
||||
date=expense.date,
|
||||
@@ -88,7 +105,7 @@ async def create_expense(
|
||||
"status": "success",
|
||||
"id": new_cost.id,
|
||||
"asset_id": new_cost.asset_id,
|
||||
"cost_category": new_cost.cost_category,
|
||||
"category_id": new_cost.category_id,
|
||||
"amount_net": new_cost.amount_net,
|
||||
"date": new_cost.date
|
||||
}
|
||||
@@ -4,6 +4,7 @@ from .vehicle_definitions import (
|
||||
VehicleType,
|
||||
FeatureDefinition,
|
||||
ModelFeatureMap,
|
||||
BodyTypeDictionary,
|
||||
)
|
||||
|
||||
from .vehicle import (
|
||||
@@ -60,4 +61,5 @@ __all__ = [
|
||||
"LogSeverity",
|
||||
# --- EXPORT LISTA KIEGÉSZÍTÉSE ---
|
||||
"MotorcycleSpecs",
|
||||
"BodyTypeDictionary",
|
||||
]
|
||||
@@ -244,7 +244,7 @@ class AssetCost(Base):
|
||||
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)
|
||||
|
||||
cost_category: Mapped[str] = mapped_column(String(50), index=True)
|
||||
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)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -253,6 +253,7 @@ class AssetCost(Base):
|
||||
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")
|
||||
|
||||
|
||||
class VehicleLogbook(Base):
|
||||
|
||||
@@ -12,10 +12,10 @@ from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
|
||||
class LogSeverity(str, enum.Enum):
|
||||
info = "info"
|
||||
warning = "warning"
|
||||
critical = "critical"
|
||||
emergency = "emergency"
|
||||
info = "INFO"
|
||||
warning = "WARNING"
|
||||
critical = "CRITICAL"
|
||||
emergency = "EMERGENCY"
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
|
||||
@@ -26,7 +26,7 @@ class CostCategory(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
Integer,
|
||||
ForeignKey("vehicle.cost_categories.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True
|
||||
@@ -35,6 +35,8 @@ class CostCategory(Base):
|
||||
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'")
|
||||
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())
|
||||
|
||||
|
||||
@@ -153,4 +153,20 @@ class ModelFeatureMap(Base):
|
||||
is_standard: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
model_definition: Mapped["VehicleModelDefinition"] = relationship("VehicleModelDefinition", back_populates="feature_maps")
|
||||
feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")
|
||||
feature: Mapped["FeatureDefinition"] = relationship("FeatureDefinition", back_populates="model_maps")
|
||||
|
||||
|
||||
class BodyTypeDictionary(Base):
|
||||
""" Szabványos karosszéria-típus szótár (BodyType Dictionary).
|
||||
A szabad szöveges body_type mező normalizálásához.
|
||||
"""
|
||||
__tablename__ = "dict_body_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint('vehicle_class', 'code', name='uix_body_type_class_code'),
|
||||
{"schema": "vehicle"}
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
vehicle_class: Mapped[str] = mapped_column(String(50), index=True, nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name_hu: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
@@ -19,7 +19,8 @@ class AssetCostBase(BaseModel):
|
||||
|
||||
class AssetCostCreate(AssetCostBase):
|
||||
asset_id: UUID
|
||||
organization_id: int
|
||||
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
|
||||
category_id: int # FK a CostCategory táblához
|
||||
|
||||
class AssetCostResponse(AssetCostBase):
|
||||
id: UUID
|
||||
|
||||
93
backend/app/scripts/seed_cost_category_visibility.py
Normal file
93
backend/app/scripts/seed_cost_category_visibility.py
Normal file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed script to update CostCategory visibility and accounting_code values.
|
||||
Also drops the old 'cost_category' column from asset_costs table.
|
||||
|
||||
Usage: docker compose exec sf_api python -m app.scripts.seed_cost_category_visibility
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Visibility rules:
|
||||
# - FUEL, MAINTENANCE, SERVICE, TIRE, INSURANCE, PARKING, TOLL, OTHER -> 'both'
|
||||
# - FINANCE, ADMIN -> 'b2b'
|
||||
|
||||
VISIBILITY_MAP = {
|
||||
"FUEL": "both",
|
||||
"MAINTENANCE": "both",
|
||||
"SERVICE": "both",
|
||||
"TIRE": "both",
|
||||
"INSURANCE": "both",
|
||||
"PARKING": "both",
|
||||
"TOLL": "both",
|
||||
"OTHER": "both",
|
||||
"FINANCE": "b2b",
|
||||
"ADMIN": "b2b",
|
||||
}
|
||||
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
# 1. Update visibility for existing categories
|
||||
logger.info("🔄 Updating visibility for existing cost categories...")
|
||||
for code, visibility in VISIBILITY_MAP.items():
|
||||
result = await session.execute(
|
||||
text("""
|
||||
UPDATE vehicle.cost_categories
|
||||
SET visibility = :visibility
|
||||
WHERE code = :code AND (visibility IS NULL OR visibility = 'both')
|
||||
"""),
|
||||
{"code": code, "visibility": visibility}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f" ✅ {code} -> {visibility}")
|
||||
|
||||
# 2. Set default visibility for any categories without it
|
||||
result = await session.execute(
|
||||
text("""
|
||||
UPDATE vehicle.cost_categories
|
||||
SET visibility = 'both'
|
||||
WHERE visibility IS NULL
|
||||
""")
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
logger.info(f" ✅ Set default 'both' for {result.rowcount} categories")
|
||||
|
||||
# 3. Drop the old cost_category column from asset_costs
|
||||
logger.info("🔄 Dropping old 'cost_category' column from asset_costs...")
|
||||
|
||||
# Check if the column still exists
|
||||
check = await session.execute(
|
||||
text("""
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_schema = 'vehicle'
|
||||
AND table_name = 'asset_costs'
|
||||
AND column_name = 'cost_category'
|
||||
""")
|
||||
)
|
||||
if check.scalar():
|
||||
await session.execute(
|
||||
text("ALTER TABLE vehicle.asset_costs DROP COLUMN cost_category")
|
||||
)
|
||||
logger.info(" ✅ Dropped old 'cost_category' column from vehicle.asset_costs")
|
||||
else:
|
||||
logger.info(" ⏭️ Column 'cost_category' already removed")
|
||||
|
||||
await session.commit()
|
||||
logger.info("✅ All updates completed successfully!")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
114
backend/app/scripts/seed_cost_subcategories.py
Normal file
114
backend/app/scripts/seed_cost_subcategories.py
Normal file
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed script to insert cost subcategories (hierarchy) under existing parent categories.
|
||||
|
||||
Usage:
|
||||
docker compose exec sf_api python -m app.scripts.seed_cost_subcategories
|
||||
|
||||
Subcategories added:
|
||||
- FUEL (ID=1): FUEL_PETROL (Benzin), FUEL_DIESEL (Dízel), FUEL_ELECTRIC (Elektromos töltés), FUEL_LPG (LPG)
|
||||
- MAINTENANCE (ID=2): MAINT_OIL (Olajcsere), MAINT_SERVICE (Kötelező szerviz), MAINT_BRAKES (Fékcsere)
|
||||
- FEES (ID=6): FEE_HIGHWAY (Autópálya matrica), FEE_STREET_PARKING (Közterületi parkolás), FEE_GARAGE (Parkolóház)
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Subcategory definitions: (parent_code, code, name, description, visibility)
|
||||
SUBCATEGORIES = [
|
||||
# FUEL (parent_id=1)
|
||||
("FUEL", "FUEL_PETROL", "Benzin", "Benzin üzemanyag költség", "both"),
|
||||
("FUEL", "FUEL_DIESEL", "Dízel", "Dízel üzemanyag költség", "both"),
|
||||
("FUEL", "FUEL_ELECTRIC", "Elektromos töltés", "Elektromos jármű töltési költség", "both"),
|
||||
("FUEL", "FUEL_LPG", "LPG", "LPG (autógáz) üzemanyag költség", "both"),
|
||||
|
||||
# MAINTENANCE (parent_id=2)
|
||||
("MAINTENANCE", "MAINT_SERVICE", "Kötelező szerviz", "Gyártó által előírt időszakos szerviz", "both"),
|
||||
("MAINTENANCE", "MAINT_OIL", "Olajcsere", "Motorolaj és szűrő csere", "both"),
|
||||
("MAINTENANCE", "MAINT_BRAKES", "Fékcsere", "Fékbetét, féktárcsa csere", "both"),
|
||||
|
||||
# FEES (parent_id=6)
|
||||
("FEES", "FEE_HIGHWAY", "Autópálya matrica", "Országos autópálya használati díj", "both"),
|
||||
("FEES", "FEE_STREET_PARKING", "Közterületi parkolás", "Utcai parkolóóra / parkolási díj", "both"),
|
||||
("FEES", "FEE_GARAGE", "Parkolóház", "Mélygarázs vagy parkolóház díja", "both"),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
engine = create_async_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
# Fetch parent categories map
|
||||
result = await session.execute(
|
||||
text("SELECT id, code FROM vehicle.cost_categories WHERE parent_id IS NULL")
|
||||
)
|
||||
parent_map = {row.code: row.id for row in result.fetchall()}
|
||||
logger.info(f"Found parent categories: {parent_map}")
|
||||
|
||||
# Fetch existing codes to avoid duplicates
|
||||
result = await session.execute(
|
||||
text("SELECT code FROM vehicle.cost_categories")
|
||||
)
|
||||
existing_codes = {row.code for row in result.fetchall()}
|
||||
|
||||
inserted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for parent_code, code, name, description, visibility in SUBCATEGORIES:
|
||||
if code in existing_codes:
|
||||
logger.info(f" ⏭️ SKIP (already exists): {code}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
parent_id = parent_map.get(parent_code)
|
||||
if parent_id is None:
|
||||
logger.warning(f" ⚠️ Parent code '{parent_code}' not found, skipping {code}")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
await session.execute(
|
||||
text("""
|
||||
INSERT INTO vehicle.cost_categories (code, name, description, parent_id, visibility, is_system, created_at, updated_at)
|
||||
VALUES (:code, :name, :description, :parent_id, :visibility, true, NOW(), NOW())
|
||||
"""),
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parent_id": parent_id,
|
||||
"visibility": visibility,
|
||||
}
|
||||
)
|
||||
logger.info(f" ✅ INSERTED: {code} -> {parent_code} (parent_id={parent_id})")
|
||||
inserted_count += 1
|
||||
|
||||
await session.commit()
|
||||
logger.info(f"✅ Done! Inserted={inserted_count}, Skipped={skipped_count}")
|
||||
|
||||
# Verify the tree structure
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT p.code AS parent_code, p.name AS parent_name,
|
||||
c.id, c.code, c.name, c.parent_id
|
||||
FROM vehicle.cost_categories c
|
||||
JOIN vehicle.cost_categories p ON c.parent_id = p.id
|
||||
ORDER BY p.id, c.id
|
||||
""")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
logger.info("\n=== HIERARCHY TREE ===")
|
||||
for row in rows:
|
||||
logger.info(f" {row.parent_code:15s} -> {row.code:20s} ({row.name})")
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional, Dict, Any, TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, distinct
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -62,10 +62,21 @@ class AssetService:
|
||||
allowed_limit = await AssetService.get_user_vehicle_limit(db, user_id, target_org_id)
|
||||
|
||||
# Csak aktív járművek számítanak a limitbe (draft-ok nem)
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id == target_org_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
# FIX: Count only the CURRENT USER's active vehicles, not ALL vehicles in the org
|
||||
if target_org_id is not None:
|
||||
# Organization mode: count vehicles owned by this user within the org
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id == target_org_id,
|
||||
Asset.owner_person_id == user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
else:
|
||||
# Personal mode: count vehicles owned personally by this user
|
||||
count_stmt = select(func.count(Asset.id)).where(
|
||||
Asset.current_organization_id.is_(None),
|
||||
Asset.owner_person_id == user.person_id,
|
||||
Asset.status == "active"
|
||||
)
|
||||
current_count = (await db.execute(count_stmt)).scalar()
|
||||
|
||||
# Determine status based on data completeness (use Pydantic validator's logic)
|
||||
@@ -86,7 +97,15 @@ class AssetService:
|
||||
else:
|
||||
status = "active"
|
||||
|
||||
# SAFETY: Ensure limit is at least 1 — no registered user should ever have 0 limit
|
||||
allowed_limit = max(allowed_limit or 1, 1)
|
||||
logger.info(
|
||||
f"Limit check for user {user_id} (org={target_org_id}): "
|
||||
f"current_count={current_count}, allowed_limit={allowed_limit}, status={status}"
|
||||
)
|
||||
|
||||
if current_count >= allowed_limit and status == "active":
|
||||
logger.error(f"QUOTA BLOCK TRIGGERED - User Person ID: {user.person_id}, Active Count: {current_count}, Limit: {allowed_limit}")
|
||||
raise ValueError(f"Limit túllépés! A csomagod {allowed_limit} aktív autót engedélyez.")
|
||||
|
||||
# 2. LÉTEZIK-E MÁR A JÁRMŰ? (csak ha van VIN)
|
||||
@@ -400,7 +419,11 @@ class AssetService:
|
||||
# Get user-specific limit (based on role or subscription plan)
|
||||
user_limit = limits.get(user_role)
|
||||
if user_limit is None:
|
||||
user_limit = limits.get(subscription_plan.lower(), 100)
|
||||
# FIX: If role not found (e.g. "user" not in dict), try subscription plan
|
||||
user_limit = limits.get(subscription_plan.lower())
|
||||
if user_limit is None:
|
||||
# FIX: Ultimate fallback - safe default for free/basic users
|
||||
user_limit = limits.get("free", 1)
|
||||
|
||||
# Get organization-specific limit (if configured)
|
||||
org_limit = None
|
||||
@@ -431,6 +454,98 @@ class AssetService:
|
||||
# Fallback to a reasonable default
|
||||
return 100
|
||||
|
||||
@staticmethod
|
||||
async def archive_vehicle(
|
||||
db: AsyncSession,
|
||||
asset_id: uuid.UUID,
|
||||
user_id: int,
|
||||
final_mileage: int,
|
||||
archive_reason: str,
|
||||
) -> Asset:
|
||||
"""
|
||||
Strict Soft Delete — Jármű biztonságos kivezetése a garázsból.
|
||||
|
||||
A művelet:
|
||||
1. Frissíti a current_mileage értékét a megadott final_mileage-ra
|
||||
2. Állítja: status = 'archived'
|
||||
3. Nullázza az owner_person_id és owner_org_id mezőket
|
||||
4. Elmenti az archive_info metaadatokat az individual_equipment JSONB-be
|
||||
5. Naplózza a biztonsági auditba
|
||||
"""
|
||||
from app.models.identity import User
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# 1. Lekérjük a járművet — eager load catalog reláció a MissingGreenlet hiba elkerülésére
|
||||
stmt = select(Asset).where(Asset.id == asset_id).options(selectinload(Asset.catalog))
|
||||
result = await db.execute(stmt)
|
||||
asset = result.scalar_one_or_none()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Jármű nem található")
|
||||
|
||||
# 2. Jogosultság ellenőrzése — csak a tulajdonos vagy admin archiválhat
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user = (await db.execute(user_stmt)).scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Felhasználó nem található")
|
||||
|
||||
is_owner = (asset.owner_person_id == user.person_id)
|
||||
is_admin = user.role in ("admin", "superadmin") if hasattr(user, 'role') else False
|
||||
|
||||
if not is_owner and not is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nincs jogosultságod a jármű eltávolításához"
|
||||
)
|
||||
|
||||
# 3. Már archiválva van?
|
||||
if asset.status == "archived":
|
||||
raise HTTPException(status_code=400, detail="A jármű már archiválva van")
|
||||
|
||||
# 4. Mentjük az archive_info metaadatokat
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
equipment = dict(asset.individual_equipment or {})
|
||||
equipment['archive_info'] = {
|
||||
'reason': archive_reason,
|
||||
'archived_at': now_iso,
|
||||
'previous_owner_person_id': asset.owner_person_id,
|
||||
'previous_owner_org_id': asset.owner_org_id,
|
||||
'final_mileage': final_mileage,
|
||||
}
|
||||
|
||||
# 5. Frissítjük a jármű adatait
|
||||
asset.current_mileage = final_mileage
|
||||
asset.status = "archived"
|
||||
asset.owner_person_id = None
|
||||
asset.owner_org_id = None
|
||||
asset.individual_equipment = equipment
|
||||
asset.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
# 6. Biztonsági audit naplózás
|
||||
await security_service.log_event(
|
||||
db,
|
||||
user_id=user_id,
|
||||
action="VEHICLE_ARCHIVED",
|
||||
severity=LogSeverity.warning,
|
||||
target_type="Asset",
|
||||
target_id=str(asset.id),
|
||||
new_data={
|
||||
"reason": archive_reason,
|
||||
"final_mileage": final_mileage,
|
||||
"archived_at": now_iso,
|
||||
}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
|
||||
logger.info(
|
||||
f"Vehicle {asset.id} ({asset.license_plate}) archived by user {user_id}. "
|
||||
f"Reason: {archive_reason}, final_mileage: {final_mileage}"
|
||||
)
|
||||
|
||||
return asset
|
||||
|
||||
@staticmethod
|
||||
async def _award_first_car_badge(db: AsyncSession, user_id: int, org_id: int):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user