feature/bitacora-correccion-por-tenant

This commit is contained in:
2026-05-07 07:51:49 -06:00
parent 39840b47a9
commit 9152750a9f
8 changed files with 248 additions and 30 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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("/"):

View File

@@ -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

View File

@@ -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":

View File

@@ -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:

View File

@@ -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:

View File

@@ -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<AuditLog[]>([]);
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<string | null>(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<string[]>([]);
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(() => {