felhasználói felületre pü
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user