diff --git a/backend/api/v1/modules/a76/audit_log/events.py b/backend/api/v1/modules/a76/audit_log/events.py index 893d6569..3a790310 100644 --- a/backend/api/v1/modules/a76/audit_log/events.py +++ b/backend/api/v1/modules/a76/audit_log/events.py @@ -8,6 +8,7 @@ from sqlalchemy import event, inspect from sqlalchemy.orm import Session from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.invoices.models import InvoiceHeader from core.database import rls_company_var, rls_tenant_var from .services.service import AuditService @@ -48,15 +49,39 @@ def _resolve_audit_company_tenant(session: Session, target) -> tuple: company_id / tenant_id desde la fila ORM, ContextVars RLS (petición HTTP), o ``Company.tenant_id`` por ``company_id``. """ + resolution_source = "target" company_id = getattr(target, "company_id", None) if company_id is None and getattr(target, "__tablename__", None) == "company": company_id = getattr(target, "id", None) + if company_id is not None: + resolution_source = "company_self_id" if company_id is None: company_id = rls_company_var.get() + if company_id is not None: + resolution_source = "rls_context" tenant_id = getattr(target, "tenant_id", None) if tenant_id is None: tenant_id = rls_tenant_var.get() + if tenant_id is not None and resolution_source == "target": + resolution_source = "rls_context" + + # Common fallback for invoice child tables where invoice_id points to header scope. + if (company_id is None or tenant_id is None) and hasattr(target, "invoice_id"): + invoice_id = getattr(target, "invoice_id", None) + if invoice_id is not None: + invoice_scope = ( + session.query(InvoiceHeader.company_id, InvoiceHeader.tenant_id) + .filter(InvoiceHeader.id == invoice_id, InvoiceHeader.deleted_at.is_(None)) + .first() + ) + if invoice_scope: + if company_id is None: + company_id = invoice_scope[0] + if tenant_id is None: + tenant_id = invoice_scope[1] + resolution_source = "invoice_header_lookup" + if tenant_id is None and company_id is not None: row = ( session.query(Company.tenant_id) @@ -65,8 +90,9 @@ def _resolve_audit_company_tenant(session: Session, target) -> tuple: ) if row: tenant_id = int(row[0]) + resolution_source = "company_lookup" - return company_id, tenant_id + return company_id, tenant_id, resolution_source def after_insert_listener(mapper, connection, target): @@ -79,13 +105,17 @@ def after_insert_listener(mapper, connection, target): session = Session(bind=connection) try: - company_id, tenant_id = _resolve_audit_company_tenant(session, target) + company_id, tenant_id, resolution_source = _resolve_audit_company_tenant( + session, target + ) if company_id is None or tenant_id is None: - logger.debug( - "Audit skip INSERT %s: missing company_id=%s tenant_id=%s", + logger.warning( + "Audit skip INSERT table=%s record_id=%s company_id=%s tenant_id=%s source=%s", table_name, + getattr(target, "id", None), company_id, tenant_id, + resolution_source, ) return @@ -131,13 +161,17 @@ def after_update_listener(mapper, connection, target): session = Session(bind=connection) try: - company_id, tenant_id = _resolve_audit_company_tenant(session, target) + company_id, tenant_id, resolution_source = _resolve_audit_company_tenant( + session, target + ) if company_id is None or tenant_id is None: - logger.debug( - "Audit skip UPDATE %s: missing company_id=%s tenant_id=%s", + logger.warning( + "Audit skip UPDATE table=%s record_id=%s company_id=%s tenant_id=%s source=%s", table_name, + getattr(target, "id", None), company_id, tenant_id, + resolution_source, ) return @@ -169,13 +203,17 @@ def after_delete_listener(mapper, connection, target): session = Session(bind=connection) try: - company_id, tenant_id = _resolve_audit_company_tenant(session, target) + company_id, tenant_id, resolution_source = _resolve_audit_company_tenant( + session, target + ) if company_id is None or tenant_id is None: - logger.debug( - "Audit skip DELETE %s: missing company_id=%s tenant_id=%s", + logger.warning( + "Audit skip DELETE table=%s record_id=%s company_id=%s tenant_id=%s source=%s", table_name, + getattr(target, "id", None), company_id, tenant_id, + resolution_source, ) return diff --git a/backend/api/v1/modules/a76/audit_log/register.py b/backend/api/v1/modules/a76/audit_log/register.py index b90f7284..3d86eaa2 100644 --- a/backend/api/v1/modules/a76/audit_log/register.py +++ b/backend/api/v1/modules/a76/audit_log/register.py @@ -1,11 +1,22 @@ # Importar modelos para Audit Log from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos -from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, + InvoiceSalesDetails, + InvoiceComplianceMx, + InvoiceFinancials, + InvoiceLogistics, + InvoiceCollections, +) from api.v1.modules.a76.audit_log.events import register_audit_listeners # Core Modules from api.v1.modules.a76.clients_and_providers.models import ClientProvider -from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.customs_brokers.models import ( + CustomsBroker, + CustomsBrokerVU, + CustomsBrokerPersonnel, +) from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie @@ -92,11 +103,17 @@ def register_audit(): Pedimentos, InvoiceHeader, InvoiceSalesDetails, + InvoiceComplianceMx, + InvoiceFinancials, + InvoiceLogistics, + InvoiceCollections, LineItem, Serie, # Sidebar Core Modules ClientProvider, CustomsBroker, + CustomsBrokerVU, + CustomsBrokerPersonnel, Part, Company, # Transportation Modules diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py index 11f1caed..58462cfd 100644 --- a/backend/api/v1/modules/a76/audit_log/router.py +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -9,7 +9,7 @@ from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import or_, desc -from core.database import get_core_db +from core.database import get_core_db, set_rls_context from core.security import get_current_user, validate_access_to_resource from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket from .models import AuditLog @@ -209,6 +209,7 @@ async def get_bitacora( required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) + set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id) query = db.query(AuditLog).filter( AuditLog.company_id == company_id, @@ -271,6 +272,7 @@ async def get_procedures( required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) + set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id) results = ( db.query(AuditLog.procedure) @@ -301,6 +303,7 @@ async def get_audit_detail( required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) + set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id) log = ( db.query(AuditLog) @@ -339,6 +342,7 @@ async def list_tenant_files( required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) + set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id) tenant_prefix = _tenant_prefix(scope_tenant_id) rel_path = _normalize_relative_path(path) list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix @@ -434,6 +438,7 @@ async def download_tenant_file( required_permissions=["audit_logs.view"], ) scope_tenant_id = _audit_scope_tenant_id(db, company_id) + set_rls_context(db, tenant_id=scope_tenant_id, company_id=company_id) tenant_prefix = _tenant_prefix(scope_tenant_id) rel_path = _normalize_relative_path(path) if not rel_path or rel_path.endswith("/"): diff --git a/backend/api/v1/modules/a76/audit_log/services/core.py b/backend/api/v1/modules/a76/audit_log/services/core.py index 3413b148..9fc1e1de 100644 --- a/backend/api/v1/modules/a76/audit_log/services/core.py +++ b/backend/api/v1/modules/a76/audit_log/services/core.py @@ -76,6 +76,10 @@ class AuditMapper: # Invoices "invoice_header": "IMPORT INVOICE BROWSE", # BROWSEOFACIMP "invoice_sales_details": "IMPORT INVOICE UPDATE", # UPDATEOFACIMP + "invoice_compliance_mx": "IMPORT INVOICE UPDATE", + "invoice_financials": "IMPORT INVOICE UPDATE", + "invoice_logistics": "IMPORT INVOICE UPDATE", + "invoice_collections": "IMPORT INVOICE UPDATE", # Exports would be similar but we start with general @@ -88,9 +92,11 @@ class AuditMapper: # General fallbacks "clients_and_providers": "CATALOGS", - "clients_and_providers": "CATALOGS", - "clients_and_providers": "CATALOGS", "items": "CATALOGS", + "parts": "CATALOGS", + "customs_brokers": "CATALOGS", + "customs_brokers_vu": "CATALOGS", + "customs_brokers_personnel": "CATALOGS", "classes": "CATALOGS", "classification_concepts": "CATALOGS", "concepts": "CATALOGS", @@ -107,6 +113,7 @@ class AuditMapper: "ports": "CATALOGS", "prevalidators": "CATALOGS", "seal": "CATALOGS", + "seals": "CATALOGS", "signatures": "CATALOGS", "tariff_fractions": "CATALOGS", "unit_conversions": "CATALOGS", @@ -127,6 +134,18 @@ class AuditMapper: ("invoice_sales_details", "CREATE"): "ADD IMPORT_INVOICE_ITEM", ("invoice_sales_details", "UPDATE"): "EDIT IMPORT_INVOICE_ITEM", ("invoice_sales_details", "DELETE"): "DELETE IMPORT_INVOICE_ITEM", + ("invoice_compliance_mx", "CREATE"): "ADD IMPORT_INVOICE", + ("invoice_compliance_mx", "UPDATE"): "EDIT IMPORT_INVOICE", + ("invoice_compliance_mx", "DELETE"): "DELETE IMPORT_INVOICE", + ("invoice_financials", "CREATE"): "ADD IMPORT_INVOICE", + ("invoice_financials", "UPDATE"): "EDIT IMPORT_INVOICE", + ("invoice_financials", "DELETE"): "DELETE IMPORT_INVOICE", + ("invoice_logistics", "CREATE"): "ADD IMPORT_INVOICE", + ("invoice_logistics", "UPDATE"): "EDIT IMPORT_INVOICE", + ("invoice_logistics", "DELETE"): "DELETE IMPORT_INVOICE", + ("invoice_collections", "CREATE"): "ADD IMPORT_INVOICE", + ("invoice_collections", "UPDATE"): "EDIT IMPORT_INVOICE", + ("invoice_collections", "DELETE"): "DELETE IMPORT_INVOICE", ("pedimentos", "CREATE"): "ADD PEDIMENTO", ("pedimentos", "UPDATE"): "EDIT PEDIMENTO", @@ -146,6 +165,9 @@ class AuditMapper: ("company", "CREATE"): "ADD COMPANY", ("company", "UPDATE"): "EDIT COMPANY", ("company", "DELETE"): "DELETE COMPANY", + ("companies", "CREATE"): "ADD COMPANY", + ("companies", "UPDATE"): "EDIT COMPANY", + ("companies", "DELETE"): "DELETE COMPANY", } @staticmethod diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index c390e0fb..2d834aa2 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -153,7 +153,7 @@ class AuditService: elif table_name == "parts": reference = record_data.get("part_number") or reference - elif table_name == "companies": + elif table_name in {"companies", "company"}: reference = record_data.get("rfc") or reference elif table_name == "classes": diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 8958324a..030640c9 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -4,6 +4,8 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, func, or_ from core.exceptions import ErrorCollector, DuplicateResourceException from core.context import get_user_context +from api.v1.modules.a76.audit_log.models import AuditLog +from api.v1.modules.a76.audit_log.services.service import AuditService from .common.mappers import clean_dict from .imports.validators.create import validate_create as validate_create_import from .imports.validators.update import validate_update as validate_update_import @@ -221,6 +223,47 @@ def _autofill_remesa_if_needed(db: Session, invoice_data, tenant_id: int, compan return +def _ensure_create_audit_log( + db: Session, + *, + table_name: str, + record_id: str, + record_data: dict, + username: str, + tenant_id: int, + company_id: int, +) -> bool: + """ + Backup audit writer: writes CREATE only when missing. + Returns True when a backup log row was added. + """ + exists = ( + db.query(AuditLog.spec_id) + .filter( + AuditLog.table_name == table_name, + AuditLog.operation_type == "CREATE", + AuditLog.record_id == record_id, + AuditLog.tenant_id == tenant_id, + AuditLog.company_id == company_id, + ) + .first() + ) + if exists: + return False + + AuditService.log_crud_operation( + db=db, + table_name=table_name, + operation_type="CREATE", + record_data=record_data, + username=username, + record_id=record_id, + company_id=company_id, + tenant_id=tenant_id, + ) + return True + + class InvoiceService: """Service for Invoice Header operations""" @@ -521,6 +564,36 @@ class InvoiceService: db.commit() db.refresh(new_invoice) + backup_written = _ensure_create_audit_log( + db, + table_name=new_invoice.__tablename__, + record_id=str(new_invoice.id), + record_data={ + c.name: getattr(new_invoice, c.name) + for c in models.InvoiceHeader.__table__.columns + }, + username=username, + tenant_id=tenant_id, + company_id=company_id, + ) + for detail in new_invoice.details: + backup_written = ( + _ensure_create_audit_log( + db, + table_name=detail.__tablename__, + record_id=str(detail.id), + record_data={ + c.name: getattr(detail, c.name) + for c in models.InvoiceSalesDetails.__table__.columns + }, + username=username, + tenant_id=tenant_id, + company_id=company_id, + ) + or backup_written + ) + if backup_written: + db.commit() return new_invoice except Exception as e: diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index a58756f6..e291ef5c 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -12,6 +12,9 @@ from sqlalchemy.orm import selectinload from sqlalchemy.exc import IntegrityError from datetime import datetime +from api.v1.modules.a76.audit_log.models import AuditLog +from api.v1.modules.a76.audit_log.services.service import AuditService +from core.context import get_user_context from ..dtos.pedimentos import PedimentosCreate, PedimentosUpdate from .pedimento_config_additional import PedimentoConfigAdditionalService @@ -59,6 +62,54 @@ from ..models.pedimento_contributions import PedimentoContributions logger = logging.getLogger(__name__) +def _ensure_create_audit_log( + db: Session, *, table_name: str, record_id: str, record_data: Dict[str, Any], username: str, tenant_id: int, company_id: int +) -> None: + """ + Backup audit writer: inserts CREATE log only when listener did not. + """ + exists = ( + db.query(AuditLog.spec_id) + .filter( + AuditLog.table_name == table_name, + AuditLog.operation_type == "CREATE", + AuditLog.record_id == record_id, + AuditLog.tenant_id == tenant_id, + AuditLog.company_id == company_id, + ) + .first() + ) + if exists: + return + + AuditService.log_crud_operation( + db=db, + table_name=table_name, + operation_type="CREATE", + record_data=record_data, + username=username, + record_id=record_id, + company_id=company_id, + tenant_id=tenant_id, + ) + db.commit() + + +def _get_current_username() -> str: + try: + context = get_user_context() + if context: + return ( + context.get("preferred_username") + or context.get("email") + or context.get("sub") + or "System" + ) + except Exception: + pass + return "System" + + class PedimentosService: """Service class for Pedimentos business logic""" @@ -371,6 +422,15 @@ class PedimentosService: db.commit() db.refresh(pedimento) + _ensure_create_audit_log( + db, + table_name=pedimento.__tablename__, + record_id=str(pedimento.id), + record_data={c.name: getattr(pedimento, c.name) for c in Pedimentos.__table__.columns}, + username=_get_current_username(), + tenant_id=tenant_id, + company_id=company_id, + ) return pedimento except IntegrityError as e: diff --git a/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte b/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte index c31f3b8b..5b67b09d 100644 --- a/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte +++ b/frontend/src/routes/dashboard/audit_logs/bitacora-tab.svelte @@ -14,22 +14,22 @@ import { RefreshCw, Search } from 'lucide-svelte'; import { companyStore } from '$lib/stores/company.svelte'; - let logs: AuditLog[] = []; - let total = 0; - let page = 1; + let logs = $state([]); + let total = $state(0); + let page = $state(1); const pageSize = 50; - let loading = false; - let error: string | null = null; + let loading = $state(false); + let error = $state(null); - let search = ''; - let usernameFilter = ''; - let procedureFilter = ''; - let dateFrom = ''; - let dateTo = ''; + let search = $state(''); + let usernameFilter = $state(''); + let procedureFilter = $state(''); + let dateFrom = $state(''); + let dateTo = $state(''); - let procedures: string[] = []; + let procedures = $state([]); - let hasMore = true; + let hasMore = $state(true); let sentinel: HTMLElement; function isSpanish() { @@ -171,8 +171,11 @@ hasMore = true; logs = []; loading = false; - void loadProcedures(); - void loadLogs(); + // Run async loaders outside effect tracking to avoid reactive loops. + queueMicrotask(() => { + void loadProcedures(); + void loadLogs(); + }); }); onMount(() => {