diff --git a/.env.example b/.env.example index c66ef877..8e04d045 100644 --- a/.env.example +++ b/.env.example @@ -21,14 +21,12 @@ KEYCLOAK_FRONTEND_CLIENT_ID=anexo76-frontend # ----- Backend ----- DEBUG=True ENVIRONMENT=development - CORE_DB_HOST=postgres-a76 CORE_DB_PORT=5432 CORE_DB_NAME=anexo76_core CORE_DB_USER=postgres CORE_DB_PASSWORD=postgres - # ----- Frontend ----- NODE_ENV=development VITE_API_URL=http://localhost:8000/api @@ -36,3 +34,8 @@ INTERNAL_API_URL=http://backend:8000/api VITE_KEYCLOAK_REALM=master VITE_KEYCLOAK_URL=http://localhost:8080 VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend + +# ----- Sitar API ----- +SITAR_API_URL=http://api.sitar.aduanasoft.com +SITAR_API_USER=your_sitar_user +SITAR_API_PASSWORD=your_sitar_password diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index f206e756..1514055e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout código diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index 759cae59..417228ee 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -350,7 +350,6 @@ class ClientProviderService: ) if client_or_provider: - from .models import ClientOrProviderEnum query = query.filter( or_( ClientProvider.client_or_provider == client_or_provider, diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py index 00870adf..22ab8ec9 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py @@ -27,10 +27,25 @@ route_handler = TenantCRUDRoutes( max_page_size=100, ) -router = route_handler.router +crud_router = route_handler.router + +# Create a custom router for specific endpoints that must be matched BEFORE generic CRUD routes +# We use the same prefix so they are grouped together +from fastapi import APIRouter +custom_router = APIRouter(prefix="/exchange-rate", tags=[]) + +@custom_router.get("/test-ping") +async def test_ping(): + return {"message": "pong"} + +# Master router to export +router = APIRouter() +# Include custom routes FIRST to avoid shadowing by /{id} +router.include_router(custom_router) +# router.include_router(crud_router) -@router.get( +@custom_router.get( "/", response_model=Dict[str, Any], summary="List Exchange Rates", @@ -66,3 +81,37 @@ async def list_exchange_rates( "page": page, "page_size": page_size, } + +@custom_router.get( + "/dof-search", + response_model=Dict[str, Any], + summary="Fetch Exchange Rate from DOF", + description="Fetches the exchange rate from the Official Journal of the Federation (DOF) for a specific date.", +) +async def fetch_exchange_rate_dof( + date: str = Query(..., description="Date in YYYY-MM-DD format"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + # This endpoint can be public or protected. Assuming protected for now. + # No specific tenant validation needed since it's an external query, + # but good to ensure user is authenticated. + + try: + print(f"DEBUG: Route called with date={date}") + rate = ExchangeRateService.fetch_from_dof(date) + + if rate is None: + return {"success": False, "message": "No se encontró el tipo de cambio en el DOF para la fecha especificada o el servicio no está disponible.", "value": None} + + return {"success": True, "value": rate} + except Exception as e: + print(f"DEBUG: Error in route: {e}") + import traceback + traceback.print_exc() + return {"success": False, "message": f"Error interno: {str(e)}", "value": None} + +# Include routers at the end to ensure all routes are registered +# Include custom routes FIRST to avoid shadowing by /{id} of crud_router +router.include_router(custom_router) +router.include_router(crud_router) diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py index 3bc6b4a7..ee33f82a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py @@ -1,10 +1,18 @@ from typing import Optional, Tuple, List, Dict, Any from datetime import datetime, time +import requests +import re from sqlalchemy.orm import Session from sqlalchemy import cast, Date from . import dto, models +import urllib3 +from core.config import settings + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + class ExchangeRateService: @@ -135,3 +143,92 @@ class ExchangeRateService: db.delete(exchange_rate) db.commit() return True + + @staticmethod + def _get_sitar_api_token(base_url, username, password) -> Optional[str]: + """Helper to get authentication token from external API""" + try: + print(base_url) + login_url = f"{base_url}/exchange-rate/auth/login" + payload = {"username": username, "password": password} + headers = {"Content-Type": "application/json"} + + response = requests.post(login_url, json=payload, headers=headers, timeout=5) + if response.status_code not in [200, 201]: + print(f"External API Login Failed: {response.status_code} - {response.text}") + return None + + data = response.json() + return data.get("token") or data.get("access_token") + except Exception as e: + print(f"External API Login Error: {e}") + return None + + @staticmethod + def fetch_from_dof(date_str: str) -> Optional[float]: + """ + Fetches the exchange rate from an external API (replacing direct DOF scraping). + The API handles date logic (holidays, weekends) automatically. + + Args: + date_str (str): Date in 'YYYY-MM-DD' format. + + Returns: + Optional[float]: The exchange rate value if found, None otherwise. + """ + # API Credentials + API_BASE_URL = settings.SITAR_API_URL + API_USER = settings.SITAR_API_USER + API_PASS = settings.SITAR_API_PASSWORD + + if not API_USER or not API_PASS: + print("ERROR: External API credentials not properly configured in settings") + return None + + try: + print(f"DEBUG: Fetching External API for date: {date_str}") + + # 1. Get Token + token = ExchangeRateService._get_sitar_api_token(API_BASE_URL, API_USER, API_PASS) + if not token: + print("Failed to obtain external API token") + return None + + # 2. Fetch Exchange Rate + # The API endpoint is /tipoCambio/{YYYY-MM-DD} + tc_endpoint = f"{API_BASE_URL}/exchange-rate/tipoCambio/{date_str}" + + # Auth header: The API expects just the token string in common usage, but we try standard first + # based on user feedback/code: 'Authorization:' . $token + headers = { + "Authorization": token, + "Content-Type": "application/json" + } + + response = requests.get(tc_endpoint, headers=headers, timeout=5) + + # Retry logic as per PHP reference (if 401, maybe formatting issue, but requests handles headers well) + if response.status_code == 401: + # Try with Bearer prefix just in case, though PHP code suggested raw token + print("DEBUG: 401 received, retrying with Bearer prefix...") + headers["Authorization"] = f"Bearer {token}" + response = requests.get(tc_endpoint, headers=headers, timeout=5) + + if response.status_code != 200: + print(f"External API TC Error: {response.status_code} - {response.text}") + return None + + data = response.json() + # Expected response: {"Id":..., "Fecha":"...", "TipoCambio":17.452, "Mov":"..."} + + if "TipoCambio" in data: + val = float(data["TipoCambio"]) + print(f"DEBUG: External API returned value: {val}") + return val + + print(f"DEBUG: 'TipoCambio' key not found in response: {data}") + return None + + except Exception as e: + print(f"Error fetching from External API: {e}") + return None diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py index 0440bf81..afba527a 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py @@ -12,6 +12,76 @@ from api.v1.modules.public.reference_data.currency_types.models import CurrencyT from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection from ....models import TransportType, Currency, WeightUnit from core.exceptions import ErrorCollector +from typing import Dict, Any + + +def validate_required_fields_by_operation( + invoice_data: Dict[str, Any], + operation_type: str, + errors: ErrorCollector +) -> None: + """ + Valida campos obligatorios según tipo de operación. + Usar ANTES de guardar en BD. + """ + + # PROVEEDOR (SIEMPRE OBLIGATORIO - mensaje dinámico) + if not invoice_data.get('provider_id'): + # Mensaje dinámico según el header seleccionado + provider_labels = { + 'proveedor': 'Proveedor', + 'exportador': 'Exportador' + } + provider_header = invoice_data.get('provider_header') or 'proveedor' + field_label = provider_labels.get(provider_header, 'Proveedor') + + errors.add_error( + field="provider_id", + message=f"Debe seleccionar {field_label}", + solution=["Seleccione un proveedor de la lista desplegable"], + code="REQUIRED", + value=None + ) + + # VENDIDO A / CONSIGNADO A (SIEMPRE OBLIGATORIO - mensaje dinámico) + if not invoice_data.get('sold_to_id'): + # Mensaje dinámico según el header seleccionado + sold_to_labels = { + 'consignado_a': 'Consignado a', + 'vendido_a': 'Vendido a', + 'exportado_a': 'Exportado a', + 'importador': 'Importador' + } + sold_to_header = invoice_data.get('sold_to_header') or 'consignado_a' + field_label = sold_to_labels.get(sold_to_header, 'Cliente') + + errors.add_error( + field="sold_to_id", + message=f"Debe seleccionar {field_label}", + solution=["Seleccione una opción de la lista desplegable"], + code="REQUIRED", + value=None + ) + + # ENVIADO A (SIEMPRE OBLIGATORIO - mensaje fijo) + if not invoice_data.get('shipped_to_id'): + errors.add_error( + field="shipped_to_id", + message="Debe seleccionar el Destinatario", + solution=["Seleccione un destinatario de la lista desplegable"], + code="REQUIRED", + value=None + ) + + # AGENTE ADUANAL (OBLIGATORIO si hay pedimento) + if invoice_data.get('pedimento_id') and not invoice_data.get('customs_broker_id'): + errors.add_error( + field="customs_broker_id", + message="Debe seleccionar un Agente Aduanal", + solution=["Seleccione un agente aduanal de la lista desplegable"], + code="REQUIRED", + value=None + ) def validate_common( @@ -245,78 +315,87 @@ def validate_common( value=invoice.document_type, ) - provider_exists = ( - db.query(ClientProvider) - .filter( - ClientProvider.id == invoice.compliance_mx.provider_id, - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) - .first() - ) - if not provider_exists: - errors.add_error( - field="compliance_mx.provider_id", - message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Proveedor", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.provider_id, + # Validar proveedor solo si se proporciona + if invoice.compliance_mx.provider_id: + provider_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.provider_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() ) + if not provider_exists: + errors.add_error( + field="compliance_mx.provider_id", + message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Proveedor", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.provider_id, + ) - selled_to_exists = ( - db.query(ClientProvider) - .filter( - ClientProvider.id == invoice.compliance_mx.sold_to_id, - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) - .first() - ) - if not selled_to_exists: - errors.add_error( - field="compliance_mx.sold_to_id", - message="El Cliente no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Cliente", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.sold_to_id, + # Validar vendido a solo si se proporciona + if invoice.compliance_mx.sold_to_id: + selled_to_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.sold_to_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() ) + if not selled_to_exists: + errors.add_error( + field="compliance_mx.sold_to_id", + message="El Cliente no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Cliente", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.sold_to_id, + ) - shipped_to_exists = ( - db.query(ClientProvider) - .filter( - ClientProvider.id == invoice.compliance_mx.shipped_to_id, - ClientProvider.tenant_id == tenant_id, - ClientProvider.company_id == company_id, - ) - .first() - ) - if not shipped_to_exists: - errors.add_error( - field="compliance_mx.shipped_to_id", - message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Destinatario", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.shipped_to_id, + # Validar destinatario solo si se proporciona + if invoice.compliance_mx.shipped_to_id: + shipped_to_exists = ( + db.query(ClientProvider) + .filter( + ClientProvider.id == invoice.compliance_mx.shipped_to_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ) + .first() ) + if not shipped_to_exists: + errors.add_error( + field="compliance_mx.shipped_to_id", + message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Destinatario", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.shipped_to_id, + ) - customs_broker_exists = ( - db.query(CustomsBroker) - .filter( - CustomsBroker.id == invoice.compliance_mx.customs_broker_id, - CustomsBroker.tenant_id == tenant_id, - CustomsBroker.company_id == company_id, - ) - .first() - ) - if not customs_broker_exists: - errors.add_error( - field="compliance_mx.customs_broker_id", - message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.", - solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"], - code="NOT_FOUND", - value=invoice.compliance_mx.customs_broker_id, + # Validar agente aduanal solo si se proporciona + if invoice.compliance_mx.customs_broker_id: + customs_broker_exists = ( + db.query(CustomsBroker) + .filter( + CustomsBroker.id == invoice.compliance_mx.customs_broker_id, + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + ) + .first() ) + if not customs_broker_exists: + errors.add_error( + field="compliance_mx.customs_broker_id", + message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.customs_broker_id, + ) + # Validar transportista solo si se proporciona if invoice.logistics.carrier_id: carrier_exists = ( db.query(ClientProvider) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py index be6f6fc1..56800ff5 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from core.exceptions import ErrorCollector from ....schemas import InvoiceHeaderCreate -from .common import validate_common +from .common import validate_common, validate_required_fields_by_operation def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector) -> None: """ Valida la creación de una nueva factura de importe temporal """ @@ -21,22 +21,31 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c errors.add_required_error("invoice_number") if not invoice.invoice_date: - errors.add_required_error("invoice_date") - - if not invoice.compliance_mx.provider_id: - errors.add_required_error("compliance_mx.provider_id") + errors.add_required_error("invoice_date") - if not invoice.compliance_mx.sold_to_id: - errors.add_required_error("compliance_mx.sold_to_id") - - if not invoice.compliance_mx.shipped_to_id: - errors.add_required_error("compliance_mx.shipped_to_id") - - if not invoice.compliance_mx.customs_broker_id: - errors.add_required_error("compliance_mx.customs_broker_id") - if errors.has_errors(): - """Se retorna por que hay campos obligatiorios para las validaciones que tienen que ser llenados""" + """Se retorna porque hay campos obligatorios básicos que deben ser llenados""" + return + + # Validar campos obligatorios según tipo de operación + invoice_data = { + 'provider_header': invoice.compliance_mx.provider_header if invoice.compliance_mx else None, + 'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else None, + 'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None, + 'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else None, + 'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else None, + 'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else None, + 'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else None, + } + + validate_required_fields_by_operation( + invoice_data=invoice_data, + operation_type=invoice.operation_type, + errors=errors + ) + + if errors.has_errors(): + """Se retorna porque hay campos obligatorios según el tipo de operación que deben ser llenados""" return validate_common(db, invoice, tenant_id, company_id, errors) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py index 58efd41a..da4cc970 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py @@ -5,6 +5,7 @@ from decimal import Decimal from core.exceptions import ErrorCollector from ....schemas import InvoiceHeaderUpdate from ....models import InvoiceHeader +from .common import validate_required_fields_by_operation # Helper function para limpiar strings (equivalente a Clip()) @@ -33,6 +34,22 @@ def validate_update( None (modifica invoice_data in-place y acumula errores en errors) """ + # Validar campos requeridos según el tipo de operación + invoice_dict = { + 'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else None, + 'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else None, + 'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else None, + 'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else None, + 'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else None, + 'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else None, + } + + validate_required_fields_by_operation( + invoice_data=invoice_dict, + operation_type=invoice_data.operation_type or 'IMP', + errors=errors + ) + # Primero ejecutar validaciones comunes # validate_common(invoice_data, errors) diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index af111dc8..a1324e69 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -113,17 +113,17 @@ class InvoiceComplianceMxBase(BaseModel): manifest_number: Optional[str] = Field( None, max_length=15, description="Manifest number" ) - provider_header: str = Field(None, max_length=20, description="Provider header") - provider_id: int = Field(None, description="Provider ID") - sold_to_header: str = Field(None, max_length=20, description="Sold to header") - sold_to_id: int = Field(None, description="Sold to ID") - shipped_to_header: str = Field(None, max_length=20, description="Shipped to header") - shipped_to_id: int = Field(None, description="Shipped to ID") + provider_header: Optional[str] = Field(None, max_length=20, description="Provider header") + provider_id: Optional[int] = Field(None, description="Provider ID") + sold_to_header: Optional[str] = Field(None, max_length=20, description="Sold to header") + sold_to_id: Optional[int] = Field(None, description="Sold to ID") + shipped_to_header: Optional[str] = Field(None, max_length=20, description="Shipped to header") + shipped_to_id: Optional[int] = Field(None, description="Shipped to ID") shipped_by_header: Optional[int] = Field( None, max_length=20, description="Shipped by header" ) shipped_by_id: Optional[int] = Field(None, description="Shipped by ID") - customs_broker_id: int = Field(None, description="Customs broker ID") + customs_broker_id: Optional[int] = Field(None, description="Customs broker ID") customs_broker_us_id: Optional[int] = Field( None, description="US customs broker ID" ) diff --git a/backend/api/v1/modules/a76/items/line_quantities/models.py b/backend/api/v1/modules/a76/items/line_quantities/models.py index c7a60558..21baa88d 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/models.py +++ b/backend/api/v1/modules/a76/items/line_quantities/models.py @@ -34,7 +34,6 @@ class LineQuantity(Base): serial_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_SERIES/CANT_SERIESDEF # Weight - weight_unit: Mapped[Optional[str]] = mapped_column(String(3)) # 'KG' o 'LB' net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO diff --git a/backend/api/v1/modules/a76/items/line_quantities/schemas.py b/backend/api/v1/modules/a76/items/line_quantities/schemas.py index 71552342..fa1a5ecc 100644 --- a/backend/api/v1/modules/a76/items/line_quantities/schemas.py +++ b/backend/api/v1/modules/a76/items/line_quantities/schemas.py @@ -22,7 +22,6 @@ class LineQuantityBase(BaseModel): serial_count: Optional[int] = Field(None, description="Serial count (CANT_SERIES/CANT_SERIESDEF)") # Weight - weight_unit: Optional[str] = Field(None, max_length=3, description="Weight unit ('KG' or 'LB')") net_weight: Optional[Decimal] = Field(None, description="Net weight (PESONETO)") gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)") diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 912a7b89..2efe582a 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -194,7 +194,7 @@ class ItemService: # Validaciones adicionales específicas del negocio # Validar apóstrofes en número de parte - if line_data.part_number and "'" in str(line_data.part_number): + if line_data.part_number_id and "'" in str(line_data.part_number_id): errors.add_error( field=f"lines[{idx}].part_number", message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", @@ -420,7 +420,7 @@ class ItemService: # (Aplican tanto para crear como actualizar) # Validar apóstrofes en número de parte - if line_data.part_number and "'" in str(line_data.part_number): + if line_data.part_number_id and "'" in str(line_data.part_number_id): errors.add_error( field=f"lines[{idx}].part_number", message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos", diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 5cff09b4..34cbc9ef 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -10,18 +10,24 @@ from fastapi import HTTPException from sqlalchemy.orm import Session # --- MODELOS --- -from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics, InvoiceComplianceMx +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, + InvoiceLogistics, + InvoiceComplianceMx, +) from api.v1.modules.a76.items.line_financials.models import LineFinancial from api.v1.modules.a76.items.line_quantities.models import LineQuantity from api.v1.modules.a76.items.line_items.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ( - ClientProvider, ClientProviderAddress, ClientProviderPrograms + ClientProvider, + ClientProviderAddress, + ClientProviderPrograms, ) from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.pedmientos.models import Pedimentos from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.customs_brokers.models import CustomsBroker -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import Item # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -32,20 +38,27 @@ from api.v1.modules.a76.transportation.drivers.models import Driver # --- MODELO DE FRACCIONES --- from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +# --- MODELO DE UNIDADES DE MEDIDA --- +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + # --- SCHEMAS --- from .schemas import ( - ClienteSchema, PartidaSchema, TotalesSchema, - FacturaSchema, FacturaImportacionCompleta + ClienteSchema, + PartidaSchema, + TotalesSchema, + FacturaSchema, + FacturaImportacionCompleta, ) + class ConsolidadoImportacionMexService: def __init__(self): self.template_dir = Path(__file__).parent.parent / "templates" self.jinja_env = Environment( loader=FileSystemLoader(self.template_dir), - autoescape=select_autoescape(['html', 'xml']) + autoescape=select_autoescape(["html", "xml"]), ) - self.template = self.jinja_env.get_template('cons_mex_ver.html') + self.template = self.jinja_env.get_template("cons_mex_ver.html") def _get_wkhtmltopdf_config(self): path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" @@ -54,28 +67,49 @@ class ConsolidadoImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + if valor is None: + return 0.0 try: return round(float(valor), decimales) - except: return 0.0 + except: + return 0.0 def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: return fraccion_raw return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" - def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + def _obtener_datos_cliente( + self, db: Session, client_id: int, rol: str + ) -> ClienteSchema: main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() if not main: - return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") - - addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() - prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + return ClienteSchema( + header=rol, + nombre="Desconocido", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + ) + + addr = ( + db.query(ClientProviderAddress) + .filter(ClientProviderAddress.client_id == client_id) + .first() + ) + prog = ( + db.query(ClientProviderPrograms) + .filter(ClientProviderPrograms.client_id == client_id) + .first() + ) return ClienteSchema( header=rol, nombre=(main.name or main.short_name) or "S/N", - direccion=(addr.streets or "") if addr else "", + direccion=(addr.streets or "") if addr else "", num_exterior=(addr.exterior_number or "") if addr else "", num_interior=(addr.interior_number or "") if addr else "", colonia=(addr.neighborhood or "") if addr else "", @@ -83,48 +117,115 @@ class ConsolidadoImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), - programa="IMMEX" if (prog and prog.program) else "", - autorizacion=prog.program_number if prog else "", - prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", - reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( - prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + tax_id=( + prog.tax_id + if (prog and prog.tax_id) + else (getattr(main, "rfc", "") or "") + ), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=( + prog.prosec_authorization + if (prog and prog.prosec and prog.prosec_authorization) + else "" + ), + reg_emp=( + prog.val_certified_company_registry + if (prog and hasattr(prog, "val_certified_company_registry")) + else ( + prog.certified_company_registry + if (prog and prog.certified_company_registry) + else "" + ) + ), + cert=( + prog.is_certified_company + if (prog and prog.is_certified_company) + else "" ), - cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" ) - def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + def obtener_datos( + self, + db: Session, + invoice_id: int, + company_id: int, + progress_callback: Optional[Callable] = None, + ) -> FacturaImportacionCompleta: try: - if progress_callback: progress_callback(10, "Buscando factura...") - header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() - if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + if progress_callback: + progress_callback(10, "Buscando factura...") + header = ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.id == invoice_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not header: + raise HTTPException(status_code=404, detail="Factura no encontrada") - compliance = header.compliance_mx + compliance = header.compliance_mx logistics = header.logistics if header.logistics else None financials = header.financials if header.financials else None - if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") - pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id - pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None - - if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + if progress_callback: + progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = ( + compliance.pedimento_id + if (compliance and compliance.pedimento_id) + else header.related_doc_id + ) + pedimento = ( + db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() + if pedimento_id + else None + ) + + if progress_callback: + progress_callback(30, "Obteniendo cliente y proveedor...") proveedor_id = compliance.provider_id if compliance else None - cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + cliente_proveedor = ( + self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") + if proveedor_id + else ClienteSchema( + header="Proveedor", + nombre="No Asignado", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="", + ) + ) nombre_agente = "" if compliance and compliance.customs_broker_id: - broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() - if broker: nombre_agente = broker.name + broker = ( + db.query(CustomsBroker) + .filter(CustomsBroker.id == compliance.customs_broker_id) + .first() + ) + if broker: + nombre_agente = broker.name company = db.query(Company).filter(Company.id == header.company_id).first() # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) # Default Header (Company) cliente_default = ClienteSchema( header="Importer / Consignee:", - nombre=getattr(company, 'name', "Empresa Local"), + nombre=getattr(company, "name", "Empresa Local"), direccion="DOMICILIO FISCAL", - num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", - tax_id=getattr(company, 'rfc', ""), - programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + num_exterior="", + colonia="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + tax_id=getattr(company, "rfc", ""), + programa=getattr(company, "program", "IMMEX"), + autorizacion=getattr(company, "program_number", ""), ) # Left Side Logic (Consignatario / Sold To) @@ -136,34 +237,49 @@ class ConsolidadoImportacionMexService: clean_header = "Consignee / Consignatario:" else: clean_header = "Sold To / Vendido a:" - - cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) - + + cliente_vendido = self._obtener_datos_cliente( + db, compliance.sold_to_id, clean_header + ) + # Right Side Logic (Enviado A / Shipped To) cliente_enviado = cliente_default if compliance and compliance.shipped_to_id: # Map to Shipped To / Enviado a clean_header_shipped = "Shipped To / Enviado a:" - - # Fetch client data - cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) - remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" - acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + # Fetch client data + cliente_enviado = self._obtener_datos_cliente( + db, compliance.shipped_to_id, clean_header_shipped + ) + + remesa_valor = ( + str(compliance.remesa) if (compliance and compliance.remesa) else "" + ) + acuse_valor = ( + str(compliance.edocument) + if (compliance and compliance.edocument) + else "N/A" + ) patente_val = "" if pedimento and pedimento.license: patente_val = pedimento.license - elif 'broker' in locals() and broker and broker.license: + elif "broker" in locals() and broker and broker.license: patente_val = broker.license - # --- Transport Data Fetching --- - transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + transporte_txt = ( + str(logistics.transport_type) + if (logistics and logistics.transport_type) + else "" + ) num_transporte_val = (logistics.trailer_num or "") if logistics else "" - + # Init values - placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_val = ( + (logistics.license_plate or "") if logistics else "" + ) # Placas Tracto placas_remolque_val = "" transportista_val = (logistics.carrier_id or "") if logistics else "" caat_val = "" @@ -177,54 +293,74 @@ class ConsolidadoImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_key == logistics.carrier_id) + .first() + ) if transporter_obj: caat_val = transporter_obj.caat_code or "" - scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + scac_val = ( + transporter_obj.transport_code or "" + ) # Mapping transport_code to SCAC transportista_val = transporter_obj.name or logistics.carrier_id # Clarion Logic: Name first # Line 1: Name transport_lines.append(transporter_obj.name or "") - + # Line 2: Streets if transporter_obj.streets: transport_lines.append(transporter_obj.streets) - + # Line 3: City, State, Country loc_line = "" if transporter_obj.city: - loc_line = transporter_obj.city - if transporter_obj.state: - loc_line += f", {transporter_obj.state}, " - else: - loc_line += ", " + loc_line = transporter_obj.city + if transporter_obj.state: + loc_line += f", {transporter_obj.state}, " + else: + loc_line += ", " else: - if transporter_obj.state: - loc_line = f"{transporter_obj.state}," - - country_desc = transporter_obj.country or "" + if transporter_obj.state: + loc_line = f"{transporter_obj.state}," + + country_desc = transporter_obj.country or "" if loc_line: loc_line += f" {country_desc}" elif country_desc: loc_line = country_desc - + if loc_line.strip(", "): transport_lines.append(loc_line) # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: - placas_val = veh_obj.plate_number or placas_val - elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() - if veh_obj: - placas_val = veh_obj.plate_number or placas_val + placas_val = veh_obj.plate_number or placas_val + elif ( + logistics.vehicle_num + ): # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.vehicle_num) + .first() + ) + if veh_obj: + placas_val = veh_obj.plate_number or placas_val # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" @@ -232,36 +368,40 @@ class ConsolidadoImportacionMexService: if logistics.carrier_id and logistics.driver_name: conductor_nombre = logistics.driver_name # Attempt to find driver by name + carrier - drv_obj = db.query(Driver).filter( - Driver.transporter_key == logistics.carrier_id, - Driver.driver_name == logistics.driver_name - ).first() + drv_obj = ( + db.query(Driver) + .filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name, + ) + .first() + ) if drv_obj: - licencia_cond_val = drv_obj.license_number or "" - + licencia_cond_val = drv_obj.license_number or "" + # --- Building the rest of the block --- - + # Line 4: Driver if conductor_nombre: - transport_lines.append(f"Driver/Conductor: {conductor_nombre}") - + transport_lines.append(f"Driver/Conductor: {conductor_nombre}") + # Line 5: Conveyance / Transporte t_label = "Conveyance / Transporte" - t_val = placas_val # Default to Truck Plate - + t_val = placas_val # Default to Truck Plate + if logistics.transport_type: ttype = str(logistics.transport_type).lower() if "caja" in ttype or "trailer" in ttype: t_label = "Trailer / Caja" t_val = placas_remolque_val or num_transporte_val elif "placa" in ttype: - t_label = "Plates / Placas" + t_label = "Plates / Placas" elif "camion" in ttype or "truck" in ttype: - t_label = "Truck / Camión" - + t_label = "Truck / Camión" + if t_val: - transport_lines.append(f"{t_label}: {t_val}") - + transport_lines.append(f"{t_label}: {t_val}") + # Line 6: SCAC / CAAT codes_line = "" if scac_val: @@ -271,9 +411,9 @@ class ConsolidadoImportacionMexService: codes_line += f", CAAT Code/Clave: {caat_val}" else: codes_line = f"CAAT Code/Clave: {caat_val}" - + if codes_line: - transport_lines.append(codes_line) + transport_lines.append(codes_line) # Join with newlines transport_block_str = "\n".join([l for l in transport_lines if l]) @@ -281,11 +421,23 @@ class ConsolidadoImportacionMexService: factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", fecha=str(header.invoice_date) if header.invoice_date else "", - tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), - moneda=getattr(header, 'currency', "USD") or "USD", + tipo_cambio=( + float(financials.exchange_rate) + if (financials and financials.exchange_rate) + else ( + float(pedimento.exchange_rate) + if pedimento and pedimento.exchange_rate + else 1.0 + ) + ), + moneda=getattr(header, "currency", "USD") or "USD", incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", - pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + pedimento=( + f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" + if pedimento + else "" + ), clave_pedimento=pedimento.pedimento_code if pedimento else "", regimen=header.document_type or "", patente=patente_val, @@ -298,68 +450,104 @@ class ConsolidadoImportacionMexService: caat=caat_val, scac=scac_val, licencia_conductor=licencia_cond_val, - aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + aduana=( + compliance.aduana + if (compliance and compliance.aduana) + else ( + pedimento.customs_office[:2] + if (pedimento and pedimento.customs_office) + else "" + ) + ), precinto=(logistics.seal_number or "") if logistics else "", destino=(logistics.destination_goods or "") if logistics else "", - remesa=remesa_valor, acuse_electronico=acuse_valor, - representante_legal=getattr(company, 'responsible', "") or "", - nombre_empresa=getattr(company, 'name', "") or "", - transportista_info=transport_block_str + remesa=remesa_valor, + acuse_electronico=acuse_valor, + representante_legal=getattr(company, "responsible", "") or "", + nombre_empresa=getattr(company, "name", "") or "", + transportista_info=transport_block_str, ) - - if progress_callback: progress_callback(50, "Procesando partidas...") - + + if progress_callback: + progress_callback(50, "Procesando partidas...") + # --- Fetch Lines from SINGLE Invoice (Requested Scope Change) --- # User requested to ONLY report items from the specific selected invoice, # NOT consolidating all invoices from the same Pedimento. target_invoice_ids = [header.id] - - lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter( - Item.invoice_id.in_(target_invoice_ids) - ).all() + + lines = ( + db.query(LineItem) + .join(Item, LineItem.item_id == Item.id) + .filter(Item.invoice_id.in_(target_invoice_ids)) + .all() + ) partidas_list = [] - + # --- AGGREGATION LOGIC (Refactoring based on Clarion) --- from collections import defaultdict + # Key: (us_fraction_code, origin_country) # Value: Object with accumulated fields - aggregated_data = defaultdict(lambda: { - "qty": 0.0, - "net_weight_kgs": 0.0, - "gross_weight_kgs": 0.0, - "total_value": 0.0, - "est_total_value": 0.0, - "description": "", - "advalorem_txt": "0%", - "unit_measure": "PZA", # Placeholder, takes first one found - "hts_code_print": "", - "part_number_display": "CONSOLIDADO" - }) + aggregated_data = defaultdict( + lambda: { + "qty": 0.0, + "net_weight_kgs": 0.0, + "gross_weight_kgs": 0.0, + "total_value": 0.0, + "est_total_value": 0.0, + "description": "", + "advalorem_txt": "0%", + "unit_measure": "PZA", # Placeholder, takes first one found + "hts_code_print": "", + "part_number_display": "CONSOLIDADO", + } + ) # Pre-fetch US Tariff Fractions for efficiency if possible, or query inside loop (caching recommended) - # For simplicity in this step, we query inside or rely on Part data. + # For simplicity in this step, we query inside or rely on Part data. # Ideally fetch USTariffFraction from DB based on Part.us_fraction # --- Optimización: Cargar Facturas en Memoria --- - invoices_list = db.query(InvoiceHeader).filter(InvoiceHeader.id.in_(target_invoice_ids)).all() + invoices_list = ( + db.query(InvoiceHeader) + .filter(InvoiceHeader.id.in_(target_invoice_ids)) + .all() + ) invoice_map = {inv.id: inv for inv in invoices_list} - from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction + from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import ( + USTariffFraction, + ) for line in lines: - qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() - fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + qty = ( + db.query(LineQuantity) + .filter(LineQuantity.item_line_id == line.id) + .first() + ) + fin = ( + db.query(LineFinancial) + .filter(LineFinancial.item_line_id == line.id) + .first() + ) part_master = db.query(Part).filter(Part.id == line.part_number).first() - + # --- Resolver Identificadores (MOVED INSIDE MAIN LOOP) --- us_fraction_raw = "" origin_final = "MEX" - + if part_master: - origin_final = part_master.fa_data.origin_country if (part_master.fa_data and part_master.fa_data.origin_country) else "MEX" - us_fraction_raw = part_master.us_fraction if part_master.us_fraction else "" - + origin_final = ( + part_master.fa_data.origin_country + if (part_master.fa_data and part_master.fa_data.origin_country) + else "MEX" + ) + us_fraction_raw = ( + part_master.us_fraction if part_master.us_fraction else "" + ) + # Key for aggregation us_frac_clean = us_fraction_raw.strip() agg_key = (us_frac_clean, origin_final) @@ -367,13 +555,13 @@ class ConsolidadoImportacionMexService: q_line = float(qty.quantity) if (qty and qty.quantity) else 0.0 nw_line = float(qty.net_weight) if qty else 0.0 gw_line = float(qty.gross_weight) if qty else 0.0 - + # --- Multi-Currency Normalization Logic --- # Determine Line Currency context # Use manual lookup instead of specific attribute invoice_id = line.item.invoice_id if line.item else None line_invoice = invoice_map.get(invoice_id) if invoice_id else None - + line_currency_is_mxn = False line_exchange_rate = 1.0 @@ -381,30 +569,36 @@ class ConsolidadoImportacionMexService: # Check explicit currency string AND code curr_desc = str(line_invoice.financials.currency or "").upper() curr_code = str(line_invoice.financials.currency_type or "").upper() - + # Logic: It is MXN if description says PESO/MX or code is MXN/MN - is_mx_desc = ("MX" in curr_desc or "PESO" in curr_desc) - is_mx_code = ("MXN" in curr_code or "MN" == curr_code) - + is_mx_desc = "MX" in curr_desc or "PESO" in curr_desc + is_mx_code = "MXN" in curr_code or "MN" == curr_code + # But if code allows clarifying USD, prioritize that - is_usd_code = ("USD" in curr_code) - + is_usd_code = "USD" in curr_code + if is_usd_code: line_currency_is_mxn = False elif is_mx_code or is_mx_desc: line_currency_is_mxn = True else: - line_currency_is_mxn = False # Default to Foreign/USD if unsure + line_currency_is_mxn = False # Default to Foreign/USD if unsure + + line_exchange_rate = float( + line_invoice.financials.exchange_rate or 1.0 + ) - line_exchange_rate = float(line_invoice.financials.exchange_rate or 1.0) - # Target Report Currency - report_is_mxn = (factura_schema.moneda == 'MXN') + report_is_mxn = factura_schema.moneda == "MXN" # DEBUG LOGGING if line_invoice: - print(f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}") - print(f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}") + print( + f"DEBUG: Line {line.id} - Inv {line_invoice.id} - CurrDesc: '{curr_desc}' Code: '{curr_code}' - Rate: {line_exchange_rate}" + ) + print( + f"DEBUG: Is MXN Context? {line_currency_is_mxn}. Report is MXN? {report_is_mxn}" + ) # --- Get Financials for Line (Raw) --- v_total_raw = 0.0 @@ -421,11 +615,11 @@ class ConsolidadoImportacionMexService: total_comm = float(fin.total_commercial_value or 0.0) unit_comm_usd = float(fin.unit_cost_commercial_usd or 0.0) unit_usd = float(fin.unit_cost_usd or 0.0) - + # 1. Direct Total: Custom Value (Best case) if val_usd > 0: v_total_raw = val_usd - + # 2. Direct Total: Commercial Total elif total_comm > 0: # Convert if invoice currency is MXN @@ -433,18 +627,18 @@ class ConsolidadoImportacionMexService: v_total_raw = total_comm / line_exchange_rate else: v_total_raw = total_comm - + # 3. Calc from Commercial Unit Cost (Safe Fallback) elif unit_comm_usd > 0 and q_line > 0: v_total_raw = unit_comm_usd * q_line - + # 4. Calc from Customs Unit Cost (Unknown Risk - Last Resort) elif unit_usd > 0 and q_line > 0: v_total_raw = unit_usd * q_line - + else: v_total_raw = 0.0 - + # NOTE: v_unitario_raw is left as 0.0 here. # It will be calculated in the 'Calculation Gap Fill' block below: # v_unitario_raw = v_total_raw / q_line @@ -474,40 +668,70 @@ class ConsolidadoImportacionMexService: # else: # v_total_line = 0.0 # v_unitario_line = 0.0 - + print(f"DEBUG: ValRaw: {v_total_raw} -> ValFinal: {v_total_line}") - + # --- Resolve Fraction Details (Description & Rate) --- # Only if this is the first time we see this key (or overwrite, doesn't matter much as they should be same for same HTS) - # We check if we already have description set to avoid re-querying if we want optimization, + # We check if we already have description set to avoid re-querying if we want optimization, # but relying on DB query per distinct fraction is safer. - + current_agg = aggregated_data[agg_key] - + if not current_agg["description"]: - us_frac_db = db.query(USTariffFraction).filter(USTariffFraction.code == us_frac_clean).first() + us_frac_db = ( + db.query(USTariffFraction) + .filter(USTariffFraction.code == us_frac_clean) + .first() + ) if us_frac_db: - current_agg["description"] = us_frac_db.description or "Sin Descripción" - # Parse AdValorem from DB if available, else 0 ?? - # Creating logical placeholder. The provided Clarion code used `FraAme.Adv` - adv_val = us_frac_db.ad_valorem # Assuming field exists based on viewing file later? - # Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`. - current_agg["advalorem_txt"] = f"{adv_val}%" if adv_val is not None else "0%" + current_agg["description"] = ( + us_frac_db.description or "Sin Descripción" + ) + # Parse AdValorem from DB if available, else 0 ?? + # Creating logical placeholder. The provided Clarion code used `FraAme.Adv` + adv_val = ( + us_frac_db.ad_valorem + ) # Assuming field exists based on viewing file later? + # Wait, in us-tariff-fractions.ts I saw `ad_valorem: number | null`. + current_agg["advalorem_txt"] = ( + f"{adv_val}%" if adv_val is not None else "0%" + ) else: - current_agg["description"] = part_master.description_spanish if part_master else "S/D" + current_agg["description"] = ( + part_master.description_spanish if part_master else "S/D" + ) current_agg["hts_code_print"] = us_frac_clean - current_agg["unit_measure"] = qty.weight_unit if qty else "KGS" # Default to first found + + # Obtener descripción de la unidad de medida desde la tabla a76.item_lines + if line.unit_of_measure: + uom = ( + db.query(UnitOfMeasure) + .filter( + UnitOfMeasure.id == line.unit_of_measure, + UnitOfMeasure.company_id == company_id, + ) + .first() + ) + current_agg["unit_measure"] = ( + uom.description + if (uom and uom.description) + else (uom.code if uom else "KGS") + ) + else: + current_agg["unit_measure"] = "KGS" # Default fallback # --- Calculate Estimated Tax for this Line --- rate = 0.0 try: clean_adv = current_agg["advalorem_txt"].replace("%", "").strip() rate = float(clean_adv) / 100.0 - except: rate = 0.0 - + except: + rate = 0.0 + v_est_line = v_total_line * rate - + # --- Accumulate --- current_agg["qty"] += q_line current_agg["net_weight_kgs"] += nw_line @@ -515,51 +739,59 @@ class ConsolidadoImportacionMexService: current_agg["total_value"] += v_total_line current_agg["est_total_value"] += v_est_line - # --- Convert Aggregated Data to Schema List --- partidas_list = [] - + for (hts, origin), data in aggregated_data.items(): - + # Calculate Unit Price based on Total Value / Total Qty unit_price = 0.0 if data["qty"] > 0: unit_price = data["total_value"] / data["qty"] - - partidas_list.append(PartidaSchema( - numero_parte="VARIOS", # Or empty - descripcion=data["description"], - fraccion=data["hts_code_print"], - origen=origin, - advalorem=data["advalorem_txt"], - preferencia="General", - cantidad_importacion=self.formatear_numero(data["qty"]), - unidad_medida=data["unit_measure"], - cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later - clave_bultos="", - peso_neto=self.formatear_numero(data["net_weight_kgs"]), - peso_bruto=self.formatear_numero(data["gross_weight_kgs"]), - valor_costo_unitario=self.formatear_numero(unit_price), - valor_total=self.formatear_numero(data["total_value"]), - valor_estimado=self.formatear_numero(data["est_total_value"]) - )) - + + partidas_list.append( + PartidaSchema( + numero_parte="VARIOS", # Or empty + descripcion=data["description"], + fraccion=data["hts_code_print"], + origen=origin, + advalorem=data["advalorem_txt"], + preferencia="General", + cantidad_importacion=self.formatear_numero(data["qty"]), + unidad_medida=data["unit_measure"], + cantidad_bultos=0, # Summing bultos might be tricky if not homogeneous, check logic later + clave_bultos="", + peso_neto=self.formatear_numero(data["net_weight_kgs"]), + peso_bruto=self.formatear_numero(data["gross_weight_kgs"]), + valor_costo_unitario=self.formatear_numero(unit_price), + valor_total=self.formatear_numero(data["total_value"]), + valor_estimado=self.formatear_numero(data["est_total_value"]), + ) + ) + # Sort by Fraction (HTS Code) partidas_list.sort(key=lambda x: x.fraccion) - totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + totales = self.calcular_totales( + partidas_list, Decimal(factura_schema.tipo_cambio) + ) return FacturaImportacionCompleta( - cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, - cliente_enviado=cliente_enviado, factura=factura_schema, - partidas=partidas_list, totales=totales + cliente_proveedor=cliente_proveedor, + cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, + factura=factura_schema, + partidas=partidas_list, + totales=totales, ) except Exception as e: print(f"Error Service A76: {e}") raise HTTPException(status_code=500, detail=f"Error: {str(e)}") - def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + def calcular_totales( + self, partidas: List[PartidaSchema], tipo_cambio: Decimal + ) -> TotalesSchema: cant = sum(p.cantidad_importacion for p in partidas) valor = sum(p.valor_total for p in partidas) peso_n = sum(p.peso_neto for p in partidas) @@ -567,23 +799,41 @@ class ConsolidadoImportacionMexService: bultos = sum(p.cantidad_bultos for p in partidas) claves = [p.clave_bultos for p in partidas if p.clave_bultos] clave_comun = max(set(claves), key=claves.count) if claves else "" - if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" - v_est = sum(p.valor_estimado for p in partidas if isinstance(p.valor_estimado, (int, float, Decimal))) - - tc = float(tipo_cambio) if tipo_cambio else 1.0 - return TotalesSchema( - cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, - peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), - valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), - valor_estimado_total=self.formatear_numero(v_est) + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): + clave_comun += "S" + v_est = sum( + p.valor_estimado + for p in partidas + if isinstance(p.valor_estimado, (int, float, Decimal)) ) - def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: - if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + tc = float(tipo_cambio) if tipo_cambio else 1.0 + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), + bultos_total=bultos, + clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), + peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), + valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), + valor_estimado_total=self.formatear_numero(v_est), + ) + + def generar_factura_completa( + self, + db: Session, + invoice_id: int, + company_id: int, + formato: str = "pdf", + progress_callback: Optional[Callable] = None, + ) -> Tuple[bytes, str, str]: + if progress_callback: + progress_callback(5, "Iniciando servicio de reporte...") datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) - - if progress_callback: progress_callback(80, "Renderizando plantilla...") - + + if progress_callback: + progress_callback(80, "Renderizando plantilla...") + # LOGO LOGIC logo_b64 = None try: @@ -592,7 +842,7 @@ class ConsolidadoImportacionMexService: comp_logo = db.query(Company).filter(Company.id == company_id).first() if comp_logo and comp_logo.logo: p = Path(comp_logo.logo) - + # Logic robusta de búsqueda (igual que en routes.py) target_path = p if not target_path.exists(): @@ -604,27 +854,49 @@ class ConsolidadoImportacionMexService: if target_path.exists(): with open(target_path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + encoded_string = base64.b64encode(image_file.read()).decode( + "utf-8" + ) # Detect MIME type loosely mime = "image/png" - if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + if target_path.suffix.lower() in [".jpg", ".jpeg"]: + mime = "image/jpeg" logo_b64 = f"data:{mime};base64,{encoded_string}" except Exception as e: print(f"Error loading logo: {e}") context = { - 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), - 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), - 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), - 'logo_b64': logo_b64 + "cliente_proveedor": datos.cliente_proveedor.model_dump(), + "cliente_vendido": datos.cliente_vendido.model_dump(), + "cliente_enviado": datos.cliente_enviado.model_dump(), + "factura": datos.factura.model_dump(), + "partidas": [p.model_dump() for p in datos.partidas], + "totales": datos.totales.model_dump(), + "logo_b64": logo_b64, } html_content = self.template.render(**context) nombre = f"Consolidado_{datos.factura.numero}.{formato}" - if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" - - if progress_callback: progress_callback(90, "Generando PDF final...") - options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} - pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) - - if progress_callback: progress_callback(100, "Completado") + if formato == "html": + return html_content.encode("utf-8"), nombre, "text/html" + + if progress_callback: + progress_callback(90, "Generando PDF final...") + options = { + "page-size": "Letter", + "margin-top": "0.5in", + "margin-right": "0.5in", + "margin-bottom": "0.5in", + "margin-left": "0.5in", + "encoding": "UTF-8", + "enable-local-file-access": None, + } + pdf = pdfkit.from_string( + html_content, + False, + options=options, + configuration=self._get_wkhtmltopdf_config(), + ) + + if progress_callback: + progress_callback(100, "Completado") return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 200a3942..b6746120 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -10,18 +10,20 @@ from fastapi import HTTPException from sqlalchemy.orm import Session # --- MODELOS --- -from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics from api.v1.modules.a76.items.line_financials.models import LineFinancial from api.v1.modules.a76.items.line_quantities.models import LineQuantity from api.v1.modules.a76.items.line_items.models import LineItem from api.v1.modules.a76.clients_and_providers.models import ( - ClientProvider, ClientProviderAddress, ClientProviderPrograms + ClientProvider, + ClientProviderAddress, + ClientProviderPrograms, ) from api.v1.modules.a76.parts.models import Part from api.v1.modules.a76.pedmientos.models import Pedimentos from api.v1.modules.a76.general_catalogs.company.models import Company from api.v1.modules.a76.customs_brokers.models import CustomsBroker -from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.models import Item # --- TRANSPORTATION MODELS --- from api.v1.modules.a76.transportation.transporters.models import Transporter @@ -32,20 +34,27 @@ from api.v1.modules.a76.transportation.drivers.models import Driver # --- MODELO DE FRACCIONES --- from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +# --- MODELO DE UNIDADES DE MEDIDA --- +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + # --- SCHEMAS --- from .schemas import ( - ClienteSchema, PartidaSchema, TotalesSchema, - FacturaSchema, FacturaImportacionCompleta + ClienteSchema, + PartidaSchema, + TotalesSchema, + FacturaSchema, + FacturaImportacionCompleta, ) + class FacturaImportacionMexService: def __init__(self): self.template_dir = Path(__file__).parent.parent / "templates" self.jinja_env = Environment( loader=FileSystemLoader(self.template_dir), - autoescape=select_autoescape(['html', 'xml']) + autoescape=select_autoescape(["html", "xml"]), ) - self.template = self.jinja_env.get_template('factura_mex_ver.html') + self.template = self.jinja_env.get_template("factura_mex_ver.html") def _get_wkhtmltopdf_config(self): path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" @@ -54,28 +63,49 @@ class FacturaImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + if valor is None: + return 0.0 try: return round(float(valor), decimales) - except: return 0.0 + except: + return 0.0 def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: return fraccion_raw return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}" - def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema: + def _obtener_datos_cliente( + self, db: Session, client_id: int, rol: str + ) -> ClienteSchema: main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first() if not main: - return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX") - - addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first() - prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first() + return ClienteSchema( + header=rol, + nombre="Desconocido", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + ) + + addr = ( + db.query(ClientProviderAddress) + .filter(ClientProviderAddress.client_id == client_id) + .first() + ) + prog = ( + db.query(ClientProviderPrograms) + .filter(ClientProviderPrograms.client_id == client_id) + .first() + ) return ClienteSchema( header=rol, nombre=(main.name or main.short_name) or "S/N", - direccion=(addr.streets or "") if addr else "", + direccion=(addr.streets or "") if addr else "", num_exterior=(addr.exterior_number or "") if addr else "", num_interior=(addr.interior_number or "") if addr else "", colonia=(addr.neighborhood or "") if addr else "", @@ -83,47 +113,114 @@ class FacturaImportacionMexService: ciudad=(addr.city or "") if addr else "", estado=(addr.state or "") if addr else "", pais=(addr.country or "MEX") if addr else "MEX", - tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""), - programa="IMMEX" if (prog and prog.program) else "", - autorizacion=prog.program_number if prog else "", - prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "", - reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else ( - prog.certified_company_registry if (prog and prog.certified_company_registry) else "" + tax_id=( + prog.tax_id + if (prog and prog.tax_id) + else (getattr(main, "rfc", "") or "") + ), + programa="IMMEX" if (prog and prog.program) else "", + autorizacion=prog.program_number if prog else "", + prosec=( + prog.prosec_authorization + if (prog and prog.prosec and prog.prosec_authorization) + else "" + ), + reg_emp=( + prog.val_certified_company_registry + if (prog and hasattr(prog, "val_certified_company_registry")) + else ( + prog.certified_company_registry + if (prog and prog.certified_company_registry) + else "" + ) + ), + cert=( + prog.is_certified_company + if (prog and prog.is_certified_company) + else "" ), - cert=prog.is_certified_company if (prog and prog.is_certified_company) else "" ) - def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta: + def obtener_datos( + self, + db: Session, + invoice_id: int, + company_id: int, + progress_callback: Optional[Callable] = None, + ) -> FacturaImportacionCompleta: try: - if progress_callback: progress_callback(10, "Buscando factura...") - header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() - if not header: raise HTTPException(status_code=404, detail="Factura no encontrada") + if progress_callback: + progress_callback(10, "Buscando factura...") + header = ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.id == invoice_id, + InvoiceHeader.company_id == company_id, + ) + .first() + ) + if not header: + raise HTTPException(status_code=404, detail="Factura no encontrada") - compliance = header.compliance_mx + compliance = header.compliance_mx logistics = header.logistics if header.logistics else None financials = header.financials if header.financials else None - if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...") - pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id - pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None - - if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...") + if progress_callback: + progress_callback(20, "Obteniendo datos de pedimento...") + pedimento_id = ( + compliance.pedimento_id + if (compliance and compliance.pedimento_id) + else header.related_doc_id + ) + pedimento = ( + db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() + if pedimento_id + else None + ) + + if progress_callback: + progress_callback(30, "Obteniendo cliente y proveedor...") proveedor_id = compliance.provider_id if compliance else None - cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="") + cliente_proveedor = ( + self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") + if proveedor_id + else ClienteSchema( + header="Proveedor", + nombre="No Asignado", + direccion="", + tax_id="", + codigo_postal="", + ciudad="", + estado="", + pais="", + ) + ) nombre_agente = "" if compliance and compliance.customs_broker_id: - broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first() - if broker: nombre_agente = broker.name + broker = ( + db.query(CustomsBroker) + .filter(CustomsBroker.id == compliance.customs_broker_id) + .first() + ) + if broker: + nombre_agente = broker.name company = db.query(Company).filter(Company.id == header.company_id).first() # Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A) cliente_default = ClienteSchema( header="Importador / consignatario:", - nombre=getattr(company, 'name', "Empresa Local"), + nombre=getattr(company, "name", "Empresa Local"), direccion="DOMICILIO FISCAL", - num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX", - tax_id=getattr(company, 'rfc', ""), - programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "") + num_exterior="", + colonia="", + codigo_postal="", + ciudad="", + estado="", + pais="MEX", + tax_id=getattr(company, "rfc", ""), + programa=getattr(company, "program", "IMMEX"), + autorizacion=getattr(company, "program_number", ""), ) # Left Side Logic (Consignatario / Sold To) @@ -131,34 +228,51 @@ class FacturaImportacionMexService: if compliance and compliance.sold_to_id: raw_header = compliance.sold_to_header or "CONSIGNATARIO" clean_header = raw_header.replace("_", " ").capitalize() + ":" - cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header) - + cliente_vendido = self._obtener_datos_cliente( + db, compliance.sold_to_id, clean_header + ) + # Right Side Logic (Enviado A / Shipped To) cliente_enviado = cliente_default if compliance and compliance.shipped_to_id: # Clean header: "enviado_a" -> "Enviado a:" raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO" - clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":" - - # Fetch client data - cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped) + clean_header_shipped = ( + raw_header_shipped.replace("_", " ").capitalize() + ":" + ) - remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else "" - acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A" + # Fetch client data + cliente_enviado = self._obtener_datos_cliente( + db, compliance.shipped_to_id, clean_header_shipped + ) + + remesa_valor = ( + str(compliance.remesa) if (compliance and compliance.remesa) else "" + ) + acuse_valor = ( + str(compliance.edocument) + if (compliance and compliance.edocument) + else "N/A" + ) patente_val = "" if pedimento and pedimento.license: patente_val = pedimento.license - elif 'broker' in locals() and broker and broker.license: + elif "broker" in locals() and broker and broker.license: patente_val = broker.license - # --- Transport Data Fetching --- - transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else "" + transporte_txt = ( + str(logistics.transport_type) + if (logistics and logistics.transport_type) + else "" + ) num_transporte_val = (logistics.trailer_num or "") if logistics else "" - + # Init values - placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto + placas_val = ( + (logistics.license_plate or "") if logistics else "" + ) # Placas Tracto placas_remolque_val = "" transportista_val = (logistics.carrier_id or "") if logistics else "" caat_val = "" @@ -168,46 +282,82 @@ class FacturaImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_key == logistics.carrier_id) + .first() + ) if transporter_obj: caat_val = transporter_obj.caat_code or "" - scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC + scac_val = ( + transporter_obj.transport_code or "" + ) # Mapping transport_code to SCAC transportista_val = transporter_obj.name or logistics.carrier_id # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: - placas_val = veh_obj.plate_number or placas_val - elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first() - if veh_obj: - placas_val = veh_obj.plate_number or placas_val + placas_val = veh_obj.plate_number or placas_val + elif ( + logistics.vehicle_num + ): # Fallback to vehicle_num if populated and transport_id failed/empty + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.vehicle_num) + .first() + ) + if veh_obj: + placas_val = veh_obj.plate_number or placas_val # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" # 4. Driver (License) if logistics.carrier_id and logistics.driver_name: # Attempt to find driver by name + carrier - drv_obj = db.query(Driver).filter( - Driver.transporter_key == logistics.carrier_id, - Driver.driver_name == logistics.driver_name - ).first() + drv_obj = ( + db.query(Driver) + .filter( + Driver.transporter_key == logistics.carrier_id, + Driver.driver_name == logistics.driver_name, + ) + .first() + ) if drv_obj: - licencia_cond_val = drv_obj.license_number or "" + licencia_cond_val = drv_obj.license_number or "" factura_schema = FacturaSchema( numero=header.invoice_number or "S/N", fecha=str(header.invoice_date) if header.invoice_date else "", - tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0), - moneda=getattr(header, 'currency', "USD") or "USD", + tipo_cambio=( + float(financials.exchange_rate) + if (financials and financials.exchange_rate) + else ( + float(pedimento.exchange_rate) + if pedimento and pedimento.exchange_rate + else 1.0 + ) + ), + moneda=getattr(header, "currency", "USD") or "USD", incoterm=(logistics.incoterm or "") if logistics else "", observaciones=header.observation_es or header.observation_en or "", - pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "", + pedimento=( + f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" + if pedimento + else "" + ), clave_pedimento=pedimento.pedimento_code if pedimento else "", regimen=header.document_type or "", patente=patente_val, @@ -220,45 +370,74 @@ class FacturaImportacionMexService: caat=caat_val, scac=scac_val, licencia_conductor=licencia_cond_val, - aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""), + aduana=( + compliance.aduana + if (compliance and compliance.aduana) + else ( + pedimento.customs_office[:2] + if (pedimento and pedimento.customs_office) + else "" + ) + ), precinto=(logistics.seal_number or "") if logistics else "", destino=(logistics.destination_goods or "") if logistics else "", - remesa=remesa_valor, acuse_electronico=acuse_valor + remesa=remesa_valor, + acuse_electronico=acuse_valor, + ) + + if progress_callback: + progress_callback(50, "Procesando partidas...") + lines = ( + db.query(LineItem) + .join(Item, LineItem.item_id == Item.id) + .filter(Item.invoice_id == header.id) + .all() ) - - if progress_callback: progress_callback(50, "Procesando partidas...") - lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all() partidas_list = [] - + for line in lines: - qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first() - fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first() + qty = ( + db.query(LineQuantity) + .filter(LineQuantity.item_line_id == line.id) + .first() + ) + fin = ( + db.query(LineFinancial) + .filter(LineFinancial.item_line_id == line.id) + .first() + ) part_master = db.query(Part).filter(Part.id == line.part_number).first() desc_final = "S/D" num_parte_final = str(line.part_number or "S/N") - fraccion_raw = "" + fraccion_raw = "" origen_final = "MEX" if part_master: - desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc." + desc_final = ( + part_master.description_spanish + or part_master.description_english + or "Sin Desc." + ) num_parte_final = part_master.part_number fraccion_raw = part_master.fraction if part_master.fraction else "" - + # Fetch Origin from Master Catalog (FaPart) if part_master.fa_data and part_master.fa_data.origin_country: origen_final = part_master.fa_data.origin_country - fraccion_limpia = fraccion_raw.replace(".", "").strip() if fraccion_limpia: fraccion_limpia = fraccion_limpia[:8].zfill(8) # Consultar tabla tariff_fractions - fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first() + fraccion_db = ( + db.query(TariffFraction) + .filter(TariffFraction.code == fraccion_limpia) + .first() + ) - - preferencia_txt = "General" + preferencia_txt = "General" advalorem_txt = "0%" fraccion_imprimir = fraccion_raw @@ -268,20 +447,20 @@ class FacturaImportacionMexService: if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]: advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%" else: - advalorem_txt = "0%" - + advalorem_txt = "0%" + fraccion_imprimir = fraccion_db.fraction or fraccion_raw else: - + fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia) # Logic to determine values - Prioritize Specific Currency Columns v_unitario = 0.0 v_total = 0.0 - + if fin: - is_mxn = (factura_schema.moneda == 'MXN') - + is_mxn = factura_schema.moneda == "MXN" + # 1. Try Specific Currency Columns First if is_mxn: v_unitario = float(fin.unit_cost_commercial_mxn or 0.0) @@ -292,50 +471,83 @@ class FacturaImportacionMexService: # 2. Fallback to Generic independently if Specific is 0 if not v_unitario: - v_unitario = float(fin.commercial_unit_cost or 0.0) - + v_unitario = float(fin.commercial_unit_cost or 0.0) + if not v_total: - v_total = float(fin.total_commercial_value or 0.0) + v_total = float(fin.total_commercial_value or 0.0) # 3. Calculate from Quantity if still missing cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0 - + if cantidad > 0: if v_unitario > 0 and v_total == 0: v_total = v_unitario * cantidad elif v_total > 0 and v_unitario == 0: v_unitario = v_total / cantidad - partidas_list.append(PartidaSchema( - numero_parte=num_parte_final, - descripcion=desc_final, - fraccion=fraccion_imprimir, - origen=origen_final, - advalorem=advalorem_txt, - preferencia=preferencia_txt, - cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0), - unidad_medida=qty.weight_unit if qty else "PZA", - cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0, - clave_bultos=(qty.package_key or "") if qty else "", - peso_neto=self.formatear_numero(qty.net_weight if qty else 0), - peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0), - valor_costo_unitario=self.formatear_numero(v_unitario), - valor_total=self.formatear_numero(v_total) - )) + # Obtener descripción de la unidad de medida desde la tabla a76.item_lines + unidad_desc = "" + if line.unit_of_measure: + uom = ( + db.query(UnitOfMeasure) + .filter( + UnitOfMeasure.id == line.unit_of_measure, + UnitOfMeasure.company_id == company_id, + ) + .first() + ) + if uom: + unidad_desc = uom.description or uom.code + else: + unidad_desc = "" - totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio)) + partidas_list.append( + PartidaSchema( + numero_parte=num_parte_final, + descripcion=desc_final, + fraccion=fraccion_imprimir, + origen=origen_final, + advalorem=advalorem_txt, + preferencia=preferencia_txt, + cantidad_importacion=self.formatear_numero( + qty.quantity if qty else 0 + ), + unidad_medida=unidad_desc, + cantidad_bultos=( + int(qty.package_quantity) + if qty and qty.package_quantity + else 0 + ), + clave_bultos=(qty.package_key or "") if qty else "", + peso_neto=self.formatear_numero(qty.net_weight if qty else 0), + peso_bruto=self.formatear_numero( + qty.gross_weight if qty else 0 + ), + valor_costo_unitario=self.formatear_numero(v_unitario), + valor_total=self.formatear_numero(v_total), + ) + ) + + totales = self.calcular_totales( + partidas_list, Decimal(factura_schema.tipo_cambio) + ) return FacturaImportacionCompleta( - cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido, - cliente_enviado=cliente_enviado, factura=factura_schema, - partidas=partidas_list, totales=totales + cliente_proveedor=cliente_proveedor, + cliente_vendido=cliente_vendido, + cliente_enviado=cliente_enviado, + factura=factura_schema, + partidas=partidas_list, + totales=totales, ) except Exception as e: print(f"Error Service A76: {e}") raise HTTPException(status_code=500, detail=f"Error: {str(e)}") - def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema: + def calcular_totales( + self, partidas: List[PartidaSchema], tipo_cambio: Decimal + ) -> TotalesSchema: cant = sum(p.cantidad_importacion for p in partidas) valor = sum(p.valor_total for p in partidas) peso_n = sum(p.peso_neto for p in partidas) @@ -343,20 +555,34 @@ class FacturaImportacionMexService: bultos = sum(p.cantidad_bultos for p in partidas) claves = [p.clave_bultos for p in partidas if p.clave_bultos] clave_comun = max(set(claves), key=claves.count) if claves else "" - if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S" + if bultos > 1 and clave_comun and not clave_comun.endswith("S"): + clave_comun += "S" tc = float(tipo_cambio) if tipo_cambio else 1.0 - return TotalesSchema( - cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun, - peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b), - valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0) + return TotalesSchema( + cantidad_total=self.formatear_numero(cant), + bultos_total=bultos, + clave_bultos=clave_comun, + peso_neto_total=self.formatear_numero(peso_n), + peso_bruto_total=self.formatear_numero(peso_b), + valor_total_total=self.formatear_numero(valor), + valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0), ) - def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: - if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + def generar_factura_completa( + self, + db: Session, + invoice_id: int, + company_id: int, + formato: str = "pdf", + progress_callback: Optional[Callable] = None, + ) -> Tuple[bytes, str, str]: + if progress_callback: + progress_callback(5, "Iniciando servicio de reporte...") datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) - - if progress_callback: progress_callback(80, "Renderizando plantilla...") - + + if progress_callback: + progress_callback(80, "Renderizando plantilla...") + # LOGO LOGIC logo_b64 = None try: @@ -365,7 +591,7 @@ class FacturaImportacionMexService: comp_logo = db.query(Company).filter(Company.id == company_id).first() if comp_logo and comp_logo.logo: p = Path(comp_logo.logo) - + # Logic robusta de búsqueda (igual que en routes.py) target_path = p if not target_path.exists(): @@ -377,27 +603,49 @@ class FacturaImportacionMexService: if target_path.exists(): with open(target_path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + encoded_string = base64.b64encode(image_file.read()).decode( + "utf-8" + ) # Detect MIME type loosely mime = "image/png" - if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg" + if target_path.suffix.lower() in [".jpg", ".jpeg"]: + mime = "image/jpeg" logo_b64 = f"data:{mime};base64,{encoded_string}" except Exception as e: print(f"Error loading logo: {e}") context = { - 'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(), - 'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(), - 'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(), - 'logo_b64': logo_b64 + "cliente_proveedor": datos.cliente_proveedor.model_dump(), + "cliente_vendido": datos.cliente_vendido.model_dump(), + "cliente_enviado": datos.cliente_enviado.model_dump(), + "factura": datos.factura.model_dump(), + "partidas": [p.model_dump() for p in datos.partidas], + "totales": datos.totales.model_dump(), + "logo_b64": logo_b64, } html_content = self.template.render(**context) nombre = f"Factura_{datos.factura.numero}.{formato}" - if formato == "html": return html_content.encode('utf-8'), nombre, "text/html" - - if progress_callback: progress_callback(90, "Generando PDF final...") - options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None} - pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) - - if progress_callback: progress_callback(100, "Completado") - return pdf, nombre, "application/pdf" \ No newline at end of file + if formato == "html": + return html_content.encode("utf-8"), nombre, "text/html" + + if progress_callback: + progress_callback(90, "Generando PDF final...") + options = { + "page-size": "Letter", + "margin-top": "0.5in", + "margin-right": "0.5in", + "margin-bottom": "0.5in", + "margin-left": "0.5in", + "encoding": "UTF-8", + "enable-local-file-access": None, + } + pdf = pdfkit.from_string( + html_content, + False, + options=options, + configuration=self._get_wkhtmltopdf_config(), + ) + + if progress_callback: + progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" diff --git a/backend/core/config.py b/backend/core/config.py index c27954eb..37c5d52f 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -42,8 +42,16 @@ class Settings(BaseSettings): # License LICENSE_CHECK_ENABLED: bool = True + # External APIs + SITAR_API_URL: str = "api.sitar.aduanasoft.com:880" + SITAR_API_USER: str = "" + SITAR_API_PASSWORD: str = "" + model_config = SettingsConfigDict( - env_file=".env", case_sensitive=True, extra="ignore", env_file_encoding="utf-8" + env_file=[".env", "../.env"], + case_sensitive=True, + extra="ignore", + env_file_encoding="utf-8", ) @property diff --git a/backend/requirements.txt b/backend/requirements.txt index ff7a7cfa..00ebff8e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -21,6 +21,7 @@ passlib[bcrypt]==1.7.4 httpx==0.28.1 requests==2.32.5 + # Utilities python-multipart==0.0.20 python-dotenv==1.1.1 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index eb05fe4c..4eabf108 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -159,6 +159,9 @@ services: - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - CORS_ORIGINS=${CORS_ORIGINS:-https://anexo76-dev.aduanasoft.com,http://localhost:3000} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} ports: - "3467:8000" depends_on: @@ -166,7 +169,8 @@ services: condition: service_healthy keycloak: condition: service_healthy - volumes: + volumes: + - backend_uploads:/app/uploads - ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro networks: - backend-net @@ -180,11 +184,13 @@ services: "-k", "uvicorn.workers.UvicornWorker", "-w", - "${WEB_CONCURRENCY:-4}", + "${WEB_CONCURRENCY:-1}", "-b", "0.0.0.0:8000", "--log-level", - "info" + "info", + "--forwarded-allow-ips", + "*" ] healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"] @@ -204,6 +210,28 @@ services: reservations: memory: 256M + # celery + celery_worker: + image: dev.aduanasoft.com/anexo76/backend:latest + container_name: worker + command: celery -A core.celery_app worker --loglevel=info + environment: + - VALKEY_URL=redis://valkey:6379/0 + depends_on: + - backend + - valkey + networks: + - backend-net + + valkey: + image: valkey/valkey:7.2 + container_name: valkey + restart: always + ports: + - "6579:6379" + networks: + - backend-net + # Frontend - SvelteKit frontend: image: dev.aduanasoft.com/anexo76/frontend:latest @@ -262,6 +290,8 @@ volumes: driver: local backend_cache: driver: local + backend_uploads: + driver: local networks: backend-net: diff --git a/docker-compose.yml b/docker-compose.yml index c26575a5..041c1bdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -175,6 +175,9 @@ services: - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000} + - SITAR_API_URL=${SITAR_API_URL} + - SITAR_API_USER=${SITAR_API_USER} + - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} ports: - "8000:8000" depends_on: @@ -185,6 +188,7 @@ services: volumes: - ./backend:/app - backend_cache:/app/__pycache__ + - backend_uploads:/app/uploads - ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro networks: - backend-net @@ -265,7 +269,7 @@ services: # celery celery_worker: build: ./backend - container_name: a76_worker + container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: - VALKEY_URL=redis://valkey:6379/0 @@ -277,7 +281,7 @@ services: valkey: image: valkey/valkey:7.2 - container_name: a76_valkey + container_name: valkey restart: always ports: - "6379:6379" @@ -295,6 +299,8 @@ volumes: driver: local backend_cache: driver: local + backend_uploads: + driver: local networks: backend-net: diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4225b510..49d51d59 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -91,6 +91,11 @@ }, "clients_and_providers": "Clients and Providers", "customs_brokers": "Customs Brokers", + "client_provider_type": { + "client_indicator": "C", + "provider_indicator": "P", + "both_indicator": "B" + }, "nav_user": { "profile": "Profile", "settings": "Settings", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 2acc042b..48dcc061 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -91,6 +91,11 @@ }, "clients_and_providers": "Clientes y Proveedores", "customs_brokers": "Agentes Aduanales", + "client_provider_type": { + "client_indicator": "C", + "provider_indicator": "P", + "both_indicator": "A" + }, "nav_user": { "profile": "Perfil", "settings": "Configuración" diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 369cde53..ee507945 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -19,8 +19,7 @@ export interface Company { responsible_last_name: string | null; responsible_mother_last_name: string | null; responsible_rfc?: string | null; - position?: string | null; - logo?: string | null; + position?: string | null; has_express_line?: boolean; is_service_company?: boolean; order_format_type?: string | null; @@ -107,7 +106,7 @@ export async function getCompanies( } export async function getCompany(id: number): Promise> { - return await api.get(`/v1/a76/company/${id}/`); + return await api.get(`/v1/a76/company/${id}`); } export async function createCompany(data: CompanyCreate): Promise> { @@ -115,7 +114,7 @@ export async function createCompany(data: CompanyCreate): Promise> { - return await api.put(`/v1/a76/company/${id}/`, data); + return await api.put(`/v1/a76/company/${id}`, data); } export async function deleteCompany(id: number): Promise> { diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts index 5ec45db9..0ef3e8a5 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/exchange-rate.ts @@ -37,10 +37,12 @@ export interface ExchangeRateFilters { page_size?: number; } +import type { ApiResponse } from '$lib/api'; + export async function getExchangeRates( companyId: number, filters?: ExchangeRateFilters -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); if (filters) { @@ -57,7 +59,7 @@ export async function getExchangeRates( export async function getExchangeRate( exchangeRateId: number, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.get(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`); } @@ -65,7 +67,7 @@ export async function getExchangeRate( export async function createExchangeRate( data: ExchangeRateCreate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.post(`/v1/a76/exchange-rate/?${params.toString()}`, data); } @@ -74,7 +76,7 @@ export async function updateExchangeRate( exchangeRateId: number, data: ExchangeRateUpdate, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.put( `/v1/a76/exchange-rate/${exchangeRateId}/?${params.toString()}`, @@ -85,7 +87,17 @@ export async function updateExchangeRate( export async function deleteExchangeRate( exchangeRateId: number, companyId: number -): Promise { +): Promise> { const params = new URLSearchParams({ company_id: companyId.toString() }); return api.delete(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`); } + +export interface DofResponse { + success: boolean; + message?: string; + value?: number | null; +} + +export async function getDofExchangeRate(date: string): Promise> { + return api.get(`/v1/a76/exchange-rate/dof-search?date=${date}`); +} diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 5c04f975..353ddcfa 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -244,7 +244,7 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.get(`/v1/a76/items/invoice/${invoiceId}/items?${params.toString()}`); + return api.get(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`); }, /** @@ -264,7 +264,7 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.post(`/v1/a76/items?${params.toString()}`, data); + return api.post(`/v1/a76/items/?${params.toString()}`, data); }, /** @@ -274,7 +274,7 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.put(`/v1/a76/items/${itemId}?${params.toString()}`, data); + return api.put(`/v1/a76/items/${itemId}/?${params.toString()}`, data); }, /** @@ -284,6 +284,6 @@ export const itemsApi = { const params = new URLSearchParams({ company_id: companyId.toString() }); - return api.delete(`/v1/a76/items/${itemId}?${params.toString()}`); + return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); } }; diff --git a/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte index 34c61657..f85eec38 100644 --- a/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/exchange_rate/create-edit-dialog.svelte @@ -1,27 +1,41 @@ + + diff --git a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte index 4e0eef19..c7037523 100644 --- a/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/create-edit-dialog.svelte @@ -5,9 +5,11 @@ import { Label } from "$lib/components/ui/label"; import * as Select from "$lib/components/ui/select"; import { invoicesApi, type Invoice, type CreateInvoiceData, type UpdateInvoiceData } from "$lib/api/dashboard/a76/invoices"; + import { getExchangeRates } from "$lib/api/dashboard/a76/general_catalogs/exchange-rate"; import { companyStore } from "$lib/stores/company.svelte"; import { LoaderCircle } from 'lucide-svelte'; import * as Tabs from "$lib/components/ui/tabs"; + import ExchangeRateDialog from "$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte"; let { open = $bindable(false), @@ -72,6 +74,9 @@ let loading = $state(false); let error = $state(null); + let showExchangeRateDialog = $state(false); + let missingExchangeRateDate = $state(""); + // Actualizar formData cuando item cambia $effect(() => { if (item) { @@ -181,6 +186,15 @@ error = null; try { + // Verificar tipo de cambio antes de guardar + if (formData.invoice_date) { + const rateExists = await checkExchangeRate(formData.invoice_date); + if (!rateExists) { + loading = false; + return; + } + } + let response; if (isEditing && item) { const payload: UpdateInvoiceData = { @@ -287,11 +301,37 @@ window.location.reload(); }, 1500); } else { + // Convertir el error a string para buscar mensajes específicos (maneja objetos/arrays de DRF) + const errorStr = typeof response.error === 'string' + ? response.error + : JSON.stringify(response.error); + + if (errorStr.includes('No existe un Tipo de Cambio registrado') || errorStr.includes('financials.exchange_rate')) { + // Interceptar error de tipo de cambio + console.log("Interceptor: Exchange rate missing error caught (Invoice)."); + error = null; + + const dateMatch = errorStr.match(/(\d{4}-\d{2}-\d{2})/); + missingExchangeRateDate = dateMatch ? dateMatch[0] : (formData.invoice_date || ""); + + showExchangeRateDialog = true; + return; + } + error = response.error; } return; } + // Check exchange rate BEFORE calling API to avoid 400 error + if (formData.invoice_date) { + const rateExists = await checkExchangeRate(formData.invoice_date); + if (!rateExists) { + loading = false; + return; + } + } + // Éxito open = false; if (onSuccess) { @@ -305,6 +345,33 @@ } } + async function checkExchangeRate(date: string): Promise { + if (!date || !companyStore.activeCompany?.id) return true; + + try { + const response = await getExchangeRates(companyStore.activeCompany.id, { + date: date, + page_size: 1 + }); + + // api.get returns { data: ..., status: ... } and types now reflect that + const items = response.data?.items || []; + + // Verificar estrictamente que haya items + if (items.length === 0) { + missingExchangeRateDate = date; + showExchangeRateDialog = true; + return false; + } + return true; + } catch (error) { + console.error('Error checking exchange rate:', error); + missingExchangeRateDate = date; + showExchangeRateDialog = true; + return false; + } + } + function handleOpenChange(newOpen: boolean) { if (!newOpen) { resetForm(); @@ -679,3 +746,10 @@ + + {/* Optional: maybe refresh something or just let user continue */}} +/> diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index c7875be8..f9c18642 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -8,6 +8,12 @@ import type { CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers'; import type { ClientProvider } from '$lib/api/dashboard/a76/clients-providers'; + interface CodePedimentoRegimen { + regimen_code: string; + type_code: string; + [key: string]: any; + } + let { invoice, formData = $bindable(), @@ -22,6 +28,7 @@ customsSections = [], codePedimentoRegimens = [], operationType = undefined, + defaultOperationType = undefined, exchangeRate = undefined }: { invoice: Invoice | null; @@ -37,8 +44,8 @@ drivers?: any[]; trailers?: any[]; customsSections?: any[]; - codePedimentoRegimens?: any[]; - defaultOperationType?: string | null; + codePedimentoRegimens?: CodePedimentoRegimen[]; + defaultOperationType?: string | number | null; defaultInvoiceType?: string | null; operationType?: number | null; exchangeRate?: number | null; @@ -176,25 +183,55 @@ ] ); - // Combinar clientes y proveedores para shipped_to - const allClientsProviders = [...clients, ...providers]; - - // Filtrar regímenes por tipo de operación (1='E' exp, 2='I' imp) y obtener valores únicos - const filteredRegimens = $derived.by(() => { - const typeCode = operationType === 1 ? 'E' : operationType === 2 ? 'I' : null; - const filtered = codePedimentoRegimens.filter(r => r.type_code === typeCode); - - // Obtener solo regímenes únicos por regimen_code + // Combinar clientes y proveedores para shipped_to, evitando duplicados de tipo "both" + const allClientsProviders = $derived.by(() => { const uniqueMap = new Map(); - filtered.forEach(r => { - if (r.regimen_code && !uniqueMap.has(r.regimen_code)) { - uniqueMap.set(r.regimen_code, r); + + // Agregar todos los clientes + clients.forEach(c => { + uniqueMap.set(c.id, { ...c, type: c.client_or_provider }); + }); + + // Agregar proveedores solo si no existen (evita duplicados de "both") + providers.forEach(p => { + if (!uniqueMap.has(p.id)) { + uniqueMap.set(p.id, { ...p, type: p.client_or_provider }); } }); return Array.from(uniqueMap.values()); }); + // Determinar tipo de código basado en operationType prop, defaultOperationType o invoice.operation_type + const typeCode = $derived( + operationType === 1 ? 'E' + : operationType === 2 ? 'I' + : defaultOperationType === 1 ? 'E' + : defaultOperationType === 2 ? 'I' + : defaultOperationType === 'exp' ? 'E' + : defaultOperationType === 'imp' ? 'I' + : invoice?.operation_type === 'exp' ? 'E' + : invoice?.operation_type === 'imp' ? 'I' + : null + ); + + // Filtrar regímenes por tipo de operación (1='E' exp, 2='I' imp) y obtener valores únicos + const filteredRegimens = $derived( + !codePedimentoRegimens || codePedimentoRegimens.length === 0 || !typeCode + ? [] + : Array.from( + codePedimentoRegimens + .filter(r => r.type_code === typeCode) + .reduce((map, r) => { + if (r.regimen_code && !map.has(r.regimen_code)) { + map.set(r.regimen_code, r); + } + return map; + }, new Map()) + .values() + ) + ); + // Efecto: Limpiar régimen si no existe en los regímenes filtrados al cambiar operation_type $effect(() => { if (formData.document_type && filteredRegimens.length > 0) { @@ -263,9 +300,11 @@ > - {formData.provider_id - ? providers.find(p => p.id === formData.provider_id)?.name || 'Selecciona...' - : 'Selecciona...'} + {#if formData.provider_id} + {providers.find(p => p.id === formData.provider_id)?.name || 'Selecciona...'} + {:else} + Selecciona... + {/if} @@ -309,9 +348,11 @@ > - {formData.sold_to_id - ? clients.find(c => c.id === formData.sold_to_id)?.name || 'Selecciona...' - : 'Selecciona...'} + {#if formData.sold_to_id} + {clients.find(c => c.id === formData.sold_to_id)?.name || 'Selecciona...'} + {:else} + Selecciona... + {/if} @@ -355,15 +396,17 @@ > - {formData.shipped_to_id - ? allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...' - : 'Selecciona...'} + {#if formData.shipped_to_id} + {allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'} + {:else} + Selecciona... + {/if} {#each allClientsProviders as cp} - {cp.name} ({cp.type === 'client' ? 'C' : 'P'}) + {cp.name} {/each} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte b/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte index 47743eef..e3549f05 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte @@ -46,6 +46,16 @@ } }); + // Efecto para actualizar operation_type cuando cambia defaultOperationType + $effect(() => { + if (formData && defaultOperationType !== undefined && defaultOperationType !== null) { + // Si operation_type está vacío, null, o undefined, actualizarlo con defaultOperationType + if (!formData.operation_type) { + formData.operation_type = defaultOperationType; + } + } + }); + if (!formData) { let operationType: string | null = null; if (invoice?.operation_type) { @@ -69,6 +79,11 @@ clave_pedimento: '', regimen_pedimento: '', }; + } else { + // Si formData ya existe pero operation_type está vacío, usar defaultOperationType + if (!formData.operation_type && defaultOperationType !== undefined && defaultOperationType !== null) { + formData.operation_type = defaultOperationType; + } } diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index fbd26b64..e6f6b550 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -97,7 +97,7 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise void): ColumnDef[] { return renderSnippet(dateSnippet, { date: formatDate(row.original.pedimento_dates?.payment_date) }); } }, - /*{ - accessorKey: "pedimento_config_update_rectification.pediment_rectifed_18", - header: "Pedimento 18", - meta: { className: "hidden lg:table-cell" }, - cell: ({ row }) => { - const ped18Snippet = createRawSnippet<[{ value?: string | null }]>((getValue) => { - const { value } = getValue(); - return { - render: () => - `
${value || '-'}
` - }; - }); - return renderSnippet(ped18Snippet, { value: row.original.pedimento_config_update_rectification?.pediment_rectifed_18 }); - } - },*/ - /*{ - accessorKey: "pedimento_config_update_rectification.r1", - header: "Pedimento R1", - meta: { className: "hidden lg:table-cell" }, - cell: ({ row }) => { - const r1Snippet = createRawSnippet<[{ value?: string | null }]>((getValue) => { - const { value } = getValue(); - return { - render: () => - `
${value || '-'}
` - }; - }); - return renderSnippet(r1Snippet, { value: row.original.pedimento_config_update_rectification?.r1 }); - } - },*/ { accessorKey: "pedimento_validation.electronic_signature", header: "Acuse Electrónico", diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte index 99f50cce..774be895 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte @@ -13,6 +13,7 @@ import type { ClientProvider } from '$lib/api/dashboard/a76/clients-providers'; import type { CodePedimentoRegimen } from '$lib/api/dashboard/refrence_data/code_pedimento_regimens'; import IdentificadoresTabForm from './identifiers-tab-form.svelte'; + import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte'; import { Calendar, Clock } from 'lucide-svelte'; import { loadServerDate, @@ -45,6 +46,10 @@ // Estado para controlar la sección activa de la navegación let activeSection = $state('fechas'); + // Exchange Rate Check State + let showExchangeRateDialog = $state(false); + let missingExchangeRateDate = $state(""); + // Extraer regímenes únicos de codePedimentoRegimens const uniqueRegimens = $derived( Array.from(new Set(codePedimentoRegimens.map(r => r.regimen_code).filter((code): code is string => code !== null))) @@ -314,6 +319,32 @@ { value: 'adicional', label: 'Adicional' }, { value: 'decrementable', label: 'Decrementable' } ]; + + export async function checkPaymentDateRate(date: string): Promise { + if (!date || !companyStore.activeCompany?.id) return true; + + try { + const rate = await getExchangeRateByDate(date, companyStore.activeCompany.id); + if (!rate) { + // Abrir modal preventivamente + missingExchangeRateDate = date; + showExchangeRateDialog = true; + return false; + } + return true; + } catch (error) { + console.error('Error checking payment date rate:', error); + // Si hay error de red, asumimos que falta para forzar reintento/captura segura + missingExchangeRateDate = date; + showExchangeRateDialog = true; + return false; + } + } + + export function openExchangeRateDialog(date: string) { + missingExchangeRateDate = date; + showExchangeRateDialog = true; + } @@ -1159,3 +1190,10 @@ + + {/* Optional: maybe refresh something or just let user continue */}} +/> diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 63921af7..9891e638 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -7,6 +7,7 @@ import { Separator } from "$lib/components/ui/separator/index.js"; import * as Sidebar from "$lib/components/ui/sidebar/index.js"; import { companyStore } from "$lib/stores/company.svelte"; + import ExchangeRateGuard from "$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte"; let { data, children }: { data: LayoutData; children: any } = $props(); @@ -61,3 +62,5 @@ + + diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 06086755..61138a73 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -427,11 +427,6 @@ function handleCreateClick() { const params = new URLSearchParams(window.location.search); - const operationType = params.get('operation_type'); - if (operationType) { - const operationTypeNumber = operationType === 'exp' ? 1 : 2; - params.set('operation_type', operationTypeNumber.toString()); - } const queryString = params.toString(); const url = queryString diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts index 2ec29c64..78a4d101 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.server.ts @@ -20,13 +20,10 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => { const operationTypeParam = url.searchParams.get('operation_type'); const invoiceTypeParam = url.searchParams.get('invoice_type'); - // Parsear operation_type de forma segura - let parsedOperationType: number | null = null; - if (operationTypeParam) { - const parsed = parseInt(operationTypeParam, 10); - if (!isNaN(parsed)) { - parsedOperationType = parsed; - } + // Validar que operation_type sea 'exp' o 'imp' + let parsedOperationType: string | null = null; + if (operationTypeParam && (operationTypeParam === 'exp' || operationTypeParam === 'imp')) { + parsedOperationType = operationTypeParam; } // Cargar datos de referencia necesarios diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 62065835..9567cb94 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -1,7 +1,8 @@