feature/permisos-invoices
This commit is contained in:
@@ -1,144 +1,125 @@
|
||||
from typing import List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from api.v1.modules.a76.invoice_settings import services
|
||||
from api.v1.modules.a76.invoice_settings.dto import InvoiceSettingsRequest, InvoiceSettingsResponse, OperationType
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/a76/invoice-settings",
|
||||
tags=["a76/invoice-settings"]
|
||||
)
|
||||
|
||||
|
||||
def _invoice_perm_base_for_settings(operation_type: OperationType, invoice_type: str) -> str:
|
||||
"""Alineado con get_invoice_permission_base en invoices/routes (defaults por tipo)."""
|
||||
op = (
|
||||
operation_type.value
|
||||
if hasattr(operation_type, "value")
|
||||
else str(operation_type).lower().split(".")[-1]
|
||||
)
|
||||
inv = (invoice_type or "").upper()
|
||||
if op == "imp":
|
||||
if inv == "TEM":
|
||||
return "invoice.imp.tem"
|
||||
if inv == "DEF":
|
||||
return "invoice.imp.def"
|
||||
if inv == "MEX":
|
||||
return "invoice.imp.cm"
|
||||
if inv == "CR":
|
||||
return "invoice.imp.cr"
|
||||
return "invoice.imp.tem"
|
||||
if op == "exp":
|
||||
if inv == "REPAR":
|
||||
return "invoice.exp.rep"
|
||||
return "invoice.exp"
|
||||
return "invoice.imp.tem"
|
||||
|
||||
|
||||
def _can_read_invoice_settings_row(
|
||||
db: Session,
|
||||
company_id: int,
|
||||
current_user: Dict[str, Any],
|
||||
invoice_type: str,
|
||||
operation_type: OperationType,
|
||||
) -> bool:
|
||||
"""
|
||||
Ver configuración por tipo/op: settings_general.view O ver facturas de ese mismo contexto
|
||||
(para cargar defaults en alta/edición sin abrir la pantalla de parámetros).
|
||||
"""
|
||||
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||
if "admin" in user_roles:
|
||||
return True
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if not user_id:
|
||||
return False
|
||||
ps = PermissionService(db)
|
||||
if ps.has_permission(str(user_id), company_id, "settings_general.view"):
|
||||
return True
|
||||
base = _invoice_perm_base_for_settings(operation_type, invoice_type)
|
||||
return ps.has_permission(str(user_id), company_id, f"{base}.view")
|
||||
|
||||
|
||||
@router.get("/{invoice_type}", response_model=InvoiceSettingsResponse)
|
||||
def get_invoice_settings(
|
||||
invoice_type: str,
|
||||
operation_type: OperationType = Query(...),
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get settings for a specific invoice type and operation"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
if not _can_read_invoice_settings_row(
|
||||
db, company_id, current_user, invoice_type, operation_type
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Missing required permissions: settings_general.view "
|
||||
f"(o permiso de vista del tipo de factura solicitado, p. ej. {_invoice_perm_base_for_settings(operation_type, invoice_type)}.view)",
|
||||
)
|
||||
|
||||
settings = services.get_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
invoice_type,
|
||||
operation_type
|
||||
)
|
||||
|
||||
if not settings:
|
||||
# Return empty default if not found, to simplify frontend logic
|
||||
return InvoiceSettingsResponse(
|
||||
invoice_type=invoice_type,
|
||||
operation_type=operation_type,
|
||||
settings={},
|
||||
id=0,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id
|
||||
)
|
||||
return InvoiceSettingsResponse.model_validate(settings)
|
||||
|
||||
@router.get("/", response_model=List[InvoiceSettingsResponse])
|
||||
def list_invoice_settings(
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""List all configured settings for validation or overview"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=["settings_general.view"],
|
||||
)
|
||||
|
||||
return services.list_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id
|
||||
)
|
||||
|
||||
@router.put("/", response_model=InvoiceSettingsResponse)
|
||||
def save_invoice_settings(
|
||||
settings_data: InvoiceSettingsRequest,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create or update invoice settings"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=["settings_general.edit"],
|
||||
)
|
||||
|
||||
return services.upsert_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
settings_data
|
||||
)
|
||||
from typing import List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, validate_access_to_resource
|
||||
from api.v1.modules.a76.invoice_settings import services
|
||||
from api.v1.modules.a76.invoice_settings.dto import InvoiceSettingsRequest, InvoiceSettingsResponse, OperationType
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
from api.v1.modules.a76.invoices.permission_map import get_invoice_permission_base
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/a76/invoice-settings",
|
||||
tags=["a76/invoice-settings"]
|
||||
)
|
||||
|
||||
|
||||
def _invoice_perm_base_for_settings(operation_type: OperationType, invoice_type: str) -> str:
|
||||
"""Alineado con `permission_map.get_invoice_permission_base`."""
|
||||
return get_invoice_permission_base(operation_type, invoice_type)
|
||||
|
||||
|
||||
def _can_read_invoice_settings_row(
|
||||
db: Session,
|
||||
company_id: int,
|
||||
current_user: Dict[str, Any],
|
||||
invoice_type: str,
|
||||
operation_type: OperationType,
|
||||
) -> bool:
|
||||
"""
|
||||
Ver configuración por tipo/op: settings_general.view O ver facturas de ese mismo contexto
|
||||
(para cargar defaults en alta/edición sin abrir la pantalla de parámetros).
|
||||
"""
|
||||
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||
if "admin" in user_roles:
|
||||
return True
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if not user_id:
|
||||
return False
|
||||
ps = PermissionService(db)
|
||||
if ps.has_permission(str(user_id), company_id, "settings_general.view"):
|
||||
return True
|
||||
base = _invoice_perm_base_for_settings(operation_type, invoice_type)
|
||||
return ps.has_permission(str(user_id), company_id, f"{base}.view")
|
||||
|
||||
|
||||
@router.get("/{invoice_type}", response_model=InvoiceSettingsResponse)
|
||||
def get_invoice_settings(
|
||||
invoice_type: str,
|
||||
operation_type: OperationType = Query(...),
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get settings for a specific invoice type and operation"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
if not _can_read_invoice_settings_row(
|
||||
db, company_id, current_user, invoice_type, operation_type
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Missing required permissions: settings_general.view "
|
||||
f"(o permiso de vista del tipo de factura solicitado, p. ej. {_invoice_perm_base_for_settings(operation_type, invoice_type)}.view)",
|
||||
)
|
||||
|
||||
settings = services.get_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
invoice_type,
|
||||
operation_type
|
||||
)
|
||||
|
||||
if not settings:
|
||||
# Return empty default if not found, to simplify frontend logic
|
||||
return InvoiceSettingsResponse(
|
||||
invoice_type=invoice_type,
|
||||
operation_type=operation_type,
|
||||
settings={},
|
||||
id=0,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id
|
||||
)
|
||||
return InvoiceSettingsResponse.model_validate(settings)
|
||||
|
||||
@router.get("/", response_model=List[InvoiceSettingsResponse])
|
||||
def list_invoice_settings(
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""List all configured settings for validation or overview"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=["settings_general.view"],
|
||||
)
|
||||
|
||||
return services.list_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id
|
||||
)
|
||||
|
||||
@router.put("/", response_model=InvoiceSettingsResponse)
|
||||
def save_invoice_settings(
|
||||
settings_data: InvoiceSettingsRequest,
|
||||
company_id: int = Query(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Create or update invoice settings"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=["settings_general.edit"],
|
||||
)
|
||||
|
||||
return services.upsert_settings(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
settings_data
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from core.security import get_current_user, validate_access_to_resource
|
||||
from api.v1.modules.core.tasks_tracking import track_and_dispatch
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, OperationType
|
||||
from api.v1.modules.a76.invoices.routes import get_invoice_permission_base
|
||||
from api.v1.modules.a76.invoices.permission_map import get_invoice_permission_base
|
||||
from .task import process_invoice_task
|
||||
from ...exports.process.task import process_export_invoice_task
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from core.security import get_current_user, validate_access_to_resource
|
||||
from api.v1.modules.core.tasks_tracking import track_and_dispatch
|
||||
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, OperationType
|
||||
from api.v1.modules.a76.invoices.routes import get_invoice_permission_base
|
||||
from api.v1.modules.a76.invoices.permission_map import get_invoice_permission_base
|
||||
from .task import revert_invoice_task as revert_import_invoice_task
|
||||
from ...exports.revert.task import revert_invoice_task as revert_export_invoice_task
|
||||
|
||||
|
||||
91
backend/api/v1/modules/a76/invoices/permission_map.py
Normal file
91
backend/api/v1/modules/a76/invoices/permission_map.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Única fuente de verdad para permisos de facturas por (operation_type, invoice_type).
|
||||
|
||||
Debe mantenerse alineado con el seed en api/v1/modules/core/permissions/seed.py
|
||||
y con frontend/src/lib/permissions/invoice-permissions.ts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
# Tipos de exportación que cubre únicamente invoice.exp.view (histórico list_invoices).
|
||||
EXP_TYPES_FROM_GENERAL_EXPORT_VIEW: tuple[str, ...] = (
|
||||
"EXDEF",
|
||||
"MATDE",
|
||||
"NODES",
|
||||
"PTERM",
|
||||
"SCRAP",
|
||||
"VEMEX",
|
||||
"VIRTU",
|
||||
"AFIJO",
|
||||
"REEXP",
|
||||
)
|
||||
|
||||
|
||||
def get_invoice_permission_base(operation_type: Any, invoice_type: Any) -> str:
|
||||
"""Devuelve la clave base del permiso (sin .view/.create/...) según tipo de factura."""
|
||||
op = str(operation_type).lower().split(".")[-1] if operation_type else ""
|
||||
inv = str(invoice_type).upper() if invoice_type else ""
|
||||
|
||||
if op == "imp":
|
||||
if inv == "TEM":
|
||||
return "invoice.imp.tem"
|
||||
if inv == "DEF":
|
||||
return "invoice.imp.def"
|
||||
if inv == "MEX":
|
||||
return "invoice.imp.cm"
|
||||
if inv == "CR":
|
||||
return "invoice.imp.cr"
|
||||
if inv == "REP":
|
||||
return "invoice.imp.rep"
|
||||
return "invoice.imp.tem"
|
||||
if op == "exp":
|
||||
if inv == "REPAR":
|
||||
return "invoice.exp.rep"
|
||||
if inv == "DONAC":
|
||||
return "invoice.exp.donac"
|
||||
return "invoice.exp"
|
||||
|
||||
return "invoice.imp.tem"
|
||||
|
||||
|
||||
def build_allowed_types_from_view_permissions(
|
||||
perm_codes: Iterable[str],
|
||||
) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Construye la lista de tuplas (operation_type en minúsculas, invoice_type en MAYÚSCULAS)
|
||||
permitidas por los permisos *.view del usuario. Misma lógica que el antiguo list_invoices
|
||||
(sin permiso global invoice.view_all — eliminado de producto).
|
||||
"""
|
||||
codes = set(perm_codes)
|
||||
allowed: list[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def add_pair(op: str, inv: str) -> None:
|
||||
pair = (op.lower(), inv.upper())
|
||||
if pair not in seen:
|
||||
seen.add(pair)
|
||||
allowed.append(pair)
|
||||
|
||||
if "invoice.imp.tem.view" in codes:
|
||||
add_pair("imp", "TEM")
|
||||
if "invoice.imp.def.view" in codes:
|
||||
add_pair("imp", "DEF")
|
||||
if "invoice.imp.cm.view" in codes:
|
||||
add_pair("imp", "MEX")
|
||||
if "invoice.imp.cr.view" in codes:
|
||||
add_pair("imp", "CR")
|
||||
if "invoice.imp.rep.view" in codes:
|
||||
add_pair("imp", "REP")
|
||||
|
||||
if "invoice.exp.rep.view" in codes:
|
||||
add_pair("exp", "REPAR")
|
||||
if "invoice.exp.donac.view" in codes:
|
||||
add_pair("exp", "DONAC")
|
||||
|
||||
if "invoice.exp.view" in codes:
|
||||
for t in EXP_TYPES_FROM_GENERAL_EXPORT_VIEW:
|
||||
add_pair("exp", t)
|
||||
|
||||
return allowed
|
||||
@@ -1,4 +1,7 @@
|
||||
import logging
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.security import collect_user_role_names, get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path
|
||||
@@ -7,28 +10,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from . import schemas, services, models
|
||||
from .catalog_service import InvoiceCatalogService
|
||||
from .permission_map import (
|
||||
build_allowed_types_from_view_permissions,
|
||||
get_invoice_permission_base,
|
||||
)
|
||||
|
||||
# Create main router
|
||||
router = APIRouter()
|
||||
|
||||
# --- 🛡️ FUNCIÓN EVALUADORA DE PERMISOS DINÁMICOS ---
|
||||
def get_invoice_permission_base(operation_type: Any, invoice_type: Any) -> str:
|
||||
"""Devuelve la clave base del permiso dependiendo del tipo de factura"""
|
||||
# Limpiamos los valores por si vienen como Enums
|
||||
op = str(operation_type).lower().split('.')[-1] if operation_type else ''
|
||||
inv = str(invoice_type).upper() if invoice_type else ''
|
||||
|
||||
if op == 'imp':
|
||||
if inv == 'TEM': return 'invoice.imp.tem'
|
||||
if inv == 'DEF': return 'invoice.imp.def'
|
||||
if inv == 'MEX': return 'invoice.imp.cm'
|
||||
if inv == 'CR': return 'invoice.imp.cr'
|
||||
return 'invoice.imp.tem' # Fallback
|
||||
elif op == 'exp':
|
||||
if inv == 'REPAR': return 'invoice.exp.rep'
|
||||
return 'invoice.exp'
|
||||
|
||||
return 'invoice.imp.tem' # Fallback general
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- RUTAS DE UTILIDAD ---
|
||||
@@ -36,17 +25,50 @@ def get_invoice_permission_base(operation_type: Any, invoice_type: Any) -> str:
|
||||
@router.get("/invoices/creation-data", response_model=schemas.InvoiceCreationResponse)
|
||||
def get_creation_data(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
operation_type: schemas.OperationType = Query(..., description="Operation type for the new invoice"),
|
||||
invoice_type: Optional[str] = Query(
|
||||
None,
|
||||
description="Invoice type key (required for import; optional for export — uses invoice.exp.create)",
|
||||
),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""Get consolidated data for creating a new invoice"""
|
||||
try:
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
ot = operation_type.value if hasattr(operation_type, "value") else operation_type
|
||||
if ot == "imp":
|
||||
if not invoice_type:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="invoice_type is required for import invoices",
|
||||
)
|
||||
perm_base = get_invoice_permission_base(operation_type, invoice_type)
|
||||
validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=[f"{perm_base}.create"],
|
||||
)
|
||||
else:
|
||||
if invoice_type:
|
||||
perm_base = get_invoice_permission_base(operation_type, invoice_type)
|
||||
validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=[f"{perm_base}.create"],
|
||||
)
|
||||
else:
|
||||
validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=["invoice.exp.create"],
|
||||
)
|
||||
return InvoiceCatalogService.get_creation_data(db, tenant_id, company_id)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] get_creation_data failed: {str(e)}")
|
||||
traceback.print_exc()
|
||||
logger.exception("get_creation_data failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Error al cargar datos de creación: {str(e)}")
|
||||
|
||||
@router.get("/invoices/{invoice_id}/edition-data", response_model=schemas.InvoiceEditionResponse)
|
||||
@@ -74,19 +96,68 @@ def get_edition_data(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] get_edition_data failed: {str(e)}")
|
||||
traceback.print_exc()
|
||||
logger.exception("get_edition_data failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Error al cargar datos de edición: {str(e)}")
|
||||
|
||||
@router.get("/invoices/remesa-suggestion", response_model=Dict[str, int])
|
||||
def get_remesa_suggestion(
|
||||
pedimento_id: int = Query(..., description="Pedimento ID"),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
operation_type: Optional[schemas.OperationType] = Query(
|
||||
None,
|
||||
description="Required when no invoice exists yet for this pedimento (context for permission)",
|
||||
),
|
||||
invoice_type: Optional[str] = Query(
|
||||
None,
|
||||
description="Required when no invoice exists yet for this pedimento (context for permission)",
|
||||
),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Sugiere siguiente remesa. Si ya hay facturas ligadas al pedimento, el permiso se infiere de ellas.
|
||||
Si no hay facturas en compliance para ese pedimento, pasar operation_type e invoice_type del alta en curso.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
|
||||
invoice_for_perm = (
|
||||
db.query(models.InvoiceHeader)
|
||||
.join(
|
||||
models.InvoiceComplianceMx,
|
||||
models.InvoiceComplianceMx.invoice_id == models.InvoiceHeader.id,
|
||||
)
|
||||
.filter(
|
||||
models.InvoiceComplianceMx.pedimento_id == pedimento_id,
|
||||
models.InvoiceHeader.tenant_id == tenant_id,
|
||||
models.InvoiceHeader.company_id == company_id,
|
||||
)
|
||||
.order_by(models.InvoiceHeader.id.desc())
|
||||
.first()
|
||||
)
|
||||
if invoice_for_perm:
|
||||
perm_base = get_invoice_permission_base(
|
||||
invoice_for_perm.operation_type, invoice_for_perm.invoice_type
|
||||
)
|
||||
validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=[f"{perm_base}.edit"],
|
||||
)
|
||||
elif operation_type is not None and invoice_type:
|
||||
perm_base = get_invoice_permission_base(operation_type, invoice_type)
|
||||
validate_access_to_resource(
|
||||
db,
|
||||
company_id,
|
||||
current_user,
|
||||
required_permissions=[f"{perm_base}.edit"],
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="operation_type and invoice_type are required when no invoice is linked to this pedimento yet",
|
||||
)
|
||||
|
||||
max_rem = (
|
||||
db.query(func.max(models.InvoiceComplianceMx.remesa))
|
||||
.filter(
|
||||
@@ -117,9 +188,7 @@ def create_invoice(
|
||||
|
||||
return services.InvoiceService.create(db, data, tenant_id, company_id)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] create_invoice failed: {str(e)}")
|
||||
traceback.print_exc()
|
||||
logger.exception("create_invoice failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Error al guardar factura: {str(e)}")
|
||||
|
||||
@router.get("/invoices/{invoice_id}", response_model=schemas.InvoiceHeaderResponse)
|
||||
@@ -203,56 +272,38 @@ def list_invoices(
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
# Roles vienen del Hub (/auth/me), no de realm_access del JWT crudo.
|
||||
is_hub_admin = "admin" in collect_user_role_names(current_user)
|
||||
allowed_filters = []
|
||||
|
||||
# Información del usuario para debugging (se ve en los logs del servidor)
|
||||
user_name = current_user.get('preferred_username') or current_user.get('email', 'Desconocido')
|
||||
|
||||
from api.v1.modules.core.permissions.service import PermissionService
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
perm_service = PermissionService(db)
|
||||
perm_codes = perm_service.get_user_permissions(user_id, company_id)
|
||||
|
||||
# 🕵️ DEBUG LOGS - Cruciales para diagnosticar filtrado que no funciona
|
||||
print(f"[AUTH] User: {user_name} (ID: {user_id})")
|
||||
print(f"[AUTH] App Permissions (C{company_id}): {perm_codes}")
|
||||
|
||||
# Definimos si debe saltar el filtrado granular (SOLO con permiso explícito)
|
||||
has_global_view = "invoice.view_all" in perm_codes
|
||||
|
||||
# El rol de admin de Keycloak ya NO otorga bypass automático si hay permisos granulares
|
||||
if has_global_view:
|
||||
print(f"[AUTH] GLOBAL ACCESS for {user_name}")
|
||||
allowed_filters = None
|
||||
else:
|
||||
# Aplicamos filtros basados en permisos específicos
|
||||
# Importaciones
|
||||
if "invoice.imp.tem.view" in perm_codes: allowed_filters.append(("imp", "TEM"))
|
||||
if "invoice.imp.def.view" in perm_codes: allowed_filters.append(("imp", "DEF"))
|
||||
if "invoice.imp.cm.view" in perm_codes: allowed_filters.append(("imp", "MEX"))
|
||||
if "invoice.imp.cr.view" in perm_codes: allowed_filters.append(("imp", "CR"))
|
||||
if "invoice.imp.rep.view" in perm_codes: allowed_filters.append(("imp", "REP"))
|
||||
|
||||
# Exportaciones
|
||||
if "invoice.exp.rep.view" in perm_codes: allowed_filters.append(("exp", "REPAR"))
|
||||
if "invoice.exp.donac.view" in perm_codes: allowed_filters.append(("exp", "DONAC"))
|
||||
|
||||
# Permiso general de exportación
|
||||
if "invoice.exp.view" in perm_codes:
|
||||
for t in ["EXDEF", "MATDE", "NODES", "PTERM", "SCRAP", "VEMEX", "VIRTU", "AFIJO", "REEXP"]:
|
||||
if ("exp", t) not in allowed_filters:
|
||||
allowed_filters.append(("exp", t))
|
||||
if settings.ENVIRONMENT == "development":
|
||||
user_name = current_user.get("preferred_username") or current_user.get(
|
||||
"email", "Desconocido"
|
||||
)
|
||||
logger.debug(
|
||||
"[invoices.list] user=%s id=%s company=%s perms=%s",
|
||||
user_name,
|
||||
user_id,
|
||||
company_id,
|
||||
perm_codes,
|
||||
)
|
||||
|
||||
print(f"[AUTH] Filtered access for {user_name}. Allowed types count: {len(allowed_filters)}")
|
||||
allowed_filters = build_allowed_types_from_view_permissions(perm_codes)
|
||||
|
||||
if not allowed_filters:
|
||||
# Si no tiene ningún permiso de factura, bloqueamos
|
||||
# Excepto si es admin (Hub roles / Keycloak), fallback a ver todo.
|
||||
if is_hub_admin:
|
||||
print(f"[AUTH] Keycloak Admin {user_name} has no app permissions. Granting view_all as fallback.")
|
||||
allowed_filters = None
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="No tienes permisos para ver facturas en esta empresa")
|
||||
if not allowed_filters:
|
||||
if is_hub_admin:
|
||||
logger.debug(
|
||||
"[invoices.list] hub admin without invoice view perms — unfiltered types fallback"
|
||||
)
|
||||
allowed_filters = None
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="No tienes permisos para ver facturas en esta empresa",
|
||||
)
|
||||
|
||||
try:
|
||||
skip = (page - 1) * page_size
|
||||
@@ -283,9 +334,7 @@ def list_invoices(
|
||||
"page_size": page_size
|
||||
}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] list_invoices failed: {str(e)}")
|
||||
traceback.print_exc()
|
||||
logger.exception("list_invoices failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error in invoices list: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@@ -400,10 +400,14 @@ export const invoicesApi = {
|
||||
/**
|
||||
* Obtiene datos para la creación de una factura (catálogos consolidados)
|
||||
*/
|
||||
getCreationData: (companyId: number) => {
|
||||
getCreationData: (companyId: number, operationType: string, invoiceType?: string | null) => {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
company_id: companyId.toString(),
|
||||
operation_type: operationType
|
||||
});
|
||||
if (invoiceType) {
|
||||
params.set('invoice_type', invoiceType);
|
||||
}
|
||||
return api.get<any>(`/v1/a76/invoices/creation-data?${params.toString()}`);
|
||||
},
|
||||
|
||||
|
||||
114
frontend/src/lib/permissions/invoice-permissions.ts
Normal file
114
frontend/src/lib/permissions/invoice-permissions.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Permisos de facturas — mantener alineado con
|
||||
* backend/api/v1/modules/a76/invoices/permission_map.py
|
||||
*/
|
||||
import type { User } from '$lib/auth';
|
||||
import { userHasPermission } from '$lib/auth';
|
||||
|
||||
/** Permisos *.view definidos en seed (facturas); incluye genérico exp. */
|
||||
export const INVOICE_VIEW_PERMISSION_CODES: string[] = [
|
||||
'invoice.imp.tem.view',
|
||||
'invoice.imp.def.view',
|
||||
'invoice.imp.cm.view',
|
||||
'invoice.imp.cr.view',
|
||||
'invoice.imp.rep.view',
|
||||
'invoice.exp.rep.view',
|
||||
'invoice.exp.donac.view',
|
||||
'invoice.exp.view'
|
||||
];
|
||||
|
||||
/** Permisos *.create del seed de facturas (para “puede crear algo”). */
|
||||
const INVOICE_ANY_CREATE_CODES: string[] = [
|
||||
'invoice.imp.tem.create',
|
||||
'invoice.imp.def.create',
|
||||
'invoice.imp.cm.create',
|
||||
'invoice.imp.cr.create',
|
||||
'invoice.imp.rep.create',
|
||||
'invoice.exp.create',
|
||||
'invoice.exp.rep.create',
|
||||
'invoice.exp.donac.create'
|
||||
];
|
||||
|
||||
export function getInvoicePermissionBase(operationType: string, invoiceType: string): string {
|
||||
const op = (operationType || '').toLowerCase().split('.').pop() || '';
|
||||
const inv = (invoiceType || '').toUpperCase();
|
||||
|
||||
if (op === 'imp') {
|
||||
if (inv === 'TEM') return 'invoice.imp.tem';
|
||||
if (inv === 'DEF') return 'invoice.imp.def';
|
||||
if (inv === 'MEX') return 'invoice.imp.cm';
|
||||
if (inv === 'CR') return 'invoice.imp.cr';
|
||||
if (inv === 'REP') return 'invoice.imp.rep';
|
||||
return 'invoice.imp.tem';
|
||||
}
|
||||
if (op === 'exp') {
|
||||
if (inv === 'REPAR') return 'invoice.exp.rep';
|
||||
if (inv === 'DONAC') return 'invoice.exp.donac';
|
||||
return 'invoice.exp';
|
||||
}
|
||||
return 'invoice.imp.tem';
|
||||
}
|
||||
|
||||
export function userHasInvoiceAction(
|
||||
user: User | null,
|
||||
operationType: string,
|
||||
invoiceType: string,
|
||||
action: 'view' | 'create' | 'edit' | 'delete' | 'process'
|
||||
): boolean {
|
||||
if (!operationType || !invoiceType) return false;
|
||||
const base = getInvoicePermissionBase(operationType, invoiceType);
|
||||
return userHasPermission(user, `${base}.${action}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acceso a la lista de facturas según filtros de URL.
|
||||
* Sin operation_type/invoice_type: requiere al menos un permiso *.view de facturas.
|
||||
*/
|
||||
export function userCanViewInvoiceListPage(
|
||||
user: User | null,
|
||||
operationType: string,
|
||||
invoiceType: string
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
const ot = (operationType || '').trim();
|
||||
const it = (invoiceType || '').trim();
|
||||
|
||||
if (ot && it) {
|
||||
const base = getInvoicePermissionBase(ot, it);
|
||||
return userHasPermission(user, `${base}.view`);
|
||||
}
|
||||
|
||||
if (ot && !it) {
|
||||
const prefix = ot === 'imp' ? 'invoice.imp.' : ot === 'exp' ? 'invoice.exp.' : '';
|
||||
if (!prefix) {
|
||||
return INVOICE_VIEW_PERMISSION_CODES.some((code) => userHasPermission(user, code));
|
||||
}
|
||||
return INVOICE_VIEW_PERMISSION_CODES.some(
|
||||
(code) => code.startsWith(prefix) && code.endsWith('.view') && userHasPermission(user, code)
|
||||
);
|
||||
}
|
||||
|
||||
return INVOICE_VIEW_PERMISSION_CODES.some((code) => userHasPermission(user, code));
|
||||
}
|
||||
|
||||
export function userCanCreateInvoiceForListFilters(
|
||||
user: User | null,
|
||||
operationType: string,
|
||||
invoiceType: string
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
const ot = (operationType || '').trim();
|
||||
const it = (invoiceType || '').trim();
|
||||
|
||||
if (ot === 'imp' && it) {
|
||||
return userHasInvoiceAction(user, ot, it, 'create');
|
||||
}
|
||||
if (ot === 'exp') {
|
||||
if (it) {
|
||||
return userHasInvoiceAction(user, ot, it, 'create');
|
||||
}
|
||||
return userHasPermission(user, 'invoice.exp.create');
|
||||
}
|
||||
|
||||
return INVOICE_ANY_CREATE_CODES.some((code) => userHasPermission(user, code));
|
||||
}
|
||||
@@ -24,6 +24,12 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getAccessTokenFromDocument } from '$lib/access-token-cookie-browser';
|
||||
import { currentUser } from '$lib/auth';
|
||||
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
|
||||
import {
|
||||
userCanViewInvoiceListPage,
|
||||
userHasInvoiceAction,
|
||||
userCanCreateInvoiceForListFilters
|
||||
} from '$lib/permissions/invoice-permissions';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getCompany } from '$lib/api/dashboard/a76/general_catalogs/company';
|
||||
import { usersAPI } from '$lib/api/dashboard/users';
|
||||
@@ -265,6 +271,71 @@
|
||||
|
||||
const hasSelection = $derived(selectedInvoiceIds.length > 0);
|
||||
|
||||
const canViewInvoiceSection = $derived(
|
||||
userCanViewInvoiceListPage(
|
||||
$currentUser,
|
||||
filters.operation_type,
|
||||
filters.invoice_type
|
||||
)
|
||||
);
|
||||
|
||||
const canCreateInvoice = $derived(
|
||||
userCanCreateInvoiceForListFilters(
|
||||
$currentUser,
|
||||
filters.operation_type,
|
||||
filters.invoice_type
|
||||
)
|
||||
);
|
||||
|
||||
const canEditSelected = $derived(
|
||||
selectedInvoice
|
||||
? userHasInvoiceAction(
|
||||
$currentUser,
|
||||
String(selectedInvoice.operation_type),
|
||||
String(selectedInvoice.invoice_type),
|
||||
'edit'
|
||||
)
|
||||
: false
|
||||
);
|
||||
|
||||
const canDeleteSelection = $derived.by(() => {
|
||||
if (selectedInvoiceIds.length === 0) return false;
|
||||
const items = selectedInvoiceIds
|
||||
.map((id) => allItems.find((i) => i.id === id))
|
||||
.filter((i): i is Invoice => i !== undefined);
|
||||
if (items.length !== selectedInvoiceIds.length) return false;
|
||||
return items.every((inv) =>
|
||||
userHasInvoiceAction(
|
||||
$currentUser,
|
||||
String(inv.operation_type),
|
||||
String(inv.invoice_type),
|
||||
'delete'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const canProcessSelected = $derived(
|
||||
selectedInvoice
|
||||
? userHasInvoiceAction(
|
||||
$currentUser,
|
||||
String(selectedInvoice.operation_type),
|
||||
String(selectedInvoice.invoice_type),
|
||||
'process'
|
||||
)
|
||||
: false
|
||||
);
|
||||
|
||||
const canViewSelected = $derived(
|
||||
selectedInvoice
|
||||
? userHasInvoiceAction(
|
||||
$currentUser,
|
||||
String(selectedInvoice.operation_type),
|
||||
String(selectedInvoice.invoice_type),
|
||||
'view'
|
||||
)
|
||||
: false
|
||||
);
|
||||
|
||||
// Submenú contextual para Interface VU (click derecho); overlay conservado para trabajo futuro
|
||||
let showVuSubmenu = $state(false);
|
||||
let vuSubmenuPosition = $state({ x: 0, y: 0 });
|
||||
@@ -1438,6 +1509,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !canViewInvoiceSection}
|
||||
<div
|
||||
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
|
||||
>
|
||||
<ErrorState status={403} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
|
||||
<a
|
||||
href="#invoice-list-footer"
|
||||
@@ -1455,7 +1533,7 @@
|
||||
<Settings class="mr-2" size={16} />
|
||||
{m.invoice_list_actions_parameters()}
|
||||
</Button>
|
||||
<Button class="h-9" onclick={handleCreateClick}>
|
||||
<Button class="h-9" onclick={handleCreateClick} disabled={!canCreateInvoice}>
|
||||
<Plus class="mr-2" size={16} />
|
||||
{m.invoice_list_actions_new_invoice()}
|
||||
</Button>
|
||||
@@ -2010,7 +2088,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || selectedInvoiceIds.length !== 1}
|
||||
disabled={loading || selectedInvoiceIds.length !== 1 || !canProcessSelected}
|
||||
onclick={handleProcessInvoice}
|
||||
data-footer-action="procesar"
|
||||
>
|
||||
@@ -2020,7 +2098,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || selectedInvoiceIds.length !== 1}
|
||||
disabled={loading || selectedInvoiceIds.length !== 1 || !canProcessSelected}
|
||||
onclick={() => (isRevertConfirmOpen = true)}
|
||||
data-footer-action="desactualizar"
|
||||
>
|
||||
@@ -2030,7 +2108,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={selectedInvoiceIds.length !== 1}
|
||||
disabled={selectedInvoiceIds.length !== 1 || !canViewSelected}
|
||||
onclick={() => (showDetailsDialog = true)}
|
||||
data-footer-action="detalles"
|
||||
>
|
||||
@@ -2052,7 +2130,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={selectedInvoiceIds.length !== 1}
|
||||
disabled={selectedInvoiceIds.length !== 1 || !canEditSelected}
|
||||
onclick={handleEditSelected}
|
||||
data-footer-action="editar"
|
||||
>
|
||||
@@ -2062,7 +2140,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={selectedInvoiceIds.length === 0}
|
||||
disabled={selectedInvoiceIds.length === 0 || !canDeleteSelection}
|
||||
onclick={() => (showDeleteDialog = true)}
|
||||
data-footer-action="eliminar"
|
||||
>
|
||||
@@ -2103,3 +2181,4 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -57,22 +57,26 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
|
||||
if (params.id === 'new') {
|
||||
isCreate = true;
|
||||
// Call Consolidated Creation Endpoint
|
||||
const creationQs = new URLSearchParams({
|
||||
company_id: String(companyId),
|
||||
operation_type: parsedOperationType || ''
|
||||
});
|
||||
if (invoiceTypeParam) {
|
||||
creationQs.set('invoice_type', invoiceTypeParam);
|
||||
}
|
||||
// Call Consolidated Creation Endpoint (RBAC: tipo de alta en query)
|
||||
const [creationResponse, settingsResult] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`v1/a76/invoices/creation-data?company_id=${companyId}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
),
|
||||
authenticatedFetch(`v1/a76/invoices/creation-data?${creationQs.toString()}`, {}, cookies, fetch),
|
||||
settingsPromise
|
||||
]);
|
||||
|
||||
if (creationResponse.ok) {
|
||||
catalogsData = await creationResponse.json();
|
||||
} else if (creationResponse.status === 403) {
|
||||
throw error(403, 'No tienes permiso para crear este tipo de factura.');
|
||||
} else {
|
||||
console.error('Error fetching creation data:', creationResponse.status);
|
||||
// We continuing with empty catalogs might be better than crashing?
|
||||
// We continuing with empty catalogs might be better than crashing?
|
||||
// But UI will likely be broken. Let's rely on empty arrays initialization below.
|
||||
}
|
||||
|
||||
|
||||
@@ -649,9 +649,18 @@
|
||||
const myReq = ++remesaSuggestionReqId;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(
|
||||
`/v1/a76/invoices/remesa-suggestion?company_id=${companyId}&pedimento_id=${pedimentoId}`
|
||||
);
|
||||
const op =
|
||||
InvoiceTopFieldsFormData?.operation_type ||
|
||||
data.invoice?.operation_type ||
|
||||
data.filters?.operation_type;
|
||||
const inv =
|
||||
InvoiceTopFieldsFormData?.invoice_type ||
|
||||
data.invoice?.invoice_type ||
|
||||
data.filters?.invoice_type;
|
||||
let remesaUrl = `/v1/a76/invoices/remesa-suggestion?company_id=${companyId}&pedimento_id=${pedimentoId}`;
|
||||
if (op) remesaUrl += `&operation_type=${encodeURIComponent(String(op))}`;
|
||||
if (inv) remesaUrl += `&invoice_type=${encodeURIComponent(String(inv))}`;
|
||||
const res = await api.get(remesaUrl);
|
||||
if (myReq !== remesaSuggestionReqId) return;
|
||||
const next = (res as any)?.data?.next_remesa;
|
||||
if (typeof next === 'number' && next > 0) {
|
||||
|
||||
Reference in New Issue
Block a user