""" Anexo76 - Aplicación SaaS para gestión de comercio exterior Backend API con FastAPI + Keycloak + SQLAlchemy """ import logging import subprocess # Importar modelos para registrar con SQLAlchemy # Reference Data (Dependencies) from api.v1.modules.public.reference_data.countries.models import Country from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod # Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen para que # SQLAlchemy resuelva los nombres en relationship() al configurar el mapper from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( CodePedimentoRegimen, ) from api.v1.modules.public.reference_data.sectors.models import Sector from api.v1.modules.public.reference_data.states.models import State from api.v1.modules.public.reference_data.transport_modes.models import TransportMode from api.v1.modules.public.reference_data.transport_types.models import TransportType from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept from api.v1.modules.a76.general_catalogs.concepts.models import Concept from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog from api.v1.modules.a76.general_catalogs.doda.models import Doda from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog from api.v1.modules.a76.general_catalogs.inpc.models import INPC from api.v1.modules.a76.general_catalogs.legends.models import Legend from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType from api.v1.modules.a76.general_catalogs.packages.models import Package from api.v1.modules.a76.general_catalogs.ports.models import Port from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator from api.v1.modules.a76.general_catalogs.seal.models import Seal from api.v1.modules.a76.general_catalogs.signatures.models import Signature from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import ( TariffFraction, ) from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( USTariffFraction, ) # Core Modules & Reference Data (Dependencies) 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.general_catalogs.company.models import Company # Transportation Modules from api.v1.modules.a76.transportation.trailers.models import Trailer from api.v1.modules.a76.transportation.transporters.models import Transporter from api.v1.modules.a76.transportation.vehicles.models import Vehicle # Core Modules & Transactional Models from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie from api.v1.modules.a76.parts.models import Part from api.v1.modules.a24.fa.fa_parts.models import FaPart from api.v1.modules.a24.inv.inv_parts.models import InvPart from api.v1.modules.a76.manifests.manifest.models import Manifest from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation # Transactional Primary Models 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.audit_log.events import register_audit_listeners # Core Modules (Secondary) import core.celery_app # Initialize Celery App from api.v1.router import router as api_v1_router from core.config import settings from core.database import init_db from core.error_handlers import register_exception_handlers from core.middleware import ( LicenseValidationMiddleware, RequestLoggingMiddleware, TenantMiddleware, ) from fastapi import FastAPI, Request, status, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pathlib import Path # Importar modelos para registrar con SQLAlchemy # IMPORTANT: Import FaLineItem BEFORE LineItem for relationship resolution from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie from api.v1.modules.a76.parts.models import Part from api.v1.modules.a24.fa.fa_parts.models import FaPart from api.v1.modules.a24.inv.inv_parts.models import InvPart from api.v1.modules.a76.manifests.manifest.models import Manifest from api.v1.modules.a76.manifests.concept_manifestation.models import ( ConceptManifestation, ) from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation # Configurar logging logging.basicConfig( level=logging.INFO if not settings.DEBUG else logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) # Crear aplicación FastAPI app = FastAPI( title="Anexo76 API", version=settings.APP_VERSION, description="Aplicación SaaS para gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT", docs_url="/api/docs" if settings.DEBUG else None, redoc_url="/api/redoc" if settings.DEBUG else None, openapi_url="/api/openapi.json" if settings.DEBUG else None, ) logger = logging.getLogger(__name__) # Registrar manejadores de excepciones register_exception_handlers(app) def _cors_headers_for_request(request: Request): """Return CORS headers if request Origin is allowed (so error responses don't get blocked by browser).""" origin = request.headers.get("origin") if not origin: return {} allowed = settings.cors_origins_list if origin in allowed: return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", } return {} # Add validation error handler @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): logger.error( f"Validation error for {request.method} {request.url.path}: {exc.errors()}" ) logger.error(f"Request body: {await request.body()}") response = JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"detail": exc.errors(), "body": exc.body}, ) for k, v in _cors_headers_for_request(request).items(): response.headers[k] = v return response # Add HTTP exception handler @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): logger.error( f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}" ) response = JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, ) for k, v in _cors_headers_for_request(request).items(): response.headers[k] = v return response def run_migrations(): subprocess.run(["alembic", "upgrade", "head"], check=True) def create_transportation_tables(): """Crea las tablas de transporte directamente si no existen. Se usa en lugar de una migración Alembic para evitar gestionar versiones. """ from sqlalchemy import text from core.database import core_engine ddl_statements = [ """ CREATE TABLE IF NOT EXISTS a76.transporter ( transporter_key VARCHAR(23) PRIMARY KEY, name VARCHAR(256), short_name VARCHAR(10), responsible VARCHAR(100), rfc VARCHAR(30), streets VARCHAR(100), postal_code VARCHAR(15), city VARCHAR(30), state VARCHAR(30), country VARCHAR(3), loader_code VARCHAR(9), caat_code VARCHAR(49), transport_code VARCHAR(8), transport_interface_type VARCHAR(20), ftp_server VARCHAR(200), ftp_user VARCHAR(200), ftp_password VARCHAR(100), ftp_directory VARCHAR(1000), filler_code VARCHAR(20), tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), company_id INTEGER NOT NULL REFERENCES a76.company(id), created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), deleted_at TIMESTAMP ); """, """ CREATE TABLE IF NOT EXISTS a76.trailer ( trailer_number VARCHAR(20) PRIMARY KEY, ace_trailer_number VARCHAR(10), trailer_type_key VARCHAR(2), seal VARCHAR(15), entity_code VARCHAR(1), plate_number VARCHAR(17), state VARCHAR(30), country VARCHAR(3), container_key VARCHAR(3), tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), company_id INTEGER NOT NULL REFERENCES a76.company(id), created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), deleted_at TIMESTAMP ); """, """ CREATE TABLE IF NOT EXISTS a76.vehicle ( vehicle_key VARCHAR(14) PRIMARY KEY, ace_vehicle_key VARCHAR(10), transporter_key VARCHAR(23), transport_identifier VARCHAR(30), transport_type VARCHAR(2), entity_code VARCHAR(1), transponder_number VARCHAR(16), dot_number VARCHAR(8), plate_number VARCHAR(17), city VARCHAR(30), state VARCHAR(30), country VARCHAR(3), seal VARCHAR(49), insurance_company_name VARCHAR(30), insurance_number VARCHAR(20), insurance_amount NUMERIC(13, 2), insurance_date INTEGER, box_number VARCHAR(300), brand VARCHAR(20), year VARCHAR(4), series VARCHAR(30), description VARCHAR(100), engine_number VARCHAR(50), sct_permission VARCHAR(40), color VARCHAR(20), container_key VARCHAR(3), tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), company_id INTEGER NOT NULL REFERENCES a76.company(id), created_at TIMESTAMP NOT NULL DEFAULT now(), updated_at TIMESTAMP NOT NULL DEFAULT now(), deleted_at TIMESTAMP ); """, ] with core_engine.connect() as conn: for stmt in ddl_statements: conn.execute(text(stmt)) # Ampliar columnas que pudieron haberse creado con tamaño incorrecto conn.execute(text(""" DO $$ BEGIN -- Fix transporter_key si fue creada como VARCHAR(5) IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema='a76' AND table_name='transporter' AND column_name='transporter_key' AND character_maximum_length < 23 ) THEN ALTER TABLE a76.transporter ALTER COLUMN transporter_key TYPE VARCHAR(23); END IF; -- Fix filler_code si fue creada como VARCHAR(4) IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema='a76' AND table_name='transporter' AND column_name='filler_code' AND character_maximum_length < 20 ) THEN ALTER TABLE a76.transporter ALTER COLUMN filler_code TYPE VARCHAR(20); END IF; -- Eliminar constraint de trailer_type_key en trailer si existe IF EXISTS ( SELECT 1 FROM information_schema.table_constraints WHERE constraint_name='trailer_trailer_type_key_fkey' AND table_schema='a76' AND table_name='trailer' ) THEN ALTER TABLE a76.trailer DROP CONSTRAINT trailer_trailer_type_key_fkey; END IF; END$$; """)) conn.commit() logger.info("Tablas de transporte verificadas/creadas correctamente.") # Inicializar la base de datos @app.on_event("startup") async def on_startup(): """Evento de inicio de la aplicación""" logger.info("Iniciando la aplicación Anexo76...") init_db() run_migrations() create_transportation_tables() logger.info("Base de datos inicializada correctamente.") # Configurar CORS app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Agregar middlewares personalizados if settings.DEBUG: app.add_middleware(RequestLoggingMiddleware) app.add_middleware(LicenseValidationMiddleware) app.add_middleware(TenantMiddleware) # Middleware de Contexto de Usuario (Audit Log) from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware app.add_middleware(UserContextMiddleware) # 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.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.parts.models import Part from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.general_catalogs.company.models import Company # Reference Data from api.v1.modules.public.reference_data.countries.models import Country from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from api.v1.modules.public.reference_data.customs_warehouses.models import ( CustomsWarehouse, ) from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.pedimento_regimens.models import ( RegimenPedimento, ) from api.v1.modules.public.reference_data.sectors.models import Sector from api.v1.modules.public.reference_data.states.models import State from api.v1.modules.public.reference_data.transport_modes.models import TransportMode from api.v1.modules.public.reference_data.transport_types.models import TransportType from api.v1.modules.public.reference_data.valuation_methods.models import ( ValuationMethod, ) from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.classification_concepts.models import ( ClassificationConcept, ) from api.v1.modules.a76.general_catalogs.concepts.models import Concept from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import ( CustomsBrokerConcept, ) from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import ( DepreciationCatalog, ) from api.v1.modules.a76.general_catalogs.doda.models import Doda from api.v1.modules.a76.general_catalogs.electronic_notices.models import ( ElectronicNotice, ) from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog from api.v1.modules.a76.general_catalogs.inpc.models import INPC from api.v1.modules.a76.general_catalogs.legends.models import Legend from api.v1.modules.a76.general_catalogs.multi_currency_types.models import ( MultiCurrencyType, ) from api.v1.modules.a76.general_catalogs.packages.models import Package from api.v1.modules.a76.general_catalogs.ports.models import Port from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator from api.v1.modules.a76.general_catalogs.seal.models import Seal from api.v1.modules.a76.general_catalogs.signatures.models import Signature from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import ( TariffFraction, ) from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( USTariffFraction, ) # Registrar Listeners de Auditoría @app.on_event("startup") def register_audit(): register_audit_listeners( [ # Core Transactions Pedimentos, InvoiceHeader, InvoiceSalesDetails, LineItem, # Sidebar Core Modules ClientProvider, CustomsBroker, Part, Company, # Transportation Modules Trailer, Transporter, Vehicle, # Reference Data Country, CurrencyType, CustomsSection, CustomsWarehouse, Incoterm, InvoiceType, MaterialType, PaymentMethod, PedimentoCode, RegimenPedimento, Sector, State, TransportMode, TransportType, ValuationMethod, UnitOfMeasure, ExchangeRate, Identifier, Class, ClassificationConcept, Concept, CustomsBrokerConcept, DepreciationCatalog, Doda, ElectronicNotice, Equivalency, ErrorCatalog, FDACatalog, INPC, Legend, MultiCurrencyType, Package, Port, Prevalidator, Seal, Signature, TariffFraction, UnitConversion, USTariffFraction, ] ) # Crear directorio de uploads si no existe y montar archivos estáticos uploads_dir = Path("uploads").resolve() uploads_dir.mkdir(parents=True, exist_ok=True) app.mount("/api/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") # Registrar routers app.include_router(api_v1_router, prefix="/api/v1") @app.get("/api/") async def root(): """Root endpoint""" return { "name": "Anexo76 API", "version": settings.APP_VERSION, "status": "running", "docs": "/api/docs" if settings.DEBUG else "disabled in production", } @app.get("/api/health") async def health_check(): """Health check endpoint""" return {"status": "healthy", "environment": settings.ENVIRONMENT} @app.get("/api/version") async def get_version(): """ Endpoint de versión de la aplicación Retorna la versión de la aplicación que fue incrustada en la imagen Docker durante el proceso de CI/CD. La versión se genera automáticamente según la rama: - development: YY.MM.1. - main: YY.MM.0. Returns: dict: Información de versión y entorno """ return { "service": settings.APP_NAME, "version": settings.APP_VERSION, "environment": settings.ENVIRONMENT, "debug": settings.DEBUG, }