Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/manuales

This commit is contained in:
2026-05-07 11:56:36 -05:00
11 changed files with 261 additions and 33 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

@@ -13,6 +13,7 @@ router = TenantCRUDRoutes(
tags=["a76.general_catalogs.legends"],
resource_name="Legend",
enable_list=True,
enable_filters=True,
list_permissions=["cat_legends.view"],
get_permissions=["cat_legends.view"],
create_permissions=["cat_legends.create"],

View File

@@ -1,6 +1,6 @@
from typing import List, Optional, Tuple, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import select
from sqlalchemy import cast, String, select
from sqlalchemy.exc import IntegrityError
from fastapi import HTTPException
import logging
@@ -28,7 +28,11 @@ class LegendService:
if filters:
if filters.get("code"):
query = query.filter(Legend.code.ilike(f"%{filters['code']}%"))
code_str = str(filters["code"]).strip()
if code_str:
query = query.filter(
cast(Legend.code, String).ilike(f"%{code_str}%")
)
if filters.get("description"):
query = query.filter(
Legend.description.ilike(f"%{filters['description']}%")

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

@@ -32,11 +32,16 @@ export async function getLegends(
companyId: number,
filters: Record<string, any> = {}
): Promise<ApiResponse<LegendListResponse>> {
const cleanFilters = Object.fromEntries(
Object.entries(filters).filter(
([, v]) => v !== undefined && v !== null && v !== ''
)
);
const params = new URLSearchParams({
page: page.toString(),
page_size: pageSize.toString(),
company_id: companyId.toString(),
...filters
...cleanFilters
});
return await api.get(`/v1/a76/legends/?${params.toString()}`);
}

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(() => {