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,
|
||||
|
||||
@@ -120,6 +120,7 @@ class AssetCostResponse(AssetCostBase):
|
||||
# Derived / enriched fields (not in DB model directly)
|
||||
category_name: Optional[str] = None # Resolved from CostCategory relationship
|
||||
category_code: Optional[str] = None # Resolved from CostCategory relationship
|
||||
parent_category_id: Optional[int] = None # Resolved from CostCategory.parent_id — needed for two-level category dropdown on frontend
|
||||
description: Optional[str] = None # Extracted from data['description']
|
||||
mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost']
|
||||
|
||||
@@ -129,4 +130,7 @@ class AssetCostResponse(AssetCostBase):
|
||||
# P0 HYBRID VENDOR REFACTOR: service_provider_id a response-ban
|
||||
service_provider_id: Optional[int] = None
|
||||
|
||||
# P0 BUGFIX: Enriched provider name (resolved from ServiceProvider.name)
|
||||
service_provider_name: Optional[str] = None # Resolved from ServiceProvider.name via service_provider_id
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -34,6 +34,34 @@ class IssuerResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FinancialLedgerResponse(BaseModel):
|
||||
"""Response schema for FinancialLedger admin listing."""
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
person_id: Optional[int] = None
|
||||
amount: float
|
||||
currency: Optional[str] = None
|
||||
transaction_type: Optional[str] = None
|
||||
entry_type: Optional[str] = None
|
||||
wallet_type: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
created_at: Optional[datetime] = None
|
||||
# Joined user info
|
||||
user_email: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FinancialLedgerListResponse(BaseModel):
|
||||
"""Paginated response for FinancialLedger admin listing."""
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: List[FinancialLedgerResponse]
|
||||
|
||||
|
||||
class IssuerUpdate(BaseModel):
|
||||
"""Update schema for Issuer entities (PATCH)."""
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/system.py
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemParameterBase(BaseModel):
|
||||
description: Optional[str] = None
|
||||
value: Dict[str, Any] # JSONB mező
|
||||
value: Any # JSONB mező — bármilyen JSON-szeriális érték lehet (int, str, dict, list)
|
||||
scope_level: str = 'global'
|
||||
scope_id: Optional[str] = None
|
||||
is_active: bool = True
|
||||
@@ -18,7 +18,7 @@ class SystemParameterCreate(SystemParameterBase):
|
||||
|
||||
class SystemParameterUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
value: Optional[Dict[str, Any]] = None
|
||||
value: Optional[Any] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
|
||||
@@ -320,6 +320,15 @@ async def seed_params():
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 11. KÜLSŐ API-K (DVLA, UK) ---
|
||||
# --- 11. INACTIVITY MONITOR (Robot-21) ---
|
||||
{
|
||||
"key": "inactivity_threshold_days",
|
||||
"value": 180,
|
||||
"category": "system",
|
||||
"description": "Felhasználói inaktivitás küszöbértéke napokban. Ha a user utolsó aktivitása régebbi, a fiók felfüggesztésre kerül és a MLM jutalékrendszer megkerüli.",
|
||||
"scope_level": "global"
|
||||
},
|
||||
|
||||
# --- 12. KÜLSŐ API-K (DVLA, UK) ---
|
||||
{
|
||||
"key": "dvla_api_en
|
||||
@@ -573,16 +573,22 @@ class AtomicTransactionManager:
|
||||
# Convert to dictionary format
|
||||
transactions = []
|
||||
for entry in ledger_entries:
|
||||
# Extract description from JSONB details field (legacy compatibility)
|
||||
desc = None
|
||||
if entry.details and isinstance(entry.details, dict):
|
||||
desc = entry.details.get("description") or entry.details.get("desc") or None
|
||||
elif entry.details and isinstance(entry.details, str):
|
||||
desc = entry.details
|
||||
transactions.append({
|
||||
"id": entry.id,
|
||||
"user_id": entry.user_id,
|
||||
"amount": float(entry.amount),
|
||||
"entry_type": entry.entry_type.value,
|
||||
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
|
||||
"description": entry.description,
|
||||
"description": desc,
|
||||
"transaction_id": str(entry.transaction_id),
|
||||
"reference_type": entry.reference_type,
|
||||
"reference_id": entry.reference_id,
|
||||
"reference_type": None,
|
||||
"reference_id": None,
|
||||
"balance_after": float(entry.balance_after) if entry.balance_after else None,
|
||||
"created_at": entry.created_at.isoformat() if entry.created_at else None
|
||||
})
|
||||
@@ -622,15 +628,15 @@ class AtomicTransactionManager:
|
||||
return {
|
||||
"wallet_id": wallet.id,
|
||||
"balances": {
|
||||
"earned": float(wallet.earned_credits),
|
||||
"purchased": float(wallet.purchased_credits),
|
||||
"service_coins": float(wallet.service_coins),
|
||||
"voucher": float(voucher_balance),
|
||||
"earned": float(wallet.earned_credits or 0),
|
||||
"purchased": float(wallet.purchased_credits or 0),
|
||||
"service_coins": float(wallet.service_coins or 0),
|
||||
"voucher": float(voucher_balance or 0),
|
||||
"total": float(
|
||||
wallet.earned_credits +
|
||||
wallet.purchased_credits +
|
||||
wallet.service_coins +
|
||||
voucher_balance
|
||||
(wallet.earned_credits or 0) +
|
||||
(wallet.purchased_credits or 0) +
|
||||
(wallet.service_coins or 0) +
|
||||
(voucher_balance or 0)
|
||||
)
|
||||
},
|
||||
"recent_transactions": recent_transactions,
|
||||
|
||||
@@ -33,6 +33,7 @@ from app.models.marketplace.commission import (
|
||||
CommissionTier,
|
||||
)
|
||||
from app.models.identity.identity import User, Wallet
|
||||
from app.models.system import SystemParameter
|
||||
from app.models.system.audit import (
|
||||
FinancialLedger,
|
||||
LedgerEntryType,
|
||||
@@ -589,26 +590,59 @@ async def _credit_commission_to_wallet(
|
||||
)
|
||||
|
||||
|
||||
async def _get_inactivity_threshold(db: AsyncSession, default: int = 180) -> int:
|
||||
"""
|
||||
Read the inactivity_threshold_days from system.system_parameters.
|
||||
Falls back to `default` (180) if not found.
|
||||
"""
|
||||
try:
|
||||
stmt = select(SystemParameter).where(
|
||||
SystemParameter.key == "inactivity_threshold_days",
|
||||
SystemParameter.scope_level == "global",
|
||||
SystemParameter.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
param = result.scalar_one_or_none()
|
||||
if param is not None:
|
||||
val = param.value
|
||||
if isinstance(val, dict):
|
||||
return int(val.get("value", val.get("days", default)))
|
||||
return int(val)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to read inactivity_threshold_days from DB: {e}. "
|
||||
f"Falling back to {default}."
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
async def _is_user_recently_active(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
inactivity_days: int = 180,
|
||||
inactivity_days: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has been active within the last `inactivity_days` days.
|
||||
Check if a user has been active within the inactivity threshold.
|
||||
|
||||
A user is considered active if:
|
||||
1. Their subscription is active (subscription_expires_at > now), OR
|
||||
2. They have a FinancialLedger entry within the inactivity window.
|
||||
|
||||
The inactivity threshold is read from system.system_parameters
|
||||
(key: inactivity_threshold_days), falling back to 180 days.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The user to check.
|
||||
inactivity_days: Number of days of inactivity before considered inactive.
|
||||
inactivity_days: Optional override. If None, reads from DB settings.
|
||||
|
||||
Returns:
|
||||
True if the user is recently active, False otherwise.
|
||||
"""
|
||||
# Resolve threshold: parameter override or DB setting
|
||||
if inactivity_days is None:
|
||||
inactivity_days = await _get_inactivity_threshold(db)
|
||||
|
||||
# Check subscription status first
|
||||
user = await _lookup_user(db, user_id)
|
||||
if not user:
|
||||
|
||||
@@ -393,22 +393,42 @@ class FinancialOrchestrator:
|
||||
await db.flush()
|
||||
|
||||
# 3. Pénztárca egyenleg visszaállítása
|
||||
# JAVÍTÁS: A Wallet modellben NINCS "wallet_type" és "balance" mező.
|
||||
# A usernek egyetlen wallet-je van (user_id UNIQUE constraint),
|
||||
# a credit típusok külön oszlopokban: earned_credits, purchased_credits, service_coins.
|
||||
# A wallet_type-t az eredeti FinancialLedger bejegyzésből olvassuk ki,
|
||||
# és a megfelelő oszlopot frissítjük (pont mint process_payment()-ben).
|
||||
wallet_query = select(Wallet).where(
|
||||
and_(
|
||||
Wallet.user_id == original_entry.user_id,
|
||||
Wallet.wallet_type == original_entry.wallet_type
|
||||
)
|
||||
Wallet.user_id == original_entry.user_id
|
||||
).with_for_update()
|
||||
|
||||
wallet_result = await db.execute(wallet_query)
|
||||
wallet = wallet_result.scalar_one_or_none()
|
||||
|
||||
if wallet:
|
||||
new_balance = wallet.balance - original_entry.amount
|
||||
wallet_type = original_entry.wallet_type
|
||||
update_values = {}
|
||||
|
||||
if wallet_type == WalletType.EARNED:
|
||||
new_balance = Decimal(str(wallet.earned_credits)) + original_entry.amount
|
||||
update_values['earned_credits'] = float(new_balance)
|
||||
elif wallet_type == WalletType.PURCHASED:
|
||||
new_balance = Decimal(str(wallet.purchased_credits)) + original_entry.amount
|
||||
update_values['purchased_credits'] = float(new_balance)
|
||||
elif wallet_type == WalletType.SERVICE_COINS:
|
||||
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
|
||||
update_values['service_coins'] = float(new_balance)
|
||||
elif wallet_type == WalletType.VOUCHER:
|
||||
# VOUCHER típusnál nincs dedikált mező, SERVICE_COINS-ba tesszük
|
||||
new_balance = Decimal(str(wallet.service_coins)) + original_entry.amount
|
||||
update_values['service_coins'] = float(new_balance)
|
||||
else:
|
||||
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
|
||||
|
||||
await db.execute(
|
||||
update(Wallet)
|
||||
.where(Wallet.id == wallet.id)
|
||||
.values(balance=new_balance)
|
||||
.values(**update_values)
|
||||
)
|
||||
|
||||
# 4. Számlakiállító bevételének csökkentése
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🤖 Inactivity Monitor Worker (Robot-21)
|
||||
Detects users who have been inactive for 180+ days and flags them so the
|
||||
MLM Commission Engine can bypass them for Gen1/Gen2 reward calculations.
|
||||
Detects users who have been inactive for N+ days (configured via system_parameters)
|
||||
and flags them so the MLM Commission Engine can bypass them for Gen1/Gen2 rewards.
|
||||
|
||||
Process:
|
||||
1. Find users where last_activity_at < NOW() - INTERVAL '180 days'
|
||||
1. Read inactivity_threshold_days from system.system_parameters (fallback: 180)
|
||||
2. Find users where last_activity_at < NOW() - INTERVAL 'N days'
|
||||
AND is_active = True AND is_deleted = False
|
||||
2. Set is_active = False
|
||||
3. Set custom_permissions['inactivity_suspended'] = True
|
||||
3. Set is_active = False
|
||||
4. Set custom_permissions['inactivity_suspended'] = True
|
||||
and custom_permissions['inactivity_suspended_at'] = ISO timestamp
|
||||
4. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||
5. Send notification
|
||||
5. Record ledger entry (ACCOUNT_SUSPENDED_INACTIVITY)
|
||||
6. Send notification
|
||||
|
||||
MLM Integration:
|
||||
- The custom_permissions['inactivity_suspended'] flag is checked by the
|
||||
@@ -38,30 +39,61 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.identity import User
|
||||
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
|
||||
from app.models.system import SystemParameter
|
||||
from app.services.notification_service import NotificationService
|
||||
|
||||
logger = logging.getLogger("inactivity-monitor-worker")
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────────
|
||||
PROCESS_NAME = "Inactivity-Monitor"
|
||||
INACTIVITY_DAYS = 180
|
||||
DEFAULT_INACTIVITY_DAYS = 180
|
||||
|
||||
|
||||
async def _get_inactivity_threshold(db: AsyncSession) -> int:
|
||||
"""
|
||||
Read the inactivity_threshold_days from system.system_parameters.
|
||||
Falls back to DEFAULT_INACTIVITY_DAYS (180) if not found.
|
||||
"""
|
||||
try:
|
||||
stmt = select(SystemParameter).where(
|
||||
SystemParameter.key == "inactivity_threshold_days",
|
||||
SystemParameter.scope_level == "global",
|
||||
SystemParameter.is_active == True,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
param = result.scalar_one_or_none()
|
||||
if param is not None:
|
||||
val = param.value
|
||||
if isinstance(val, dict):
|
||||
# Could be stored as {"value": 180} or just 180
|
||||
return int(val.get("value", val.get("days", DEFAULT_INACTIVITY_DAYS)))
|
||||
return int(val)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to read inactivity_threshold_days from DB: {e}. "
|
||||
f"Falling back to {DEFAULT_INACTIVITY_DAYS}."
|
||||
)
|
||||
return DEFAULT_INACTIVITY_DAYS
|
||||
|
||||
|
||||
async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
"""
|
||||
Finds and flags users inactive for 180+ days.
|
||||
Finds and flags users inactive for N+ days (configurable via system_parameters).
|
||||
|
||||
Two-phase detection:
|
||||
A. Users with last_activity_at: last_activity_at < NOW() - 180 days
|
||||
A. Users with last_activity_at: last_activity_at < NOW() - N days
|
||||
B. Users without last_activity_at (never logged in):
|
||||
created_at < NOW() - 180 days AND last_activity_at IS NULL
|
||||
created_at < NOW() - N days AND last_activity_at IS NULL
|
||||
|
||||
Returns:
|
||||
dict: Statistics (processed_count, suspended_users, errors)
|
||||
"""
|
||||
inactivity_days = await _get_inactivity_threshold(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff = now - timedelta(days=INACTIVITY_DAYS)
|
||||
stats = {"processed": 0, "suspended": [], "errors": []}
|
||||
cutoff = now - timedelta(days=inactivity_days)
|
||||
stats = {"processed": 0, "suspended": [], "errors": [], "threshold_days": inactivity_days}
|
||||
|
||||
logger.info(f"Inactivity threshold: {inactivity_days} days (cutoff={cutoff.isoformat()})")
|
||||
|
||||
# ── Phase A: Users with last_activity_at ──
|
||||
# NOTE: We use a subquery approach to avoid PostgreSQL's
|
||||
@@ -114,7 +146,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
try:
|
||||
# Determine the reference timestamp for this user
|
||||
ref_ts = user.last_activity_at or user.created_at
|
||||
days_since = (now - ref_ts).days if ref_ts else INACTIVITY_DAYS
|
||||
days_since = (now - ref_ts).days if ref_ts else inactivity_days
|
||||
|
||||
# 1. Deactivate the user
|
||||
user.is_active = False
|
||||
@@ -141,7 +173,7 @@ async def process_inactive_users(db: AsyncSession) -> dict:
|
||||
),
|
||||
"inactivity_days": days_since,
|
||||
"last_activity_at": ref_ts.isoformat() if ref_ts else None,
|
||||
"cutoff_days": INACTIVITY_DAYS,
|
||||
"cutoff_days": inactivity_days,
|
||||
},
|
||||
)
|
||||
db.add(ledger_entry)
|
||||
|
||||
@@ -72,7 +72,11 @@ async def process_expired_subscriptions(db: AsyncSession) -> dict:
|
||||
user_id=user.id,
|
||||
amount=0.0,
|
||||
entry_type=LedgerEntryType.DEBIT,
|
||||
wallet_type=WalletType.SYSTEM,
|
||||
# JAVÍTÁS: WalletType.SYSTEM nem létező enum érték.
|
||||
# A WalletType csak EARNED, PURCHASED, SERVICE_COINS, VOUCHER értékeket támogat.
|
||||
# Mivel ez csak egy subscription lejárati naplóbejegyzés (amount=0),
|
||||
# a SERVICE_COINS a legmegfelelőbb alapértelmezett választás.
|
||||
wallet_type=WalletType.SERVICE_COINS,
|
||||
transaction_type="SUBSCRIPTION_EXPIRED",
|
||||
description=f"Előfizetés lejárt: {old_plan} → FREE",
|
||||
reference_type="subscription",
|
||||
|
||||
Reference in New Issue
Block a user