egyedi jármű szerkesztés előtti mentés
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user