felhasználói felületre pü

This commit is contained in:
Roo
2026-07-27 08:39:18 +00:00
parent c9118bf52f
commit 6f28d3e70d
72 changed files with 6311 additions and 749 deletions

View File

@@ -27,6 +27,7 @@ api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expe
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
api_router.include_router(finance_admin.router, prefix="/finance/issuers", tags=["finance-admin"])
api_router.include_router(finance_admin.router, prefix="/admin/finance", tags=["admin-finance"])
api_router.include_router(analytics.router, prefix="/analytics", tags=["Analytics"])
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["Vehicles"])
api_router.include_router(system_parameters.router, prefix="/system/parameters", tags=["System Parameters"])

View File

@@ -42,7 +42,7 @@ from app.models.vehicle.asset import Asset
from app.models.vehicle.asset import AssetEvent
from app.models.vehicle.asset import OdometerReading
from app.models.marketplace.organization import Organization
from app.models.marketplace.organization import OrganizationMember
from app.models.marketplace.organization import OrganizationMember, OrgRole
from app.models.identity.social import ServiceProvider
from app.models.marketplace.service import ServiceProfile, ServiceExpertise
from app.models.system.system import SystemParameter
@@ -54,6 +54,123 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ── SINGLE EXPENSE READ ENDPOINT ──
# P0 BUGFIX (2026-07-26): Dedicated single-expense endpoint that returns
# enriched AssetCostResponse with resolved category name, parent_category_id,
# provider name, etc. This is used by the frontend edit modal hydration.
#
# NOTE: This MUST be registered BEFORE the {asset_id} wildcard route to avoid
# FastAPI routing the "by-id" path segment as an asset_id UUID.
@router.get("/by-id/{expense_id}")
async def get_expense_by_id(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
"""
Get a single expense by its ID with enriched fields.
Returns a fully populated AssetCostResponse including:
- category_name, category_code, parent_category_id (from CostCategory JOIN)
- service_provider_name (from ServiceProvider JOIN)
- vendor_name (from Organization JOIN via vendor_organization_id)
- description, mileage_at_cost (extracted from data JSONB)
This is the preferred endpoint for frontend edit modal hydration.
"""
from app.models.fleet_finance.models import CostCategory
from app.models.identity.social import ServiceProvider
from app.models.marketplace.organization import Organization
stmt = (
select(AssetCost)
.where(AssetCost.id == expense_id)
)
result = await db.execute(stmt)
cost = result.scalar_one_or_none()
if not cost:
raise HTTPException(status_code=404, detail="Expense not found.")
# Resolve enriched fields via explicit queries (no lazy loading surprises)
# 1. Category details + parent_id
category_name = None
category_code = None
parent_category_id = None
if cost.category_id is not None:
cat_stmt = select(CostCategory).where(CostCategory.id == cost.category_id)
cat_result = await db.execute(cat_stmt)
category = cat_result.scalar_one_or_none()
if category:
category_name = category.name
category_code = category.code
parent_category_id = category.parent_id
# 2. Service provider name
service_provider_name = None
if cost.service_provider_id is not None:
sp_stmt = select(ServiceProvider).where(ServiceProvider.id == cost.service_provider_id)
sp_result = await db.execute(sp_stmt)
provider = sp_result.scalar_one_or_none()
if provider:
service_provider_name = provider.name
# 3. Vendor organization name
vendor_name = None
if cost.vendor_organization_id is not None:
org_stmt = select(Organization).where(Organization.id == cost.vendor_organization_id)
org_result = await db.execute(org_stmt)
vendor_org = org_result.scalar_one_or_none()
if vendor_org:
vendor_name = vendor_org.name
# 4. Extract description and mileage from data JSONB
data = cost.data or {}
description = data.get("description")
mileage_at_cost = data.get("mileage_at_cost")
response = AssetCostResponse(
id=cost.id,
asset_id=cost.asset_id,
organization_id=cost.organization_id,
category_id=cost.category_id,
status=cost.status,
linked_asset_event_id=cost.linked_asset_event_id,
# Monetary
amount_gross=cost.amount_gross,
amount_net=cost.amount_net,
vat_rate=cost.vat_rate,
currency=cost.currency,
# Dates
cost_date=cost.date,
invoice_number=cost.invoice_number,
# Invoice dates
invoice_date=cost.invoice_date,
fulfillment_date=cost.fulfillment_date,
# Vendor fields
vendor_organization_id=cost.vendor_organization_id,
service_provider_id=cost.service_provider_id,
external_vendor_name=cost.external_vendor_name,
# Raw data JSONB
data=data,
# Enriched fields
category_name=category_name,
category_code=category_code,
parent_category_id=parent_category_id,
description=description,
mileage_at_cost=mileage_at_cost,
vendor_name=vendor_name,
service_provider_name=service_provider_name,
)
return {
"status": "success",
"data": response,
}
# ── FUEL CATEGORY IDS ──
# These are the category IDs that represent fuel costs.
# Used for auto-approval logic: fuel costs are always auto-approved.
@@ -100,22 +217,34 @@ async def _check_org_capability(
Uses the JSONB permissions field from fleet.org_roles.
Returns True if the user's role has the capability, False otherwise.
"""
from app.models.fleet.org_role import OrgRole
stmt = (
select(OrgRole.permissions)
.join(OrganizationMember, OrganizationMember.role == OrgRole.name)
.where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
)
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and isinstance(row, dict):
return row.get(capability, False)
NOTE: Two-step query (not JOIN) to avoid PG ENUM vs varchar type mismatch.
OrganizationMember.role is PG ENUM 'fleet.orguserrole', OrgRole.name_key is varchar.
A direct JOIN would cause: operator does not exist: fleet.orguserrole = character varying
See assets.py:_get_user_permissions() for the same pattern.
"""
# Step 1: Get the user's role name (Python string — asyncpg auto-converts ENUM)
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active",
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# Step 2: Look up permissions from fleet.org_roles using the role name
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True,
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if permissions and isinstance(permissions, dict):
return permissions.get(capability, False)
return False

View File

@@ -1,22 +1,29 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/finance_admin.py
"""
Finance Admin API endpoints for managing Issuers with strict RBAC protection.
Finance Admin API endpoints for managing Issuers and FinancialLedger with strict RBAC protection.
Protected by DB-driven RequirePermission("finance:view") dependency.
"""
from fastapi import APIRouter, Depends, HTTPException, status
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from sqlalchemy import select, func, text
from typing import List, Optional
from app.api import deps
from app.models.identity import User
from app.models.marketplace.finance import Issuer
from app.schemas.finance import IssuerResponse, IssuerUpdate
from app.models.system.audit import FinancialLedger
from app.schemas.finance import IssuerResponse, IssuerUpdate, FinancialLedgerListResponse, FinancialLedgerResponse
logger = logging.getLogger(__name__)
router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# Issuer Endpoints (existing)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/", response_model=List[IssuerResponse], tags=["finance-admin"])
async def list_issuers(
db: AsyncSession = Depends(deps.get_db),
@@ -27,6 +34,7 @@ async def list_issuers(
List all Issuers (billing entities).
Protected by RequirePermission("finance:view").
"""
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).order_by(Issuer.id))
issuers = result.scalars().all()
return issuers
@@ -44,6 +52,7 @@ async def update_issuer(
Update an Issuer's details (activate/deactivate, revenue limit, API config).
Protected by RequirePermission("finance:edit").
"""
from app.models.marketplace.finance import Issuer
result = await db.execute(select(Issuer).where(Issuer.id == issuer_id))
issuer = result.scalar_one_or_none()
@@ -61,4 +70,134 @@ async def update_issuer(
await db.commit()
await db.refresh(issuer)
return issuer
return issuer
# ──────────────────────────────────────────────────────────────────────────────
# FinancialLedger Admin Endpoints (NEW)
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/ledger", response_model=FinancialLedgerListResponse, tags=["finance-admin"])
async def list_financial_ledger(
page: int = Query(1, ge=1, description="Oldalszám (1-indexelt)"),
page_size: int = Query(50, ge=1, le=200, description="Rekordok száma oldalanként"),
user_id: Optional[int] = Query(None, description="Szűrés felhasználó ID alapján"),
transaction_type: Optional[str] = Query(None, description="Tranzakció típus szűrés"),
entry_type: Optional[str] = Query(None, description="Könyvelési irány (DEBIT / CREDIT)"),
wallet_type: Optional[str] = Query(None, description="Tárcatípus szűrés"),
date_from: Optional[str] = Query(None, description="Dátum szűrés kezdete (ISO, pl. 2026-01-01)"),
date_to: Optional[str] = Query(None, description="Dátum szűrés vége (ISO, pl. 2026-12-31)"),
sort_by: str = Query("created_at", description="Rendezési mező (created_at, amount, id)"),
sort_order: str = Query("desc", description="Rendezési irány (asc / desc)"),
db: AsyncSession = Depends(deps.get_db),
current_user: User = Depends(deps.get_current_active_user),
_ = Depends(deps.RequirePermission("finance:view")),
):
"""
List FinancialLedger entries with pagination, filtering, and user details.
Uses raw SQL with explicit joins to avoid SQLAlchemy relationship issues.
Protected by RequirePermission("finance:view").
Returns paginated results with joined user email and person name.
"""
# ── Build WHERE clauses dynamically ──
where_clauses = []
params = {}
if user_id is not None:
where_clauses.append("fl.user_id = :user_id")
params["user_id"] = user_id
if transaction_type is not None:
where_clauses.append("fl.transaction_type = :transaction_type")
params["transaction_type"] = transaction_type
if entry_type is not None:
where_clauses.append("fl.entry_type = :entry_type")
params["entry_type"] = entry_type
if wallet_type is not None:
where_clauses.append("fl.wallet_type = :wallet_type")
params["wallet_type"] = wallet_type
if date_from is not None:
where_clauses.append("fl.created_at >= :date_from::timestamp")
params["date_from"] = date_from
if date_to is not None:
where_clauses.append("fl.created_at <= :date_to::timestamp")
params["date_to"] = date_to
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
# Validate sort_by to prevent SQL injection
allowed_sort_fields = {"created_at", "amount", "id"}
if sort_by not in allowed_sort_fields:
sort_by = "created_at"
sort_direction = "ASC" if sort_order == "asc" else "DESC"
# ── Count query ──
count_sql = f"""
SELECT COUNT(*) FROM audit.financial_ledger fl
WHERE {where_sql}
"""
count_result = await db.execute(text(count_sql), params)
total = count_result.scalar() or 0
# ── Data query with joins ──
offset = (page - 1) * page_size
data_sql = f"""
SELECT
fl.id,
fl.user_id,
fl.person_id,
fl.amount,
fl.currency,
fl.transaction_type,
fl.entry_type::text,
fl.wallet_type::text,
fl.status::text,
fl.details,
fl.created_at,
u.email AS user_email,
p.first_name AS person_first_name,
p.last_name AS person_last_name
FROM audit.financial_ledger fl
LEFT JOIN identity.users u ON u.id = fl.user_id
LEFT JOIN identity.persons p ON p.id = u.person_id
WHERE {where_sql}
ORDER BY fl.{sort_by} {sort_direction}
LIMIT :limit OFFSET :offset
"""
params["limit"] = page_size
params["offset"] = offset
data_result = await db.execute(text(data_sql), params)
rows = data_result.all()
# ── Build response ──
items = []
for row in rows:
user_name = None
if row.person_first_name or row.person_last_name:
parts = [p for p in [row.person_last_name, row.person_first_name] if p]
user_name = " ".join(parts)
items.append(FinancialLedgerResponse(
id=row.id,
user_id=row.user_id,
person_id=row.person_id,
amount=float(row.amount) if row.amount else 0.0,
currency=row.currency,
transaction_type=row.transaction_type,
entry_type=row.entry_type,
wallet_type=row.wallet_type,
status=row.status,
details=row.details,
created_at=row.created_at,
user_email=row.user_email,
user_name=user_name,
))
return FinancialLedgerListResponse(
total=total,
page=page,
page_size=page_size,
items=items,
)

View File

@@ -163,11 +163,7 @@ async def list_expertise_categories(
ExpertiseCategoryOut(
id=t.id,
key=t.key,
# --- 🏗️ i18n JSONB Migration (Phase 3) ---
name_i18n=t.name_i18n if t.name_i18n else {},
# --- [DEPRECATED] Old columns (Phase 3) ---
name_hu=t.name_hu,
name_en=t.name_en,
category=t.category,
level=t.level,
parent_id=t.parent_id,