diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 5bffa511..1a4f86ed 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -528,6 +528,7 @@ def upgrade() -> None: ) if values_historical_fractions: + op.execute("ALTER TABLE a76.historical_tariff_fractions DISABLE TRIGGER ALL;") op.execute( f""" INSERT INTO a76.historical_tariff_fractions @@ -539,6 +540,7 @@ def upgrade() -> None: ON CONFLICT DO NOTHING; """ ) + op.execute("ALTER TABLE a76.historical_tariff_fractions ENABLE TRIGGER ALL;") def downgrade() -> None: diff --git a/backend/api/v1/modules/a76/classes/dto.py b/backend/api/v1/modules/a76/classes/dto.py index 00c141b4..68054f2a 100644 --- a/backend/api/v1/modules/a76/classes/dto.py +++ b/backend/api/v1/modules/a76/classes/dto.py @@ -223,6 +223,7 @@ class ClassWithFADataResponse(BaseModel): fa_class_id: Optional[int] = None depreciation_rate: Optional[Decimal] = None fda_code: Optional[str] = None + eccn_code: Optional[str] = None class_enabled: Optional[bool] = None model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 1e3ba949..4decee1c 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -152,6 +152,7 @@ class ClassService: "fa_class_id": fa_class.id if fa_class else None, "depreciation_rate": fa_class.depreciation_rate if fa_class else None, "fda_code": fa_class.fda_code if fa_class else None, + "eccn_code": fa_class.eccn_code if fa_class else None, "class_enabled": fa_class.class_enabled if fa_class else None, } combined.append(class_dict) diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py index b1857de0..6b31d570 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py @@ -7,7 +7,7 @@ from typing import List, Optional from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from .models import ClientOrProviderEnum @@ -40,7 +40,10 @@ async def get_clients_and_providers( """Get clients and providers""" tenant_id = validate_access_to_resource(db, company_id, current_user) - query = db.query(ClientProvider).filter( + query = db.query(ClientProvider).options( + joinedload(ClientProvider.address), + joinedload(ClientProvider.programs) + ).filter( ClientProvider.tenant_id == tenant_id, ClientProvider.company_id == company_id, ) diff --git a/backend/api/v1/modules/a76/invoices/catalog_service.py b/backend/api/v1/modules/a76/invoices/catalog_service.py index 9ad64b0b..2dee90a7 100644 --- a/backend/api/v1/modules/a76/invoices/catalog_service.py +++ b/backend/api/v1/modules/a76/invoices/catalog_service.py @@ -134,12 +134,14 @@ class InvoiceCatalogService: # Drivers try: - drivers, _ = DriverService.get_all(db, tenant_id, company_id, limit=1000) + drivers = DriverService.list_drivers(db, str(company_id), str(tenant_id)) response.drivers = [ DriverResponseDTO.model_validate(d) for d in drivers ] except Exception as e: print(f"Error fetching drivers: {e}") + # Initialize drivers as empty list if an error occurs + drivers = [] # Trailers try: 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 9c254a9b..ac85ff24 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 @@ -36,17 +36,17 @@ def validate_update( # 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, + 'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None), + 'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None), + 'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None), + 'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None), + 'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None), + 'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None), } validate_required_fields_by_operation( invoice_data=invoice_dict, - operation_type=invoice_data.operation_type or 'IMP', + operation_type=invoice_data.operation_type or (existing_invoice.operation_type or 'imp'), errors=errors ) @@ -57,34 +57,36 @@ def validate_update( # Siguiendo la lógica del código Clarion original # Columna A: Pedimento (si no viene en CSV, usar el existente) - if invoice_data.compliance_mx.pedimento_id: - invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id - else: - invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.pedimento_id: + invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id + else: + invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None # Columna B: Remesa - if invoice_data.compliance_mx.remesa: - invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa - else: - invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.remesa: + invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa + else: + invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None # Columna C: Factura (OBLIGATORIO) - invoice_data.invoice_number = clean_str(invoice_data.invoice_number) - if not invoice_data.invoice_number: - errors.add_required_error("invoice_number") + if invoice_data.invoice_number is not None: + invoice_data.invoice_number = clean_str(invoice_data.invoice_number) + if not invoice_data.invoice_number: + errors.add_required_error("invoice_number") + else: + invoice_data.invoice_number = existing_invoice.invoice_number # Columna D: Fecha - if invoice_data.invoice_date: - invoice_data.invoice_date = invoice_data.invoice_date - else: + if not invoice_data.invoice_date: invoice_data.invoice_date = existing_invoice.invoice_date # Columna E: Tipo Cambio - if invoice_data.financials and invoice_data.financials.exchange_rate is not None: - invoice_data.financials.exchange_rate = invoice_data.financials.exchange_rate - else: - if existing_invoice.financials: - invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate + if invoice_data.financials: + if invoice_data.financials.exchange_rate is None: + if existing_invoice.financials: + invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate # Columna F: Régimen if invoice_data.document_type: @@ -93,157 +95,161 @@ def validate_update( invoice_data.document_type = existing_invoice.document_type # Columna G: Clave Proveedor - if invoice_data.compliance_mx and invoice_data.compliance_mx.provider_id is not None: - invoice_data.compliance_mx.provider_id = invoice_data.compliance_mx.provider_id - else: - invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.provider_id is None: + invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None # Columna H: Clave Vendido A - if invoice_data.compliance_mx and invoice_data.compliance_mx.sold_to_id is not None: - invoice_data.compliance_mx.sold_to_id = invoice_data.compliance_mx.sold_to_id - else: - invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.sold_to_id is None: + invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None # Columna I: Clave Enviado A - if invoice_data.compliance_mx and invoice_data.compliance_mx.shipped_to_id is not None: - invoice_data.compliance_mx.shipped_to_id = invoice_data.compliance_mx.shipped_to_id - else: - invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.shipped_to_id is None: + invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None # Columna J: Clave A. Aduanal - if invoice_data.compliance_mx and invoice_data.compliance_mx.customs_broker_id is not None: - invoice_data.compliance_mx.customs_broker_id = invoice_data.compliance_mx.customs_broker_id - else: - invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if invoice_data.compliance_mx.customs_broker_id is None: + invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None # Columna K: Clave Transportista - if invoice_data.logistics and invoice_data.logistics.carrier_id is not None: - invoice_data.logistics.carrier_id = invoice_data.logistics.carrier_id - else: - invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None + if invoice_data.logistics: + # Note: logistics in update schema seems to be a single object, but in model it's a list. + # This validator seems to expect a single object (InvoiceLogisticsUpdate). + # We'll stick to the existing logic but make it safe. + if hasattr(invoice_data.logistics, 'carrier_id') and invoice_data.logistics.carrier_id is None: + invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None # Columna L: Nombre Conductor - if invoice_data.logistics and invoice_data.logistics.driver_name: - invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name) - else: - invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'driver_name') and not invoice_data.logistics.driver_name: + invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'driver_name'): + invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name) # Columna M: Tipo Transporte - if invoice_data.logistics and invoice_data.logistics.transport_type: - invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type) - else: - invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'transport_type') and not invoice_data.logistics.transport_type: + invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'transport_type'): + invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type) # Columna N: Número de Transporte - if invoice_data.logistics and invoice_data.logistics.transport_num: - invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num) - else: - invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'transport_num') and not invoice_data.logistics.transport_num: + invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'transport_num'): + invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num) # Columna O: Tipo de Moneda - if invoice_data.financials and invoice_data.financials.currency: - invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower() - else: - invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None + if invoice_data.financials: + if not invoice_data.financials.currency: + invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None + else: + invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower() # Columna P: Clave Moneda - if invoice_data.financials and invoice_data.financials.currency_type: - invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper() - else: - invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None + if invoice_data.financials: + if not invoice_data.financials.currency_type: + invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None + else: + invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper() # Columna Q: Flete - if invoice_data.financials and invoice_data.financials.freight is not None: - invoice_data.financials.freight = invoice_data.financials.freight - else: - invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.freight is None: + invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None # Columna R: Val Seguros - if invoice_data.financials and invoice_data.financials.insurance_value is not None: - invoice_data.financials.insurance_value = invoice_data.financials.insurance_value - else: - invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.insurance_value is None: + invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None # Columna S: Seguros - if invoice_data.financials and invoice_data.financials.insurance is not None: - invoice_data.financials.insurance = invoice_data.financials.insurance - else: - invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.insurance is None: + invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None # Columna T: Embalaje - if invoice_data.financials and invoice_data.financials.packaging is not None: - invoice_data.financials.packaging = invoice_data.financials.packaging - else: - invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.packaging is None: + invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None # Columna U: Otros Incrementables - if invoice_data.financials and invoice_data.financials.other_increments is not None: - invoice_data.financials.other_increments = invoice_data.financials.other_increments - else: - invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None + if invoice_data.financials: + if invoice_data.financials.other_increments is None: + invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None # Columna V: Incoterms - if invoice_data.logistics and invoice_data.logistics.incoterm: - invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper() - else: - invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'incoterm') and not invoice_data.logistics.incoterm: + invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'incoterm'): + invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper() # Columna W: Precinto - if invoice_data.logistics and invoice_data.logistics.seal_number: - invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number) - else: - invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'seal_number') and not invoice_data.logistics.seal_number: + invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'seal_number'): + invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number) # Columna X: Fecha de Emisión - if invoice_data.emission_date: - invoice_data.emission_date = invoice_data.emission_date - else: + if not invoice_data.emission_date: invoice_data.emission_date = existing_invoice.emission_date # Columna Y: Tipo de Peso (Opcional) - if invoice_data.logistics and invoice_data.logistics.weight_type: - invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper() - else: - invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None + if invoice_data.logistics: + if hasattr(invoice_data.logistics, 'weight_type') and not invoice_data.logistics.weight_type: + invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None + elif hasattr(invoice_data.logistics, 'weight_type'): + invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper() # Columna Z: E-Document (Opcional) - if invoice_data.compliance_mx and invoice_data.compliance_mx.edocument: - invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument) - else: - invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.edocument: + invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument) # Columna AA: Num. Operación (Opcional) - if invoice_data.compliance_mx and invoice_data.compliance_mx.vucem_operation_num: - invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num) - else: - invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.vucem_operation_num: + invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num) # Columna AB: Aduana (OBLIGATORIO) - if invoice_data.compliance_mx and invoice_data.compliance_mx.aduana: - invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana) - else: - invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.aduana: + invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana) # Validar que aduana sea obligatorio (excepto para MEX) if existing_invoice.invoice_type != "MEX": - if not invoice_data.compliance_mx or not invoice_data.compliance_mx.aduana: + current_aduana = invoice_data.compliance_mx.aduana if invoice_data.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None) + if not current_aduana: errors.add_required_error("aduana") # Columna AC: Sección de Despacho / Puerto de Entrada (Opcional) - if invoice_data.compliance_mx and invoice_data.compliance_mx.port_of_entry: - invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry) - else: - invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None + if invoice_data.compliance_mx: + if not invoice_data.compliance_mx.port_of_entry: + invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None + else: + invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry) # Columna AD: Observación en Español (Opcional) - if invoice_data.observation_es: - invoice_data.observation_es = clean_str(invoice_data.observation_es) - else: + if not invoice_data.observation_es: invoice_data.observation_es = existing_invoice.observation_es + else: + invoice_data.observation_es = clean_str(invoice_data.observation_es) # Columna AD: Observación en Inglés (Opcional) - if invoice_data.observation_en: - invoice_data.observation_en = clean_str(invoice_data.observation_en) - else: + if not invoice_data.observation_en: invoice_data.observation_en = existing_invoice.observation_en + else: + invoice_data.observation_en = clean_str(invoice_data.observation_en) diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index c658e9ae..f117377e 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -123,7 +123,7 @@ class InvoiceComplianceMxBase(BaseModel): None, max_length=20, description="Shipped by header" ) shipped_by_id: Optional[int] = Field(None, description="Shipped by ID") - customs_broker_id: Optional[int] = Field(None, description="Customs broker ID") + customs_broker_id: int = Field(None, description="Customs broker ID") customs_broker_us_id: Optional[int] = Field( None, description="US customs broker ID" ) @@ -144,7 +144,7 @@ class InvoiceComplianceMxBase(BaseModel): ) value_method: Optional[str] = Field(None, max_length=2, description="Value method") act_value: Optional[str] = Field(None, max_length=5, description="Act value") - is_pedimento_pending: bool = Field(..., description="Is pedimento pending") + is_pedimento_pending: Optional[bool] = Field(False, description="Is pedimento pending") is_owner_of_goods: Optional[bool] = Field(False, description="Is owner of goods") generate_balances: Optional[bool] = Field(False, description="Generate balances") was_reviewed_by_company: Optional[bool] = Field( diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 19158bf0..f232366d 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -1,7 +1,9 @@ import traceback from typing import Optional, List, Tuple from sqlalchemy.orm import Session +from sqlalchemy import func from core.exceptions import ErrorCollector, DuplicateResourceException +from core.context import get_user_context from .common.mappers import clean_dict from .imports.temporary.validators.create import validate_create from .imports.temporary.validators.update import validate_update @@ -10,6 +12,26 @@ from .common.common_validators import invoice_exists from . import models, schemas +def _get_current_username() -> str: + """Helper to get current username from context or fallback to System""" + try: + context = get_user_context() + if context: + # Token usually has 'preferred_username' or 'name' or 'sub' + username = ( + context.get("preferred_username") + or context.get("email") + or context.get("sub") + or "System" + ) + print(f"DEBUG: _get_current_username found context: {username}") + return username + except Exception: + pass + print("DEBUG: _get_current_username NO context found, using System") + return "System" + + class InvoiceService: """Service for Invoice Header operations""" @@ -46,7 +68,7 @@ class InvoiceService: # Apply filters if provided if filters: if filters.get("status"): - query = query.filter(models.InvoiceHeader.status == filters["status"]) + query = query.filter(models.InvoiceHeader.is_updated == filters["status"]) if filters.get("operation_type"): query = query.filter( models.InvoiceHeader.operation_type == filters["operation_type"] @@ -129,6 +151,11 @@ class InvoiceService: invoice_dict["tenant_id"] = tenant_id invoice_dict["company_id"] = company_id + # Automatic status and audit fields + username = _get_current_username() + invoice_dict["capture_user"] = username + invoice_dict["who_updated"] = username + # Ensure document_type respects DB constraints for MEX invoices (bypass clean_dict) if invoice_dict.get("invoice_type") == "MEX" and not invoice_dict.get("document_type"): invoice_dict["document_type"] = None @@ -253,6 +280,7 @@ class InvoiceService: # Update main invoice header fields update_dict = invoice_data.model_dump( exclude={ + "id", "compliance_mx", "financials", "logistics", @@ -264,6 +292,16 @@ class InvoiceService: for key, value in update_dict.items(): setattr(invoice, key, value) + # Audit update fields + username = _get_current_username() + invoice.who_updated = username + invoice.updated_date = func.now() + + # Backfill capture_user if missing or previous generic 'System' + if not invoice.capture_user or invoice.capture_user == "System": + if username != "System": + invoice.capture_user = username + # Update compliance_mx if provided if invoice_data.compliance_mx is not None: print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}") diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index 70dc5245..677df0ed 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -188,6 +188,23 @@ def validate_create( line.financial.unit_cost_mxn = unit_cost_capture # Si es otro tipo de moneda, dejamos el costo como está + # Calcular valores totales basados en cantidad y costo unitario + quantity = line.quantity.quantity or Decimal("0") + + # Valor Comercial + if line.financial.unit_cost_usd is not None: + line.financial.value_usd = line.financial.unit_cost_usd * quantity + if line.financial.unit_cost_mxn is not None: + line.financial.value_mxn = line.financial.unit_cost_mxn * quantity + + # Valor Aduanas (asumiendo que es igual al Valor Comercial por defecto) + line.financial.customs_value_usd = line.financial.value_usd + line.financial.customs_value_mxn = line.financial.value_mxn + + # Valor MP Temp (Materia Prima Temporal) + line.financial.value_temp_material_usd = line.financial.value_usd + line.financial.value_temp_material_mxn = line.financial.value_mxn + # ========================================== # VALIDAR Y CONVERTIR PESOS NETOS # ========================================== diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py index 1f5b917e..23717f2d 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py @@ -64,6 +64,30 @@ def validate_update( # Costo unitario if line.financial.unit_cost_capture is None: line.financial.unit_cost_capture = existing_line.financial.unit_cost_capture + + # Recalcular valores monetarios si el costo o la cantidad cambian + currency_type = invoice.financials.currency_type + unit_cost_capture = line.financial.unit_cost_capture or Decimal("0") + + if currency_type in ["USD", "ME"]: + line.financial.unit_cost_usd = unit_cost_capture + line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate + elif currency_type in ["MXN", "MN"]: + line.financial.unit_cost_usd = (unit_cost_capture / exchange_rate) if exchange_rate else Decimal("0") + line.financial.unit_cost_mxn = unit_cost_capture + + quantity = line.quantity.quantity if line.quantity.quantity is not None else existing_line.quantity.quantity + + if line.financial.unit_cost_usd is not None: + line.financial.value_usd = line.financial.unit_cost_usd * quantity + if line.financial.unit_cost_mxn is not None: + line.financial.value_mxn = line.financial.unit_cost_mxn * quantity + + line.financial.customs_value_usd = line.financial.value_usd + line.financial.customs_value_mxn = line.financial.value_mxn + + line.financial.value_temp_material_usd = line.financial.value_usd + line.financial.value_temp_material_mxn = line.financial.value_mxn # Convertir peso neto si se proporcionó invoice_weight_type = invoice.logistics.weight_type diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index cad3a134..b54c914b 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -4,7 +4,7 @@ Complete nested one-to-one structure: LineItem -> LineFinancial -> LineQuantity -> LineCustoms -> LineDescription -> LineReference """ -from typing import Any, Optional +from typing import Any, Optional, Union from datetime import datetime from decimal import Decimal from pydantic import BaseModel, Field, ConfigDict, model_validator @@ -58,13 +58,13 @@ class LineItemBase(BaseModel): line_number: int = Field(..., description="Line number") # Part identification - part_number_id: Optional[int] = Field( + part_number_id: Union[int, str, None] = Field( None, description="Part number", alias="part_number", serialization_alias="part_number_id", ) - component_part_number_id: Optional[int] = Field( + component_part_number_id: Union[int, str, None] = Field( None, description="Component part number", alias="component_part_number", diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index bf8321af..f75fa1d6 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -36,6 +36,7 @@ from .line_references.models import LineReference from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem from .models import LineItem from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.parts.models import Part logger = logging.getLogger(__name__) @@ -45,6 +46,33 @@ class ItemService: Service for managing Items and related entities with tenant/company isolation """ + @staticmethod + def _resolve_part_number( + db: Session, + part_number: Optional[str], + tenant_id: int, + company_id: int, + ) -> Optional[int]: + """Try to resolve a part number string to its database ID.""" + if not part_number: + return None + + # If it's already an integer (or a string representing an integer), it might be the ID + try: + return int(part_number) + except (ValueError, TypeError): + # It's a string part number (e.g., "MAQ-001"), look it up + part = ( + db.query(Part) + .filter( + Part.part_number == part_number, + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ) + .first() + ) + return part.id if part else None + @staticmethod def _get_next_line_number(db: Session, invoice_id: int) -> int: """Calculate the next line_number for a given invoice based on database.""" @@ -282,6 +310,24 @@ class ItemService: # Calculate the next line number for this single item line_number = ItemService._get_next_line_number(db, item_data.invoice_id) + # Resolve part ID if a string is provided in part_number (alias for part_number_id) + if item_data.part_number_id and not isinstance(item_data.part_number_id, int): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.part_number_id = resolved_id + + # Resolve component part ID + if item_data.component_part_number_id and not isinstance( + item_data.component_part_number_id, int + ): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.component_part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.component_part_number_id = resolved_id + # Validar el item validate_create( db, @@ -378,6 +424,24 @@ class ItemService: ): errors.raise_if_errors("Error al actualizar el item") + # Resolve part ID if a string is provided in part_number (alias for part_number_id) + if hasattr(item_data, 'part_number_id') and item_data.part_number_id and not isinstance(item_data.part_number_id, int): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.part_number_id = resolved_id + + # Resolve component part ID + if hasattr(item_data, 'component_part_number_id') and item_data.component_part_number_id and not isinstance( + item_data.component_part_number_id, int + ): + resolved_id = ItemService._resolve_part_number( + db, str(item_data.component_part_number_id), tenant_id, company_id + ) + if resolved_id: + item_data.component_part_number_id = resolved_id + # Validar el item que se va a actualizar validate_update( db, diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py index 796bfee1..c9718994 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py @@ -17,10 +17,10 @@ class PedimentoDecrementablesBase(BaseModel): others: Optional[Decimal] = Field(None, description="Others") currency: Optional[str] = Field(None, max_length=3, description="Currency") currency_factor: Optional[Decimal] = Field(None, description="Currency factor") - not_affect_usd_value: Optional[bool] = Field( + not_affect_usd_value: Optional[int] = Field( None, description="Not affect USD value" ) - not_affect_customs_value: Optional[bool] = Field( + not_affect_customs_value: Optional[int] = Field( None, description="Not affect customs value" ) @@ -41,8 +41,8 @@ class PedimentoDecrementablesUpdate(BaseModel): others: Optional[Decimal] = None currency: Optional[str] = Field(None, max_length=3) currency_factor: Optional[Decimal] = None - not_affect_usd_value: Optional[bool] = None - not_affect_customs_value: Optional[bool] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None class PedimentoDecrementablesResponse(PedimentoDecrementablesBase): diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py index c8100d01..acc6312e 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py @@ -18,10 +18,10 @@ class PedimentoIncrementablesBase(BaseModel): deductibles: Optional[Decimal] = Field(None, description="Deductibles") currency: Optional[str] = Field(None, max_length=3, description="Currency") currency_factor: Optional[Decimal] = Field(None, description="Currency factor") - not_affect_usd_value: Optional[bool] = Field( + not_affect_usd_value: Optional[int] = Field( None, description="Not affect USD value" ) - not_affect_customs_value: Optional[bool] = Field( + not_affect_customs_value: Optional[int] = Field( None, description="Not affect customs value" ) @@ -43,8 +43,8 @@ class PedimentoIncrementablesUpdate(BaseModel): deductibles: Optional[Decimal] = None currency: Optional[str] = Field(None, max_length=3) currency_factor: Optional[Decimal] = None - not_affect_usd_value: Optional[bool] = None - not_affect_customs_value: Optional[bool] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None class PedimentoIncrementablesResponse(PedimentoIncrementablesBase): diff --git a/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py index 9a5fda2f..66f92514 100644 --- a/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py +++ b/backend/api/v1/modules/a76/pedmientos/models/pedimento_rectification_origin.py @@ -40,21 +40,21 @@ class PedimentoRectificationOrigin(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer) pedimento_id: Mapped[int] = mapped_column(Integer, nullable=False) - original_pedimento_year: Mapped[str] = mapped_column(String(2)) - original_customs_office: Mapped[str] = mapped_column(String(3)) - original_license: Mapped[str] = mapped_column(String(4)) - original_pedimento_number: Mapped[str] = mapped_column(String(7)) - original_pedimento_code: Mapped[str] = mapped_column(String(2)) - original_payment_date: Mapped[datetime] = mapped_column(DateTime) - total_cash: Mapped[int] = mapped_column(Integer) - total_others: Mapped[int] = mapped_column(Integer) - reason: Mapped[str] = mapped_column(String(255)) - charge_to_client: Mapped[int] = mapped_column(SmallInteger) - use_original_payment_date_for_interest_calc: Mapped[int] = mapped_column( - SmallInteger + original_pedimento_year: Mapped[str | None] = mapped_column(String(2), nullable=True) + original_customs_office: Mapped[str | None] = mapped_column(String(3), nullable=True) + original_license: Mapped[str | None] = mapped_column(String(4), nullable=True) + original_pedimento_number: Mapped[str | None] = mapped_column(String(7), nullable=True) + original_pedimento_code: Mapped[str | None] = mapped_column(String(2), nullable=True) + original_payment_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + total_cash: Mapped[int | None] = mapped_column(Integer, nullable=True) + total_others: Mapped[int | None] = mapped_column(Integer, nullable=True) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + charge_to_client: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + use_original_payment_date_for_interest_calc: Mapped[int | None] = mapped_column( + SmallInteger, nullable=True ) - manual_calculation: Mapped[int] = mapped_column(SmallInteger) - original_pedimento_norms: Mapped[int] = mapped_column(SmallInteger) + manual_calculation: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) + original_pedimento_norms: Mapped[int | None] = mapped_column(SmallInteger, nullable=True) pedimento: Mapped["Pedimentos"] = relationship( "Pedimentos", back_populates="pedimento_rectification_origin" diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py index b6538b9c..eab865ff 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_destination.py @@ -28,6 +28,7 @@ class PedimentoRectificationDestinationService: .filter( PedimentoRectificationDestination.pedimento_id == pedimento_id, PedimentoRectificationDestination.tenant_id == tenant_id, + PedimentoRectificationDestination.company_id == company_id, ) .first() ) diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py index b92db00e..39304792 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimento_rectification_origin.py @@ -26,6 +26,7 @@ class PedimentoRectificationOriginService: .filter( PedimentoRectificationOrigin.pedimento_id == pedimento_id, PedimentoRectificationOrigin.tenant_id == tenant_id, + PedimentoRectificationOrigin.company_id == company_id, ) .first() ) diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index 31a1f658..92f4d00f 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -148,7 +148,7 @@ class PedimentosService: Pedimento or None if not found """ query = db.query(Pedimentos).filter( - Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id + Pedimentos.id == pedimento_id, Pedimentos.tenant_id == tenant_id ) if company_id is not None: @@ -199,6 +199,25 @@ class PedimentosService: Created pedimento """ try: + # Check for existing pedimento with same key (Year, Aduana, Patente, Number) + # This avoids IntegrityError in many cases and provides a better error message. + existing = db.query(Pedimentos).filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + Pedimentos.year == pedimento_data.year, + Pedimentos.customs_office == pedimento_data.customs_office, + Pedimentos.license == pedimento_data.license, + Pedimentos.pedimento_number == pedimento_data.pedimento_number, + Pedimentos.deleted_at.is_(None) + ).first() + + if existing: + raise ValueError( + f"Ya existe un pedimento con estos datos: {pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}" + ) + + # Extraer datos de tablas relacionadas + # Extraer datos de tablas relacionadas related_data = { 'pedimento_dates': pedimento_data.pedimento_dates, @@ -329,11 +348,23 @@ class PedimentosService: except IntegrityError as e: db.rollback() - # Detectar si es un error de pedimento duplicado - error_msg = str(e.orig) - if 'pedimentos_unique_key' in error_msg or 'duplicate key value violates unique constraint' in error_msg: - logger.warning(f"Attempted to create duplicate pedimento: {e}") - raise ValueError("Ya existe un pedimento con estos datos (Año, Aduana, Patente, Número)") + # Detectar si es un error de integridad de duplicados o similar + error_msg = str(e.orig).lower() + + # Case-insensitive check and support for both Spanish and English common error patterns + is_unique_violation = any(kw in error_msg for kw in [ + 'pedimentos_unique_key', + 'unique constraint', + 'duplicate key', + 'duplicada', + 'unicidad', + 'ya existe' + ]) + + if is_unique_violation: + logger.warning(f"Attempted to create duplicate pedimento or common record: {e}") + raise ValueError("Ya existe un pedimento o registro relacionado con estos datos. Verifica los campos únicos.") + logger.error(f"Integrity error creating pedimento: {e}") raise except Exception as e: @@ -363,6 +394,9 @@ class PedimentosService: if not pedimento: return None + # Ensure company_id is set from the existing record + company_id = pedimento.company_id + try: # Actualizar campos principales del pedimento update_data = pedimento_data.model_dump(exclude_unset=True, exclude={ 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 4a07a9a1..82e0c897 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 @@ -71,12 +71,18 @@ class ConsolidadoImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: 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 bdd5adca..1163644e 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 @@ -103,12 +103,18 @@ class FacturaImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index 324bb91a..12595fda 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -41,9 +41,10 @@ async def trigger_descarga_factura( invoice_id: int, company_id: int = Query(..., description="ID de la empresa"), invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"), + currency_code: str = Query('ORIGINAL', description="Moneda: 'MXN', 'USD', o 'ORIGINAL'"), current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_core_db) ): validate_access_to_resource(db, company_id, current_user) - task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type) + task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type, currency_code) return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index fd45cd0f..951a3135 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -103,11 +103,12 @@ class FacturaImportacionUsaService: def formatear_numero(self, valor, decimales: int = 2): if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 8500c2d4..7238e369 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -66,10 +66,18 @@ class PackingListService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ + if valor is None: + valor = 0.0 try: - return round(float(valor), decimales) - except: return 0.0 + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" + except: + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/movements/__init__.py b/backend/api/v1/modules/a76/reports/movements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/__init__.py b/backend/api/v1/modules/a76/reports/movements/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py new file mode 100644 index 00000000..be0ea597 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -0,0 +1,151 @@ +""" +CSV generation utilities for invoice movement reports. +""" +import csv +import io +from typing import List, Union +from datetime import datetime, date + +from .schemas import MovementItem, MovementItemDetailed, AllMovementsFilter + + +def generate_csv_from_movements( + movements: List[Union[MovementItem, MovementItemDetailed]], + filters: AllMovementsFilter +) -> str: + """ + Generate CSV content from movement items. + + Args: + movements: List of movement items (normal or detailed) + filters: Filter object containing report parameters + + Returns: + CSV content as string + """ + output = io.StringIO() + + if filters.report_type.value.lower() == "normal": + # Normal report + fieldnames = [ + # Identification + 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Values + 'ValorComercialMN', 'ValorMPTemp', 'TipoCambio', 'ValorAgre', + # Classification + 'TipoMovTemDef', 'Estatus', 'TipoExpo', 'EsCambioRegimen', + # Dates + 'Fecha_Pago', + # References + 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Logistics + 'NumCaja', 'NumGafUni', 'AduanaCru', + # Metadata + 'BaseDeDatos', 'UsuarioCap', 'UsuarioAcr' + ] + + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for movement in movements: + row = movement.model_dump() + # Format datetime fields + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['ValorMPTemp'] = _format_decimal(row.get('ValorMPTemp')) + row['ValorAgre'] = _format_decimal(row.get('ValorAgre')) + + writer.writerow(row) + + else: + # Detailed report + fieldnames = [ + # Identification + 'Linea', 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Parties + 'Proveedor', 'RFCProveedor', 'ProveedorTaxID', + 'VendidoA', 'VendidoARFC', 'VendidoATaxID', + # Customs broker + 'AgenteAduanal', 'Patente', + # Product + 'NumParte', 'DescripcionE', 'DescripcionI', 'CantidadIE', 'UniMed', + # Classification + 'FraccionArancelaria', 'FraccionAmericana', 'ECCN', 'Sector', 'PaisOrigen', + # Values + 'ValorComercialMN', 'TipoCambio', 'PesoNeto', 'PesoBruto', + # Customs + 'TipoMovTemDef', 'Regimen', 'Aduana', 'Advalorem', 'Preferencia', + # References + 'OrdenCompraVenta', 'Remesa', 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Identifiers + 'Series', 'Marca', 'Modelo', 'SimboloEx', + # Dates + 'Fecha_Pago', 'Fecha_Inicio', 'Fecha_Fin', 'FechaEmision', + # Logistics + 'Transportista', 'NumCaja', 'NumGafUni', 'AduanaCru', 'Lote', + # Metadata + 'Estatus', 'BaseDeDatos', 'TipoExpo', 'EsCambioRegimen', 'Pedimento18', + 'UsuarioCap', 'UsuarioAcr' + ] + + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for movement in movements: + row = movement.model_dump() + # Format datetime fields + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + row['Fecha_Inicio'] = _format_datetime(row.get('Fecha_Inicio')) + row['Fecha_Fin'] = _format_datetime(row.get('Fecha_Fin')) + row['FechaEmision'] = _format_datetime(row.get('FechaEmision')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['CantidadIE'] = _format_decimal(row.get('CantidadIE')) + row['PesoNeto'] = _format_decimal(row.get('PesoNeto')) + row['PesoBruto'] = _format_decimal(row.get('PesoBruto')) + + writer.writerow(row) + + csv_content = output.getvalue() + output.close() + return csv_content + + +def _format_datetime(dt) -> str: + """Format datetime for CSV export.""" + if not dt or dt == '' or dt == '-' or dt == '0': + return '' + try: + if isinstance(dt, str): + if 'T' in dt: + dt_obj = datetime.strptime(dt.split('T')[0], '%Y-%m-%d') + elif len(dt) == 8 and dt.isdigit(): + dt_obj = datetime.strptime(dt, '%Y%m%d') + elif '-' in dt: + dt_obj = datetime.strptime(dt, '%Y-%m-%d') + else: + return dt + elif isinstance(dt, (datetime, date)): + dt_obj = dt + else: + return '' + return dt_obj.strftime('%d/%m/%Y') + except (ValueError, TypeError): + return str(dt) if dt else '' + + +def _format_decimal(value, decimals: int = 2) -> str: + """Format decimal values for CSV export.""" + if value is None: + return '' + try: + return f"{float(value):.{decimals}f}" + except (ValueError, TypeError): + return str(value) if value else '' \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py new file mode 100644 index 00000000..41af2b37 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py @@ -0,0 +1,316 @@ +""" +Unified service for invoice movement operations. +This service delegates to specialized handlers for each import type. +""" + +import logging +from sqlalchemy.orm import Session +from typing import List + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + ExportRepairFilter, + AllMovementsFilter, + MovementItem, + MovementItemDetailed, + ReportType +) +from .services.temporary import TemporaryImportService +from .services.definitive import DefinitiveImportService +from .services.repair import RepairImportService +from .services.export import ExportService +from .services.export_repair import ExportRepairService + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Unified service for handling all types of movements. + Delegates to specialized services for each movement type. + """ + + def __init__(self): + self.temporary_service = TemporaryImportService() + self.definitive_service = DefinitiveImportService() + self.repair_service = RepairImportService() + self.export_service = ExportService() + self.export_repair_service = ExportRepairService() + + # ===== TEMPORARY IMPORTS ===== + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """Get temporary import movements (normal mode - grouped by invoice).""" + return self.temporary_service.get_movements(db, filters) + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """Get temporary import movements (detailed mode - line by line).""" + return self.temporary_service.get_movements_detailed(db, filters) + + # ===== DEFINITIVE IMPORTS ===== + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """Get definitive import movements (normal mode - grouped by invoice).""" + return self.definitive_service.get_movements(db, filters) + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """Get definitive import movements (detailed mode - line by line).""" + return self.definitive_service.get_movements_detailed(db, filters) + + # ===== REPAIR IMPORTS ===== + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """Get repair import movements (normal mode - grouped by invoice).""" + return self.repair_service.get_movements(db, filters) + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """Get repair import movements (detailed mode - line by line).""" + return self.repair_service.get_movements_detailed(db, filters) + + # ===== EXPORTS ===== + + def get_export_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """Get export movements (normal mode - grouped by invoice).""" + return self.export_service.get_movements(db, filters) + + def get_export_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """Get export movements (detailed mode - line by line).""" + return self.export_service.get_movements_detailed(db, filters) + + # ===== EXPORT REPAIRS ===== + + def get_export_repair_movements( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItem]: + """Get export repair movements (normal mode - grouped by invoice).""" + return self.export_repair_service.get_movements(db, filters) + + def get_export_repair_movements_detailed( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItemDetailed]: + """Get export repair movements (detailed mode - line by line).""" + return self.export_repair_service.get_movements_detailed(db, filters) + + # ===== ALL MOVEMENTS ===== + + def get_all_movements( + self, + db: Session, + filters: AllMovementsFilter + ) -> List[MovementItem]: + """ + Get all invoice movements (all types combined). + + This combines: + - Temporary imports + - Definitive imports + - Repair imports + - All export types + - Export repairs + + Returns a unified list sorted by date. + """ + all_movements = [] + + # Convert AllMovementsFilter to individual filter types + # We'll use the same filter parameters for all queries + + # Determine which services to call based on granular flags + # Default behavior: If granular flags are all defaults (True) but operation_type is set, + # we might need to respect operation_type. + # But for simplicity, we assume granular flags from frontend are the source of truth. + # If frontend didn't set them (legacy call?), they default to True. + + # Override based on operation_type if provided (legacy compatibility or coarse filter) + if filters.operation_type == 'imp': + filters.export_def = False + filters.export_rep = False + elif filters.operation_type == 'exp': + filters.import_temp = False + filters.import_def = False + filters.import_rep = False + + logger.info(f"Fetching movements with flags: Temp={filters.import_temp}, Def={filters.import_def}, Rep={filters.import_rep}, ExpDef={filters.export_def}, ExpRep={filters.export_rep}") + + # 1. Temporary Imports + if filters.import_temp: + temp_filter = ImportTemporaryFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default' + ) + if filters.report_type == ReportType.DETAILED: + temp_movements = self.temporary_service.get_movements_detailed(db, temp_filter) + else: + temp_movements = self.temporary_service.get_movements(db, temp_filter) + all_movements.extend(temp_movements) + logger.info(f"Added {len(temp_movements)} temporary import movements") + + # 2. Definitive Imports + if filters.import_def: + def_filter = ImportDefinitiveFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + def_movements = self.definitive_service.get_movements_detailed(db, def_filter) + else: + def_movements = self.definitive_service.get_movements(db, def_filter) + all_movements.extend(def_movements) + logger.info(f"Added {len(def_movements)} definitive import movements") + + # 3. Repair Imports + if filters.import_rep: + repair_filter = ImportRepairFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + discharge_filter='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + repair_movements = self.repair_service.get_movements_detailed(db, repair_filter) + else: + repair_movements = self.repair_service.get_movements(db, repair_filter) + all_movements.extend(repair_movements) + logger.info(f"Added {len(repair_movements)} repair import movements") + + # 4. Exports (Definitive) + if filters.export_def: + export_filter = ExportFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + discharge_filter='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + export_movements = self.export_service.get_movements_detailed(db, export_filter) + else: + export_movements = self.export_service.get_movements(db, export_filter) + all_movements.extend(export_movements) + logger.info(f"Added {len(export_movements)} export movements") + + # 5. Export Repairs + if filters.export_rep: + export_repair_filter = ExportRepairFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + discharge_filter='ALL' + ) + if filters.report_type == ReportType.DETAILED: + export_repair_movements = self.export_repair_service.get_movements_detailed(db, export_repair_filter) + else: + export_repair_movements = self.export_repair_service.get_movements(db, export_repair_filter) + all_movements.extend(export_repair_movements) + logger.info(f"Added {len(export_repair_movements)} export repair movements") + + # Sort all movements by date (Fecha field) + # Handle mixed datetime and string types + def get_sort_key(movement): + fecha = movement.FechaFactura + if not fecha: + return "" + # Convert datetime to string for consistent comparison + if hasattr(fecha, 'strftime'): + return fecha.strftime('%Y%m%d') + return str(fecha) + + all_movements.sort(key=get_sort_key) + + logger.info(f"Total movements combined: {len(all_movements)}") + return all_movements + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py new file mode 100644 index 00000000..351e4d9e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -0,0 +1,748 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Union + +from core.database import get_core_db +from core.security import get_current_user +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + ExportRepairFilter, + AllMovementsFilter, + MovementItem, + MovementItemDetailed +) +from .movement_service import movement_service + +logger = logging.getLogger(__name__) + +router = APIRouter( + tags=["Reports - Movement Invoices"] +) + + +@router.post( + "/temporary", + response_model=List[MovementItem], + summary="Get Temporary Import Movements", + description=""" + Retrieve temporary import movements from legacy database based on filter criteria. + This endpoint corresponds to the 'LLENADOTEMPORAL' (Fill Temporary) logic from the legacy system. + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_temporary_import_movements( + filters: ImportTemporaryFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get temporary import movements based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting temporary import movements" + ) + movements = movement_service.get_temporary_import_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} movements") + return movements + except ValueError as e: + logger.warning(f"Validation error fetching movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error fetching movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing import temporary movements: {str(e)}" + ) + + +@router.post( + "/temporary-detailed", + response_model=List[MovementItemDetailed], + summary="Get Detailed Temporary Import Movements", + description=""" + Retrieve detailed temporary import movements (line by line) from legacy database. + This endpoint corresponds to the 'LLENADOTEMPORAL - DETALLADO' logic from the legacy system. + + Each line/partida is returned separately with complete information including: + - Provider and buyer details (name, RFC, Tax ID) + - Customs broker information + - Item descriptions and specifications + - Series information + - All related metadata + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_temporary_import_movements_detailed( + filters: ImportTemporaryFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed temporary import movements (line by line) based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of detailed movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting DETAILED temporary import movements" + ) + movements = movement_service.get_temporary_import_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed movements") + return movements + except ValueError as e: + logger.warning(f"Validation error fetching detailed movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + logger.error(f"Error fetching detailed movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed import temporary movements: {str(e)}" + ) + + +@router.post( + "/definitive", + response_model=List[MovementItem], + summary="Get Definitive Import Movements", + description=""" + Retrieve definitive import movements from legacy database based on filter criteria. + This endpoint corresponds to the 'LLENADODEFINITIVO - NORMAL' logic from the legacy system. + + Definitive imports are aggregated by invoice number and can be filtered by: + - Movement type (COMEX or IMPDF based on ProvImpoDefCR field) + - Date range (invoice date or payment date) + - Provider and buyer + - Pedimento code + - Status (active or including cancelled) + + **Special Features**: + - Supports shelter company logic for exchange rate calculations + - MetTrans# = 1 logic for specific pedimento types (1, 4, 98E) + - Retrieves driver badge information + - Handles rectification pedimento lookups + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_definitive_import_movements( + filters: ImportDefinitiveFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get definitive import movements based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting definitive import movements" + ) + movements = movement_service.get_definitive_import_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} definitive movements") + return movements + except Exception as e: + logger.error(f"Error fetching definitive movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing definitive import movements: {str(e)}" + ) + + +@router.post( + "/definitive-detailed", + response_model=List[MovementItemDetailed], + summary="Get Detailed Definitive Import Movements", + description=""" + Retrieve detailed definitive import movements (line by line) from legacy database. + This endpoint corresponds to the 'LLENADODEFINITIVO - DETALLADO' logic from the legacy system. + + Each line/partida is returned separately with complete information including: + - Provider and buyer details (name, RFC, Tax ID) + - Customs broker information + - Item descriptions and specifications + - Series information from QSeriesDef table + - All related metadata + + **Special Logic**: + - Only Partidas (EsSubPartida = 'P') have values calculated + - Subpartidas (EsSubPartida = 'S') return with zero values + - Series formatted as: "1) SERIE123. Modelo: MOD1. Parte: PART1 | 2) SERIE456..." + - Exchange rate calculation supports shelter and non-shelter logic + - MetTrans# = 1 logic for pedimento types 1, 4, 98E + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_definitive_import_movements_detailed( + filters: ImportDefinitiveFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed definitive import movements (line by line) based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of detailed movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting DETAILED definitive import movements" + ) + movements = movement_service.get_definitive_import_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed definitive movements") + return movements + except Exception as e: + logger.error(f"Error fetching detailed definitive movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed definitive import movements: {str(e)}" + ) + + +@router.post( + "/repair", + response_model=List[MovementItem], + summary="Get Repair Import Movements", + description=""" + Retrieve repair import movements from legacy database based on filter criteria. + This endpoint corresponds to the 'LLENADOIMP_REPARACION - NORMAL' logic from the legacy system. + + Repair imports are aggregated by invoice number and can be filtered by: + - Discharge status (SiDes: discharged, NoDes: not discharged, ALL: no filter) + - Date range (invoice date or payment date) + - Provider and buyer + - Pedimento code + - Status (active or including cancelled) + + **Special Features**: + - Excludes regime changes (EsCambioRegimen <> 'S') + - Supports discharge filter (unique to repair imports) + - Exchange rate calculation with shelter/non-shelter logic + - MetTrans# = 1 logic for specific pedimento types (1, 4, 98E) + - Retrieves driver badge information + + **Database Tables**: + - QFacImpRep: Repair import invoices + - QEqiMaqRep: Repair import items/partidas + - QPedimentos: Pedimentos (customs declarations) + + **Note**: Requires connection to legacy SQL Server database. + """ +) +def get_repair_import_movements( + filters: ImportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get repair import movements based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting repair import movements" + ) + movements = movement_service.get_repair_import_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} repair movements") + return movements + except Exception as e: + logger.error(f"Error fetching repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing repair import movements: {str(e)}" + ) + + +@router.post( + "/repair-detailed", + response_model=List[MovementItemDetailed], + summary="Get detailed repair import movements", + description=""" + Retrieve detailed repair import movements (IMPRE) with individual partida lines. + + **LLENADOIMP_REPARACION - DETALLADO** + + Returns individual partida (line item) records for repair imports with full detail including: + - Complete invoice and customs clearance information + - Series, model, and part numbers for each item + - Client/supplier and sold-to information with tax IDs + - Exchange rate calculations (MN/ME) based on filter options + - Customs agent and customs section details + - Driver badge unique number + - All partida-level fields (part number, descriptions, quantities, weights, etc.) + + **Discharge Filter Options:** + - `SiDes`: Only include discharged items (Descarga = 1) + - `NoDes`: Only include non-discharged items (Descarga = 0) + - `ALL`: Include all items regardless of discharge status + + **Database Tables Used:** + - QFacImpRep: Repair import invoices + - QPedimentos: Customs declarations + - QEqiMaqRep: Repair import partidas (line items) + - QSeriesImpoRep: Series information + - GClientesPro: Suppliers + - GCliVendido: Sold-to clients + - GAAduanal: Customs agents + - GAduanaSec: Customs sections + - GConductor: Drivers (for badge numbers) + - GTipoCambio: Exchange rates + """, + tags=["Import Movements - Repair"] +) +async def get_import_repair_movements_detailed( + filters: ImportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed repair import movements based on filter criteria. + Returns partida-level detail with series information and full client/customs data. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting detailed repair movements") + movements = movement_service.get_repair_import_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed repair partidas") + return movements + except Exception as e: + logger.error(f"Error fetching detailed repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed repair import movements: {str(e)}" + ) + + +@router.post( + "/export", + response_model=List[MovementItem], + summary="Get export movements", + description=""" + Retrieve export movements (EXPO DEF) grouped by invoice. + + **LLENADOEXPORTACION - NORMAL** + + Returns aggregated data grouped by invoice number for export movements. + + **Movement Type Options:** + - `AFIJO`: Fixed assets + - `NODES`: No discharge + - `SCRAP`: Scrap materials + - `REEXP`: Re-exports + - `DONAC`: Donations + - `VEMEX`: Sales to Mexico + - `ALL`: All movement types + + **Discharge Filter Options:** + - `SiDes`: Only discharged items (Descarga = 1) + - `NoDes`: Only non-discharged items (Descarga = 0) + - `ALL`: All items regardless of discharge status + + **Database Tables Used:** + - QFacExp: Export invoices + - QEqeMaq: Export partidas (line items) + - QPedimentos: Customs declarations + - QClaAct: Part classifications + - GAAduanal: Customs agents + - GAduanaSec: Customs sections + - GConductor: Drivers + - GTipoCambio: Exchange rates + + Automatically excludes regime changes (EsCambioRegimen = 'N') + """, + tags=["Export Movements"] +) +async def get_export_movements( + filters: ExportFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get export movements based on filter criteria. + Returns aggregated data grouped by invoice. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting export movements") + movements = movement_service.get_export_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} export movements") + return movements + except Exception as e: + logger.error(f"Error fetching export movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing export movements: {str(e)}" + ) + + +@router.post( + "/export-detailed", + response_model=List[MovementItemDetailed], + summary="Get detailed export movements", + description=""" + Retrieve detailed export movements with individual partida lines. + + **LLENADOEXPORTACION - DETALLADO** + + Returns individual partida (line item) records for exports with full detail including: + - Complete invoice and customs clearance information + - Series, model, and part numbers for each item + - Client/supplier and buyer information with tax IDs + - Exchange rate calculations (MN/ME) based on filter options + - Customs agent and customs section details + - Driver badge unique number + - All partida-level fields + + **Movement Type Options:** + - `AFIJO`: Fixed assets + - `NODES`: No discharge + - `SCRAP`: Scrap materials + - `REEXP`: Re-exports + - `DONAC`: Donations + - `VEMEX`: Sales to Mexico + - `ALL`: All movement types + + **Discharge Filter Options:** + - `SiDes`: Only discharged items + - `NoDes`: Only non-discharged items + - `ALL`: All items + + **Database Tables Used:** + - QFacExp: Export invoices + - QEqeMaq: Export partidas + - QSeriesExpo: Serial numbers + - QPedimentos: Customs declarations + - GClientesPro: Suppliers + - GCliVendido: Buyers + - GAAduanal: Customs agents + - GAduanaSec: Customs sections + - GConductor: Drivers + - GTipoCambio: Exchange rates + """, + tags=["Export Movements"] +) +async def get_export_movements_detailed( + filters: ExportFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed export movements based on filter criteria. + Returns partida-level detail with series information and full client/customs data. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting detailed export movements") + movements = movement_service.get_export_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed export partidas") + return movements + except Exception as e: + logger.error(f"Error fetching detailed export movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed export movements: {str(e)}" + ) + + +@router.post("/export-repair", response_model=List[MovementItem]) +def get_export_repair_movements( + filters: ExportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + **LLENADOEXP_REPARACION - NORMAL** + + Get export repair movements (EXPO REP) based on filter criteria. + Groups results by invoice (FacturaExpo). + + Clarion logic: + - Query from QFacExpRep, QEqeMaqRep tables + - Filters: date range (FF/FP), provider, buyer, pedimento code + - Movement types: AFIJO, NODES + - Discharge filter: SiDes, NoDes, or ALL + - Calculates totals from partidas where EsSubpartida = 'P' + - Exchange rate logic based on currency type and Scaii.ini MetTrans + - Always filters by EsCambioRegimen = 'N' + """ + try: + logger.info(f"User {current_user.get('sub')} requesting export repair movements") + movements = movement_service.get_export_repair_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} export repair invoices") + return movements + except Exception as e: + logger.error(f"Error fetching export repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing export repair movements: {str(e)}" + ) + + +@router.post("/export-repair-detailed", response_model=List[MovementItemDetailed]) +def get_export_repair_movements_detailed( + filters: ExportRepairFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get detailed export repair movements based on filter criteria. + Returns partida-level detail with series information and full client/customs data. + """ + try: + logger.info(f"User {current_user.get('sub')} requesting detailed export repair movements") + movements = movement_service.get_export_repair_movements_detailed( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} detailed export repair partidas") + return movements + except Exception as e: + logger.error(f"Error fetching detailed export repair movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing detailed export repair movements: {str(e)}" + ) + + +@router.post( + "/all", + response_model=Union[List[MovementItemDetailed], List[MovementItem]], + summary="Get All Invoice Movements", + description=""" + Retrieve all invoice movements (imports and exports of all types) from database. + This endpoint combines temporary, definitive, and repair imports with all export types. + + Use this when "TODAS" checkbox is selected to get a comprehensive view of all movements + regardless of their specific type. + + If send_email is True, the report will be sent to the authenticated user's email address. + """ +) +async def get_all_movements( + filters: AllMovementsFilter, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user) +): + """ + Get all invoice movements (all types combined) based on filters. + + Args: + filters: Filter criteria for querying movements + db: Database session + current_user: Authenticated user information + + Returns: + List of all movement items matching the criteria + + Raises: + HTTPException: If database query fails or user is unauthorized + """ + try: + logger.info( + f"User {current_user.get('preferred_username', 'unknown')} " + f"requesting all invoice movements (send_email={filters.send_email})" + ) + movements = movement_service.get_all_movements( + db=db, + filters=filters + ) + logger.info(f"Successfully retrieved {len(movements)} total movements") + + # Send email if requested + if filters.send_email: + user_email = current_user.get('email') + if not user_email: + logger.warning(f"User {current_user.get('sub')} has no email address - skipping email") + else: + try: + from core.email import EmailService + from .csv_utils import generate_csv_from_movements + from datetime import datetime + + # Generate CSV + csv_content = generate_csv_from_movements( + movements=movements, + filters=filters + ) + + # Generate filename + filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Send email + email_sent = await EmailService.send_report_email( + recipient_email=user_email, + subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}", + body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.", + csv_content=csv_content, + filename=filename + ) + + if email_sent: + logger.info(f"Report emailed successfully to {user_email}") + else: + logger.warning(f"Failed to send email to {user_email} - SMTP may not be configured correctly") + + except Exception as email_error: + logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation") + + return movements + return movements + except ValueError as e: + logger.warning(f"Validation error fetching all movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error fetching all movements: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error processing all movements: {str(e)}" + ) + + +@router.post( + "/generate", + summary="Generate Invoice Report (Async)", + description="Trigger background generation of invoice report." +) +def generate_invoice_report_async( + filters: AllMovementsFilter, + current_user: dict = Depends(get_current_user) +): + """ + Trigger background generation of invoice report. + Returns task_id to poll status. + """ + from .tasks import generate_invoice_movements_async + + logger.info(f"User {current_user.get('preferred_username', 'unknown')} triggering async report generation") + + # Serialize filters to dict for Celery + filter_data = filters.model_dump() + user_email = current_user.get('email') + + # Trigger task + task = generate_invoice_movements_async.delay(filter_data, user_email) + + return {"task_id": task.id} + + +@router.get( + "/task/{task_id}", + summary="Get Async Task Status", + description="Check status of background report generation task." +) +def get_task_status(task_id: str): + """ + Get status of background task. + """ + from celery.result import AsyncResult + from core.celery_app import celery_app + + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "status": task_result.status, + } + + if task_result.state == 'PROCESSING': + response["meta"] = task_result.info + + if task_result.ready(): + response["result"] = task_result.result + + return response diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py new file mode 100644 index 00000000..002331cf --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py @@ -0,0 +1,609 @@ +from typing import Optional +from pydantic import BaseModel, Field +from datetime import datetime, date +from enum import Enum + + +class RangeType(str, Enum): + """Date range type for filtering""" + INVOICE_DATE = "FF" # Filter by invoice date + PAYMENT_DATE = "FP" # Filter by payment date + + +class ReportType(str, Enum): + """Report type""" + NORMAL = "Normal" + DETAILED = "Detallado" + + +class CurrencyType(str, Enum): + """Currency type for calculations""" + FOREIGN = "ME" # Foreign currency (Moneda Extranjera) + LOCAL = "MN" # Local currency (Moneda Nacional) + + +class ExchangeRateType(str, Enum): + """Exchange rate calculation type""" + PAYMENT = "FP" # Use payment date + INVOICE = "FF" # Use invoice date + + +class MovementTypeFilter(str, Enum): + """Movement type filter for definitive imports""" + COMEX = "COMEX" # ProvImpoDefCR = 'P' + IMPDF = "IMPDF" # ProvImpoDefCR != 'P' + ALL = "ALL" # No filter + + +class DischargeFilter(str, Enum): + """Discharge filter for repair imports""" + DISCHARGED = "SiDes" # RepPim.Descarga = 1 + NOT_DISCHARGED = "NoDes" # RepPim.Descarga = 0 + ALL = "ALL" # No filter + + +class ExportMovementType(str, Enum): + """Export movement type filter""" + AFIJO = "AFIJO" # Fixed assets + NODES = "NODES" # No discharge + SCRAP = "SCRAP" # Scrap + REEXP = "REEXP" # Re-export + DONAC = "DONAC" # Donation + VEMEX = "VEMEX" # Sale to Mexico + ALL = "ALL" # All types + + +class AllMovementsFilter(BaseModel): + """Filters for all movements query (all types combined)""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal (grouped by invoice) or Detailed (line by line)" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + operation_type: Optional[str] = Field( + default=None, + description="Filter by operation type: 'imp' for imports only, 'exp' for exports only, None for all" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + # Granular movement selection + import_temp: bool = Field(default=True, description="Include temporary imports (IMTEM)") + import_def: bool = Field(default=True, description="Include definitive imports (IMPDF/COMEX)") + import_rep: bool = Field(default=True, description="Include repair imports (IMPRE)") + export_def: bool = Field(default=True, description="Include definitive exports") + export_rep: bool = Field(default=True, description="Include repair exports") + + # Specific filters + export_types: Optional[list[str]] = Field( + default=None, + description="Specific export legacy codes to include (AFIJO, NODES, etc)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Global discharge filter for repair movements" + ) + + +class ImportTemporaryFilter(BaseModel): + """Filters for temporary import movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal or Detailed" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + database_name: str = Field( + ..., + description="Legacy database name to query from" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + +class ImportDefinitiveFilter(BaseModel): + """Filters for definitive import movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + movement_type: MovementTypeFilter = Field( + default=MovementTypeFilter.ALL, + description="Movement type filter: COMEX, IMPDF, or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal or Detailed" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + use_transport_method: bool = Field( + default=False, + description="Use MetTrans# = 1 logic for specific pedimento types" + ) + database_name: str = Field( + ..., + description="Legacy database name to query from" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + +class MovementItem(BaseModel): + """Movement item representing a temporary import invoice""" + Factura: Optional[str] = Field(None, description="Invoice number") + Pedimento: Optional[str] = Field(None, description="Pedimento number") + FechaFactura: Optional[datetime] = Field(None, description="Invoice date") + Estatus: Optional[str] = Field(None, description="Status (AC=Active, etc)") + ClavePed: Optional[str] = Field(None, description="Pedimento code") + TipoMovTemDef: Optional[str] = Field(None, description="Movement type (IMTEM=Temporary Import)") + EsCambioRegimen: Optional[str] = Field(None, description="Is regime change (S/N)") + ValorMPTemp: Optional[float] = Field(None, description="Temporary raw material value") + ValorComercialMN: Optional[float] = Field(None, description="Commercial value in MN") + TipoCambio: Optional[float] = Field(None, description="Exchange rate used") + ValorAgre: Optional[float] = Field(default=0.0, description="Aggregate value") + TipoExpo: Optional[str] = Field(default='', description="Export type") + PedimentoR1: Optional[str] = Field(None, description="Rectification pedimento") + EDocument: Optional[str] = Field(None, description="Electronic document") + NumOperacionVU: Optional[str] = Field(None, description="VU operation number") + BaseDeDatos: Optional[str] = Field(None, description="Source database name") + NumGafUni: Optional[str] = Field(None, description="Unique badge number (driver)") + UsuarioCap: Optional[str] = Field(None, description="Capture user") + UsuarioAcr: Optional[str] = Field(None, description="Update user") + Fecha_Pago: Optional[datetime] = Field(None, description="Payment date") + NumCaja: Optional[str] = Field(None, description="Box/Container number") + Pedimento18: Optional[str] = Field(None, description="18-digit pedimento") + AduanaCru: Optional[str] = Field(None, description="Crossing customs") + Lote: Optional[str] = Field(None, description="Lot number") + + model_config = { + "json_schema_extra": { + "example": { + "Factura": "F-2024-001", + "Pedimento": "24 47 3807 8001234", + "FechaFactura": "2024-01-15T00:00:00", + "Estatus": "AC", + "ClavePed": "IM", + "TipoMovTemDef": "IMTEM", + "ValorMPTemp": 10000.50, + "TipoCambio": 17.25 + } + } + } + + +class ImportRepairFilter(BaseModel): + """Filters for repair import movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus != 'AC')" + ) + provider: Optional[str] = Field( + default=None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + default=None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + default=None, + description="Filter by pedimento code (ClavePed)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Discharge filter: SiDes (discharged), NoDes (not discharged), or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal or Detailed" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type for value calculations" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate calculation method" + ) + is_shelter: bool = Field( + default=False, + description="Use shelter company logic" + ) + use_transport_method: bool = Field( + default=False, + description="Use MetTrans# = 1 logic for specific pedimento types" + ) + database_name: str = Field( + ..., + description="Legacy database name to query from" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + +class MovementItemDetailed(BaseModel): + """Detailed movement item with all line-level information""" + Linea: Optional[int] = Field(None, description="Line number") + Factura: Optional[str] = Field(None, description="Invoice number") + Pedimento: Optional[str] = Field(None, description="Pedimento number") + FechaFactura: Optional[datetime] = Field(None, description="Invoice date") + Estatus: Optional[str] = Field(None, description="Status (AC=Active, etc)") + ClavePed: Optional[str] = Field(None, description="Pedimento code") + TipoMovTemDef: Optional[str] = Field(None, description="Movement type") + EsCambioRegimen: Optional[str] = Field(None, description="Is regime change (S/N)") + Regimen: Optional[str] = Field(None, description="Regime") + Fecha_Inicio: Optional[datetime] = Field(None, description="Start date") + Fecha_Fin: Optional[datetime] = Field(None, description="End date") + Fecha_Pago: Optional[datetime] = Field(None, description="Payment date") + Remesa: Optional[str] = Field(None, description="Remesa") + + # Provider information + Proveedor: Optional[str] = Field(None, description="Provider name") + RFCProveedor: Optional[str] = Field(None, description="Provider RFC") + ProveedorTaxID: Optional[str] = Field(None, description="Provider Tax ID") + + # Buyer information + VendidoA: Optional[str] = Field(None, description="Buyer name") + VendidoARFC: Optional[str] = Field(None, description="Buyer RFC") + VendidoATaxID: Optional[str] = Field(None, description="Buyer Tax ID") + + # Customs broker + AgenteAduanal: Optional[str] = Field(None, description="Customs broker name") + Patente: Optional[str] = Field(None, description="Customs broker patent") + + # Item details + NumParte: Optional[str] = Field(None, description="Part number") + DescripcionE: Optional[str] = Field(None, description="Spanish description") + DescripcionI: Optional[str] = Field(None, description="English description") + CantidadIE: Optional[float] = Field(None, description="Quantity") + UniMed: Optional[str] = Field(None, description="Unit of measure") + ValorComercialMN: Optional[float] = Field(None, description="Commercial value in MN") + TipoCambio: Optional[float] = Field(None, description="Exchange rate") + PesoNeto: Optional[float] = Field(None, description="Net weight") + PesoBruto: Optional[float] = Field(None, description="Gross weight") + + # Additional fields + OrdenCompraVenta: Optional[str] = Field(None, description="Purchase order") + FraccionArancelaria: Optional[str] = Field(None, description="Tariff fraction") + Preferencia: Optional[str] = Field(None, description="Preference") + Sector: Optional[str] = Field(None, description="Sector") + PaisOrigen: Optional[str] = Field(None, description="Country of origin") + Aduana: Optional[str] = Field(None, description="Customs office") + Advalorem: Optional[str] = Field(None, description="Ad valorem") + TipoExpo: Optional[str] = Field(default='', description="Export type") + PedimentoR1: Optional[str] = Field(None, description="Rectification pedimento") + EDocument: Optional[str] = Field(None, description="Electronic document") + NumOperacionVU: Optional[str] = Field(None, description="VU operation number") + + # Series information + Series: Optional[str] = Field(None, description="Serial numbers") + Marca: Optional[str] = Field(None, description="Brand") + Modelo: Optional[str] = Field(None, description="Model") + FraccionAmericana: Optional[str] = Field(None, description="American tariff fraction") + ECCN: Optional[str] = Field(None, description="ECCN code") + SimboloEx: Optional[str] = Field(None, description="Export symbol/license") + FechaEmision: Optional[datetime] = Field(None, description="Emission date") + + # Metadata + BaseDeDatos: Optional[str] = Field(None, description="Source database") + NumGafUni: Optional[str] = Field(None, description="Unique badge number") + UsuarioCap: Optional[str] = Field(None, description="Capture user") + UsuarioAcr: Optional[str] = Field(None, description="Update user") + Transportista: Optional[str] = Field(None, description="Transporter") + NumCaja: Optional[str] = Field(None, description="Box number") + Pedimento18: Optional[str] = Field(None, description="18-digit pedimento") + AduanaCru: Optional[str] = Field(None, description="Crossing customs") + Lote: Optional[str] = Field(None, description="Lot number") + + model_config = { + "json_schema_extra": { + "example": { + "Linea": 1 + } + } + } + + +class ExportFilter(BaseModel): + """Filters for export movements query""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus = 'NA')" + ) + provider: Optional[str] = Field( + None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + None, + description="Filter by pedimento code (ClavePed)" + ) + movement_type: ExportMovementType = Field( + default=ExportMovementType.ALL, + description="Filter by export movement type (AFIJO, NODES, SCRAP, REEXP, DONAC, VEMEX)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Filter by discharge status: SiDes, NoDes, or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Normal (grouped by invoice) or Detallado (line by line)" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type: ME (foreign) or MN (local)" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate type: FP (payment date) or FF (invoice date)" + ) + is_shelter: bool = Field( + default=False, + description="Shelter company flag" + ) + use_transport_method: bool = Field( + default=False, + description="Use transport method for exchange rate logic" + ) + database_name: str = Field( + ..., + description="Legacy database name" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + model_config = { + "json_schema_extra": { + "example": { + "range_type": "FF", + "start_date": "20240101", + "end_date": "20240131", + "include_cancelled": False, + "provider": None, + "buyer": None, + "pedimento_code": None, + "movement_type": "ALL", + "discharge_filter": "ALL", + "report_type": "Normal", + "currency_type": "ME", + "exchange_rate_type": "FP", + "is_shelter": False, + "use_transport_method": False, + "database_name": "MYDB" + } + } + } + +class ExportRepairFilter(BaseModel): + """Filters for export repair movements query (EXPO REP)""" + range_type: RangeType = Field( + default=RangeType.INVOICE_DATE, + description="Date range type: FF for invoice date, FP for payment date" + ) + start_date: str = Field( + ..., + description="Start date in YYYYMMDD format or ISO format" + ) + end_date: str = Field( + ..., + description="End date in YYYYMMDD format or ISO format" + ) + include_cancelled: bool = Field( + default=False, + description="Include cancelled invoices (Estatus = 'NA')" + ) + provider: Optional[str] = Field( + None, + description="Filter by provider code" + ) + buyer: Optional[str] = Field( + None, + description="Filter by buyer code (VendidoA)" + ) + pedimento_code: Optional[str] = Field( + None, + description="Filter by pedimento code (ClavePed)" + ) + movement_type: ExportMovementType = Field( + default=ExportMovementType.ALL, + description="Filter by movement type (AFIJO, NODES for repair exports)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Filter by discharge status: SiDes, NoDes, or ALL" + ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Normal (grouped by invoice) or Detallado (line by line)" + ) + currency_type: CurrencyType = Field( + default=CurrencyType.FOREIGN, + description="Currency type: ME (foreign) or MN (local)" + ) + exchange_rate_type: ExchangeRateType = Field( + default=ExchangeRateType.PAYMENT, + description="Exchange rate type: FP (payment date) or FF (invoice date)" + ) + is_shelter: bool = Field( + default=False, + description="Shelter company flag" + ) + database_name: str = Field( + ..., + description="Legacy database name" + ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + model_config = { + "json_schema_extra": { + "example": { + "range_type": "FF", + "start_date": "20240101", + "end_date": "20240131", + "include_cancelled": False, + "provider": None, + "buyer": None, + "pedimento_code": None, + "movement_type": "ALL", + "discharge_filter": "ALL", + "report_type": "Normal", + "currency_type": "ME", + "exchange_rate_type": "FP", + "is_shelter": False, + "database_name": "MYDB" + } + } + } \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services.py b/backend/api/v1/modules/a76/reports/movements/invoices/services.py new file mode 100644 index 00000000..f218bd71 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services.py @@ -0,0 +1,112 @@ +""" +Unified service for invoice movement operations. +This service delegates to specialized handlers for each import type. +""" + +import logging +from sqlalchemy.orm import Session +from typing import List + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + MovementItem, + MovementItemDetailed +) +from .services.temporary import TemporaryImportService +from .services.definitive import DefinitiveImportService +from .services.repair import RepairImportService +from .services.export import ExportService + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Unified service for handling all types of movements. + Delegates to specialized services for each movement type. + """ + + def __init__(self): + self.temporary_service = TemporaryImportService() + self.definitive_service = DefinitiveImportService() + self.repair_service = RepairImportService() + self.export_service = ExportService() + + # ===== TEMPORARY IMPORTS ===== + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """Get temporary import movements (normal mode - grouped by invoice).""" + return self.temporary_service.get_movements(db, filters) + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """Get temporary import movements (detailed mode - line by line).""" + return self.temporary_service.get_movements_detailed(db, filters) + + # ===== DEFINITIVE IMPORTS ===== + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """Get definitive import movements (normal mode - grouped by invoice).""" + return self.definitive_service.get_movements(db, filters) + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """Get definitive import movements (detailed mode - line by line).""" + return self.definitive_service.get_movements_detailed(db, filters) + + # ===== REPAIR IMPORTS ===== + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """Get repair import movements (normal mode - grouped by invoice).""" + return self.repair_service.get_movements(db, filters) + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """Get repair import movements (detailed mode - line by line).""" + return self.repair_service.get_movements_detailed(db, filters) + + # ===== EXPORTS ===== + + def get_export_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """Get export movements (normal mode - grouped by invoice).""" + return self.export_service.get_movements(db, filters) + + def get_export_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """Get export movements (detailed mode - line by line).""" + return self.export_service.get_movements_detailed(db, filters) + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py new file mode 100644 index 00000000..fdde7fed --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py @@ -0,0 +1,26 @@ +""" +Invoice Movement Services Module + +This package contains the business logic for handling different types of movements: +- Temporary imports (IMTEM) +- Definitive imports (COMEX/IMPDF) +- Repair imports (IMPRE) +- Exports (EXPO DEF) +- Export repairs (EXPO REP) + +The services are organized into specialized modules for better maintainability. +""" + +from .temporary import TemporaryImportService +from .definitive import DefinitiveImportService +from .repair import RepairImportService +from .export import ExportService +from .export_repair import ExportRepairService + +__all__ = [ + 'TemporaryImportService', + 'DefinitiveImportService', + 'RepairImportService', + 'ExportService', + 'ExportRepairService', +] diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py new file mode 100644 index 00000000..e04262a8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py @@ -0,0 +1,85 @@ +""" +Base utilities and configuration helpers for invoice movement services. +""" + +import logging +import configparser +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ConfigHelper: + """Helper for reading configuration files.""" + + @staticmethod + def get_met_trans_config() -> int: + """ + Read MetTrans configuration from Scaii.ini file. + + Returns: + MetTrans value (0 or 1) + """ + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + return met_trans + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + return 0 + + +class StringHelper: + """Helper for string manipulation.""" + + @staticmethod + def remove_commas(text: Optional[str]) -> Optional[str]: + """Remove commas from text for CSV compatibility.""" + if not text: + return text + return text.replace(',', '') + + @staticmethod + def clean_text(text: Optional[str]) -> Optional[str]: + """Clean text by stripping whitespace and removing special characters.""" + if not text: + return None + # Remove special characters and extra whitespace + cleaned = text.strip() + return cleaned if cleaned else None + + +class DateHelper: + """Helper for date-related operations.""" + + @staticmethod + def get_fecha_tipo_cambio( + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + use_transport_method: bool, + met_trans: int + ): + """ + Determine which date to use for exchange rate lookup based on MetTrans logic. + + Args: + fecha_pago: Payment date + fecha_inicio: Start/entry date + tipo_pedimento: Pedimento type code + use_transport_method: Whether to apply transport method logic + met_trans: MetTrans configuration value + + Returns: + Date to use for exchange rate lookup + """ + fecha = fecha_pago + + # MetTrans# = 1 logic: use fecha_inicio for specific pedimento types + if use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha = fecha_inicio + + return fecha diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py new file mode 100644 index 00000000..e6ab3db8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py @@ -0,0 +1,520 @@ +""" +Database query helpers for invoice movements. +""" + +import logging +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import Optional, Dict + +logger = logging.getLogger(__name__) + + +class DatabaseHelper: + """Helper for common database operations.""" + + @staticmethod + def get_database_name(db: Session) -> Optional[str]: + """ + Get the current database name from the session. + + Returns: + Database name or None if not found + """ + try: + result = db.execute(text("SELECT current_database()")).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error getting database name: {e}") + return None + + @staticmethod + def get_exchange_rate( + db: Session, + db_name: str, + fecha, + is_shelter: bool = False, + raise_on_missing: bool = False, + pedimento_number: Optional[str] = None + ) -> Optional[float]: + """ + Get exchange rate for the given date from exchange_rate table. + + Args: + db: Database session + db_name: Legacy database name (kept for compatibility, not used) + fecha: Date for exchange rate lookup + is_shelter: Shelter company flag (when True and rate not found, raises detailed error) + raise_on_missing: If True, raises ValueError when rate not found + pedimento_number: Pedimento number for error messages + + Returns: + Exchange rate as float, or None if not found + + Raises: + ValueError: When is_shelter=True and exchange rate not found + """ + if not fecha: + return None + + try: + # TODO: Verify exchange_rate table structure and column names + sql_tc = text(""" + SELECT rate + FROM a76.exchange_rate + WHERE rate_date = :fecha + ORDER BY rate_date DESC + LIMIT 1 + """) + res = db.execute(sql_tc, {"fecha": fecha}).fetchone() + if res and res[0]: + return float(res[0]) + else: + # Clarion logic: For Shelter operations with FP, missing exchange rate is an error + if is_shelter and raise_on_missing: + fecha_str = fecha.strftime('%d/%m/%Y') if hasattr(fecha, 'strftime') else str(fecha) + ped_info = f" del Pedimento: {pedimento_number}" if pedimento_number else "" + raise ValueError( + f"Falta el tipo de cambio del día {fecha_str}. " + f"Por favor regístralo en el catálogo de Tipos de Cambio." + ) + logger.warning(f"Exchange rate not found for date {fecha}") + return None + except ValueError: + raise # Re-raise validation errors + except Exception as e: + logger.error(f"Error fetching exchange rate for date {fecha}: {e}") + return None + + @staticmethod + def get_client_info( + db: Session, + db_name: str, + client_code: str, + is_supplier: bool = True + ) -> Dict[str, Optional[str]]: + """ + Get client or supplier information (name, RFC, TaxID). + + Args: + db: Database session + db_name: Database name (kept for compatibility, not used) + client_code: Client/supplier code + is_supplier: True for suppliers, False for clients + + Returns: + Dict with 'name', 'rfc', 'tax_id' keys + """ + if not client_code: + return {"name": None, "rfc": None, "tax_id": None} + + client_type = 'PROVIDER' if is_supplier else 'CLIENT' + + try: + sql = text(""" + SELECT cp.name, cp.rfc, cpp.tax_id + FROM a76.clients_and_providers cp + LEFT JOIN a76.clients_and_providers_programs cpp ON cpp.client_id = cp.id + WHERE cp.id = :client_code AND cp.client_or_provider = :client_type + """) + result = db.execute(sql, {"client_code": client_code, "client_type": client_type}).fetchone() + + if result: + return { + "name": result[0], + "rfc": result[1], + "tax_id": result[2] + } + else: + logger.debug(f"Client {client_code} not found as {client_type}") + return {"name": None, "rfc": None, "tax_id": None} + except Exception as e: + logger.error(f"Error fetching client info for {client_code}: {e}") + raise + + @staticmethod + def get_customs_agent_info( + db: Session, + db_name: str, + agent_code: str + ) -> Dict[str, Optional[str]]: + """ + Get customs agent information (name, license). + + Args: + db: Database session + db_name: Database name (kept for compatibility, not used) + agent_code: Customs agent code + + Returns: + Dict with 'name', 'license' keys + """ + if not agent_code: + return {"name": None, "license": None} + + try: + sql = text(""" + SELECT name, license + FROM a76.customs_brokers + WHERE id = :agent_code + LIMIT 1 + """) + result = db.execute(sql, {"agent_code": agent_code}).fetchone() + + if result: + return { + "name": result[0], + "license": result[1] + } + else: + logger.debug(f"Customs agent {agent_code} not found") + return {"name": None, "license": None} + except Exception as e: + logger.error(f"Error fetching customs agent info for {agent_code}: {e}") + raise + + @staticmethod + def get_aduana_seccion_nombre( + db: Session, + db_name: str, + aduana_seccion: str + ) -> Optional[str]: + """ + Get customs section name. + + Args: + db: Database session + db_name: Database name + aduana_seccion: Customs section code + + Returns: + Customs section name or None + """ + if not aduana_seccion: + return None + + try: + query = text(""" + SELECT section_name + FROM public.customs_sections + WHERE customs_code = :code + """) + result = db.execute(query, {"code": aduana_seccion}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching customs section name: {e}") + raise + + @staticmethod + def get_series_info( + db: Session, + db_name: str, + invoice_id: int, + linea: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for import items. + + Args: + db: Database session + db_name: Legacy database name (not used in PostgreSQL) + invoice_id: Invoice header ID + linea: Line number + is_shelter: Shelter flag (not used) + + Returns: + Formatted series string or None + """ + if not invoice_id or not linea: + return None + + try: + query = text(""" + SELECT serial_numbers, model, brand + FROM a76.item_line_series ils + INNER JOIN a76.item_lines il ON ils.line_item_id = il.id + WHERE il.invoice_id = :invoice_id + AND il.line_number = :linea + ORDER BY ils.id + LIMIT 1 + """) + result = db.execute(query, { + "invoice_id": invoice_id, + "linea": linea + }).fetchone() + + if result: + serial_numbers, model, brand = result + parts = [] + if serial_numbers: + parts.append(serial_numbers) + if model: + parts.append(model) + if brand: + parts.append(brand) + return " / ".join(parts) if parts else None + return None + except Exception as e: + logger.error(f"Error fetching series info: {e}") + raise + + @staticmethod + def get_series_info_export( + db: Session, + db_name: str, + invoice_id: int, + linea: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for export items. + + Args: + db: Database session + db_name: Legacy database name + invoice_id: Invoice header ID + linea: LineaExpo value + is_shelter: Shelter flag + + Returns: + Formatted series string or None + """ + if not invoice_id or not linea: + return None + + try: + # Note: Postgres items table calls it expo_brad (typo in DB schema) + # ItemLineSeries FK is line_item_id, not item_line_id + query = text(""" + SELECT serial_numbers, model, expo_brad + FROM a76.item_line_series ils + INNER JOIN a76.item_lines il ON ils.line_item_id = il.id + WHERE il.invoice_id = :invoice_id + AND il.line_number = :linea + ORDER BY ils.id + LIMIT 1 + """) + result = db.execute(query, { + "invoice_id": invoice_id, + "linea": linea + }).fetchone() + + if result: + serial_numbers, model, expo_brand = result + parts = [] + if serial_numbers: + parts.append(serial_numbers) + if model: + parts.append(model) + if expo_brand: + parts.append(expo_brand) + return " | ".join(parts) if parts else None + return None + except Exception as e: + logger.error(f"Error fetching export series info: {e}") + raise + + @staticmethod + def get_rectification_pedimento( + db: Session, + pedimento: str, + ped_rectifica: Optional[str], + is_shelter: bool = False + ) -> Optional[str]: + """ + Get final pedimento rectification number following the chain recursively. + + Clarion logic: + - IF Loc:OpcionShelter = 1 THEN: use direct field value (PedRectifica) + - ELSE: call BuscarRectificacion() - follows rectification chain recursively + + BuscarRectificacion follows the chain: + Example: A1 -> A2 -> A3 -> A4 (returns A4, the final rectification) + + Args: + db: Database session + pedimento: Original pedimento number + ped_rectifica: Initial rectification pedimento from database field + (already resolved from pedimento_rectification_origin JOIN in query) + is_shelter: Shelter company flag + + Returns: + Final rectification pedimento number in the chain, or None/empty if no rectification + """ + if is_shelter: + # Shelter: use direct value from PedRectifica field + result = ped_rectifica + else: + # Non-Shelter: implement BuscarRectificacion logic + result = DatabaseHelper._buscar_rectificacion(db, pedimento, ped_rectifica) + return result + + @staticmethod + def _buscar_rectificacion( + db: Session, + pedimento_orig: str, + ped_rec: Optional[str] + ) -> Optional[str]: + """ + BUSCA ULTIMO PEDIMENTO DE RECTIFICACION + Returns the rectification origin pedimento string already resolved by the query + builder's JOIN on pedimento_rectification_origin. + + Args: + db: Database session + pedimento_orig: Original pedimento number (e.g. "1234567") + ped_rec: Rectification pedimento origin string already computed by the SQL JOIN + (e.g. "25-470-8000-1234567") + + Returns: + The rectification origin string, or empty string if none. + """ + if not ped_rec: + return '' + + # The ped_rec value already comes from the JOIN on pedimento_rectification_origin + # in the query builder, so it is the directly stored origin pedimento. + # Return it directly without any further recursive DB lookup. + return ped_rec + + @staticmethod + def _busca_pedimento_r1( + db: Session, + pedimento: str, + visited: set + ) -> Optional[str]: + """ + BUSCA_PEDIMENTO_R1 ROUTINE - Recursive search for final rectification pedimento + using pedimento_rectification_origin table. + + Args: + db: Database session + pedimento: Current pedimento number to check + visited: Set of already visited pedimentos (prevents infinite loops) + + Returns: + Final pedimento in chain, or None if circular reference detected + """ + if pedimento in visited: + # Circular reference detected (ERRORCODE = 30 equivalent) + logger.warning(f"Circular reference detected in rectification chain: {pedimento}") + return None + + try: + # Query pedimento_rectification_origin for the next pedimento in the chain. + # NOTE: The a76.pedimentos table does NOT have a ped_rectifica column. + # Rectification data lives in pedimento_rectification_origin. + sql = text(""" + SELECT + pro.original_pedimento_year || '-' || pro.original_customs_office || + '-' || pro.original_license || '-' || pro.original_pedimento_number AS ped_origen + FROM a76.pedimento_rectification_origin pro + INNER JOIN a76.pedimentos ped ON ped.id = pro.pedimento_id + WHERE ped.pedimento_number = :pedimento + AND pro.deleted_at IS NULL + LIMIT 1 + """) + result = db.execute(sql, {"pedimento": pedimento}).fetchone() + + if result and result[0] and result[0].replace('-', '').strip(): + ped_rectifica_next = result[0] + + # Add current pedimento to visited set + visited.add(pedimento) + + # Recurse with next rectification origin + final_ped = DatabaseHelper._busca_pedimento_r1( + db, ped_rectifica_next, visited + ) + + return final_ped if final_ped else pedimento + else: + # No more rectifications, this is the final pedimento + return pedimento + + except Exception as e: + logger.error(f"Error fetching rectification origin for pedimento {pedimento}: {e}") + return None + + @staticmethod + def get_driver_badge( + db: Session, + db_name: str, + factura: str + ) -> Optional[str]: + """ + Get driver unique badge number (NUMGAFETEUNICO) for invoice. + + Clarion query: + SELECT NUMGAFETEUNICO FROM GConductor + LEFT JOIN QFacImp ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = '' + + Modern schema: + - invoice_header has invoice_number + - invoice_logistics links to invoice via invoice_id and has driver_name + - driver table has unique_badge_number and driver_name + + Args: + db: Database session + db_name: Database name (not used in modern schema) + factura: Invoice number + + Returns: + Driver unique badge number or None + """ + if not factura: + return None + + try: + # Join invoice_header -> invoice_logistics -> driver via driver_name + query = text(""" + SELECT d.unique_badge_number + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.driver d ON d.driver_name = log.driver_name + WHERE ih.invoice_number = :factura + AND d.unique_badge_number IS NOT NULL + LIMIT 1 + """) + result = db.execute(query, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching driver badge for invoice {factura}: {e}") + raise + + @staticmethod + def get_part_export_symbol( + db: Session, + db_name: str, + num_parte: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get export symbol/license for a part number. + + Args: + db: Database session + db_name: Database name (not used in PostgreSQL, kept for compatibility) + num_parte: Part number + is_shelter: Shelter flag (not used, kept for compatibility) + + Returns: + Export symbol/license or None + """ + if not num_parte: + return None + + try: + query = text(""" + SELECT exclusion_symbol + FROM a76.parts + WHERE part_number = :num_parte + LIMIT 1 + """) + result = db.execute(query, {"num_parte": num_parte}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching export symbol for part {num_parte}: {e}") + raise diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py new file mode 100644 index 00000000..dd710d24 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py @@ -0,0 +1,383 @@ +""" +Definitive import service - handles COMEX/IMPDF movements. +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..schemas import ImportDefinitiveFilter, MovementItem, MovementItemDetailed + +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import DefinitiveImportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class DefinitiveImportService: + """Service for handling definitive import movements (COMEX/IMPDF).""" + + def get_movements( + self, + db: Session, + filters: "ImportDefinitiveFilter" + ) -> List["MovementItem"]: + """ + Get definitive import movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + from ..schemas import MovementItem + + try: + logger.info(f"Fetching definitive import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + sql = text(DefinitiveImportQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} definitive import invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaImpoDef + estatus = row[3] # C4 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + # Si include_cancelled=False, solo mostrar AC (is_updated=true) + # Si include_cancelled=True, mostrar todas (AC y NA) + if not filters.include_cancelled and estatus != 'AC': + continue + + invoice_id = row[16] # C35 - invoice ID + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + # Totals come directly from GROUP BY query (no N+1 problem) + total_me = to_float(row[28]) # total_me from SUM aggregation + total_mn = to_float(row[29]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[30]) + sum_value_mxn = to_float(row[31]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=to_float(row[20]), # C51 - TipoCambio + fecha_pago=row[8], # C13 - Fecha_Pago + fecha_inicio=row[6], # C11 - Fecha_Inicio + tipo_pedimento=row[4], # C5 - ClavePed (Fix: using C5 instead of empty C59) + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoImpoDef + row[17], # C42 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, factura + ) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoImpoDef + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C4 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef='IMPDF', + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C43 - EDocument + NumOperacionVU=row[19], # C44 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C53 - UsuarioCap + UsuarioAcr=row[23], # C54 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[8]), # C13 - Fecha_Pago + NumCaja=row[24], # C56 - Transporte + NumTrasporte + Pedimento18=row[25], # C57 - empty (index 25) + AduanaCru=row[15], # C39 - Aduana_Cruce + Lote=row[26] # C58 - empty (index 26) + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching definitive import movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: "ImportDefinitiveFilter" + ) -> List["MovementItemDetailed"]: + """ + Get definitive import movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + from ..schemas import MovementItemDetailed + + try: + logger.info(f"Fetching detailed definitive import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute main query + sql = text(DefinitiveImportQueries.build_main_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed definitive import partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus + continue + + # Get additional detailed information + # Provider and client names now come directly from query (row[7], row[8]) + # But we still need RFC and TaxID from the helper + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[15], is_supplier=True + ) + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[16], is_supplier=False + ) + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[17] + ) + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[38] + ) + + # Calculate values using unified method + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[39], # C40 - EsSubPartida + valor_me=row[26], # C27 - ValorImpoME + valor_mn_direct=row[24], # C25 - ValorImpoMN + fecha_pago=row[12], # C13 - Fecha_Pago + fecha_inicio=row[10], # C1 entry_date + clave_ped=row[57] if len(row) > 57 else '', # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[49], # C50 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + if row[39] == 'P': # C40 - EsSubPartida + peso_neto = float(row[28]) if row[28] else 0.0 # C29 + peso_bruto = float(row[29]) if row[29] else 0.0 # C30 + else: + peso_neto = 0.0 + peso_bruto = 0.0 + + series_info = DatabaseHelper.get_series_info( + db, filters.database_name, row[38], row[43], filters.is_shelter # C39, C44 + ) + + simbolo_ex = None + if row[48]: # C49 - Part Number + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[48], filters.is_shelter + ) + + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, row[1], row[40], filters.is_shelter # C2, C41 + ) + + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, row[0] # C1 + ) + + movement = MovementItemDetailed( + Linea=row[43], # C44 + Factura=row[0], # C1 + Pedimento=row[1], # C2 + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef='IMPDF', + EsCambioRegimen='N', + Regimen=row[9], # C10 + Fecha_Inicio=parse_yyyymmdd_date(row[10]), # C11 + Fecha_Fin=parse_yyyymmdd_date(row[11]), # C12 + Fecha_Pago=parse_yyyymmdd_date(row[12]), # C13 + Remesa=str(row[13]) if row[13] is not None else None, # C14 + Proveedor=row[7], # C8 - Provider name (from JOIN) + RFCProveedor=proveedor_info.get('rfc'), + ProveedorTaxID=proveedor_info.get('tax_id'), + VendidoA=row[8], # C9 - Client name (from JOIN) + VendidoARFC=vendido_info.get('rfc'), + VendidoATaxID=vendido_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[48], # C49 + DescripcionE=StringHelper.clean_text(row[20]), # C21 + DescripcionI=StringHelper.clean_text(row[21]), # C22 + CantidadIE=float(row[22]) if row[22] else 0.0, # C23 + UniMed=row[23], # C24 + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[30], # C31 + FraccionArancelaria=row[31], # C32 + Preferencia=row[32], # C33 + Sector=row[34], # C35 + PaisOrigen=row[36], # C37 + Aduana=aduana_nombre, + Advalorem='P' if row[39] == 'P' else 'S', # C40 + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[41], # C42 + NumOperacionVU=row[42], # C43 + Series=series_info, + Marca=StringHelper.clean_text(row[44]), # C45 + Modelo=StringHelper.clean_text(row[45]), # C46 + FraccionAmericana=row[46], # C47 + ECCN=row[47], # C48 + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[50]) if row[50] else None, # C51 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[51], # C52 + UsuarioAcr=row[52], # C53 + Transportista=row[53], # C54 + NumCaja=row[54], # C55 + Pedimento18=row[55] if len(row) > 55 else '', # C56 + AduanaCru=row[37], # C38 + Lote=row[56] if len(row) > 56 else '' # C57 + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed definitive import movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: "ImportDefinitiveFilter") -> str: + """Build WHERE clause for definitive imports query.""" + where_conditions = [] + + # STRICT SEPARATION: Only imports + where_conditions.append("ih.operation_type = 'imp'") + + # GOLDEN RULE: If movement_type is ALL, only filter by operation_type + # ALWAYS filter by specific invoice_type to avoid duplication with Temporary service + where_conditions.append("ih.invoice_type IN ('DEF', 'MATDE', 'EXDEF')") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Note: Status filter applied at Python level after CASE WHEN in SELECT + # because is_updated doesn't directly represent AC/NA status + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(where_conditions) + + def _calculate_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """Calculate totals for main partidas only. + + Only sums partidas where is_subpartida is false (equivalent to EsSubpartida = 'P' in Clarion). + """ + sql = text(""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + AND COALESCE(il.is_subpartida, false) = false + """) + + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + total_me = float(result[0]) if result and result[0] is not None else 0.0 + total_mn = float(result[1]) if result and result[1] is not None else 0.0 + + return total_me, total_mn diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py new file mode 100644 index 00000000..5c9f2010 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py @@ -0,0 +1,193 @@ +""" +Exchange rate calculation logic for invoice movements. +""" + +import logging +from sqlalchemy.orm import Session +from typing import Tuple, Optional +from .base import DateHelper +from .database_helpers import DatabaseHelper + +logger = logging.getLogger(__name__) + + +class ExchangeRateCalculator: + """Handles exchange rate calculations and commercial value conversions.""" + + @staticmethod + def calculate_exchange_rate_and_value( + db: Session, + db_name: str, + es_subpartida: str, + valor_me: Optional[float], + valor_mn_direct: Optional[float], + fecha_pago, + fecha_inicio, + clave_ped: str, + tipo_cambio_partida: Optional[float], + currency_type: str, + exchange_rate_type: str, + met_trans: int + ) -> Tuple[float, Optional[float]]: + """ + Unified method to calculate exchange rate and commercial value. + Eliminates duplicated logic across all import types. + + Args: + db: Database session + db_name: Database name + es_subpartida: Subpartida flag ('P' for partida, 'S' for subpartida) + valor_me: Value in foreign currency (ME) + valor_mn_direct: Direct value in local currency (MN) + fecha_pago: Payment date + fecha_inicio: Start/entry date + clave_ped: Pedimento type code + tipo_cambio_partida: Exchange rate from partida record + currency_type: "ME" or "MN" + exchange_rate_type: "FP" (payment date) or "FT" (transaction date) + met_trans: MetTrans configuration value + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio_final) + """ + # Handle subpartidas - always return zero + if es_subpartida == 'S': + return (0.0, None) + + # Handle foreign currency (ME) case + if currency_type == "ME": + valor_comercial = valor_me or 0.0 + tipo_cambio_final = tipo_cambio_partida + + # Try to get exchange rate from GTipoCambio if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=clave_ped, + use_transport_method=True, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc) + if tc_value: + tipo_cambio_final = tc_value + + # For ME, the value is always in foreign currency (USD) + return (valor_comercial, tipo_cambio_final) + + # Handle local currency (MN) case + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=clave_ped, + use_transport_method=True, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc) + + if tc_value and valor_me is not None: + # Calculate MN value from ME * payment date exchange rate + return (valor_me * tc_value, tc_value) + else: + if tc_value is None: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using partida values") + return (valor_mn_direct or 0.0, tipo_cambio_partida) + else: + # exchange_rate_type == "FT" (Invoice Date) + # Use direct MN value and partida exchange rate + return (valor_mn_direct or 0.0, tipo_cambio_partida) + + @staticmethod + def calculate_for_aggregated( + db: Session, + db_name: str, + valor_me: float, + valor_mn: float, + tipo_cambio_db: float, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + currency_type: str, + exchange_rate_type: str, + is_shelter: bool, + use_transport_method: bool, + met_trans: int + ) -> Tuple[float, Optional[float]]: + """ + Calculate exchange rate and value for aggregated (normal mode) movements. + + This method is used when movements are grouped by invoice rather than + showing individual partidas. + + Args: + db: Database session + db_name: Database name + valor_me: Aggregated value in foreign currency + valor_mn: Aggregated value in local currency + tipo_cambio_db: Exchange rate from database + fecha_pago: Payment date + fecha_inicio: Start date + tipo_pedimento: Pedimento type + currency_type: "ME" or "MN" + exchange_rate_type: "FP" or "FT" + is_shelter: Shelter company flag (kept for compatibility) + use_transport_method: Use transport method flag + met_trans: MetTrans value from config + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio) + """ + # Foreign currency case + if currency_type == "ME": + valor_comercial = valor_me + tipo_cambio = tipo_cambio_db + + # Try to get exchange rate if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + use_transport_method=use_transport_method, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate( + db, db_name, fecha_tc, + is_shelter=is_shelter, + raise_on_missing=is_shelter + ) + + if tc_value: + # For ME, the value is always in foreign currency (USD). Just return the new exchange rate. + return (valor_comercial, tc_value) + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + + return (valor_comercial, tipo_cambio) + + # Local currency case + else: + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = DateHelper.get_fecha_tipo_cambio( + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + use_transport_method=use_transport_method, + met_trans=met_trans + ) + tc_value = DatabaseHelper.get_exchange_rate( + db, db_name, fecha_tc, + is_shelter=is_shelter, + raise_on_missing=is_shelter + ) + + if tc_value and valor_me is not None: + # Calculate MN value from ME * payment date exchange rate + return (valor_me * tc_value, tc_value) + else: + if tc_value is None: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + return (valor_mn, tipo_cambio_db) + else: + return (valor_mn, tipo_cambio_db) diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py new file mode 100644 index 00000000..de65c9db --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -0,0 +1,408 @@ +""" +Export service - handles export movements (EXPO DEF). +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional + +from ..schemas import ExportFilter, MovementItem, MovementItemDetailed +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import ExportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class ExportService: + """Service for handling export movements (EXPO DEF).""" + + def get_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """ + Get export movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + try: + logger.info(f"Fetching export movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + # Note: discharge_clause not used in aggregated query for exports + sql = text(ExportQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} export invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaExpo + tipo_mov = row[14] # C34 - TipoFactura + + # Skip cancelled if not included + if not filters.include_cancelled and row[3] != 'AC': # C6 - Estatus + continue + + consecutivo = row[15] # C35 - Consecutivo + + # Totals come directly from GROUP BY query (no N+1 problem) + def to_float(val): + if val is None or val == '': return 0.0 + try: return float(val) + except (ValueError, TypeError): return 0.0 + + total_me = to_float(row[23]) # total_me from SUM aggregation + total_mn = to_float(row[24]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[26]) + sum_value_mxn = to_float(row[27]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=row[18], # C48 - TipoCambio + fecha_pago=row[7], # C11 - Fecha_Pago + fecha_inicio=row[6], # C10 - Fecha_Inicio (mapped previously to C9) + tipo_pedimento='', # Not in aggregated query + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=filters.use_transport_method, + met_trans=met_trans + ) + + # Get pedimento rectification + rectified_pedimento = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[25], # C54 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + driver_badge = self._get_driver_badge(db, filters.database_name, factura) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C6 - Estatus + ClavePed=row[4], # C7 - ClavePed + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=to_float(row[28]), + TipoExpo='EXPO DEF', + PedimentoR1=rectified_pedimento, + EDocument=row[16], # C40 - EDocument + NumOperacionVU=row[17], # C41 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=driver_badge, + UsuarioCap=row[20], # C50 - UsuarioCap + UsuarioAcr=row[21], # C51 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[7]), # C11 - Fecha_Pago + NumCaja=row[22], # C53 - Transporte + NumTrasporte + Pedimento18='', # Not in aggregated query + AduanaCru=row[13], # C33 - Aduana_Cruce + Lote='' # Not in aggregated query + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} export movements") + return movements + + except Exception as e: + logger.error(f"Error fetching export movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """ + Get export movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + try: + logger.info(f"Fetching detailed export movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Build discharge filter for main query + discharge_clause = "" + if filters.discharge_filter == "SiDes": + discharge_clause = " AND EqiPex.Descarga = 1" + elif filters.discharge_filter == "NoDes": + discharge_clause = " AND EqiPex.Descarga = 0" + + # Modify main query to include discharge filter + where_with_discharge = where_clause + discharge_clause + + # Execute main query + sql = text(ExportQueries.build_main_query(filters.database_name, where_with_discharge)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed export partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus + continue + + # Get client/supplier information + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[13], is_supplier=True # C14 - Proveedor + ) + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[14], is_supplier=False # C15 - VendidoA + ) + + # Get customs agent information + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[15] # C16 - AAduanal + ) + + # Get customs section name + customs_name = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[32] # C33 - Aduana_Cruce + ) + + # Calculate exchange rate and value for this partida + valor_mn, tipo_cambio_final = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[37], # C38 - EsSubPartida + valor_me=row[36], # C37 - ValorExpoME + valor_mn_direct=row[35], # C36 - ValorExpoMN + fecha_pago=row[10], # C11 - Fecha_Pago + fecha_inicio=row[8], # C9 - Fecha_Inicio + clave_ped=row[55], # C56 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[47], # C48 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + # Set peso values (material_type is typically 'PT' or 'MP', not just 'P') + peso_neto_final = row[24] if row[37] != 'S' else 0 # C25 - PesoNeto + peso_bruto_final = row[25] if row[37] != 'S' else 0 # C26 - PesoBruto + + # Get series information + series_info = DatabaseHelper.get_series_info_export( + db, filters.database_name, row[34], row[41], filters.is_shelter + ) + + # Get pedimento rectification + rectified_pedimento = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[38], # C39 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + driver_badge = self._get_driver_badge(db, filters.database_name, row[0]) # C1 - FacturaExpo + + # Build detailed movement item + movement = MovementItemDetailed( + Linea=row[41], # C42 - LineaExpo + Factura=row[0], # C1 - FacturaExpo + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=row[2], # C3 - FechaFactura + Estatus=row[5], # C6 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef=row[31], # C34 - TipoFactura + EsCambioRegimen='N', + Regimen=row[5], # C6 - Regime (Shared index with Estatus in this query) + Fecha_Inicio=parse_yyyymmdd_date(row[6]), # C7 - Fecha_Inicio + Fecha_Fin=parse_yyyymmdd_date(row[7]), # C8 - Fecha_Fin + Fecha_Pago=parse_yyyymmdd_date(row[8]), # C9 - Fecha_Pago + Remesa=row[9], # C12 - Remesa + TipoCambio=tipo_cambio_final, + Proveedor=proveedor_info.get("name"), + RFCProveedor=proveedor_info.get("rfc"), + ProveedorTaxID=proveedor_info.get("tax_id"), + VendidoA=vendido_info.get("name"), + VendidoARFC=vendido_info.get("rfc"), + VendidoATaxID=vendido_info.get("tax_id"), + AgenteAduanal=agente_info.get("name"), + Patente=agente_info.get("license"), + NumParte=row[17], # C18 - NumParte + DescripcionE=StringHelper.remove_commas(row[18]), # C19 - DescripcionE + DescripcionI=StringHelper.remove_commas(row[19]), # C20 - DescripcionI + CantidadIE=row[20], # C21 - CantExpo + UniMed=row[21], # C22 - UnidadMedida + ValorComercialMN=valor_mn, + PesoNeto=peso_neto_final, + PesoBruto=peso_bruto_final, + OrdenCompraVenta=row[26], # C27 - OrdenCompra + FraccionArancelaria=row[27], # C28 - FraccionExpo + Preferencia=row[28], # C29 - TipoFraccion + Sector=row[30], # C31 - Sector + PaisOrigen=row[31], # C32 - PaisOrigen + Aduana=customs_name, + Advalorem=row[29], # C30 - Advalorem + TipoExpo='EXPO DEF', + PedimentoR1=rectified_pedimento, + EDocument=row[39], # C40 - EDocument + NumOperacionVU=row[40], # C41 - NumOperacionVU + Series=series_info, + Marca=StringHelper.clean_text(row[42]), # C43 - Marca + Modelo=StringHelper.clean_text(row[43]), # C44 - Modelo + FraccionAmericana=row[44], # C45 - FraccionAme + ECCN=row[45], # C46 - ECCN + FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaEmision + BaseDeDatos=filters.database_name, + NumGafUni=driver_badge, + UsuarioCap=row[49], # C50 - UsuarioCap + UsuarioAcr=row[50], # C51 - UsuarioAct + Transportista=row[51], # C52 - Carrier ID (derived from log.transport_id) + NumCaja=row[52], # C53 - log.transport_id || log.transport_num + Pedimento18=row[53], # C54 - empty + AduanaCru=row[32], # C33 - Aduana_Cruce + Lote=row[54] if len(row) > 54 else '' # C55 - Lote + ) + + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed export movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed export movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: ExportFilter) -> str: + """ + Build WHERE clause for export query. + + IMPORTANT: Returns conditions WITHOUT the WHERE keyword (already in base query) + AC (Active) = is_updated = true + NA (Not Applicable/Deactivated) = is_updated = false + """ + conditions = [] + + # STRICT SEPARATION: Only exports + conditions.append("ih.operation_type = 'exp'") + + # Exclude REP (export reports) + conditions.append("ih.invoice_type NOT IN ('REP')") + + # GOLDEN RULE: If movement_type is ALL, only filter by operation_type + if filters.movement_type.value != "ALL": + # Filter by invoice type for exports + conditions.append("ih.invoice_type IN ('EXP', 'EXREP')") + + # CRITICAL VALIDATION: AC/NA status filter + # If include_cancelled is False (checkbox unchecked), only show AC invoices + # AC (Active) = is_updated = true + # NA (Not Applicable/Deactivated) = is_updated = false + if not filters.include_cancelled: + conditions.append("ih.is_updated = true") + logger.debug("Filtering only active invoices (is_updated = true)") + else: + logger.debug("Including cancelled invoices (include_cancelled = true)") + + # Date range + date_field = "ih.invoice_date" if filters.range_type.value == "FF" else "pd.payment_date" + conditions.append(f"{date_field} >= TO_DATE('{filters.start_date}', 'YYYYMMDD')") + conditions.append(f"{date_field} <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Optional filters + if filters.provider: + conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + if filters.buyer: + conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + if filters.pedimento_code: + conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(conditions) + + def _build_discharge_clause(self, discharge_filter: str) -> str: + """Build discharge filter clause for totals query.""" + if discharge_filter == "SiDes": + return " AND il.is_discharged = true" + elif discharge_filter == "NoDes": + return " AND il.is_discharged = false" + return "" + + def _calculate_totals( + self, + db: Session, + db_name: str, + consecutivo: int, + discharge_clause: str + ) -> tuple: + """Calculate total values for an export invoice.""" + try: + sql = text(ExportQueries.build_totals_query(db_name, discharge_clause)) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating export totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str: + """Get driver's unique badge number for an export invoice.""" + if not factura: + return None + + try: + sql = text(ExportQueries.build_driver_badge_query(db_name)) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for export invoice {factura}: {e}") + return None diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py new file mode 100644 index 00000000..63704ddd --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py @@ -0,0 +1,409 @@ +""" +Export repair service - handles EXPO REP movements (repair exports). +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional + +from ..schemas import ExportRepairFilter, MovementItem, MovementItemDetailed +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import ExportRepairQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class ExportRepairService: + """Service for handling export repair movements (EXPO REP).""" + + def get_movements( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItem]: + """ + Get export repair movements (normal mode - grouped by invoice). + + Aggregated query column order (ExportRepairQueries.build_aggregated_query): + [0] C1 - invoice_number + [1] C2 - pedimento_number + [2] C3 - invoice_date + [3] C6 - estatus (AC/NA) + [4] C7 - pedimento_code + [5] C8 - regime + [6] C11 - payment_date + [7] C12 - remesa + [8] C13 - exchange_rate (fecha_pago context) + [9] C14 - provider_id + [10] C15 - sold_to_id + [11] C16 - customs_broker_id + [12] C27 - purchase_order + [13] C33 - customs_office ← AduanaCru + [14] C34 - document_type ← TipoFactura / tipo_mov + [15] C35 - id ← consecutivo + [16] C40 - edocument ← EDocument + [17] C41 - vucem_op_num ← NumOperacionVU + [18] C48 - exchange_rate ← TipoCambio + [19] C49 - emission_date + [20] C50 - capture_user ← UsuarioCap + [21] C51 - who_updated ← UsuarioAcr + [22] C52 - carrier_id ← Transportista + [23] C53 - transport ← NumCaja + [24] total_me + [25] total_mn + [26] C54 - ped_r1 ← PedimentoR1 + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + try: + logger.info(f"Fetching export repair movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + sql = text(ExportRepairQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} export repair invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaExpo + tipo_mov = row[14] # C34 - TipoFactura + estatus = row[3] # C6 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + if not filters.include_cancelled and estatus != 'AC': + continue + + consecutivo = row[15] # C35 - Consecutivo + + # Totals come directly from GROUP BY query (no N+1 problem) + def to_float(val): + if val is None or val == '': return 0.0 + try: return float(val) + except (ValueError, TypeError): return 0.0 + + total_me = to_float(row[24]) # total_me + total_mn = to_float(row[25]) # total_mn + sum_value_usd = to_float(row[27]) + sum_value_mxn = to_float(row[28]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=row[18], # C48 - TipoCambio + fecha_pago=row[6], # C11 - Fecha_Pago + fecha_inicio='', + tipo_pedimento='', + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + + # Get pedimento rectification (already resolved by SQL COALESCE) + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[26], # C54 - PedRectifica (pre-built by SQL) + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C6 - Estatus + ClavePed=row[4], # C7 - ClavePed + TipoMovTemDef=tipo_mov, # C34 - TipoFactura + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=to_float(row[29]), + TipoExpo='EXPO REP', + PedimentoR1=pedimento_r1, + EDocument=row[16], # C40 - EDocument ← FIXED (was 17) + NumOperacionVU=row[17], # C41 - NumOperacionVU ← FIXED (was 18) + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[20], # C50 - UsuarioCap ← FIXED (was 21) + UsuarioAcr=row[21], # C51 - UsuarioAct ← FIXED (was 22) + Fecha_Pago=parse_yyyymmdd_date(row[6]), # C11 - Fecha_Pago ← FIXED (was 8) + NumCaja=row[23], # C53 - NumCaja + Pedimento18='', + AduanaCru=row[13], # C33 - customs_office ← FIXED (was 14) + Lote='' + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} export repair movements") + return movements + + except Exception as e: + logger.error(f"Error fetching export repair movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: ExportRepairFilter + ) -> List[MovementItemDetailed]: + """ + Get export repair movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + try: + logger.info(f"Fetching detailed export repair movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Add discharge filter to WHERE clause + discharge_clause = "" + if filters.discharge_filter.value == "SiDes": + discharge_clause = " AND RepPex.Descarga = 1" + elif filters.discharge_filter.value == "NoDes": + discharge_clause = " AND RepPex.Descarga = 0" + + where_with_discharge = where_clause + discharge_clause + + # Execute main query + sql = text(ExportRepairQueries.build_main_query(filters.database_name, where_with_discharge)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed export repair partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus + continue + + # Get provider information + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[13], is_supplier=True # C14 - Proveedor + ) + + # Get buyer information + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[14], is_supplier=False # C15 - VendidoA + ) + + # Get customs agent information + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[15] # C16 - AAduanal + ) + + # Get customs section name + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[32] # C33 - Aduana_Cruce + ) + + # Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S') + if row[37] != 'S': # C38 - EsSubPartida + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_partida( + db=db, + db_name=filters.database_name, + valor_me=row[36], # C37 - ValorExpoME + valor_mn=row[35], # C36 - ValorExpoMN + tipo_cambio_db=row[47], # C48 - TipoCambio + fecha_pago=row[10], # C11 - Fecha_Pago + fecha_inicio=row[8], # C9 - Fecha_Inicio + tipo_pedimento=row[55], # C56 - TIPOPEDIMENTOTRANSPORTEE + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + peso_neto = float(row[24]) if row[24] else 0.0 # C25 + peso_bruto = float(row[25]) if row[25] else 0.0 # C26 + else: # Subpartida + valor_comercial = 0.0 + tipo_cambio = 0.0 + peso_neto = 0.0 + peso_bruto = 0.0 + + # Get series information + series_info = DatabaseHelper.get_series_info_export( + db, filters.database_name, row[34], row[41], filters.is_shelter # C35, C42 + ) + + # Get part export symbol + simbolo_ex = None + if row[46]: # C47 - NumParte + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[46], filters.is_shelter + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoExpo + row[38], # C39 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, row[0]) + + # Build detailed movement item + movement = MovementItemDetailed( + Linea=row[41], # C42 - LineaExpo + Factura=row[0], # C1 - FacturaExpo + Pedimento=row[1], # C2 - PedimentoExpo + FechaFactura=row[2], # C3 - FechaFactura + Estatus=row[5], # C6 - Estatus + ClavePed=row[6], # C7 - ClavePed + TipoMovTemDef=row[33], # C34 - TipoFactura + EsCambioRegimen='N', + Regimen=row[7], # C8 - Regimen + Fecha_Inicio=parse_yyyymmdd_date(row[8]), # C9 - Fecha_Inicio + Fecha_Fin=parse_yyyymmdd_date(row[9]), # C10 - Fecha_Fin + Fecha_Pago=parse_yyyymmdd_date(row[10]), # C11 - Fecha_Pago + Remesa=row[11], # C12 - Remesa + Proveedor=proveedor_info.get('name'), + RFCProveedor=proveedor_info.get('rfc'), + ProveedorTaxID=proveedor_info.get('tax_id'), + VendidoA=vendido_info.get('name'), + VendidoARFC=vendido_info.get('rfc'), + VendidoATaxID=vendido_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[46], # C47 - NumParte + DescripcionE=StringHelper.clean_text(row[18]), # C19 + DescripcionI=StringHelper.clean_text(row[19]), # C20 + CantidadIE=float(row[20]) if row[20] else 0.0, # C21 + UniMed=row[21], # C22 + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[26], # C27 - OrdenCompra + FraccionArancelaria=row[27], # C28 - FraccionExpo + Preferencia=row[28], # C29 - TipoFraccion + Sector=row[30], # C31 - Sector + PaisOrigen=row[31], # C32 - PaisOrigen + Aduana=aduana_nombre, + Advalorem=row[37], # C38 - EsSubPartida + TipoExpo='EXPO REP', + PedimentoR1=pedimento_r1, + EDocument=row[39], # C40 - EDocument + NumOperacionVU=row[40], # C41 - NumOperacionVU + Series=series_info, + Marca=StringHelper.clean_text(row[42]), # C43 + Modelo=StringHelper.clean_text(row[43]), # C44 + FraccionAmericana=row[44], # C45 - FraccionAme + ECCN=row[45], # C46 - ECCN + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaFactura + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[49], # C50 - UsuarioCap + UsuarioAcr=row[50], # C51 - UsuarioAct + Transportista=row[51], # C52 - Transportista + NumCaja=row[52], # C53 - Transporte + NumTrasporte + Pedimento18=row[53], # C54 - Pedimento18 + AduanaCru=row[32], # C33 - Aduana_Cruce + Lote=row[54] if len(row) > 54 else '' # C55 - Lote + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed export repair movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed export repair movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: ExportRepairFilter) -> str: + """Build WHERE clause for export repair query.""" + where_conditions = [] + + # STRICT SEPARATION: Only exports for repair + where_conditions.append("ih.operation_type = 'exp'") + # GOLDEN RULE: If movement_type is ALL, only filter by operation_type + if filters.movement_type.value == "ALL": + pass + else: + where_conditions.append("ih.invoice_type = 'REPAR'") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "AFIJO": + where_conditions.append("ih.document_type = 'AFIJO'") + elif filters.movement_type.value == "NODES": + where_conditions.append("ih.document_type = 'NODES'") + + return " AND ".join(where_conditions) + + return total_me, total_mn + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str: + """Get driver badge number for invoice.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return None \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py new file mode 100644 index 00000000..7b2d7fdb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py @@ -0,0 +1,1071 @@ +""" +SQL Query builders for invoice movement services. +Centralizes all SQL query construction logic. +""" + + +class TemporaryImportQueries: + """SQL queries for temporary imports using PostgreSQL tables.""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: db_name parameter kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_num, log.license_plate), ''), '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE(lf_agg.sum_value_mxn, 0) AS valor_comercial_mn, + COALESCE(lf_agg.sum_value_temp_mxn, 0) AS valor_mp_temp_mn, + COALESCE(lf_agg.sum_value_added_mxn, 0) AS valor_agre_mn, + COALESCE(lf_agg.sum_value_temp_usd, 0) AS valor_mp_temp_usd, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(COALESCE(lf.value_mxn, 0)) AS sum_value_mxn, + SUM(COALESCE(lf.value_temp_material_mxn, 0)) AS sum_value_temp_mxn, + SUM(COALESCE(lf.value_added_mxn, 0)) AS sum_value_added_mxn, + SUM(COALESCE(lf.value_temp_material_usd, 0)) AS sum_value_temp_usd, + SUM(COALESCE(lf.value_usd, 0)) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + ORDER BY ih.invoice_number + """ + + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL.""" + # Note: db_name parameter is kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(prov.name, '') AS C8, + COALESCE(client.name, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(il.unit_of_measure::text, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(il.order, ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(il.material_type, 'P') AS C40, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C41, -- [40] rectification_id + cmp.edocument AS C42, -- [41] + cmp.vucem_operation_num AS C43, -- [42] + COALESCE(il.line_number, 0) AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, fac.eccn_code, '') AS C48, + COALESCE(prt.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_num, log.license_plate), ''), '') AS C55, + '' AS C56, + COALESCE(ld.lot, '') AS C57, + '' AS C58, + COALESCE(lf.value_temp_material_mxn, 0) AS C59, + COALESCE(lf.value_temp_material_usd, 0) AS C60 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for an invoice.""" + return f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information.""" + return f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number.""" + return f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """ + + +class DefinitiveImportQueries: + """SQL queries for definitive imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(cmp.aduana, '') AS C39, + ih.id AS C35, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C42, + COALESCE(cmp.edocument, '') AS C43, + COALESCE(cmp.vucem_operation_num, '') AS C44, + COALESCE(fin.exchange_rate, 0) AS C51, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52, + COALESCE(ih.capture_user, '') AS C53, + COALESCE(ih.who_updated, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C56, + '' AS C57, + '' AS C58, + '' AS C59, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE') + AND {where_clause} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL for definitive imports.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(prov.name, '') AS C8, + COALESCE(client.name, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(um.code, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(il.order, ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(il.material_type, 'P') AS C40, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C41, + cmp.edocument AS C42, + cmp.vucem_operation_num AS C43, + COALESCE(il.line_number, 0) AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, fac.eccn_code, '') AS C48, + COALESCE(prt.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C55, + '' AS C56, + COALESCE(ld.lot, '') AS C57, + '' AS C58, + 0 AS C59, + 0 AS C60 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for a definitive import invoice.""" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE il.invoice_id = :consecutivo + + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for definitive imports.""" + # TODO: QSeriesDef table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for definitive imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class RepairImportQueries: + """SQL queries for repair imports (PostgreSQL schema). + + Note: Repair imports are NOT identified by invoice_type, but by having + cross-references (search_invoice field) that link them to export invoices. + These are regular import invoices (TEM, DEF, etc.) that were imported for repair. + """ + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + + return f""" + SELECT + ih.invoice_number AS C2, + COALESCE(ped.pedimento_number, '') AS C3, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5, + COALESCE(ped.pedimento_code, '') AS C6, + COALESCE(ped.regime, '') AS C7, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C9, + COALESCE(cmp.remesa::text, '') AS C10, + COALESCE(fin.exchange_rate, 0) AS C11, + COALESCE(cmp.provider_id::text, '') AS C12, + COALESCE(cmp.sold_to_id::text, '') AS C13, + COALESCE(cmp.customs_broker_id::text, '') AS C14, + COALESCE(ih.purchase_order, '') AS C24, + COALESCE(ped.customs_office, '') AS C29, + ih.id AS C30, + COALESCE(cmp.edocument, '') AS C33, + COALESCE(cmp.vucem_operation_num, '') AS C34, + COALESCE(fin.exchange_rate, 0) AS C40, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41, + COALESCE(ih.capture_user, '') AS C42, + COALESCE(ih.who_updated, '') AS C43, + COALESCE(log.carrier_id, '') AS C44, + COALESCE(log.transport_num, '') AS C45, + COALESCE(ped.pedimento_code, '') AS C47, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C48, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE 1=1 {discharge_filter} + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'imp' + AND COALESCE(cmp.is_regime_change, false) = false + AND EXISTS ( + SELECT 1 FROM a76.item_lines il2 + INNER JOIN a24.fa_item_lines fil2 ON fil2.id = il2.id + WHERE il2.invoice_id = ih.id AND fil2.search_invoice IS NOT NULL + {discharge_filter} + ) + {"AND " + where_str if where_str else ""} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build main SQL query for repair import data.""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + il.line_number, + ih.invoice_number, + COALESCE(ped.pedimento_number, ''), + TO_CHAR(ih.invoice_date, 'YYYYMMDD'), + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END, + COALESCE(ped.pedimento_code, ''), + COALESCE(ped.regime, ''), + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), ''), + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), ''), + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), ''), + COALESCE(cmp.remesa::text, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(cmp.provider_id::text, ''), + COALESCE(cmp.sold_to_id::text, ''), + COALESCE(cmp.customs_broker_id::text, ''), + COALESCE(prt.part_number::text, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lq.quantity, 0), + COALESCE(il.unit_of_measure, 0), + COALESCE(lf.value_mxn, 0), + COALESCE(lf.value_usd, 0), + COALESCE(lq.net_weight, 0), + COALESCE(lq.gross_weight, 0), + COALESCE(il.order, ih.purchase_order, ''), + COALESCE(lc.fraction, ''), + '', + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), ''), + COALESCE(lc.origin_country, ''), + COALESCE(ped.customs_office, ''), + ih.id, + COALESCE(il.material_type, 'P'), + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ), + COALESCE(cmp.edocument, ''), + COALESCE(cmp.vucem_operation_num, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lc.american_fraction, ''), + COALESCE(prt.eccn, fac.eccn_code, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''), + COALESCE(ih.capture_user, ''), + COALESCE(ih.who_updated, ''), + COALESCE(log.carrier_id, ''), + COALESCE(log.transport_num, ''), + '', + COALESCE(ped.pedimento_code, '') + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND COALESCE(cmp.is_regime_change, false) = false + AND fil.search_invoice IS NOT NULL + {"AND " + where_str if where_str else ""} + {discharge_filter} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for a repair import invoice.""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE il.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for repair imports.""" + # TODO: QSeriesImpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for repair imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportQueries: + """SQL queries for exports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """ + Build optimized query for NORMAL mode (grouped by invoice with totals). + + Args: + db_name: Database name (not used in PostgreSQL version) + where_clause: Additional WHERE conditions (without WHERE keyword) + """ + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(log.payment_receipt_num, '') AS C12, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(cmp.aduana, '') AS C33, + COALESCE(ih.invoice_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C54, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE {where_clause} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + '' AS C4, -- [3] + ped.pedimento_code AS C5, -- [4] + ped.regime AS C6, -- [5] + pd.entry_date AS C7, -- [6] + COALESCE(pd.end_date, pd.payment_date) AS C8, -- [7] + pd.payment_date AS C9, -- [8] + log.payment_receipt_num AS C12, -- [11] + '' AS C13, -- [12] + cmp.provider_id AS C14, -- [13] + cmp.sold_to_id AS C15, -- [14] + cmp.customs_broker_id AS C16, -- [15] + '' AS C17, -- [16] + COALESCE(prt.part_number::text, '') AS C18, -- [17] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, -- [18] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, -- [19] + lq.quantity AS C21, -- [20] + um.code AS C22, -- [21] + '' AS C23, -- [22] + '' AS C24, -- [23] + COALESCE(lq.net_weight, 0) AS C25, -- [24] + COALESCE(lq.gross_weight, 0) AS C26, -- [25] + COALESCE(il.order, ih.purchase_order, '') AS C27, -- [26] + COALESCE(lc.fraction, '') AS C28, -- [27] + COALESCE(lc.fraction_type, '') AS C29, -- [28] + COALESCE(lc.advalorem_numeric, 0) AS C30, -- [29] + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C31, -- [30] + COALESCE(lc.origin_country, '') AS C32, -- [31] + cmp.aduana AS C33, -- [32] + ih.invoice_type AS C34, -- [33] + ih.id AS C35, -- [34] + lf.value_mxn AS C36, -- [35] + lf.value_usd AS C37, -- [36] + il.material_type AS C38, -- [37] + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C39, -- [38] rectification_id + cmp.edocument AS C40, -- [39] + cmp.vucem_operation_num AS C41, -- [40] + il.line_number AS C42, -- [41] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, -- [42] + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, -- [43] + COALESCE(cls.us_fraction, '') AS C45, -- [44] + COALESCE(prt.eccn, fac.eccn_code, '') AS C46, -- [45] + prt.id AS C47, -- [46] + fin.exchange_rate AS C48, -- [47] + ih.emission_date AS C49, -- [48] + ih.capture_user AS C50, -- [49] + ih.who_updated AS C51, -- [50] + '' AS C52, -- [51] + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, -- [52] NumCaja + '' AS C54, -- [53] Pedimento18 + COALESCE(ld.lot, '') AS C55, -- [54] Lote + '' AS C56, -- [55] TipoPedimentoTransporte + '' AS C57, -- [56] + '' AS C58, -- [57] + '' AS C59, -- [58] + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export invoice. + + Only sums partidas where is_subitem is false (main partidas, not sub-items). + """ + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE il.invoice_id = :consecutivo + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for exports.""" + # TODO: QSeriesExpo table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for exports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportRepairQueries: + """SQL queries for export repairs (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 0) AS total_mn, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C54, + COALESCE(lf_agg.sum_value_usd, 0) AS sum_value_usd, + COALESCE(lf_agg.sum_value_mxn, 0) AS sum_value_mxn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN ( + SELECT il.invoice_id, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_mxn, 0) END) AS sum_value_mxn, + SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE COALESCE(lf.value_usd, 0) END) AS sum_value_usd + FROM a76.item_lines il + JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + WHERE 1=1 {discharge_filter} + GROUP BY il.invoice_id + ) lf_agg ON lf_agg.invoice_id = ih.id + WHERE ih.operation_type = 'exp' + AND ih.invoice_type = 'REPAR' + {"AND " + where_str if where_str else ""} + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for export repair data.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + COALESCE(fin.value_me, 0) AS C4, + COALESCE(fin.value_mn, 0) AS C5, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(pd.entry_date, 'YYYYMMDD'), '') AS C9, + COALESCE(TO_CHAR(pd.end_date, 'YYYYMMDD'), TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(pd.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + '' AS C17, + COALESCE(cls.class_code, '') AS C18, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, + COALESCE(lq.quantity, 0) AS C21, + COALESCE(il.unit_of_measure, 0) AS C22, + COALESCE(lf.customs_value_mxn, 0) AS C23, + COALESCE(lf.customs_value_usd, 0) AS C24, + COALESCE(lq.net_weight, 0) AS C25, + COALESCE(lq.gross_weight, 0) AS C26, + COALESCE(il.order, ih.purchase_order, '') AS C27, + COALESCE(lc.fraction, '') AS C28, + COALESCE(lc.fraction_type, '') AS C29, + COALESCE(lc.advalorem_numeric, 0) AS C30, + COALESCE(NULLIF(lc.sector, ''), NULLIF(fap.sector, ''), '') AS C31, + COALESCE(lc.origin_country, '') AS C32, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(lf.value_mxn, 0) AS C36, + COALESCE(lf.value_usd, 0) AS C37, + COALESCE(il.material_type, 'P') AS C38, + COALESCE( + ped_r1.year || '-' || ped_r1.customs_office || '-' || ped_r1.license || '-' || ped_r1.pedimento_number, + pro_origin.original_pedimento_year || '-' || pro_origin.original_customs_office || '-' || pro_origin.original_license || '-' || pro_origin.original_pedimento_number, + '' + ) AS C39, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(il.line_number, 0) AS C42, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, + COALESCE(cls.us_fraction, '') AS C45, + COALESCE(prt.eccn, fac.eccn_code, '') AS C46, + COALESCE(prt.part_number::text, '') AS C47, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, + '' AS C54, + COALESCE(ld.lot, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + '' AS C59 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimento_rectification_origin pro_origin ON pro_origin.pedimento_id = ped.id + LEFT JOIN a76.pedimento_rectification_origin pro_rect ON + pro_rect.original_pedimento_year = ped.year AND + pro_rect.original_customs_office = ped.customs_office AND + pro_rect.original_license = ped.license AND + pro_rect.original_pedimento_number = ped.pedimento_number + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = pro_rect.pedimento_id + LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id + LEFT JOIN a76.item_lines il ON il.invoice_id = ih.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a24.fa_classes fac ON fac.class_id = cls.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number_id + LEFT JOIN a24.fa_partes fap ON fap.id = prt.id + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET') + AND UPPER(ih.invoice_type) IN ('DEF', 'REPAR', 'EXDEF', 'MATDE') + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export repair invoice.""" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for export repairs.""" + # TODO: QSeriesExpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for export repairs.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak new file mode 100644 index 00000000..f74e0da8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak @@ -0,0 +1,877 @@ +""" +SQL Query builders for invoice movement services. +Centralizes all SQL query construction logic. +""" + + +class TemporaryImportQueries: + """SQL queries for temporary imports using PostgreSQL tables.""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: db_name parameter kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(ped_r1.pedimento_number, '') AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1 + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, cmp.remesa, fin.exchange_rate, + cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, cmp.aduana, ped_r1.pedimento_number, + cmp.edocument, cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.carrier_id, log.transport_num, log.license_plate + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL.""" + # Note: db_name parameter is kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(cmp.provider_id::text, '') AS C8, + COALESCE(cmp.sold_to_id::text, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(il.unit_of_measure::text, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(lc.sector, '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + FALSE AS C40, + '' AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(il.line_number, 0) AS C44, + '' AS C45, + '' AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, '') AS C48, + COALESCE(il.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.item_lines il ON il.item_id = ( + SELECT id FROM a76.items WHERE invoice_id = ih.id LIMIT 1 + ) + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for an invoice.""" + return f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information.""" + return f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number.""" + return f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """ + + +class DefinitiveImportQueries: + """SQL queries for definitive imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(cmp.aduana, '') AS C39, + ih.id AS C35, + COALESCE(ped_r1.pedimento_number, '') AS C42, + COALESCE(cmp.edocument, '') AS C43, + COALESCE(cmp.vucem_operation_num, '') AS C44, + COALESCE(fin.exchange_rate, 0) AS C51, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52, + COALESCE(ih.capture_user, '') AS C53, + COALESCE(ih.who_updated, '') AS C54, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, + '' AS C57, + '' AS C58, + '' AS C59, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1 + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE') + AND {where_clause} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num, + fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, + ih.purchase_order, cmp.aduana, ped_r1.pedimento_number, cmp.edocument, + cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.transport_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + ped.status AS C4, -- [3] + ped.pedimento_code AS C5, -- [4] + '' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8] + ped.regime AS C10, -- [9] + log.entry_exit_date AS C11, -- [10] + log.delivery_date AS C12, -- [11] + log.payment_date AS C13, -- [12] + log.payment_receipt_num AS C14, -- [13] + '' AS C15, -- [14] + cmp.provider_id AS C16, -- [15] + cmp.sold_to_id AS C17, -- [16] + cmp.customs_broker_id AS C18, -- [17] + '' AS C19, -- [18] + prt.part_number AS C20, -- [19] + ld.description_spanish AS C21, -- [20] + ld.description_english AS C22, -- [21] + lq.quantity AS C23, -- [22] + um.code AS C24, -- [23] + lf.value_mxn AS C25, -- [24] + '' AS C26, -- [25] + lf.value_usd AS C27, -- [26] + '' AS C28, -- [27] + lq.net_weight AS C29, -- [28] + lq.gross_weight AS C30, -- [29] + ih.purchase_order AS C31, -- [30] + lc.fraction AS C32, -- [31] + '' AS C33, '' AS C34, -- [32-33] + ih.id AS C35, -- [34] + '' AS C36, '' AS C37, -- [35-36] + lc.origin_country AS C38, -- [37] + cmp.aduana AS C39, -- [38] + il.material_type AS C40, -- [39] + il.id AS C41, -- [40] + '' AS C42, -- [41] rectification_id + cmp.edocument AS C43, -- [42] + cmp.vucem_operation_num AS C44, -- [43] + il.line_number AS C45, -- [44] + ld.brand AS C46, -- [45] + ld.model AS C47, -- [46] + prt.us_fraction AS C48, -- [47] + prt.eccn AS C49, -- [48] + prt.id AS C50, -- [49] + fin.exchange_rate AS C51, -- [50] + ih.emission_date AS C52, -- [51] + ih.capture_user AS C53, -- [52] + ih.who_updated AS C54, -- [53] + '' AS C55, -- [54] + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, -- [55] + '' AS C57, -- [56] Pedimento18 (row[56]) + COALESCE(ld.lot, '') AS C58, -- [57] Lote (row[57]) + '' AS C59, -- [58] TipoPed (row[58]) + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for a definitive import invoice.""" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for definitive imports.""" + # TODO: QSeriesDef table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for definitive imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class RepairImportQueries: + """SQL queries for repair imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + ih.invoice_number AS C2, + COALESCE(ped.pedimento_number, '') AS C3, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5, + COALESCE(ped.pedimento_code, '') AS C6, + COALESCE(ped.regime, '') AS C7, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C9, + COALESCE(cmp.remesa::text, '') AS C10, + COALESCE(fin.exchange_rate, 0) AS C11, + COALESCE(cmp.provider_id::text, '') AS C12, + COALESCE(cmp.sold_to_id::text, '') AS C13, + COALESCE(cmp.customs_broker_id::text, '') AS C14, + COALESCE(ih.purchase_order, '') AS C24, + COALESCE(ped.customs_office, '') AS C29, + ih.id AS C30, + COALESCE(cmp.edocument, '') AS C33, + COALESCE(cmp.vucem_operation_num, '') AS C34, + COALESCE(fin.exchange_rate, 0) AS C40, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41, + COALESCE(ih.capture_user, '') AS C42, + COALESCE(ih.who_updated, '') AS C43, + COALESCE(log.carrier_id, '') AS C44, + COALESCE(log.transport_num, '') AS C45, + COALESCE(ped.pedimento_code, '') AS C47, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'REP' + AND COALESCE(cmp.is_regime_change, false) = false + {"AND " + where_str if where_str else ""} + {discharge_filter} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, + cmp.customs_broker_id, ih.purchase_order, ped.customs_office, cmp.edocument, + cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.carrier_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build main SQL query for repair import data.""" + # Note: is_discharged field not yet migrated to PostgreSQL schema + # discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + il.line_number, + ih.invoice_number, + COALESCE(ped.pedimento_number, ''), + TO_CHAR(ih.invoice_date, 'YYYYMMDD'), + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END, + COALESCE(ped.pedimento_code, ''), + COALESCE(ped.regime, ''), + '', + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), ''), + COALESCE(cmp.remesa::text, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(cmp.provider_id::text, ''), + COALESCE(cmp.sold_to_id::text, ''), + COALESCE(cmp.customs_broker_id::text, ''), + COALESCE(il.part_number::text, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lq.quantity, 0), + COALESCE(il.unit_of_measure, 0), + COALESCE(lf.value_mxn, 0), + COALESCE(lf.value_usd, 0), + COALESCE(lq.net_weight, 0), + COALESCE(lq.gross_weight, 0), + COALESCE(ih.purchase_order, ''), + COALESCE(lc.fraction, ''), + '', + COALESCE(lc.sector, ''), + COALESCE(lc.origin_country, ''), + COALESCE(ped.customs_office, ''), + ih.id, + 'P', + '', + COALESCE(cmp.edocument, ''), + COALESCE(cmp.vucem_operation_num, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lc.american_fraction, ''), + COALESCE(prt.eccn, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''), + COALESCE(ih.capture_user, ''), + COALESCE(ih.who_updated, ''), + COALESCE(log.carrier_id, ''), + COALESCE(log.transport_num, ''), + '', + COALESCE(ped.pedimento_code, '') + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'REP' + AND COALESCE(cmp.is_regime_change, false) = false + {"AND " + where_str if where_str else ""} + {discharge_filter} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for a repair import invoice.""" + # Note: is_discharged field not yet migrated to PostgreSQL schema + # discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for repair imports.""" + # TODO: QSeriesImpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for repair imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportQueries: + """SQL queries for exports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """ + Build optimized query for NORMAL mode (grouped by invoice with totals). + + Args: + db_name: Database name (not used in PostgreSQL version) + where_clause: Additional WHERE conditions (without WHERE keyword) + """ + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C9, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(log.payment_receipt_num, '') AS C12, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(cmp.aduana, '') AS C33, + COALESCE(ih.invoice_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE {where_clause} + GROUP BY ih.id, ih.invoice_number, ih.is_updated, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num, + cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, ih.purchase_order, cmp.aduana, + ih.invoice_type, cmp.edocument, cmp.vucem_operation_num, fin.exchange_rate, + ih.emission_date, ih.capture_user, ih.who_updated, log.transport_id, log.transport_num, + ih.invoice_date + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + '' AS C4, -- [3] + '' AS C5, -- [4] + ped.status AS C6, -- [5] + ped.pedimento_code AS C7, -- [6] + ped.regime AS C8, -- [7] + log.entry_exit_date AS C9, -- [8] + log.delivery_date AS C10, -- [9] + log.payment_date AS C11, -- [10] + log.payment_receipt_num AS C12, -- [11] + '' AS C13, -- [12] + cmp.provider_id AS C14, -- [13] + cmp.sold_to_id AS C15, -- [14] + cmp.customs_broker_id AS C16, -- [15] + '' AS C17, -- [16] + prt.part_number AS C18, -- [17] + ld.description_spanish AS C19, -- [18] + ld.description_english AS C20, -- [19] + lq.quantity AS C21, -- [20] + um.code AS C22, -- [21] + '' AS C23, -- [22] + '' AS C24, -- [23] + lq.net_weight AS C25, -- [24] + lq.gross_weight AS C26, -- [25] + ih.purchase_order AS C27, -- [26] + lc.fraction AS C28, -- [27] + '' AS C29, -- [28] + '' AS C30, -- [29] + '' AS C31, -- [30] + '' AS C32, -- [31] + cmp.aduana AS C33, -- [32] + ih.invoice_type AS C34, -- [33] + ih.id AS C35, -- [34] + lf.value_mxn AS C36, -- [35] + lf.value_usd AS C37, -- [36] + il.material_type AS C38, -- [37] + '' AS C39, -- [38] rectification_id + cmp.edocument AS C40, -- [39] + cmp.vucem_operation_num AS C41, -- [40] + il.line_number AS C42, -- [41] + ld.brand AS C43, -- [42] + ld.model AS C44, -- [43] + prt.us_fraction AS C45, -- [44] + prt.eccn AS C46, -- [45] + prt.id AS C47, -- [46] + fin.exchange_rate AS C48, -- [47] + ih.emission_date AS C49, -- [48] + ih.capture_user AS C50, -- [49] + ih.who_updated AS C51, -- [50] + '' AS C52, -- [51] + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, -- [52] NumCaja + '' AS C54, -- [53] Pedimento18 + COALESCE(ld.lot, '') AS C55, -- [54] Lote + '' AS C56, -- [55] TipoPedimentoTransporte + '' AS C57, -- [56] + '' AS C58, -- [57] + '' AS C59, -- [58] + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export invoice. + + Only sums partidas where is_subitem is false (main partidas, not sub-items). + """ + discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + return f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for exports.""" + # TODO: QSeriesExpo table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for exports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportRepairQueries: + """SQL queries for export repairs (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: discharge_clause temporarily disabled until is_discharged field migrated + discharge_filter = "" # Will be: " AND il.is_discharged = true/false" when ready + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'exp' + AND ih.invoice_type = 'REP' + {"AND " + where_str if where_str else ""} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, + cmp.customs_broker_id, ih.purchase_order, ped.customs_office, ih.document_type, + cmp.edocument, cmp.vucem_operation_num, ih.invoice_date, ih.capture_user, + ih.who_updated, log.carrier_id, log.transport_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for export repair data.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + COALESCE(fin.value_me, 0) AS C4, + COALESCE(fin.value_mn, 0) AS C5, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + '' AS C9, + '' AS C10, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + '' AS C17, + COALESCE(cls.class_code, '') AS C18, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, + COALESCE(lq.quantity, 0) AS C21, + COALESCE(il.unit_of_measure, 0) AS C22, + COALESCE(lf.customs_value_mxn, 0) AS C23, + COALESCE(lf.customs_value_usd, 0) AS C24, + COALESCE(lq.net_weight, 0) AS C25, + COALESCE(lq.gross_weight, 0) AS C26, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(lc.fraction, '') AS C28, + COALESCE(lc.fraction_type, '') AS C29, + COALESCE(lc.advalorem_numeric, 0) AS C30, + COALESCE(lc.sector, '') AS C31, + COALESCE(lc.origin_country, '') AS C32, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(lf.value_mxn, 0) AS C36, + COALESCE(lf.value_usd, 0) AS C37, + 'P' AS C38, + '' AS C39, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(il.line_number, 0) AS C42, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, + COALESCE(cls.us_fraction, '') AS C45, + COALESCE(prt.eccn, '') AS C46, + COALESCE(il.part_number::text, '') AS C47, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + '' AS C54, + COALESCE(ld.lot, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + '' AS C59 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET') + AND UPPER(ih.invoice_type) IN ('DEF', 'REP', 'EXDEF', 'MATDE') + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export repair invoice.""" + discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + return f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for export repairs.""" + # TODO: QSeriesExpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for export repairs.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py new file mode 100644 index 00000000..bf89f124 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -0,0 +1,400 @@ +""" +Repair import service - handles IMPRE movements. +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..schemas import ImportRepairFilter, MovementItem, MovementItemDetailed + +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import RepairImportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class RepairImportService: + """Service for handling repair import movements (IMPRE).""" + + def get_movements( + self, + db: Session, + filters: "ImportRepairFilter" + ) -> List["MovementItem"]: + """ + Get repair import movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + from ..schemas import MovementItem + + try: + logger.info(f"Fetching repair import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode + sql = text(RepairImportQueries.build_aggregated_query( + filters.database_name, + where_clause, + filters.discharge_filter.value + )) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} repair import invoices") + + movements = [] + + for row in results: + factura = row[0] # C2 - FacturaImpoRep + estatus = row[3] # C5 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + # Si include_cancelled=False, solo mostrar AC (is_updated=true) + # Si include_cancelled=True, mostrar todas (AC y NA) + if not filters.include_cancelled and estatus != 'AC': + continue + + consecutivo = row[14] # C30 - Consecutivo + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + # Totals come directly from GROUP BY query (no N+1 problem) + total_me = to_float(row[24]) # total_me from SUM aggregation + total_mn = to_float(row[25]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[27]) + sum_value_mxn = to_float(row[28]) + + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=to_float(row[17]), # C40 - TipoCambio + fecha_pago=row[6], # C9 - Fecha_Pago + fecha_inicio='', # Not available in aggregated query + tipo_pedimento=row[23], # C47 - pedimento_code (used as tipo_pedimento) + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C3 - PedimentoImpoRep + row[26], # C48 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, factura + ) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C3 - PedimentoImpoRep + FechaFactura=parse_yyyymmdd_date(row[2]), # C4 - FechaFactura + Estatus=row[3], # C5 - Estatus + ClavePed=row[4], # C6 - ClavePed + TipoMovTemDef='IMPRE', + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[15], # C33 - EDocument + NumOperacionVU=row[16], # C34 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[19], # C42 - UsuarioCap + UsuarioAcr=row[20], # C43 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[6]), # C9 - Fecha_Pago + NumCaja=row[22], # C45 - Transport num + Pedimento18='', # Not in aggregated query + AduanaCru=row[13], # C29 - customs_office + Lote='' # Not in aggregated query + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching repair import movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: "ImportRepairFilter" + ) -> List["MovementItemDetailed"]: + """ + Get repair import movements (detailed mode - line by line). + + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + from ..schemas import MovementItemDetailed + + try: + logger.info(f"Fetching detailed repair import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause with discharge filter + where_clause = self._build_where_clause(filters) + + # Add discharge filter to WHERE clause + discharge_clause = "" + if filters.discharge_filter.value == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif filters.discharge_filter.value == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + where_with_discharge = where_clause + discharge_clause + + # Execute main query + sql = text(RepairImportQueries.build_main_query(filters.database_name, where_with_discharge)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed repair import partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[4] != 'AC': # C5 - Estatus + continue + + # Get all detailed information + proveedor_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[11], is_supplier=True + ) + vendido_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[12], is_supplier=False + ) + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[13] + ) + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[28] + ) + + # Calculate values using unified method + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[30], # 'P' or 'S' + valor_me=row[21], + valor_mn_direct=row[20], + fecha_pago=row[8], + fecha_inicio=row[7], + clave_ped=row[45], + tipo_cambio_partida=row[38], + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + if row[31] == 'P': + peso_neto = float(row[22]) if row[22] else 0.0 + peso_bruto = float(row[23]) if row[23] else 0.0 + else: + peso_neto = 0.0 + peso_bruto = 0.0 + + series_info = DatabaseHelper.get_series_info( + db, filters.database_name, row[29], row[0], filters.is_shelter + ) + + simbolo_ex = None + if row[15]: + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[15], filters.is_shelter + ) + + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, row[2], row[32], filters.is_shelter + ) + + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, row[1] + ) + + movement = MovementItemDetailed( + Linea=row[0], + Factura=row[1], + Pedimento=row[2], + FechaFactura=row[3], + Estatus=row[4], + ClavePed=row[5], + TipoMovTemDef='IMPRE', + EsCambioRegimen='N', + Regimen=row[6], + Fecha_Inicio=parse_yyyymmdd_date(row[7]), + Fecha_Fin=parse_yyyymmdd_date(row[8]), + Fecha_Pago=parse_yyyymmdd_date(row[9]), + Remesa=row[10], + Proveedor=proveedor_info.get('name'), + RFCProveedor=proveedor_info.get('rfc'), + ProveedorTaxID=proveedor_info.get('tax_id'), + VendidoA=vendido_info.get('name'), + VendidoARFC=vendido_info.get('rfc'), + VendidoATaxID=vendido_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[15], + DescripcionE=StringHelper.clean_text(row[16]), + DescripcionI=StringHelper.clean_text(row[17]), + CantidadIE=float(row[18]) if row[18] else 0.0, + UniMed=row[19], + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[24], + FraccionArancelaria=row[25], + Preferencia=row[26], + Sector=row[27], + PaisOrigen=row[28], + Aduana=aduana_nombre, + Advalorem='', + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[33], + NumOperacionVU=row[34], + Series=series_info, + Marca=StringHelper.clean_text(row[35]), + Modelo=StringHelper.clean_text(row[36]), + FraccionAmericana=row[37], + ECCN=row[38], + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[40]) if row[40] else None, # C41 - FechaEmision + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[41], + UsuarioAcr=row[42], + Transportista=row[43], + NumCaja=row[44], + Pedimento18=row[45], + AduanaCru=row[29], + Lote=row[54] if len(row) > 54 else '' # Not in query but mapped safely + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed repair import movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: "ImportRepairFilter") -> str: + """Build WHERE clause for repair imports query.""" + where_conditions = [] + + # STRICT SEPARATION: Only imports for repair + where_conditions.append("ih.operation_type = 'imp'") + # Repair imports are identified by cross-references, not invoice_type + where_conditions.append("COALESCE(cmp.is_regime_change, false) = false") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Note: Status filter applied at Python level after CASE WHEN in SELECT + # because is_updated doesn't directly represent AC/NA status + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(where_conditions) + + def _calculate_totals(self, db: Session, db_name: str, consecutivo: int, discharge_filter: str) -> tuple: + """Calculate totals for main partidas with discharge filter.""" + # Build discharge clause + # Note: is_discharged field not yet migrated to PostgreSQL schema + discharge_clause = "" + # Temporarily disabled until schema migration: + # if discharge_filter == "SiDes": + # discharge_clause = " AND il.is_discharged = true" + # elif discharge_filter == "NoDes": + # discharge_clause = " AND il.is_discharged = false" + + sql = text(f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + AND il.is_subitem = false + {discharge_clause} + """) + + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + total_me = float(result[0]) if result and result[0] is not None else 0.0 + total_mn = float(result[1]) if result and result[1] is not None else 0.0 + + return total_me, total_mn diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py new file mode 100644 index 00000000..1e3faa70 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -0,0 +1,459 @@ +""" +Temporary import service - handles IMTEM movements. +""" + +import logging +from datetime import datetime +from sqlalchemy import text +from sqlalchemy.orm import Session +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ..schemas import ImportTemporaryFilter, MovementItem, MovementItemDetailed + +from .base import ConfigHelper, StringHelper +from .database_helpers import DatabaseHelper +from .exchange_rate import ExchangeRateCalculator +from .query_builders import TemporaryImportQueries + +logger = logging.getLogger(__name__) + + +def parse_yyyymmdd_date(date_str: str) -> Optional[datetime]: + """Parse date string in YYYYMMDD format to datetime.""" + if not date_str or date_str == '': + return None + try: + return datetime.strptime(date_str, '%Y%m%d') + except (ValueError, TypeError): + return None + + +class TemporaryImportService: + """Service for handling temporary import movements (IMTEM).""" + + def get_movements( + self, + db: Session, + filters: "ImportTemporaryFilter" + ) -> List["MovementItem"]: + """ + Get temporary import movements (normal mode - grouped by invoice). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of movement items grouped by invoice + """ + from ..schemas import MovementItem + + try: + logger.info(f"Fetching temporary import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute optimized aggregated query for NORMAL mode (GROUP BY with totals) + sql = text(TemporaryImportQueries.build_aggregated_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} temporary import invoices") + + movements = [] + + for row in results: + factura = row[0] # C1 - FacturaImpo + estatus = row[3] # C4 - Estatus (AC o NA) + + # Filtrar facturas según include_cancelled + # Si include_cancelled=False, solo mostrar AC (is_updated=true) + # Si include_cancelled=True, mostrar todas (AC y NA) + if not filters.include_cancelled and estatus != 'AC': + continue + + consecutivo = row[15] # C39 - Consecutivo + + # Helper to convert empty strings to None + def none_if_empty(val): + return None if val == '' else val + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + + # Totals come directly from GROUP BY query (no N+1 problem) + # Correct indices based on TemporaryImportQueries.build_aggregated_query + total_me = to_float(row[28]) # total_me (index 28) + total_mn = to_float(row[29]) # total_mn (index 29) + valor_comercial_mn = to_float(row[30]) # valor_comercial_mn from item_line_financials + valor_mp_temp_mn = to_float(row[31]) # valor_mp_temp_mn from item_line_financials + valor_agre_mn = to_float(row[32]) # valor_agre_mn from item_line_financials + valor_mp_temp_usd = to_float(row[33]) # valor_mp_temp_usd from item_line_financials + sum_value_usd = to_float(row[34]) # sum_value_usd from item_line_financials (line item sum avoids zero-value header bug) + + # Replace zero values with the computed sums + total_me = sum_value_usd if sum_value_usd > 0 else total_me + total_mn = valor_comercial_mn if valor_comercial_mn > 0 else total_mn + + # Calculate exchange rate and value + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=total_me, + valor_mn=total_mn, + tipo_cambio_db=to_float(row[19]), # C50 - TipoCambio + fecha_pago=row[8], # C13 - Fecha_Pago + fecha_inicio=row[6], # C11 - Fecha_Inicio + tipo_pedimento=row[4], # C5 - ClavePed (Using correct index) + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, # Not used for temporary imports + met_trans=met_trans + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoImpo + row[16], # C41 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, factura + ) + + # Calculate exchange rate for MPTemp explicitly decoupled from Valor Comercial + valor_mp_temp_raw, _ = ExchangeRateCalculator.calculate_for_aggregated( + db=db, + db_name=filters.database_name, + valor_me=to_float(row[33]), # sum_value_temp_usd + valor_mn=to_float(row[31]), # sum_value_temp_mxn + tipo_cambio_db=to_float(row[19]), + fecha_pago=row[8], + fecha_inicio=row[6], + tipo_pedimento=row[4], + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=False, + met_trans=met_trans + ) + valor_mp_temp = float(valor_mp_temp_raw) + + # Build movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 - PedimentoImpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura + Estatus=row[3], # C4 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef='IMTEM', + EsCambioRegimen='N', + ValorMPTemp=valor_mp_temp, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=valor_agre_mn, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[17], # C42 - EDocument + NumOperacionVU=row[18], # C43 - NumOperacionVU + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[21], # C52 - UsuarioCap + UsuarioAcr=row[22], # C53 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(none_if_empty(row[8])), # C13 - Fecha_Pago + NumCaja=row[24], # C55 - transport_num || license_plate (index 24) + Pedimento18=row[25], # C56 - '' empty (index 25) + AduanaCru=row[14], # C38 - Aduana_Cruce (index 14) + Lote=row[26] # C57 - '' empty (index 26) + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} temporary import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching temporary import movements: {e}", exc_info=True) + raise + + def get_movements_detailed( + self, + db: Session, + filters: "ImportTemporaryFilter" + ) -> List["MovementItemDetailed"]: + """ + Get temporary import movements (detailed mode - line by line). + + Args: + db: Database session + filters: Filter criteria + + Returns: + List of detailed movement items (one per partida) + """ + from ..schemas import MovementItemDetailed + + try: + logger.info(f"Fetching detailed temporary import movements with filters: {filters.model_dump()}") + + # Get MetTrans configuration + met_trans = ConfigHelper.get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause(filters) + + # Execute main query + sql = text(TemporaryImportQueries.build_main_query(filters.database_name, where_clause)) + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed temporary import partidas") + + movements = [] + + for row in results: + # Skip cancelled if not included + if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus + continue + + # Provider and client names now come directly from query (C8, C9) + # No need for additional database lookups + logger.info(f"Processing invoice {row[0]}: Proveedor='{row[7]}', VendidoA='{row[8]}', CantidadIE={row[22]}, DescripcionE='{row[20][:50] if row[20] else None}'") + + # Get customs agent information + agente_info = DatabaseHelper.get_customs_agent_info( + db, filters.database_name, row[17] # C18 - AAduanal + ) + + # Get provider and client details including RFC and TaxID + provider_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[15], is_supplier=True + ) if row[15] else {} + + client_info = DatabaseHelper.get_client_info( + db, filters.database_name, row[16], is_supplier=False + ) if row[16] else {} + + # Get customs section name + aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( + db, filters.database_name, row[37] # C38 - Aduana_Cruce + ) + + # Calculate values (only for main partidas 'P', not subpartidas 'S') + valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[39], # C40 - EsSubPartida + valor_me=row[26], # C27 - ValorImpoME + valor_mn_direct=row[24], # C25 - ValorImpoMN + fecha_pago=row[12], # C13 - Fecha_Pago + fecha_inicio=row[10], # C11 - Fecha_Inicio + clave_ped=row[57], # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[49], # C50 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + + valor_mp_temp_raw, _ = ExchangeRateCalculator.calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + es_subpartida=row[39], # C40 - EsSubPartida + valor_me=float(row[59]) if row[59] else 0.0, + valor_mn_direct=float(row[58]) if row[58] else 0.0, + fecha_pago=row[12], # C13 - Fecha_Pago + fecha_inicio=row[10], # C11 - Fecha_Inicio + clave_ped=row[57], # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_cambio_partida=row[49], # C50 - TipoCambio + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + met_trans=met_trans + ) + valor_mp_temp_mn = float(valor_mp_temp_raw) + + # Assign the properly converted commercial value directly + valor_comercial_mn = float(valor_comercial) + + # Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S') + if row[39] != 'S': # C40 - EsSubPartida + peso_neto = float(row[28]) if row[28] else 0.0 # C29 + peso_bruto = float(row[29]) if row[29] else 0.0 # C30 + else: + peso_neto = 0.0 + peso_bruto = 0.0 + + # Get series information + series_info = DatabaseHelper.get_series_info( + db, filters.database_name, row[38], row[43], filters.is_shelter # C39, C44 + ) + + # Get part export symbol + simbolo_ex = None + if row[48]: # C49 - NumParte + simbolo_ex = DatabaseHelper.get_part_export_symbol( + db, filters.database_name, row[48], filters.is_shelter + ) + + # Get pedimento rectification + pedimento_r1 = DatabaseHelper.get_rectification_pedimento( + db, + row[1], # C2 - PedimentoImpo + row[40], # C41 - PedRectifica + filters.is_shelter + ) + + # Get driver badge + num_gaf_uni = DatabaseHelper.get_driver_badge( + db, filters.database_name, row[0] # C1 - FacturaImpo + ) + + # Helper to convert empty strings to None for dates + def none_if_empty(val): + if val == '' or val is None: + return None + return val + + # Helper to convert to string (for Remesa, Advalorem) + def to_str(val): + if val is None or val == '': + return None + if isinstance(val, bool): + return 'P' if val else 'S' # Convert bool to P/S for Advalorem + return str(val) + + logger.error(f"DEBUG ROW: NumParte(17)='{row[17]}', Sector(30)='{row[30]}', Partida {row[43]}, Original Part ID(46)='{row[46]}'") + + # Build detailed movement item + movement = MovementItemDetailed( + Linea=row[43], # C44 - LineaImpo + Factura=row[0], # C1 - FacturaImpo + Pedimento=row[1], # C2 - PedimentoImpo + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura (convert to datetime) + Estatus=row[3], # C4 - Estatus + ClavePed=row[4], # C5 - ClavePed + TipoMovTemDef='IMTEM', + EsCambioRegimen='N', + Regimen=row[9], # C10 - Regimen + Fecha_Inicio=parse_yyyymmdd_date(row[10]), # C11 - Fecha_Inicio + Fecha_Fin=parse_yyyymmdd_date(row[11]), # C12 - Fecha_Fin + Fecha_Pago=parse_yyyymmdd_date(row[12]), # C13 - Fecha_Pago + Remesa=to_str(row[13]), # C14 - Remesa + Proveedor=row[7], # C8 - Provider name (from JOIN) + RFCProveedor=provider_info.get('rfc'), + ProveedorTaxID=provider_info.get('tax_id'), + VendidoA=row[8], # C9 - Client name (from JOIN) + VendidoARFC=client_info.get('rfc'), + VendidoATaxID=client_info.get('tax_id'), + AgenteAduanal=agente_info.get('name'), + Patente=agente_info.get('license'), + NumParte=row[48], # C49 - Part Number (from JOIN) + DescripcionE=StringHelper.clean_text(row[20]), # C21 + DescripcionI=StringHelper.clean_text(row[21]), # C22 + CantidadIE=float(row[22]) if row[22] else 0.0, # C23 + UniMed=row[23], # C24 + ValorComercialMN=valor_comercial_mn, + ValorMPTemp=valor_mp_temp_mn, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[30], # C31 - OrdenCompra + FraccionArancelaria=row[31], # C32 - Fraccion + Preferencia=row[32], # C33 - TipoFraccion + Sector=row[34], # C35 - Sector (from COALESCE) + PaisOrigen=row[36], # C37 - PaisOrigen + Aduana=aduana_nombre, + Advalorem=to_str(row[39]), # C40 - EsSubPartida (convert bool to str) + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[41], # C42 - EDocument + NumOperacionVU=row[42], # C43 - NumOperacionVU + Series=series_info, + Marca=StringHelper.clean_text(row[44]), # C45 + Modelo=StringHelper.clean_text(row[45]), # C46 + FraccionAmericana=row[46], # C47 - FraccionAme + ECCN=row[47], # C48 - ECCN + SimboloEx=simbolo_ex, + FechaEmision=parse_yyyymmdd_date(row[50]), # C51 - FechaEmision + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[51], # C52 - UsuarioCap + UsuarioAcr=row[52], # C53 - UsuarioAct + Transportista=row[53], # C54 - Carrier ID + NumCaja=row[54], # C55 - Transport num + Pedimento18=row[55], # C56 - Pedimento18 + AduanaCru=row[37], # C38 - Aduana_Cruce + Lote=row[56] # C57 - LOTE + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed temporary import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed temporary import movements: {e}", exc_info=True) + raise + + def _build_where_clause(self, filters: "ImportTemporaryFilter") -> str: + """Build WHERE clause for temporary imports query using PostgreSQL tables.""" + where_conditions = [] + + # STRICT SEPARATION: Only temporary imports + where_conditions.append("ih.operation_type = 'imp'") + # ALWAYS filter by specific invoice_type to avoid duplication with Definitive service + where_conditions.append("ih.invoice_type IN ('TEM', 'MATTEM')") + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"ih.invoice_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND ih.invoice_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + else: + where_conditions.append(f"pd.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND pd.payment_date <= TO_DATE('{filters.end_date}', 'YYYYMMDD')") + + # Note: Status filter applied at Python level after CASE WHEN in SELECT + # because is_updated doesn't directly represent AC/NA status + + # Provider filter + if filters.provider: + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") + + return " AND ".join(where_conditions) if where_conditions else "1=1" + + def _calculate_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """Calculate totals for main partidas only using PostgreSQL. + + Only sums partidas where is_subpartida is false (equivalent to EsSubpartida = 'P' in Clarion). + """ + sql = text(""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + JOIN a76.item_lines il ON il.id = lf.item_line_id + WHERE il.invoice_id = :consecutivo + AND COALESCE(il.is_subpartida, false) = false + """) + + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + total_me = float(result[0]) if result and result[0] is not None else 0.0 + total_mn = float(result[1]) if result and result[1] is not None else 0.0 + + return total_me, total_mn diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py b/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py new file mode 100644 index 00000000..66c888df --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py @@ -0,0 +1,2115 @@ +import logging +from sqlalchemy import text +from sqlalchemy.orm import Session +import configparser +import os +from typing import List, Optional +from datetime import datetime +from decimal import Decimal + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + MovementItem, + MovementItemDetailed, + RangeType +) + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Service for handling movement operations, particularly temporary import movements. + Integrates with legacy SQL Server databases for data extraction. + """ + + def _get_met_trans_config(self) -> int: + """ + Read MetTrans configuration from Scaii.ini file. + + Returns: + MetTrans value (0 or 1) + """ + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + return met_trans + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + return 0 + + def _build_where_clause_temporary(self, filters: ImportTemporaryFilter) -> tuple: + """ + Build WHERE clause and parameters for temporary imports query. + + Returns: + Tuple of (where_string, params_dict) + """ + where_clauses = [] + params = {} + + # Date range filter + if filters.range_type.value == 'FF': + where_clauses.append("EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date") + else: + where_clauses.append("EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date") + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + # Status filter + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + # Buyer filter + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + # Pedimento code filter + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + return " AND ".join(where_clauses), params + + def _build_where_clause_definitive(self, filters: ImportDefinitiveFilter) -> str: + """ + Build WHERE clause for definitive imports query. + + Returns: + WHERE clause string + """ + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + + return " AND ".join(where_conditions) + + def _calculate_exchange_rate_and_value( + self, + db: Session, + db_name: str, + valor_me: float, + valor_mn: float, + tipo_cambio_db: float, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + currency_type: str, + exchange_rate_type: str, + is_shelter: bool, + use_transport_method: bool, + met_trans: int + ) -> tuple: + """ + Unified method to calculate exchange rate and commercial value. + Eliminates duplicated logic across temporary and definitive imports. + + Args: + db: Database session + db_name: Database name + valor_me: Value in foreign currency + valor_mn: Value in local currency + tipo_cambio_db: Exchange rate from database + fecha_pago: Payment date + fecha_inicio: Start date + tipo_pedimento: Pedimento type + currency_type: "ME" or "MN" + exchange_rate_type: "FP" or "FF" + is_shelter: Shelter company flag + use_transport_method: Use transport method flag + met_trans: MetTrans value from config + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio) + """ + # Foreign currency case - simpler + if currency_type == "ME": + valor_comercial = valor_me + tipo_cambio = tipo_cambio_db + + # Try to get exchange rate if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = self._get_fecha_tipo_cambio( + fecha_pago, fecha_inicio, tipo_pedimento, use_transport_method, met_trans + ) + tc_value = self._obtener_tipo_cambio(db, db_name, fecha_tc, is_shelter) + if tc_value: + tipo_cambio = tc_value + + return valor_comercial, tipo_cambio + + # Local currency case - more complex + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = self._get_fecha_tipo_cambio( + fecha_pago, fecha_inicio, tipo_pedimento, use_transport_method, met_trans + ) + tc_value = self._obtener_tipo_cambio(db, db_name, fecha_tc, is_shelter) + + if tc_value: + return valor_me * tc_value, tc_value + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + return valor_mn, tipo_cambio_db + else: + return valor_mn, tipo_cambio_db + + def _get_fecha_tipo_cambio( + self, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + use_transport_method: bool, + met_trans: int + ): + """ + Determine which date to use for exchange rate lookup. + + Returns: + Date to use for exchange rate + """ + fecha = fecha_pago + if use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha = fecha_inicio + return fecha + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """ + Retrieve temporary import movements from legacy database. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching temporary import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause dynamically based on filters + where_clauses = [] + params = {} + + # Date range filter + if filters.range_type == 'FF': + where_clauses.append( + "EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date" + ) + else: + where_clauses.append( + "EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date" + ) + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + # Status filter + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + # Buyer filter + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + # Pedimento code filter + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + where_str = " AND ".join(where_clauses) + db_name = filters.database_name + + logger.debug(f"WHERE clause: {where_str}") + logger.debug(f"Query params: {params}") + + # Main Query (using shared query builder) + sql_query = text(self._build_main_query(db_name, where_str)) + + try: + result = db.execute(sql_query, params) + rows = result.fetchall() + logger.info(f"Query returned {len(rows)} rows") + except Exception as e: + logger.error(f"Error executing main query: {e}") + raise Exception(f"Database query failed: {str(e)}") + + movements = [] + processed_facturas = set() + + for row in rows: + if filters.report_type == 'Normal': + C1_Factura = row[0] + + if C1_Factura in processed_facturas: + continue + + qcsv_ie = {} + processed_facturas.add(C1_Factura) + + C39_Consecutivo = row[38] + + # Totalize Items (sum values for main partidas only) + sql_total = text(f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """) + + try: + res_total = db.execute(sql_total, {"consecutivo": C39_Consecutivo}).fetchone() + val_total_me = float(res_total[0]) if res_total and res_total[0] is not None else 0.0 + val_total_mn = float(res_total[1]) if res_total and res_total[1] is not None else 0.0 + except Exception as e: + logger.warning(f"Error calculating totals for consecutivo {C39_Consecutivo}: {e}") + val_total_me = 0.0 + val_total_mn = 0.0 + + qcsv_ie['Factura'] = row[0] + qcsv_ie['Pedimento'] = row[1] + qcsv_ie['FechaFactura'] = row[2] + qcsv_ie['Estatus'] = row[3] + qcsv_ie['ClavePed'] = row[4] + qcsv_ie['TipoMovTemDef'] = 'IMTEM' + qcsv_ie['EsCambioRegimen'] = 'N' + + row_c13 = row[12] # Fecha_Pago + row_c11 = row[10] # Fecha_Inicio + row_c58 = row[57] # TIPOPEDIMENTOTRANSPORTEE + row_c50 = row[49] # TipoCambio + row_c41 = row[40] # PedRectifica + row_c2 = row[1] # PedimentoImpo + + calculated_tc = row_c50 + + # Calculate values based on currency type + if filters.currency_type == 'ME': + qcsv_ie['ValorMPTemp'] = val_total_me + qcsv_ie['ValorComercialMN'] = val_total_me + + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio( + db, db_name, row_c13, row_c11, row_c58, met_trans + ) + qcsv_ie['TipoCambio'] = calculated_tc if calculated_tc > 0 else row_c50 + else: + qcsv_ie['TipoCambio'] = row_c50 + else: + if filters.is_shelter: + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio_mn(db, db_name, row_c13, row_c11, row_c58, met_trans) + qcsv_ie['ValorMPTemp'] = val_total_me * calculated_tc + qcsv_ie['ValorComercialMN'] = val_total_me * calculated_tc + qcsv_ie['TipoCambio'] = calculated_tc + else: + qcsv_ie['ValorMPTemp'] = val_total_mn + qcsv_ie['ValorComercialMN'] = val_total_mn + qcsv_ie['TipoCambio'] = row_c50 + else: + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio(db, db_name, row_c13, row_c11, row_c58, met_trans) + qcsv_ie['ValorMPTemp'] = val_total_me * calculated_tc + qcsv_ie['ValorComercialMN'] = val_total_me * calculated_tc + qcsv_ie['TipoCambio'] = calculated_tc + else: + qcsv_ie['ValorMPTemp'] = val_total_mn + qcsv_ie['ValorComercialMN'] = val_total_mn + qcsv_ie['TipoCambio'] = row_c50 + + qcsv_ie['ValorAgre'] = 0.0 + qcsv_ie['TipoExpo'] = '' + + if filters.is_shelter: + qcsv_ie['PedimentoR1'] = row_c41 + else: + qcsv_ie['PedimentoR1'] = self._buscar_rectificacion(db, db_name, row_c2, row_c41) + + qcsv_ie['EDocument'] = row[41] + qcsv_ie['NumOperacionVU'] = row[42] + qcsv_ie['BaseDeDatos'] = db_name + + # Get driver badge number (gafete) + sql_gafete = text(f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + try: + res_gafete = db.execute(sql_gafete, {"factura": row[0]}).fetchone() + qcsv_ie['NumGafUni'] = res_gafete[0] if res_gafete and res_gafete[0] else None + except Exception as e: + logger.debug(f"Could not retrieve badge for invoice {row[0]}: {e}") + qcsv_ie['NumGafUni'] = None + + qcsv_ie['UsuarioCap'] = row[51] + qcsv_ie['UsuarioAcr'] = row[52] + qcsv_ie['Fecha_Pago'] = row[12] + qcsv_ie['NumCaja'] = row[54] + qcsv_ie['Pedimento18'] = row[55] + qcsv_ie['AduanaCru'] = row[37] + qcsv_ie['Lote'] = row[56] + + movements.append(MovementItem(**qcsv_ie)) + + return movements + + def _obtener_tipo_cambio( + self, + db: Session, + db_name: str, + fecha, + is_shelter: bool + ) -> Optional[float]: + """ + Get exchange rate for the given date. + Simplified version that works with both shelter and non-shelter logic. + + Args: + db: Database session + db_name: Legacy database name + fecha: Date for exchange rate lookup + is_shelter: Shelter company flag (currently not used but kept for compatibility) + + Returns: + Exchange rate as float, or None if not found + """ + if not fecha: + return None + + try: + sql_tc = text(f""" + SELECT TOP 1 Valor + FROM [{db_name}].dbo.GTipoCambio + WHERE Fecha = :fecha + ORDER BY Fecha DESC + """) + res = db.execute(sql_tc, {"fecha": fecha}).fetchone() + if res and res[0]: + return float(res[0]) + else: + logger.warning(f"Exchange rate not found for date {fecha}") + return None + except Exception as e: + logger.error(f"Error fetching exchange rate for date {fecha}: {e}") + return None + + def _buscar_rectificacion( + self, + pedimento: str, + ped_rectifica: Optional[str] + ) -> Optional[str]: + """ + Search for pedimento rectification. + Simplified version - returns the rectification value from database. + + Args: + pedimento: Original pedimento number + ped_rectifica: Rectification pedimento from query + + Returns: + Rectification pedimento number or None + """ + # TODO: Implement full rectification search logic if needed + # For now, returning the value from the query + return ped_rectifica + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed temporary import movements (line by line) from legacy database. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of detailed movement items (one per line/partida) + """ + logger.info(f"Fetching DETAILED temporary import movements with filters: {filters.model_dump()}") + + # Read INI configuration + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause (same as normal report) + where_clauses = [] + params = {} + + if filters.range_type == 'FF': + where_clauses.append( + "EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date" + ) + else: + where_clauses.append( + "EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date" + ) + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + where_str = " AND ".join(where_clauses) + db_name = filters.database_name + + logger.debug(f"WHERE clause: {where_str}") + logger.debug(f"Query params: {params}") + + # Same main query as normal report + sql_query = text(self._build_main_query(db_name, where_str)) + + try: + result = db.execute(sql_query, params) + rows = result.fetchall() + logger.info(f"Query returned {len(rows)} rows for detailed processing") + except Exception as e: + logger.error(f"Error executing main query: {e}") + raise Exception(f"Database query failed: {str(e)}") + + movements = [] + + # Process each row individually (detailed mode) + for row in rows: + item = {} + + # Basic invoice info + item['Linea'] = row[43] # C44 - LineaImpo + item['Factura'] = row[0] # C1 + item['Pedimento'] = row[1] # C2 + item['FechaFactura'] = row[2] # C3 + item['Estatus'] = row[3] # C4 + item['ClavePed'] = row[4] # C5 + item['TipoMovTemDef'] = 'IMTEM' + item['EsCambioRegimen'] = 'N' + item['Regimen'] = row[9] # C10 + item['Fecha_Inicio'] = row[10] # C11 + item['Fecha_Fin'] = row[11] # C12 + item['Fecha_Pago'] = row[12] # C13 + item['Remesa'] = row[13] # C14 + + # Get provider information + provider_code = row[15] # C16 - Proveedor + provider_info = self._get_client_provider_info(db, db_name, provider_code, filters.is_shelter) + item['Proveedor'] = provider_info.get('nombre') + item['RFCProveedor'] = provider_info.get('rfc') + item['ProveedorTaxID'] = provider_info.get('tax_id') + + # Get buyer information + buyer_code = row[16] # C17 - VendidoA + buyer_info = self._get_client_buyer_info(db, db_name, buyer_code, filters.is_shelter) + item['VendidoA'] = buyer_info.get('nombre') + item['VendidoARFC'] = buyer_info.get('rfc') + item['VendidoATaxID'] = buyer_info.get('tax_id') + + # Get customs broker info + customs_broker_code = row[17] # C18 - AAduanal + broker_info = self._get_customs_broker_info(db, db_name, customs_broker_code) + item['AgenteAduanal'] = broker_info.get('nombre') + item['Patente'] = broker_info.get('patente') + + # Item details + item['NumParte'] = row[19] # C20 - Clase (NumParte) + item['DescripcionE'] = self._clean_text(row[20]) # C21 - Already cleaned in query + item['DescripcionI'] = self._clean_text(row[21]) # C22 - Already cleaned in query + item['CantidadIE'] = float(row[22]) if row[22] else 0.0 # C23 + item['UniMed'] = row[23] # C24 + + # Values and exchange rate (only for main partidas, not subpartidas) + if row[39] == 'P': # C40 - EsSubPartida == 'P' + # Calculate value based on currency type + if filters.currency_type == 'MN': + if filters.is_shelter: + if filters.exchange_rate_type == 'FP' and row[12]: # C13 - Fecha_Pago + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['ValorComercialMN'] = float(row[26]) * tc if row[26] else 0.0 # C27 * TC + item['TipoCambio'] = tc + else: + item['ValorComercialMN'] = float(row[24]) if row[24] else 0.0 # C25 + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + else: + if filters.exchange_rate_type == 'FP' and row[12]: + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['ValorComercialMN'] = float(row[26]) * tc if row[26] else 0.0 # C27 * TC + item['TipoCambio'] = tc + else: + item['ValorComercialMN'] = float(row[24]) if row[24] else 0.0 # C25 + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + elif filters.currency_type == 'ME': + item['ValorComercialMN'] = float(row[26]) if row[26] else 0.0 # C27 + if filters.exchange_rate_type == 'FP' and row[12]: + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['TipoCambio'] = tc if tc > 0 else float(row[49]) if row[49] else 0.0 + else: + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + + item['PesoNeto'] = float(row[28]) if row[28] else 0.0 # C29 + item['PesoBruto'] = float(row[29]) if row[29] else 0.0 # C30 + elif row[39] == 'S': # Subpartida + item['ValorComercialMN'] = 0.0 + item['PesoNeto'] = 0.0 + item['PesoBruto'] = 0.0 + item['TipoCambio'] = 0.0 + + # Additional fields + item['OrdenCompraVenta'] = row[30] # C31 + item['FraccionArancelaria'] = row[31] # C32 + item['Preferencia'] = row[32] # C33 + item['Sector'] = row[34] # C35 + item['PaisOrigen'] = row[36] # C37 + + # Get customs office name + aduana_code = row[37] # C38 - Aduana_Cruce + aduana_name = self._get_customs_office_name(db, db_name, aduana_code) + item['Aduana'] = aduana_name + + item['Advalorem'] = row[39] # C40 + item['TipoExpo'] = '' + + # Rectification + if filters.is_shelter: + item['PedimentoR1'] = row[40] # C41 + else: + item['PedimentoR1'] = self._buscar_rectificacion(db, db_name, row[1], row[40]) + + item['EDocument'] = row[41] # C42 + item['NumOperacionVU'] = row[42] # C43 + + # Get series information + consecutivo = row[38] # C39 + linea_impo = row[43] # C44 + series_info = self._get_series_info(db, db_name, consecutivo, linea_impo, filters.is_shelter) + item['Series'] = series_info + + item['Marca'] = row[44] # C45 + item['Modelo'] = row[45] # C46 + item['FraccionAmericana'] = row[46] # C47 + item['ECCN'] = row[47] # C48 + + # Get export symbol from parts + num_parte = row[48] # C49 + if num_parte: + simbolo_ex = self._get_part_export_symbol(db, db_name, num_parte, filters.is_shelter) + item['SimboloEx'] = simbolo_ex + else: + item['SimboloEx'] = None + + item['FechaEmision'] = row[50] # C51 + item['BaseDeDatos'] = db_name + + # Get driver badge + factura = row[0] # C1 + try: + sql_gafete = text(f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + res_gafete = db.execute(sql_gafete, {"factura": factura}).fetchone() + item['NumGafUni'] = res_gafete[0] if res_gafete and res_gafete[0] else None + except Exception as e: + logger.debug(f"Could not retrieve badge for invoice {factura}: {e}") + item['NumGafUni'] = None + + item['UsuarioCap'] = row[51] # C52 + item['UsuarioAcr'] = row[52] # C53 + item['Transportista'] = row[53] # C54 + item['NumCaja'] = row[54] # C55 + item['Pedimento18'] = row[55] # C56 + item['AduanaCru'] = row[37] # C38 + item['Lote'] = row[56] # C57 + + movements.append(MovementItemDetailed(**item)) + + logger.info(f"Processed {len(movements)} detailed movement items") + return movements + + def _build_main_query(self, db_name: str, where_str: str) -> str: + """Build the main SQL query for fetching invoice data""" + return f""" + SELECT + EqiFim.FacturaImpo AS C1, + EqiFim.PedimentoImpo AS C2, + EqiFim.FechaFactura AS C3, + EqiFim.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiFim.ValorImpoME AS C6, + EqiFim.ValorImpoMN AS C7, + EqiFim.Proveedor AS C8, + EqiFim.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFim.Remesa AS C14, + EqiFim.TipoCambio AS C15, + EqiFim.Proveedor AS C16, + EqiFim.VendidoA AS C17, + EqiFim.AAduanal AS C18, + '' AS C19, + EqiPim.Clase AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.DescripcionE, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(ClaAct.DescripcionI, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C22, + EqiPim.CantImpo AS C23, + EqiPim.UnidadMedida AS C24, + EqiPim.ValorImpoMN AS C25, + EqiPim.ValorAduanasMN AS C26, + EqiPim.ValorImpoME AS C27, + EqiPim.ValorAduanasME AS C28, + EqiPim.PesoNeto AS C29, + EqiPim.PesoBruto AS C30, + EqiPim.OrdenCompra AS C31, + EqiPim.Fraccion AS C32, + EqiPim.TipoFraccion AS C33, + EqiPim.AdvImpo AS C34, + EqiPim.Sector AS C35, + EqiPim.MontoIgi AS C36, + EqiPim.PaisOrigen AS C37, + EqiPed.Aduana_Cruce AS C38, + EqiFim.Consecutivo AS C39, + EqiPim.EsSubPartida AS C40, + EqiPed.PedRectifica AS C41, + EqiFim.EDocument AS C42, + EqiFim.NumOperacionVU AS C43, + EqiPim.LineaImpo AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.Marca, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.Modelo, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C46, + ClaAct.FraccionAme AS C47, + ClaAct.ECCN AS C48, + EqiPim.NumParte AS C49, + EqiFim.TipoCambio AS C50, + EqiFim.FechaEmision AS C51, + EqiFim.UsuarioCap AS C52, + EqiFim.UsuarioAct AS C53, + EqiFim.Transportista AS C54, + EqiFim.Transporte + ' ' + EqiFim.NumTrasporte AS C55, + EqiPed.Pedimento18 AS C56, + EqiPim.LOTE AS C57, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C58 + FROM [{db_name}].dbo.QFacImp EqiFim + LEFT JOIN [{db_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFim.PedimentoImpo + LEFT JOIN [{db_name}].dbo.QEqiMaq EqiPim + ON EqiPim.Consecutivo = EqiFim.Consecutivo + LEFT JOIN [{db_name}].dbo.QClaAct ClaAct + ON ClaAct.Clase = EqiPim.Clase + WHERE {where_str} + """ + + def _clean_text(self, text: Optional[str]) -> Optional[str]: + """Clean text by removing special characters""" + if not text: + return None + return text.strip() + + def _get_client_provider_info(self, db: Session, db_name: str, client_code: str, is_shelter: bool) -> dict: + """Get provider/client information""" + if not client_code: + return {"nombre": None, "rfc": None, "tax_id": None} + + try: + sql = text(f""" + SELECT TOP 1 Nombre, RFC, TaxID + FROM [{db_name}].dbo.GClientesPro + WHERE Cliente = :cliente + """) + result = db.execute(sql, {"cliente": client_code}).fetchone() + + if result: + return { + "nombre": self._clean_text(result[0]), + "rfc": result[1], + "tax_id": result[2] + } + except Exception as e: + logger.warning(f"Error fetching provider info for {client_code}: {e}") + + return {"nombre": None, "rfc": None, "tax_id": None} + + def _get_client_buyer_info(self, db: Session, db_name: str, client_code: str, is_shelter: bool) -> dict: + """Get buyer information (same structure as provider)""" + return self._get_client_provider_info(db, db_name, client_code, is_shelter) + + def _get_customs_broker_info(self, db: Session, db_name: str, broker_code: str) -> dict: + """Get customs broker information""" + if not broker_code: + return {"nombre": None, "patente": None} + + try: + sql = text(f""" + SELECT TOP 1 Nombre, Patente + FROM [{db_name}].dbo.GAAduanal + WHERE ClaveAA = :clave + """) + result = db.execute(sql, {"clave": broker_code}).fetchone() + + if result: + return { + "nombre": result[0], + "patente": result[1] + } + except Exception as e: + logger.warning(f"Error fetching customs broker info for {broker_code}: {e}") + + return {"nombre": None, "patente": None} + + def _get_customs_office_name(self, db: Session, db_name: str, aduana_code: str) -> Optional[str]: + """Get customs office name""" + if not aduana_code: + return None + + try: + sql = text(f""" + SELECT TOP 1 REPLACE(REPLACE(REPLACE(REPLACE(Nombre, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') + FROM [{db_name}].dbo.GAduanaSec + WHERE AduanaSeccion = :aduana + """) + result = db.execute(sql, {"aduana": aduana_code}).fetchone() + return result[0] if result else None + except Exception as e: + logger.warning(f"Error fetching customs office name for {aduana_code}: {e}") + return None + + def _get_series_info(self, db: Session, db_name: str, consecutivo: str, linea_impo: str, is_shelter: bool) -> Optional[str]: + """Get series information for an item""" + if not consecutivo or not linea_impo: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """) + result = db.execute(sql, {"consecutivo": consecutivo, "linea": linea_impo}).fetchall() + + if not result: + return None + + # Build series string + series_parts = [] + for idx, row in enumerate(result, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_parts.append(serie_str) + + return " | ".join(series_parts) if series_parts else None + + except Exception as e: + logger.warning(f"Error fetching series for {consecutivo}/{linea_impo}: {e}") + return None + + def _get_part_export_symbol(self, db: Session, db_name: str, num_parte: str, is_shelter: bool) -> Optional[str]: + """Get export symbol/license for a part number""" + if not num_parte: + return None + + try: + sql = text(f""" + SELECT TOP 1 SimboloExcLic + FROM [{db_name}].dbo.QPartes + WHERE NumParte = :num_parte + """) + result = db.execute(sql, {"num_parte": num_parte}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching export symbol for part {num_parte}: {e}") + return None + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """ + Retrieve definitive import movements from legacy database (LLENADODEFINITIVO - NORMAL). + Aggregates movements by invoice number. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching definitive import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: # FP + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + # If ALL, no filter added + + where_clause = " AND ".join(where_conditions) + + # Build main SQL query + sql_query = text(f""" + SELECT + EqiFid.FacturaImpoDef AS C1, + EqiFid.PedimentoImpoDef AS C2, + EqiFid.FechaFactura AS C3, + EqiFid.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiFid.ValorImpoME AS C6, + EqiFid.ValorImpoMN AS C7, + EqiFid.Proveedor AS C8, + EqiFid.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFid.Remesa AS C14, + EqiFid.TipoCambio AS C15, + EqiFid.AAduanal AS C18, + EqiFid.Consecutivo AS C40, + EqiPed.PedRectifica AS C42, + EqiFid.EDocument AS C43, + EqiFid.NumOperacionVU AS C44, + EqiFid.TipoCambio AS C51, + EqiFid.FechaEmision AS C52, + EqiFid.UsuarioCap AS C53, + EqiFid.UsuarioAct AS C54, + EqiFid.Transportista AS C55, + EqiFid.Transporte + ' ' + EqiFid.NumTrasporte AS C56, + EqiPed.Pedimento18 AS C57, + EqiPed.Aduana_Cruce AS C38, + EqiFid.ProvImpoDefCR AS C39, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C59 + FROM [{filters.database_name}].dbo.QFacImpDef EqiFid + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFid.PedimentoImpoDef + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements_dict = {} + + for row in result: + factura = row[0] # C1 + prov_impo_def_cr = row[28] # C39 + + # Determine movement type + tipo_mov = "COMEX" if prov_impo_def_cr == 'P' else "IMPDF" + + # Use factura + tipo_mov as key + key = (factura, tipo_mov) + + # If already exists, skip (we only want one entry per invoice in Normal mode) + if key not in movements_dict: + consecutivo = row[16] # C40 + fecha_pago = row[12] # C13 + tipo_pedimento = row[29] # C59 + fecha_inicio = row[10] # C11 + + # Calculate total values for this invoice + valor_me, valor_mn = self._calculate_definitive_totals( + db, filters.database_name, consecutivo + ) + + # Calculate exchange rate and values + tipo_cambio = row[20] # C51 + valor_mp_temp = 0.0 + valor_comercial_mn = 0.0 + + if filters.currency_type.value == "ME": + valor_mp_temp = float(valor_me or 0) + valor_comercial_mn = float(valor_me or 0) + tipo_cambio = float(tipo_cambio or 1.0) + else: # MN + if filters.is_shelter: + # Shelter logic with exchange rate calculation + if filters.exchange_rate_type.value == "FP" and fecha_pago: + # Check if special transport method logic applies + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_mp_temp = float(valor_me or 0) * tc_value + valor_comercial_mn = float(valor_me or 0) * tc_value + tipo_cambio = tc_value + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using values from DB") + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + # Non-shelter logic + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_mp_temp = float(valor_me or 0) * tc_value + valor_comercial_mn = float(valor_me or 0) * tc_value + tipo_cambio = tc_value + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + + # Get driver badge number + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Get rectification pedimento + pedimento = row[1] # C2 + ped_rectifica = row[17] # C42 + pedimento_r1 = self._buscar_rectificacion(pedimento, ped_rectifica) + + # Create movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_mp_temp, + ValorComercialMN=valor_comercial_mn, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C43 + NumOperacionVU=row[19], # C44 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C53 + UsuarioAcr=row[23], # C54 + Fecha_Pago=row[12], # C13 + NumCaja=row[25], # C56 + Pedimento18=row[26], # C57 + AduanaCru=row[27], # C38 + Lote=None # Lote comes from EpiDef table, not available in main query + ) + + movements_dict[key] = movement + + movements = list(movements_dict.values()) + logger.info(f"Successfully retrieved {len(movements)} definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching definitive import movements: {e}", exc_info=True) + raise + + def _calculate_definitive_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """ + Calculate total values for a definitive import invoice. + Sums up all partidas (items) excluding sub-partidas. + + Returns: + tuple: (total_valor_me, total_valor_mn) + """ + try: + sql = text(f""" + SELECT SUM(EqiPdf.ValorME), SUM(EqiPdf.ValorMN) + FROM [{db_name}].dbo.QEqiDef EqiPdf + WHERE EqiPdf.Consecutivo = :consecutivo + AND EqiPdf.EsSubpartida = 'P' + """) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> Optional[str]: + """Get driver's unique badge number (NUMGAFETEUNICO) for a definitive import invoice""" + if not factura: + return None + + try: + sql = text(f""" + SELECT NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImpDef + ON QFacImpDef.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpoDef = :factura + """) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for invoice {factura}: {e}") + return None + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed definitive import movements from legacy database (LLENADODEFINITIVO - DETALLADO). + Returns each line/partida as a separate record with full details. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of detailed movement items (one per partida/line) + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching DETAILED definitive import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause (same as normal mode) + where_conditions = [] + + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + + where_clause = " AND ".join(where_conditions) + + # Build detailed SQL query (includes partida/line details) + sql_query = text(f""" + SELECT + EqiFid.FacturaImpoDef AS C1, + EqiFid.PedimentoImpoDef AS C2, + EqiFid.FechaFactura AS C3, + EqiFid.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFid.Remesa AS C14, + EqiFid.TipoCambio AS C15, + EqiFid.Proveedor AS C16, + EqiFid.VendidoA AS C17, + EqiFid.AAduanal AS C18, + EpiDef.Clase AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.DescripcionE, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(EqiCla.DescripcionI, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C22, + EpiDef.CantImpoDef AS C23, + EpiDef.UnidadMedida AS C24, + EpiDef.ValorMN AS C25, + EpiDef.ValorME AS C27, + EpiDef.PesoNeto AS C29, + EpiDef.PesoBruto AS C30, + EpiDef.OrdenCompra AS C31, + EpiDef.Fraccion AS C32, + EpiDef.TipoFraccion AS C33, + EpiDef.Sector AS C35, + EpiDef.PaisOrigen AS C37, + EqiPed.Aduana_Cruce AS C38, + EqiFid.ProvImpoDefCR AS C39, + EqiFid.Consecutivo AS C40, + EpiDef.EsSubPartida AS C41, + EqiPed.PedRectifica AS C42, + EqiFid.EDocument AS C43, + EqiFid.NumOperacionVU AS C44, + EpiDef.LineaImpoDef AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.Marca, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C46, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.Modelo, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C47, + EqiCla.FraccionAme AS C48, + EqiCla.ECCN AS C49, + EpiDef.NumParte AS C50, + EqiFid.TipoCambio AS C51, + EqiFid.FechaEmision AS C52, + EqiFid.UsuarioCap AS C53, + EqiFid.UsuarioAct AS C54, + EqiFid.Transportista AS C55, + EqiFid.Transporte + ' ' + EqiFid.NumTrasporte AS C56, + EqiPed.Pedimento18 AS C57, + EpiDef.Lote AS C58, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C59 + FROM [{filters.database_name}].dbo.QFacImpDef EqiFid + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFid.PedimentoImpoDef + LEFT JOIN [{filters.database_name}].dbo.QEqiDef EpiDef + ON EpiDef.Consecutivo = EqiFid.Consecutivo + LEFT JOIN [{filters.database_name}].dbo.QClaAct EqiCla + ON EqiCla.Clase = EpiDef.Clase + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements = [] + + for row in result: + linea = row[31] # C45 + factura = row[0] # C1 + pedimento = row[1] # C2 + prov_impo_def_cr = row[25] # C39 + es_subpartida = row[27] # C41 + fecha_pago = row[8] # C13 + tipo_pedimento = row[44] # C59 + fecha_inicio = row[6] # C11 + consecutivo = row[26] # C40 + + # Determine movement type + tipo_mov = "COMEX" if prov_impo_def_cr == 'P' else "IMPDF" + + # Get provider info + proveedor_info = self._get_client_provider_info( + db, filters.database_name, row[11], filters.is_shelter # C16 + ) + + # Get buyer info + buyer_info = self._get_client_buyer_info( + db, filters.database_name, row[12], filters.is_shelter # C17 + ) + + # Get customs broker info + broker_info = self._get_customs_broker_info( + db, filters.database_name, row[13] # C18 + ) + + # Calculate commercial value and exchange rate + tipo_cambio = float(row[10] or 1.0) # C15 + valor_comercial_mn = 0.0 + peso_neto = 0.0 + peso_bruto = 0.0 + + # Only process values if it's a Partida (not Subpartida) + if es_subpartida == 'P': + if filters.currency_type.value == "MN": + if filters.is_shelter: + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_comercial_mn = float(row[20] or 0) * tc_value # C27 * TC + tipo_cambio = tc_value + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_comercial_mn = float(row[20] or 0) * tc_value # C27 * TC + tipo_cambio = tc_value + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: # ME + valor_comercial_mn = float(row[20] or 0) # C27 + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + tipo_cambio = tc_value + else: + tipo_cambio = float(row[37] or 1.0) # C51 + else: + tipo_cambio = float(row[37] or 1.0) # C51 + + peso_neto = float(row[21] or 0) # C29 + peso_bruto = float(row[22] or 0) # C30 + # If es_subpartida == 'S', values remain 0 + + # Get customs office name + aduana_nombre = self._get_customs_office_name( + db, filters.database_name, row[24] # C38 + ) + + # Get series information + series_info = self._get_definitive_series_info( + db, filters.database_name, consecutivo, linea, filters.is_shelter + ) + + # Get export symbol + symbolo_ex = self._get_part_export_symbol( + db, filters.database_name, row[36], filters.is_shelter # C50 + ) + + # Get rectification pedimento + pedimento_r1 = self._buscar_rectificacion(pedimento, row[28]) # C42 + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Create detailed movement item + movement = MovementItemDetailed( + Linea=linea, + Factura=factura, + Pedimento=pedimento, + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + Regimen=row[5], # C10 + Fecha_Inicio=row[6], # C11 + Fecha_Fin=row[7], # C12 + Fecha_Pago=row[8], # C13 + Remesa=row[9], # C14, + Proveedor=proveedor_info.get("nombre"), + RFCProveedor=proveedor_info.get("rfc"), + ProveedorTaxID=proveedor_info.get("tax_id"), + VendidoA=buyer_info.get("nombre"), + VendidoARFC=buyer_info.get("rfc"), + VendidoATaxID=buyer_info.get("tax_id"), + AgenteAduanal=broker_info.get("nombre"), + Patente=broker_info.get("patente"), + NumParte=row[14], # C20 + DescripcionE=row[15], # C21 + DescripcionI=row[16], # C22 + CantidadIE=float(row[17] or 0), # C23 + UniMed=row[18], # C24 + ValorComercialMN=valor_comercial_mn, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[23], # C31 + FraccionArancelaria=row[29], # C32 + Preferencia=row[30], # C33 + Sector=row[32], # C35 + PaisOrigen=row[33], # C37 + Aduana=aduana_nombre, + Advalorem=row[27], # C41 + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[34], # C43 + NumOperacionVU=row[35], # C44 + Series=series_info, + Marca=row[40], # C46 + Modelo=row[41], # C47 + FraccionAmericana=row[42], # C48 + ECCN=row[43], # C49 + SimboloEx=symbolo_ex, + FechaEmision=row[38], # C52 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[39], # C53 + UsuarioAcr=row[40], # C54 + Transportista=row[41], # C55 + NumCaja=row[42], # C56 + Pedimento18=row[43], # C57 + AduanaCru=row[24], # C38 + Lote=row[44] # C58 + ) + + movements.append(movement) + + logger.info(f"Successfully retrieved {len(movements)} detailed definitive movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed definitive movements: {e}", exc_info=True) + raise + + def _get_definitive_series_info( + self, + db: Session, + db_name: str, + consecutivo: int, + linea: int, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for a definitive import partida from QSeriesDef table. + Returns formatted string with series, model, and part info. + """ + if not consecutivo or not linea: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesDef + WHERE Consecutivo = :consecutivo + AND LineaImpoDef = :linea + """) + result = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() + + if not result: + return None + + series_list = [] + for idx, row in enumerate(result, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_list.append(serie_str) + + return " | ".join(series_list) if series_list else None + + except Exception as e: + logger.debug(f"Error fetching definitive series info for consecutivo {consecutivo}, linea {linea}: {e}") + return None + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """ + Retrieve repair import movements from legacy database (LLENADOIMP_REPARACION - NORMAL). + Aggregates movements by invoice number. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching repair import movements with filters: {filters.model_dump()}") + + # Get configuration + met_trans = self._get_met_trans_config() + + # Build WHERE clause + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"FimRep.FechaFactura >= '{filters.start_date}' AND FimRep.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("FimRep.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"FimRep.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"FimRep.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Discharge filter for repair imports + if filters.discharge_filter.value == "SiDes": + where_conditions.append("RepPim.Descarga = 1") + elif filters.discharge_filter.value == "NoDes": + where_conditions.append("RepPim.Descarga = 0") + # If ALL, no filter added + + # Exclude regime changes + where_conditions.append("FimRep.EsCambioRegimen <> 'S'") + + where_clause = " AND ".join(where_conditions) + + # Build main SQL query for repair imports + sql_query = text(f""" + SELECT + FimRep.FacturaImpo AS C1, + FimRep.PedimentoImpo AS C2, + FimRep.FechaFactura AS C3, + FimRep.Estatus AS C4, + EqiPed.ClavePed AS C5, + FimRep.ValorImpoME AS C6, + FimRep.ValorImpoMN AS C7, + FimRep.Proveedor AS C8, + FimRep.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + FimRep.Remesa AS C14, + FimRep.TipoCambio AS C15, + FimRep.AAduanal AS C18, + FimRep.Consecutivo AS C39, + EqiPed.PedRectifica AS C41, + FimRep.EDocument AS C42, + FimRep.NumOperacionVU AS C43, + FimRep.TipoCambio AS C49, + FimRep.FechaEmision AS C50, + FimRep.UsuarioCap AS C51, + FimRep.UsuarioAct AS C52, + FimRep.Transportista AS C53, + FimRep.Transporte + ' ' + FimRep.NumTrasporte AS C54, + EqiPed.Pedimento18 AS C55, + EqiPed.Aduana_Cruce AS C38, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C56 + FROM [{filters.database_name}].dbo.QFacImpRep FimRep + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = FimRep.PedimentoImpo + LEFT JOIN [{filters.database_name}].dbo.QEqiMaqRep RepPim + ON RepPim.Consecutivo = FimRep.Consecutivo + LEFT JOIN [{filters.database_name}].dbo.QClaAct ClaAct + ON ClaAct.Clase = RepPim.Clase + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements_dict = {} + + for row in result: + factura = row[0] # C1 + + # Use factura + IMPRE as key + tipo_mov = "IMPRE" + key = (factura, tipo_mov) + + # If already exists, skip (we only want one entry per invoice in Normal mode) + if key not in movements_dict: + consecutivo = row[16] # C39 + fecha_pago = row[12] # C13 + tipo_pedimento = row[28] # C56 + fecha_inicio = row[10] # C11 + + # Calculate total values for this invoice (with discharge filter) + valor_me, valor_mn = self._calculate_repair_totals( + db, filters.database_name, consecutivo, filters.discharge_filter.value + ) + + # Calculate exchange rate and values using unified method + valor_comercial, tipo_cambio = self._calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + valor_me=valor_me, + valor_mn=valor_mn, + tipo_cambio_db=float(row[20] or 1.0), # C49 + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=filters.use_transport_method, + met_trans=met_trans + ) + + # Get driver badge number + num_gaf_uni = self._get_driver_badge_repair(db, filters.database_name, factura) + + # Get rectification pedimento + pedimento = row[1] # C2 + ped_rectifica = row[17] # C41 + pedimento_r1 = self._buscar_rectificacion(pedimento, ped_rectifica) + + # Create movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C42 + NumOperacionVU=row[19], # C43 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C51 + UsuarioAcr=row[23], # C52 + Fecha_Pago=row[12], # C13 + NumCaja=row[25], # C54 + Pedimento18=row[26], # C55 + AduanaCru=row[27], # C38 + Lote=None # Repair imports don't have Lote in main query + ) + + movements_dict[key] = movement + + movements = list(movements_dict.values()) + logger.info(f"Successfully retrieved {len(movements)} repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching repair import movements: {e}", exc_info=True) + raise + + def _calculate_repair_totals( + self, + db: Session, + db_name: str, + consecutivo: int, + discharge_filter: str + ) -> tuple: + """ + Calculate total values for a repair import invoice. + Sums up all partidas (items) excluding sub-partidas, with optional discharge filter. + + Args: + db: Database session + db_name: Database name + consecutivo: Invoice consecutive number + discharge_filter: "SiDes", "NoDes", or "ALL" + + Returns: + tuple: (total_valor_me, total_valor_mn) + """ + try: + # Build discharge filter clause + discharge_clause = "" + if discharge_filter == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif discharge_filter == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + sql = text(f""" + SELECT SUM(RepPim.ValorImpoME), SUM(RepPim.ValorImpoMN) + FROM [{db_name}].dbo.QEqiMaqRep RepPim + WHERE RepPim.Consecutivo = :consecutivo + AND RepPim.EsSubpartida = 'P' + {discharge_clause} + """) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating repair totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge_repair(self, db: Session, db_name: str, factura: str) -> Optional[str]: + """Get driver's unique badge number for a repair import invoice""" + if not factura: + return None + + try: + sql = text(f""" + SELECT NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImpRep + ON QFacImpRep.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for repair invoice {factura}: {e}") + return None + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed repair import movements from legacy database (LLENADOIMP_REPARACION - DETALLADO). + Returns individual partida lines with full detail. + + Args: + db: Database session + filters: Filter parameters including date range, discharge filter, etc. + + Returns: + List of detailed movement items + """ + try: + logger.info(f"Fetching detailed repair import movements with filters: {filters}") + + # Get database name + db_name = self._get_database_name(db) + if not db_name: + logger.error("Could not determine database name") + return [] + + # Get MetTrans configuration + met_trans = self._get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause_repair(filters) + + # Build discharge filter for main query + discharge_clause = "" + if filters.discharge_filter == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif filters.discharge_filter == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + # Build main SQL query with all required fields for detailed mode + sql = text(f""" + SELECT + RepPim.LineaImpo, -- C44: Linea + Rep.FacturaImpo, -- C1: Factura + Ped.PedNumero, -- C2: Pedimento + Rep.FechaFacImpo, -- C3: FechaFactura + Rep.Estatus, -- C4: Estatus + Ped.ClavePedImpo, -- C5: ClavePed + Ped.Regimen, -- C10: Regimen + Ped.FechaEntrada, -- C11: Fecha_Inicio + Ped.FechaPago, -- C13: Fecha_Pago + Rep.Remesa, -- C14: Remesa + Rep.TipoCambio, -- C15: TipoCambio (from header) + Rep.Cliente, -- C16: Cliente/Proveedor + Rep.VendidoA, -- C17: VendidoA + Rep.AgenteAduanal, -- C18: AgenteAduanal clave + RepPim.NumParteImpo, -- C20: NumParte + RepPim.DescripcionE, -- C21: DescripcionE + RepPim.DescripcionI, -- C22: DescripcionI + RepPim.CantidadImpo, -- C23: CantidadIE + RepPim.UnidadMedImpo, -- C24: UniMed + RepPim.ValorImpoMN, -- C25: ValorComercialMN (direct) + RepPim.ValorImpoME, -- C27: ValorComercialME + RepPim.PesoNetoImpo, -- C29: PesoNeto + RepPim.PesoBrutoImpo, -- C30: PesoBruto + RepPim.OrdenCompraVta, -- C31: OrdenCompraVenta + RepPim.FraccionImpo, -- C32: FraccionArancelaria + RepPim.Preferencia, -- C33: Preferencia + RepPim.Sector, -- C35: Sector + RepPim.PaisOrigenImpo, -- C37: PaisOrigen + RepPim.Aduana, -- C38: Aduana seccion + Rep.Consecutivo, -- C39: Consecutivo + RepPim.EsSubpartida, -- C40: Advalorem/EsSubpartida + Ped.PedRectifica, -- C41: PedRectifica + Rep.eDocument, -- C42: EDocument + Rep.NumOperacionVU, -- C43: NumOperacionVU + RepPim.Marca, -- C45: Marca + RepPim.Modelo, -- C46: Modelo + RepPim.FraccionAmericana, -- C47: FraccionAmericana + RepPim.ECCN, -- C48: ECCN + RepPim.TipoCambio AS TipoCambioPartida, -- C49: TipoCambio (from partida) + Rep.FechaEmbarque, -- C50: FechaEmision + Rep.UsuarioCap, -- C51: UsuarioCap + Rep.UsuarioAct, -- C52: UsuarioAcr + Rep.Transportista, -- C53: Transportista + Rep.NumCaja, -- C54: NumCaja + Ped.Pedimento18, -- C55: Pedimento18 + Ped.ClavePedImpo -- C56: ClavePedImpo (for MetTrans check) + FROM [{db_name}].dbo.QFacImpRep Rep + LEFT JOIN [{db_name}].dbo.QPedimentos Ped ON Rep.Pedimento = Ped.PedNumero + LEFT JOIN [{db_name}].dbo.QEqiMaqRep RepPim ON Rep.Consecutivo = RepPim.Consecutivo + WHERE Rep.EsCambioRegimen <> 'S' + {where_clause} + {discharge_clause} + ORDER BY Rep.FacturaImpo, RepPim.LineaImpo + """) + + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed repair import partidas") + + movements = [] + for row in results: + # Extract all fields from query + linea = row[0] + factura = row[1] + pedimento = row[2] + fecha_factura = row[3] + estatus = row[4] + clave_ped = row[5] + regimen = row[6] + fecha_inicio = row[7] + fecha_pago = row[8] + remesa = row[9] + tipo_cambio_header = row[10] + cliente = row[11] + vendido_a = row[12] + agente_aduanal_clave = row[13] + num_parte = row[14] + descripcion_e = row[15] + descripcion_i = row[16] + cantidad = row[17] + uni_med = row[18] + valor_mn_direct = row[19] + valor_me = row[20] + peso_neto = row[21] + peso_bruto = row[22] + orden_compra = row[23] + fraccion = row[24] + preferencia = row[25] + sector = row[26] + pais_origen = row[27] + aduana_seccion = row[28] + consecutivo = row[29] + es_subpartida = row[30] + ped_rectifica = row[31] + e_document = row[32] + num_operacion_vu = row[33] + marca = row[34] + modelo = row[35] + fraccion_americana = row[36] + eccn = row[37] + tipo_cambio_partida = row[38] + fecha_emision = row[39] + usuario_cap = row[40] + usuario_acr = row[41] + transportista = row[42] + num_caja = row[43] + pedimento_18 = row[44] + clave_ped_mettrans = row[45] + + # Get client/supplier information (Proveedor) + proveedor_info = self._get_client_info(db, db_name, cliente, is_supplier=True) + + # Get sold-to client information (VendidoA) + vendido_info = self._get_client_info(db, db_name, vendido_a, is_supplier=False) + + # Get customs agent information + agente_info = self._get_customs_agent_info(db, db_name, agente_aduanal_clave) + + # Get customs section name + aduana_nombre = self._get_aduana_seccion_nombre(db, db_name, aduana_seccion) + + # Calculate exchange rate and commercial value + valor_mn, tipo_cambio_final = self._calculate_exchange_rate_and_value( + db=db, + db_name=db_name, + es_subpartida=es_subpartida, + valor_me=valor_me, + valor_mn_direct=valor_mn_direct, + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + clave_ped=clave_ped_mettrans, + tipo_cambio_partida=tipo_cambio_partida, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + peso_neto_final = peso_neto if es_subpartida == 'P' else 0 + peso_bruto_final = peso_bruto if es_subpartida == 'P' else 0 + + # Get series information for this partida + series = self._get_series_info_repair(db, db_name, consecutivo, linea) + + # Get rectification pedimento + pedimento_r1 = self._buscar_rectificacion(db, db_name, pedimento, ped_rectifica) + + # Get driver badge unique number + num_gaf_uni = self._get_driver_badge_repair(db, db_name, factura) + + # Build movement item + movement = MovementItemDetailed( + linea=linea, + factura=factura, + pedimento=pedimento, + fecha_factura=fecha_factura, + estatus=estatus, + clave_ped=clave_ped, + tipo_mov_tem_def="IMPRE", + es_cambio_regimen="N", + regimen=regimen, + fecha_inicio=fecha_inicio, + fecha_fin=None, # Not available for repair imports + fecha_pago=fecha_pago, + remesa=remesa, + tipo_cambio=tipo_cambio_final, + proveedor=proveedor_info.get("nombre"), + rfc_proveedor=proveedor_info.get("rfc"), + proveedor_tax_id=proveedor_info.get("tax_id"), + vendido_a=vendido_info.get("nombre"), + vendido_a_rfc=vendido_info.get("rfc"), + vendido_a_tax_id=vendido_info.get("tax_id"), + agente_aduanal=agente_info.get("nombre"), + patente=agente_info.get("patente"), + num_parte=num_parte, + descripcion_e=self._remove_commas(descripcion_e), + descripcion_i=self._remove_commas(descripcion_i), + cantidad_ie=cantidad, + uni_med=uni_med, + valor_comercial_mn=valor_mn, + peso_neto=peso_neto_final, + peso_bruto=peso_bruto_final, + orden_compra_venta=orden_compra, + fraccion_arancelaria=fraccion, + preferencia=preferencia, + sector=sector, + pais_origen=pais_origen, + aduana=aduana_nombre, + advalorem=es_subpartida, + tipo_expo=None, + pedimento_r1=pedimento_r1, + e_document=e_document, + num_operacion_vu=num_operacion_vu, + series=series, + marca=marca, + modelo=modelo, + fraccion_americana=fraccion_americana, + eccn=eccn, + fecha_emision=fecha_emision, + base_de_datos=db_name, + num_gaf_uni=num_gaf_uni, + usuario_cap=usuario_cap, + usuario_acr=usuario_acr, + transportista=transportista, + num_caja=num_caja, + pedimento_18=pedimento_18, + aduana_cru=aduana_seccion + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed repair import movements: {e}", exc_info=True) + raise + + def _get_series_info_repair(self, db: Session, db_name: str, consecutivo: int, linea: int) -> Optional[str]: + """Get series information for repair import partida""" + if not consecutivo or not linea: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpoRep + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY Renglon + """) + results = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() + + if not results: + return None + + series_list = [] + for idx, row in enumerate(results, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_list.append(serie_str) + + return " | ".join(series_list) if series_list else None + + except Exception as e: + logger.debug(f"Error fetching repair series info for consecutivo {consecutivo}, linea {linea}: {e}") + return None + + def _build_where_clause_repair(self, filters: ImportRepairFilter) -> str: + """Build WHERE clause for repair imports query""" + conditions = [] + + # Date range filter + if filters.range_type == RangeType.INVOICE_DATE: + conditions.append(f"Rep.FechaFacImpo BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + elif filters.range_type == RangeType.PAYMENT_DATE: + conditions.append(f"Ped.FechaPago BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + elif filters.range_type == RangeType.ENTRY_DATE: + conditions.append(f"Ped.FechaEntrada BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + + return " AND " + " AND ".join(conditions) if conditions else "" + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py new file mode 100644 index 00000000..47114e98 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py @@ -0,0 +1,108 @@ + +import base64 +import logging +import traceback +from typing import Dict, Any + +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from core.email import EmailService +from datetime import datetime + +from .movement_service import movement_service +from .schemas import AllMovementsFilter +from .csv_utils import generate_csv_from_movements + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True, name="generate_invoice_movements_async") +def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_email: str = None): + """ + Async task to generate invoice movements report. + FETCHES data -> GENERATES CSV -> SENDS EMAIL (optional) -> RETURNS CSV (base64) + """ + db = CoreSessionLocal() + try: + # 1. Update Progress + self.update_state(state='PROCESSING', meta={'current': 10, 'total': 100, 'status': 'Inicializando reporte...'}) + + # 2. Reconstruct Filter + filters = AllMovementsFilter(**filter_data) + + # 3. Fetch Data + self.update_state(state='PROCESSING', meta={'current': 30, 'total': 100, 'status': 'Obteniendo movimientos de base de datos...'}) + logger.info(f"Async Task: Fetching movements for {filters}") + + movements = movement_service.get_all_movements(db=db, filters=filters) + + self.update_state(state='PROCESSING', meta={'current': 70, 'total': 100, 'status': f'Procesando {len(movements)} registros...'}) + + # 4. Generate CSV + csv_content = generate_csv_from_movements( + movements=movements, + filters=filters + ) + + # 5. Send Email if requested + email_sent = False + if filters.send_email and user_email: + self.update_state(state='PROCESSING', meta={'current': 90, 'total': 100, 'status': 'Enviando correo electrónico...'}) + try: + # Generate filename + filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Send email (using the new async wrapper or run_until_complete if needed, + # but since we are in a sync celery task we might need to be careful with async/await. + # Actually EmailService.send_report_email is async. + # We need to run it synchronously here or make the task async. + # Celery tasks are sync by default. We can use asgiref.sync.async_to_sync + + import asyncio + from asgiref.sync import async_to_sync + + # Helper to run async method + result = async_to_sync(EmailService.send_report_email)( + recipient_email=user_email, + subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}", + body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.", + csv_content=csv_content, + filename=filename + ) + + if result: + email_sent = True + logger.info(f"Async Task: Email sent to {user_email}") + else: + logger.warning(f"Async Task: Failed to send email to {user_email}") + + except Exception as e: + logger.error(f"Async Task: Email error: {str(e)}") + + # 6. Encode and Return + self.update_state(state='PROCESSING', meta={'current': 95, 'total': 100, 'status': 'Finalizando...'}) + + # Convert string csv to bytes then base64 + pdf_b64 = base64.b64encode(csv_content.encode('utf-8')).decode('utf-8') + + return { + 'status': 'success', + 'file_name': f"reporte_facturas_{datetime.now().strftime('%Y%m%d')}.csv", + 'content': pdf_b64, + 'media_type': 'text/csv', + 'email_sent': email_sent, + 'total_records': len(movements) + } + + except Exception as e: + logger.error(f"Error in generate_invoice_movements_async: {str(e)}", exc_info=True) + self.update_state( + state='FAILURE', + meta={ + 'exc_type': type(e).__name__, + 'exc_message': str(e), + 'custom': 'Error generating report' + } + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 8191668e..570b247b 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -35,6 +35,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout from .reports.importacion.consolidados.routes import router as consolidated_reports_router from .reports.importacion.packing_list.routes import router as packing_list_router from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router +from .reports.movements.invoices.routes import router as movement_invoices_router from .reports.exportacion.descargo.routes import router as discharge_reports_router from .manifests.manifest.routes import router as manifests_router from .manifests.driver.routes import router as manifest_drivers_router @@ -44,7 +45,6 @@ from .reports.importacion.transmission.temporal.MAINX30.routes import router as from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router - # Router principal router = APIRouter() @@ -101,6 +101,13 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router( + + movement_invoices_router, + prefix="/a76/reports/movements/invoices", + tags=["a76 / reports"] +) + router.include_router( discharge_reports_router, prefix="/a76/reports/exportacion/descargo", @@ -145,4 +152,4 @@ router.include_router( # Registrar router de bitácora from .audit_log.router import router as audit_log_router -router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) \ No newline at end of file +router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 542567e6..de7d211c 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -17,6 +17,7 @@ celery_app = Celery( "api.v1.modules.a76.reports.importacion.consolidados.task", "api.v1.modules.a76.reports.importacion.packing_list.task", "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", + "api.v1.modules.a76.reports.movements.invoices.tasks", "api.v1.modules.a76.reports.exportacion.descargo.task", "api.v1.modules.a76.imports.tasks", "api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task", diff --git a/backend/core/config.py b/backend/core/config.py index 31919a35..13459073 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -50,6 +50,14 @@ class Settings(BaseSettings): SITAR_API_USER: str = "" SITAR_API_PASSWORD: str = "" + # SMTP Email Configuration + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_NAME: str = "Sistema Anexo76" + SMTP_USE_TLS: bool = True + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, diff --git a/backend/core/email.py b/backend/core/email.py new file mode 100644 index 00000000..41b81a67 --- /dev/null +++ b/backend/core/email.py @@ -0,0 +1,117 @@ +""" +Email service for sending reports via SMTP. +""" +import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from typing import List +import logging +from datetime import datetime + +from core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails with attachments.""" + + @staticmethod + async def send_report_email( + recipient_email: str, + subject: str, + body_text: str, + csv_content: str, + filename: str + ) -> bool: + """ + Send a report email with CSV attachment. + + Args: + recipient_email: Email address of recipient + subject: Email subject line + body_text: Plain text email body + csv_content: CSV file content as string + filename: Name for the CSV attachment + + Returns: + bool: True if email sent successfully, False otherwise + """ + try: + # Create message + msg = MIMEMultipart() + msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>" + msg['To'] = recipient_email + msg['Subject'] = subject + + # Email body + html_body = f""" + + +
+

+ Reporte de Facturas - Sistema Anexo76 +

+

{body_text}

+

+ El reporte se encuentra adjunto en formato CSV. +

+
+

+ Este es un correo generado automáticamente. Por favor no responder. +

+

+ Generado el {datetime.now().strftime('%d/%m/%Y a las %H:%M')} +

+
+ + + """ + msg.attach(MIMEText(html_body, 'html')) + + # CSV attachment + attachment = MIMEBase('text', 'csv') + attachment.set_payload(csv_content.encode('utf-8')) + encoders.encode_base64(attachment) + attachment.add_header( + 'Content-Disposition', + f'attachment; filename="{filename}"' + ) + msg.attach(attachment) + + # Create SSL context that ignores certificate errors + import ssl + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Send email + if settings.SMTP_PORT == 465: + # Port 465 uses implicit SSL + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + use_tls=True, # Implicit SSL + tls_context=context + ) as smtp: + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + else: + # Port 587 uses STARTTLS + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + tls_context=context + ) as smtp: + await smtp.starttls(tls_context=context) + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + + logger.info(f"Email sent successfully to {recipient_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {recipient_email}: {str(e)}") + return False diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 833d54b8..d038f4ab 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -91,16 +91,19 @@ async def integrity_error_handler( }, ) - # Intentar extraer información útil del error - error_message = "Error de integridad en la base de datos" - orig_msg = str(exc.orig).lower() - if "unique constraint" in orig_msg or "duplicate key" in orig_msg: - error_message = "El registro ya existe. Verifica los campos únicos." - elif "foreign key" in orig_msg: - error_message = "Referencia inválida a otro registro." - elif "not null" in orig_msg: - error_message = "Falta un campo requerido." + + # Check for unique/duplicate key violations (English and Spanish) + if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]): + error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)." + # Check for foreign key violations (English and Spanish) + elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]): + error_message = "Referencia inválida a otro registro. Verifica las categorías y catálogos seleccionados." + # Check for not null violations (English and Spanish) + elif any(kw in orig_msg for kw in ["not null", "no nulo", "valor nulo"]): + error_message = "Falta un campo requerido. Asegúrate de llenar todos los datos obligatorios." + else: + error_message = "Error de integridad en la base de datos" return JSONResponse( status_code=status.HTTP_409_CONFLICT, diff --git a/backend/requirements.txt b/backend/requirements.txt index c169f2ca..7480be9f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,4 +50,6 @@ redis==5.0.1 flower==2.0.1 # Barcode -pdf417gen==0.8.1 \ No newline at end of file +pdf417gen==0.8.1 +asgiref==3.8.1 +aiosmtplib==3.0.1 diff --git a/docker-compose.yml b/docker-compose.yml index 041c1bdf..a6dc68de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -272,12 +272,28 @@ services: container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: + - DEBUG=${DEBUG:-True} + - ENVIRONMENT=${ENVIRONMENT:-development} + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - VALKEY_URL=redis://valkey:6379/0 depends_on: - backend - valkey networks: - backend-net + volumes: + - ./backend:/app + valkey: image: valkey/valkey:7.2 diff --git a/frontend/src/lib/api/dashboard/a24/fa_classes.ts b/frontend/src/lib/api/dashboard/a24/fa_classes.ts index 91359f52..e65f4625 100644 --- a/frontend/src/lib/api/dashboard/a24/fa_classes.ts +++ b/frontend/src/lib/api/dashboard/a24/fa_classes.ts @@ -98,7 +98,7 @@ export const faClassesApi = { * Actualizar una clase de activo fijo existente */ update: (id: number, data: FAClassUpdate, company_id: number): Promise> => { - return api.put(`/v1/a24/fa/classes/${id}?company_id=${company_id}`, data); + return api.put(`/v1/a24/fa/classes/${id}/?company_id=${company_id}`, data); }, /** diff --git a/frontend/src/lib/api/dashboard/a76/classes.ts b/frontend/src/lib/api/dashboard/a76/classes.ts index 236c2cdb..de2a8ca5 100644 --- a/frontend/src/lib/api/dashboard/a76/classes.ts +++ b/frontend/src/lib/api/dashboard/a76/classes.ts @@ -17,6 +17,7 @@ export interface A76Class { sub_key: string; physical_review: number; iva_exempt_fraction: string; + eccn_code?: string | null; is_active?: boolean; // Agregado para el switch del formulario created_at: string; updated_at: string; @@ -35,6 +36,7 @@ export interface A76ClassCreate { sub_key?: string | null; physical_review?: number | null; iva_exempt_fraction?: string | null; + eccn_code?: string | null; is_active?: boolean; } 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 586fedf7..11b037a1 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -477,11 +477,13 @@ export async function uploadCompanyLogo(id: number, file: File): Promise> { +export async function uploadCompanyCertificate(id: number, type: string, file: File, password?: string): Promise> { const formData = new FormData(); formData.append('file', file); + if (password) { + formData.append('password', password); + } - // Add certificate type query param const token = localStorage.getItem('access_token'); const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); diff --git a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts new file mode 100644 index 00000000..5424bc4b --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts @@ -0,0 +1,203 @@ +/** + * API Client para Reportes de Movimientos de Facturas + */ +import { api } from '$lib/api'; + +// ===== TYPES ===== + +export type RangeType = 'FF' | 'FP'; // FF = fecha factura, FP = fecha pago +export type ReportType = 'Normal' | 'Detallado'; +export type CurrencyType = 'ME' | 'MN'; // ME = moneda extranjera, MN = moneda nacional +export type ExchangeRateType = 'FP' | 'FF'; +export type MovementTypeFilter = 'COMEX' | 'IMPDF' | 'ALL'; +export type DischargeFilter = 'SiDes' | 'NoDes' | 'ALL'; +export type ExportMovementType = 'AFIJO' | 'NODES' | 'SCRAP' | 'REEXP' | 'DONAC' | 'VEMEX' | 'ALL'; + +export interface BaseFilter { + range_type: RangeType; + start_date: string; // YYYYMMDD format + end_date: string; // YYYYMMDD format + include_cancelled: boolean; + provider?: string | null; + buyer?: string | null; + pedimento_code?: string | null; + report_type: ReportType; + currency_type: CurrencyType; + exchange_rate_type: ExchangeRateType; + is_shelter: boolean; + database_name: string; +} + +export interface ImportTemporaryFilter extends BaseFilter { } + +export interface ImportDefinitiveFilter extends BaseFilter { + movement_type: MovementTypeFilter; +} + +export interface ImportRepairFilter extends BaseFilter { + discharge_filter: DischargeFilter; +} + +export interface ExportFilter extends BaseFilter { + movement_type: ExportMovementType; + discharge_filter: DischargeFilter; + use_transport_method: boolean; +} + +export interface ExportRepairFilter extends Omit { + movement_type: ExportMovementType; + discharge_filter: DischargeFilter; +} + +export interface AllMovementsFilter { + range_type: RangeType; + start_date: string; // YYYYMMDD format + end_date: string; // YYYYMMDD format + include_cancelled: boolean; + provider?: string | null; + buyer?: string | null; + pedimento_code?: string | null; + report_type: ReportType; + currency_type: CurrencyType; + exchange_rate_type: ExchangeRateType; + is_shelter: boolean; + operation_type?: 'imp' | 'exp' | null; + send_email?: boolean; + // Granular flags + import_temp?: boolean; + import_def?: boolean; + import_rep?: boolean; + export_def?: boolean; + export_rep?: boolean; + export_types?: string[]; + discharge_filter?: DischargeFilter; +} + +export interface MovementItem { + Factura: string; + Pedimento: string; + FechaFactura: string | null; + Estatus: string; + ClavePed: string; + TipoMovTemDef: string; + EsCambioRegimen: string; + ValorMPTemp: number; + ValorComercialMN: number; + TipoCambio: number; + ValorAgre: number; + TipoExpo: string; + PedimentoR1: string | null; + EDocument: string | null; + NumOperacionVU: string | null; + BaseDeDatos: string; + NumGafUni: string | null; + UsuarioCap: string | null; + UsuarioAcr: string | null; + Fecha_Pago: string | null; + NumCaja: string | null; + Pedimento18: string | null; + AduanaCru: string | null; + Lote: string | null; +} + +export interface MovementItemDetailed extends MovementItem { + Linea: number; + Regimen: string | null; + Fecha_Inicio: string | null; + Fecha_Fin: string | null; + Remesa: string | null; + Proveedor: string | null; + RFCProveedor: string | null; + ProveedorTaxID: string | null; + VendidoA: string | null; + VendidoARFC: string | null; + VendidoATaxID: string | null; + AgenteAduanal: string | null; + Patente: string | null; + NumParte: string | null; + DescripcionE: string | null; + DescripcionI: string | null; + CantidadIE: number; + UniMed: string | null; + PesoNeto: number; + PesoBruto: number; + OrdenCompraVenta: string | null; + FraccionArancelaria: string | null; + Preferencia: string | null; + Sector: string | null; + PaisOrigen: string | null; + Aduana: string | null; + Advalorem: string | null; + Series: string | null; + Marca: string | null; + Modelo: string | null; + FraccionAmericana: string | null; + ECCN: string | null; + SimboloEx: string | null; + FechaEmision: string | null; + Transportista: string | null; +} + +// ===== API METHODS ===== + +export const invoiceMovementsApi = { + // Temporary Imports + getTemporaryImports: (filters: ImportTemporaryFilter) => + api.post('/v1/a76/reports/movements/invoices/temporary', filters), + + getTemporaryImportsDetailed: (filters: ImportTemporaryFilter) => + api.post( + '/v1/a76/reports/movements/invoices/temporary-detailed', + filters + ), + + // Definitive Imports + getDefinitiveImports: (filters: ImportDefinitiveFilter) => + api.post('/v1/a76/reports/movements/invoices/definitive', filters), + + getDefinitiveImportsDetailed: (filters: ImportDefinitiveFilter) => + api.post( + '/v1/a76/reports/movements/invoices/definitive-detailed', + filters + ), + + // Repair Imports + getRepairImports: (filters: ImportRepairFilter) => + api.post('/v1/a76/reports/movements/invoices/repair', filters), + + getRepairImportsDetailed: (filters: ImportRepairFilter) => + api.post( + '/v1/a76/reports/movements/invoices/repair-detailed', + filters + ), + + // Exports + getExports: (filters: ExportFilter) => + api.post('/v1/a76/reports/movements/invoices/export', filters), + + getExportsDetailed: (filters: ExportFilter) => + api.post('/v1/a76/reports/movements/invoices/export-detailed', filters), + + // Export Repairs + getExportRepairs: (filters: ExportRepairFilter) => + api.post('/v1/a76/reports/movements/invoices/export-repair', filters), + + getExportRepairsDetailed: (filters: ExportRepairFilter) => + api.post( + '/v1/a76/reports/movements/invoices/export-repair-detailed', + filters + ), + + // All Movements + getAllMovements: (filters: AllMovementsFilter) => + api.post('/v1/a76/reports/movements/invoices/all', filters), + + // Async Generation + generateReportAsync: (filters: AllMovementsFilter) => + api.post<{ task_id: string }>('/v1/a76/reports/movements/invoices/generate', filters), + + getTaskStatus: (taskId: string) => + api.get<{ task_id: string; status: string; result?: any; meta?: any }>( + `/v1/a76/reports/movements/invoices/task/${taskId}` + ) +}; diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts index ea5c2194..3e537e6d 100644 --- a/frontend/src/lib/api/dashboard/a76/invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/invoices.ts @@ -319,6 +319,7 @@ export interface UpdateInvoiceData { cfdi_uuid?: string | null; path_pdf?: string | null; path_xml?: string | null; + is_updated?: boolean | null; compliance_mx?: Partial | null; financials?: Partial | null; logistics?: Partial[] | null; diff --git a/frontend/src/lib/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts index a5069c9f..582e913d 100644 --- a/frontend/src/lib/api/dashboard/a76/pedimentos.ts +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -10,12 +10,12 @@ export interface PedimentoDates { pedimento_date?: string | null; payment_date?: string | null; rectification_payment_date?: string | null; - extraction_date?: string | null; + extraction_date?: string | null; submission_date?: string | null; eucan_date?: string | null; - original_date?: string | null; - start_date?: string | null; - end_date?: string | null; + original_date?: string | null; + start_date?: string | null; + end_date?: string | null; } export interface PedimentoPayments { @@ -279,9 +279,9 @@ export const pedimentosApi = { * @param filters - Filtros opcionales * @param companyId - ID de la compañía (por defecto 1) */ - list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId = 1) => { + list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId?: number) => { let url = `/v1/a76/pedimentos/?company_id=${companyId}&page=${page}&page_size=${pageSize}`; - + if (filters?.status) { url += `&status=${encodeURIComponent(filters.status)}`; } @@ -291,7 +291,7 @@ export const pedimentosApi = { if (filters?.year) { url += `&year=${encodeURIComponent(filters.year)}`; } - + return api.get(url); }, @@ -300,14 +300,14 @@ export const pedimentosApi = { * @param id - ID del pedimento * @param companyId - ID de la compañía (por defecto 1) */ - get: (id: number, companyId = 1) => api.get(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`), + get: (id: number, companyId?: number) => api.get(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`), /** * Crea un nuevo pedimento * @param data - Datos del pedimento a crear * @param companyId - ID de la compañía (por defecto 1) */ - create: (data: CreatePedimentoData, companyId = 1) => + create: (data: CreatePedimentoData, companyId?: number) => api.post(`/v1/a76/pedimentos/?company_id=${companyId}`, data), /** @@ -316,7 +316,7 @@ export const pedimentosApi = { * @param data - Datos a actualizar * @param companyId - ID de la compañía (por defecto 1) */ - update: (id: number, data: UpdatePedimentoData, companyId = 1) => + update: (id: number, data: UpdatePedimentoData, companyId?: number) => api.put(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`, data), /** @@ -324,5 +324,5 @@ export const pedimentosApi = { * @param id - ID del pedimento a eliminar * @param companyId - ID de la compañía (por defecto 1) */ - delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) + delete: (id: number, companyId?: number) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) }; diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index e0977939..fb406f32 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -732,18 +732,14 @@
- -
- - -
+ +
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 82adf098..a9cfd904 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 @@ -74,6 +74,7 @@ is_pedimento_pending: false, pedimento_id: invoice?.compliance_mx?.pedimento_id || '', remesa: invoice?.compliance_mx?.remesa || '', + invoice_number: invoice?.invoice_number || '', invoice_date: invoice?.invoice_date || new Date().toISOString().split('T')[0], emission_date: new Date().toISOString().split('T')[0], diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 8bc0f930..af2950b8 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -5,14 +5,14 @@ import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; import PartNumberDialog from './part-number-dialog.svelte'; - import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; + import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; - let { + let { lineItem = $bindable(), - descriptions = $bindable() - }: { + descriptions = $bindable() + }: { lineItem: Partial; - descriptions: LineDescriptions; + descriptions: LineDescriptions; } = $props(); let showPartDialog = $state(false); @@ -22,30 +22,30 @@ lineItem.fa_data = {}; } - // Helper to map boolean to string for RadioGroup - let isSubPartidaValue = $derived(lineItem.fa_data?.is_subitem ? 'subpartida' : 'partida'); - function setIsSubPartida(val: string) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.is_subitem = val === 'subpartida'; - } + // Helper to map boolean to string for RadioGroup + let isSubPartidaValue = $derived(lineItem.fa_data?.is_subitem ? 'subpartida' : 'partida'); + function setIsSubPartida(val: string) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.is_subitem = val === 'subpartida'; + } - let containsSubPartidasValue = $derived(lineItem.fa_data?.contains_subitems ? 'si' : 'no'); - function setContinueSubPartidas(val: string) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.contains_subitems = val === 'si'; - } + let containsSubPartidasValue = $derived(lineItem.fa_data?.contains_subitems ? 'si' : 'no'); + function setContinueSubPartidas(val: string) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.contains_subitems = val === 'si'; + } - // Helper for subitem_number binding - let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0); - function setSubitemNumber(val: number) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.subitem_number = val; - } + // Helper for subitem_number binding + let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0); + function setSubitemNumber(val: number) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.subitem_number = val; + } function handlePartSelect(part: any) { lineItem.part_number = part.id; // Store part number for display - (lineItem as any).part_number = part.part_number; + (lineItem as any).part_number_display = part.part_number; (lineItem as any).part_description_es = part.description_spanish; (lineItem as any).part_description_en = part.description_english; } @@ -53,15 +53,12 @@ -
+
-
- Is - +
+ Is +
@@ -73,12 +70,15 @@
{#if isSubPartidaValue === 'partida'} -
- Contains Sub-Items - + Contains Sub-Items + + class="flex gap-3" + >
@@ -90,31 +90,33 @@
{:else if isSubPartidaValue === 'subpartida'} -
- Main Item Number - + Main Item Number + setSubitemNumber(e.currentTarget.valueAsNumber || 0)} - class="h-7 text-xs" + class="h-7 text-xs" placeholder="Enter main item number" /> -
- {/if} +
+ {/if}
-
+
- (showPartDialog = true)} /> @@ -124,29 +126,33 @@ class="h-7 w-7 shrink-0" onclick={() => (showPartDialog = true)} > - +
{#if (lineItem as any).part_description_es} -

{(lineItem as any).part_description_es}

+

+ {(lineItem as any).part_description_es} +

{/if}
- -
-
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte index a4a10bb3..13b20bf5 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/inv/item-sheet-inv.svelte @@ -2,14 +2,16 @@ import * as Sheet from '$lib/components/ui/sheet'; import * as Tabs from '$lib/components/ui/tabs'; import { Input } from '$lib/components/ui/input'; + import { Textarea } from '$lib/components/ui/textarea'; import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import { Badge } from '$lib/components/ui/badge'; - import { Loader2, FileText } from 'lucide-svelte'; + import { Loader2, FileText, Folder } from 'lucide-svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import type { Item } from '$lib/api/dashboard/a76/items'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosPestanasItemInv } from '$lib/config/shortcuts/dashboard/invoices/item/inventory'; + import PartNumberDialog from '../fa/part-number-dialog.svelte'; let { open = $bindable(), @@ -34,6 +36,7 @@ } = $props(); let activeTab = $state('general'); + let showPartDialog = $state(false); const tabMapping: Record = { tab1: 'general', @@ -42,6 +45,19 @@ tab4: 'otros' }; + function handlePartSelect(part: any) { + editingItem.part_number = part.id; + // Store part number for display + (editingItem as any).part_number_display = part.part_number; + if (editingItem.description) { + editingItem.description.description_spanish = part.description_spanish; + editingItem.description.description_english = part.description_english; + } + if (editingItem.customs) { + editingItem.customs.fraction = part.fraction; + } + } + useShortcuts( 'Invoice Item Form (Inventory)', obtenerAtajosPestanasItemInv({ @@ -60,18 +76,16 @@ // Initialize missing nested objects if they don't exist $effect(() => { if (open && editingItem) { - if (editingItem && !editingItem.quantity) - editingItem.quantity = {} as any; - if (editingItem && !editingItem.financial) - editingItem.financial = {} as any; - if (editingItem && !editingItem.customs) - editingItem.customs = {} as any; - if (editingItem && !editingItem.description) - editingItem.description = {} as any; + if (editingItem && !editingItem.quantity) editingItem.quantity = {} as any; + if (editingItem && !editingItem.financial) editingItem.financial = {} as any; + if (editingItem && !editingItem.customs) editingItem.customs = {} as any; + if (editingItem && !editingItem.description) editingItem.description = {} as any; } }); + +
{#if line} - +
+ (showPartDialog = true)} + /> + +
{/if}
@@ -227,20 +249,20 @@
{#if line?.description} - {/if}
{#if line?.description} - {/if}
@@ -363,7 +385,7 @@ {/if}
@@ -377,7 +399,7 @@ id="imported_quantity" type="number" placeholder="0" - bind:value={(line.quantity as any).quantity_imported} + bind:value={(line.quantity as any).quantity_imported} /> {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index a8e24e06..372198ff 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -87,6 +87,7 @@ item?.description?.description_english || '', unit_of_measure_code: item?.quantity?.unit_of_measure || item?.unit_of_measure, + part_number_display: (item as any).part_number_display || item?.part_number, fa_data: item?.fa_data || {}, warehouse: item?.warehouse, full_item: item @@ -261,11 +262,11 @@ payment_method: undefined, igi_amount: undefined, is_military_mcia: false, - wildcard_field: undefined, + wildcard_field: undefined, reference_number: '', order: invoice?.purchase_order || '', warehouse: '', - location: '', + location: '', // Nested relations financial: { unit_cost_usd: undefined, @@ -314,7 +315,7 @@ }, reference: { serie_id: undefined - } + } }; } @@ -422,8 +423,7 @@ quantity: Number(draft.quantity) || 0 }, financial: { - unit_cost_usd: - draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined + unit_cost_usd: draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined } }); } @@ -574,15 +574,15 @@ } // Load part number data - if (item.part_number) { + if (item.part_number_id) { try { const response = await fetch( - `/api-sveltekit/parts/${item.part_number}?company_id=${activeCompanyId}`, + `/api-sveltekit/parts/${item.part_number_id}?company_id=${activeCompanyId}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } } ); if (response.ok) { const partData = await response.json(); - (item as any).part_number = partData.part_number; + (item as any).part_number_display = partData.part_number; (item as any).part_description_es = partData.description_spanish; (item as any).part_description_en = partData.description_english; } @@ -661,7 +661,8 @@ if (Array.isArray(packages)) { const pkg = packages.find((p: any) => p.id === packageId); if (pkg) { - (item.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key; + (item.quantity as any).package_description = + pkg.description_es || pkg.description_en || pkg.key; (item.quantity as any).package_key = pkg.key; (item.quantity as any).package_weight_unit = pkg.weight_unit || 0; } @@ -990,15 +991,15 @@
-
-
+
+

Items de la Factura

Carga partidas, crea o aplica plantillas sin salir de esta vista.

-
+
@@ -1037,7 +1038,7 @@ Preferencia Contiene Subpartida Partida Principal - Acciones + Acciones @@ -1066,7 +1067,7 @@ {item.is_subitem ? 'S' : 'P'} {item.quantity?.quantity || '0'} {item.class_code || '-'} - {item.part_number || '-'} + {item.part_number_display || '-'} {item.is_subitem ? 'S' : 'P'} {item.quantity?.quantity || '0'} {item.class_code || '-'} - {item.part_number || '-'} + {item.part_number_display || '-'} {item.fa_data?.search_line || '-'} {item.is_subitem ? 'S' : 'P'} {item.class_code || '-'} - {item.part_number || '-'} + {item.part_number_display || '-'}
@@ -1249,10 +1250,10 @@ }} > -
+
Usar plantilla @@ -1267,7 +1268,7 @@ disabled={isLoadingPresets} class="h-8 text-muted-foreground" > - + Actualizar
-
+
-
+
@@ -1303,47 +1304,47 @@
{#if isLoadingPresets}
- + Cargando...
{:else if filteredPresets.length === 0}
- +

No se encontraron plantillas

{:else} {#each filteredPresets as preset} @@ -1496,7 +1495,11 @@
- +
@@ -1510,18 +1513,18 @@
-
+
{builderItems.length} items/líneas
-
-
- +
+ Items de la plantilla
@@ -1531,7 +1534,7 @@ # Descripción Cant. - Acciones + Acciones @@ -1539,7 +1542,7 @@ Usa el botón "Agregar Item/Línea" para definir el contenido de la plantilla. @@ -1552,7 +1555,7 @@
- + {item?.[0]?.description?.description_spanish || 'Sin descripción'} {item?.[0]?.quantity?.quantity || 0} - +
@@ -1597,14 +1600,14 @@ diff --git a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte index b53cc53f..8f80a09a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte @@ -47,6 +47,8 @@ adendas: invoice.compliance_mx?.addendum_vu || '', observations_vu: invoice.vu_observations || '', certified_number: invoice.compliance_mx?.certificate_number || '', + entry_exit_date: invoice.logistics?.entry_exit_date || '', + payment_date: invoice.logistics?.payment_date || '', // New Export fields bill_number: invoice.logistics?.bill_number || '', guide_number: invoice.logistics?.guide_number || '', @@ -80,6 +82,8 @@ adendas: '', observations_vu: '', certified_number: '', + entry_exit_date: '', + payment_date: '', // New Export fields bill_number: '', guide_number: '', @@ -145,8 +149,8 @@
- (formData.is_mixed = v === 'true')} class="flex gap-4" > @@ -297,7 +301,6 @@
-
@@ -317,6 +320,16 @@
+ +
+ + +
+
+ + +
+
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 aa8070a8..141a1fbd 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -114,6 +114,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI invoice_type: InvoiceTopFieldsFormData?.invoice_type || undefined, document_type: generalFormData?.document_type || undefined, invoice_number: InvoiceTopFieldsFormData?.invoice_number || undefined, + purchase_order: InvoiceTopFieldsFormData?.purchase_order || continuationFormData?.purchase_order || undefined, invoice_date: InvoiceTopFieldsFormData?.invoice_date || undefined, emission_date: InvoiceTopFieldsFormData?.emission_date || undefined, proforma_number: observationFormData?.proforma_number || undefined, @@ -126,7 +127,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI vu_observations: othersFormData?.observations_vu || undefined, comments_status: othersFormData?.comments_status || undefined, option_iv18: othersFormData?.option_iv18 || undefined, - purchase_order: continuationFormData?.purchase_order || undefined, + payment_terms: continuationFormData?.payment_terms || undefined, handling_fees: continuationFormData?.handling_fees || undefined, cfdi_uuid: continuationFormData?.cfdi_uuid || undefined, @@ -234,8 +235,9 @@ function buildComplianceMxData(invoiceType: string, InvoiceTopFieldsFormData: an function buildFinancialsData(generalFormData: any, observationFormData: any, othersFormData: any, InvoiceTopFieldsFormData?: any) { return { // Currency from generalFormData - currency_type: generalFormData?.currency_type || null, + currency_type: generalFormData?.currency_type || (generalFormData?.currency === 'foreign' ? 'USD' : null), currency: generalFormData?.currency || null, + exchange_rate: generalFormData?.exchange_rate ? Number(generalFormData.exchange_rate) : null, iva_factor: (InvoiceTopFieldsFormData?.iva_factor || generalFormData?.iva_factor) ? String(InvoiceTopFieldsFormData?.iva_factor || generalFormData.iva_factor) : null, // Costs & increments from observationFormData freight: observationFormData?.freight || null, @@ -255,9 +257,11 @@ function buildLogisticsData(generalFormData: any, observationFormData: any, othe // Retornar logistics como objeto único con datos de continuación return { carrier_id: generalFormData?.carrier_id || null, + transport_id: generalFormData?.transport_id || null, transport_type: generalFormData?.transport_type || 'none', driver_name: generalFormData?.driver_name || null, - vehicle_num: generalFormData?.transport_num || continuationFormData?.numero_tipo_transporte || null, + license_plate: generalFormData?.transport_num || null, + vehicle_num: continuationFormData?.numero_tipo_transporte || null, incoterm: observationFormData?.incoterm || null, // Campos de continuación mapeados a logistics transport_num: continuationFormData?.numero_tipo_transporte || null, @@ -285,6 +289,10 @@ function buildLogisticsData(generalFormData: any, observationFormData: any, othe green_light_us: continuationFormData?.semaforo_verde_aduana_americana || false, red_light_mx: continuationFormData?.semaforo_rojo_aduana_mexicana || false, red_light_us: continuationFormData?.semaforo_rojo_aduana_americana || false, + // Fechas Logísticas de OthersTabForm (se pasan en othersFormData) + entry_exit_date: othersFormData?.entry_exit_date || null, + payment_date: othersFormData?.payment_date || null, + vehicle_data: continuationFormData?.vehicle_data || null, }; } diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte index a7a749f6..1682fadb 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte @@ -83,6 +83,22 @@ actualizar_ieps_rect: boolean; calcular_recargos_diferencias: boolean; + // Rectificación (R1) + es_rectificacion: string; + pedimento_original: string; + clave_pedimento_original: string; + fecha_pago_original: string; + anio_impresion_rect: string; + aduana_seccion_original: string; + patente_original: string; + utilizar_fecha_pago_original: boolean; + calculo_manual_contribuciones: boolean; + causa: string; + motivo: string; + + // Diferencias en contribuciones + liquidacion_diferencias: { gravamen: string; forma_pago: string; importe: string }[]; + // Parámetro de cambio de DTA proveedor_nacional_modifico_dta: boolean; @@ -133,6 +149,18 @@ actualizar_cc_rect: false, actualizar_ieps_rect: false, calcular_recargos_diferencias: false, + es_rectificacion: '', + pedimento_original: '', + clave_pedimento_original: '', + fecha_pago_original: '', + anio_impresion_rect: '', + aduana_seccion_original: '', + patente_original: '', + utilizar_fecha_pago_original: false, + calculo_manual_contribuciones: false, + causa: '', + motivo: '', + liquidacion_diferencias: [], proveedor_nacional_modifico_dta: false, calculo_valor_aduana_v2: false, calculo_2_decimales_valor_unitario: false, @@ -275,20 +303,6 @@ // Estados para vista de Rectificación let showRectificacion = $state(false); - let rectificacionFormData = $state({ - es_rectificacion: '', - pedimento_original: '00-0000:0000000', - clave_pedimento_original: '', - fecha_pago_original: '', - anio_impresion_rect: '', - aduana_seccion_original: '', - patente_original: '', - utilizar_fecha_pago_original: false, - calculo_manual_contribuciones: false - }); - let liquidacionDiferencias = $state<{ gravamen: string; forma_pago: string; importe: string }[]>( - [] - ); // Estados para diálogo de diferencias en contribuciones let isDiferenciasDialogOpen = $state(false); @@ -308,10 +322,6 @@ // Estados para vista de Rectificación II let showRectificacionII = $state(false); - let rectificacionIIFormData = $state({ - causa: '', - motivo: '' - }); // Estados para vista de Notas let showNotas = $state(false); @@ -576,9 +586,9 @@ }; if (editingDiferenciaIndex !== null) { - liquidacionDiferencias[editingDiferenciaIndex] = newItem; + formData.liquidacion_diferencias[editingDiferenciaIndex] = newItem; } else { - liquidacionDiferencias.push(newItem); + formData.liquidacion_diferencias = [...formData.liquidacion_diferencias, newItem]; } isDiferenciasDialogOpen = false; } @@ -737,7 +747,7 @@ } -
+
{#if !showBitacora && !showPartesII && !showRectificacion && !showRectificacionII && !showNotas && !showSeleccionAutomatizada && !showMultas} @@ -748,7 +758,7 @@ -
+

Parámetros de cálculo:

@@ -792,49 +802,49 @@ id="dta_operacion" bind:checked={formData.dta_por_operacion_ag_facturas} /> -
-
- +
-
-
+
-
-
-
@@ -844,7 +854,7 @@ id="cuota_fija_veh" bind:checked={formData.cuota_fija_adicional_vehiculo} /> -
@@ -870,7 +880,7 @@
-
@@ -888,7 +898,7 @@
-
@@ -909,7 +919,7 @@
-
+

@@ -918,35 +928,35 @@
-
-
-
-
-
@@ -959,35 +969,35 @@
-
-
-
-
-
@@ -997,7 +1007,7 @@ id="calc_recargos" bind:checked={formData.calcular_recargos_diferencias} /> -
@@ -1006,7 +1016,7 @@
-
+

Parámetro de cambio de DTA:

@@ -1015,15 +1025,15 @@ id="prov_nacional" bind:checked={formData.proveedor_nacional_modifico_dta} /> -
-
+
-
@@ -1033,7 +1043,7 @@ id="calc_2dec" bind:checked={formData.calculo_2_decimales_valor_unitario} /> -
@@ -1043,7 +1053,7 @@ id="calc_base_part" bind:checked={formData.calcular_valor_aduana_base_partidas} /> -
@@ -1058,42 +1068,42 @@
-
-
-
-
- +
-
+
@@ -1126,7 +1136,7 @@ Registro de movimientos sobre el Pedimento -
+
@@ -1141,13 +1151,13 @@ {#if bitacoraMovimientos.length === 0} - + No hay movimientos registrados {:else if paginatedBitacoraMovimientos.length === 0} - + No hay datos en esta página @@ -1175,7 +1185,7 @@ variant="outline" onclick={goToBitacoraFirstPage} disabled={currentBitacoraPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1184,11 +1194,11 @@ variant="outline" onclick={goToBitacoraPreviousPage} disabled={currentBitacoraPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > - + Página {currentBitacoraPage + 1} de {totalBitacoraPages || 1} @@ -1205,7 +1215,7 @@ variant="outline" onclick={goToBitacoraLastPage} disabled={currentBitacoraPage >= totalBitacoraPages - 1} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1223,14 +1233,14 @@ {:else if showPartesII} -
+
Embarques Parciales -
+
@@ -1249,13 +1259,13 @@ {#if embarquesParciales.length === 0} - + No hay embarques registrados {:else if paginatedEmbarquesParciales.length === 0} - + No hay datos en esta página @@ -1297,7 +1307,7 @@ variant="outline" onclick={goToPartesIIFirstPage} disabled={currentPartesIIPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1306,11 +1316,11 @@ variant="outline" onclick={goToPartesIIPreviousPage} disabled={currentPartesIIPage === 0} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > - + Página {currentPartesIIPage + 1} de {totalPartesIIPages || 1} @@ -1327,7 +1337,7 @@ variant="outline" onclick={goToPartesIILastPage} disabled={currentPartesIIPage >= totalPartesIIPages - 1} - class="w-8 h-8 p-0" + class="h-8 w-8 p-0" > @@ -1374,42 +1384,42 @@ -
+
- +
-
+
- +

Partidas del Pedimento

-
+
- +
-
+
- +

Embarques

-
+
- +
-
+
- +
-
+
- +
@@ -1455,21 +1465,21 @@ Rectificación -
+
{ - if (v) rectificacionFormData.es_rectificacion = v; + if (v) formData.es_rectificacion = v; }} > - {rectificacionFormData.es_rectificacion === 'si' + {formData.es_rectificacion === 'si' ? 'Sí' - : rectificacionFormData.es_rectificacion === 'no' + : formData.es_rectificacion === 'no' ? 'No' : 'Seleccionar'} @@ -1485,7 +1495,8 @@
@@ -1496,7 +1507,8 @@ >
@@ -1505,7 +1517,7 @@
@@ -1513,7 +1525,8 @@
@@ -1521,13 +1534,14 @@
- +
@@ -1535,10 +1549,9 @@
{ - rectificacionFormData.utilizar_fecha_pago_original = - !rectificacionFormData.utilizar_fecha_pago_original; + formData.utilizar_fecha_pago_original = !formData.utilizar_fecha_pago_original; }} />
-
-
+
+

Cuadro de liquidación para diferencias en contribuciones

{ - rectificacionFormData.calculo_manual_contribuciones = - !rectificacionFormData.calculo_manual_contribuciones; + formData.calculo_manual_contribuciones = + !formData.calculo_manual_contribuciones; }} />
-
+
@@ -1580,14 +1593,14 @@ - {#if liquidacionDiferencias.length === 0} + {#if formData.liquidacion_diferencias.length === 0} No hay registros {:else} - {#each liquidacionDiferencias as item} + {#each formData.liquidacion_diferencias as item} {item.gravamen} {item.forma_pago} @@ -1631,8 +1644,8 @@ @@ -1641,8 +1654,8 @@ @@ -1659,7 +1672,7 @@ -
+
@@ -1668,16 +1681,16 @@ Comentario - + Fecha Hora - + {#if notasPedimento.length === 0} - + No hay notas registradas @@ -1696,7 +1709,7 @@ -
+
- + Página {currentNotasPage + 1} de {totalNotasPages || 1}
@@ -1829,7 +1842,7 @@
@@ -1873,7 +1886,7 @@
- +
-
+
# Descripción de Mercancía - Cantidad UMC - Cantidad UMT - Peso + Cantidad UMC + Cantidad UMT + Peso {#if mercancias.length === 0} - + No hay mercancías registradas @@ -2086,17 +2099,17 @@ -
- - - -
@@ -2109,7 +2122,7 @@ type="number" bind:value={currentEmbarque.cantidad_transportes} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2119,7 +2132,7 @@ type="number" bind:value={currentEmbarque.cantidad_partidas} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2130,7 +2143,7 @@ step="0.001" value={currentEmbarque.suma_cantidad_umc.toFixed(3)} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2140,7 +2153,7 @@ type="number" bind:value={currentEmbarque.cantidad_embarques} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2150,7 +2163,7 @@ type="number" bind:value={currentEmbarque.cantidad_mercancias} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2161,13 +2174,13 @@ step="0.001" value={currentEmbarque.suma_cantidad_umc_embarques.toFixed(3)} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> -
+
@@ -2190,7 +2203,7 @@ - + Mercancías del Embarque Parcial @@ -2201,7 +2214,7 @@
@@ -2311,7 +2324,7 @@
diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte index cd7288cb..35f52052 100644 --- a/frontend/src/lib/components/keyboard/KeyboardManager.svelte +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -117,6 +117,7 @@ const authenticated = isAuthenticated(); const { key, altKey, ctrlKey, metaKey, shiftKey } = event; + if (!key) return; const lowerKey = key.toLowerCase(); // Ignore standalone modifiers diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 90c86c47..5e7fe6ae 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -5,6 +5,7 @@ import { BadgeCheck, ChartPie, Database, + FileSearch, FileText, Frame, GalleryVerticalEnd, @@ -13,9 +14,8 @@ import { Package, Settings2, Shield, - Users, Ship, - MoreHorizontal, + Users, } from 'lucide-svelte'; import * as m from "$lib/paraglide/messages.js"; import { Title } from '../ui/alert'; @@ -473,6 +473,17 @@ export function getSidebarData(): SidebarData { icon: BadgeCheck, items: [], }, + { + title: "Reportes", + url: "#", + icon: FileSearch, + items: [ + { + title: "Facturas Impo/Expo", + url: "/dashboard/reports/invoices", + }, + ], + }, { title: m["sidebar.reference_data.configuracion"](), url: "#", diff --git a/frontend/src/lib/components/ui/icons/FolderIcon.svelte b/frontend/src/lib/components/ui/icons/FolderIcon.svelte new file mode 100644 index 00000000..beada7c8 --- /dev/null +++ b/frontend/src/lib/components/ui/icons/FolderIcon.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/src/lib/date-utils.ts b/frontend/src/lib/date-utils.ts index 71c7d963..e21c9f25 100644 --- a/frontend/src/lib/date-utils.ts +++ b/frontend/src/lib/date-utils.ts @@ -22,7 +22,9 @@ export function prepareDateForBackend(dateStr: string, timeStr: string = '00:00' // Combinar fecha y hora usando la zona horaria local const dateTime = toCalendarDateTime(date, time); const zonedDateTime = dateTime.toDate(localTimeZone); - return zonedDateTime.toISOString(); + // Convertir a objeto Date nativo de JavaScript para obtener ISO string correcto + const jsDate = new Date(zonedDateTime.toString()); + return jsDate.toISOString(); } catch (e) { console.error('Error parsing date:', e); return null; diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 95c9163b..44acac62 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -58,7 +58,7 @@
- {@render children()} + {@render children?.()}
diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index ba7e4207..8f5bbad8 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -233,8 +233,6 @@ import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosEdicionSocio } from '$lib/config/shortcuts/dashboard/clients_and_providers/edit'; - // ... previous imports / code ... - function handleCancel() { goto('/dashboard/clients_and_providers'); } @@ -271,7 +269,7 @@ Nuevo {/if}
-

+

{isEditing ? 'Edita la información del cliente o proveedor' : 'Registra un nuevo cliente o proveedor en el sistema'} @@ -314,11 +312,11 @@

- (formData.client_or_provider = v)} - > + (formData.client_or_provider = v)} + > {typeLabels[formData.client_or_provider] || 'Selecciona un tipo'} @@ -331,8 +329,8 @@
-
-
+
+
- (formData.type_nat_foreign = v)} - > + (formData.type_nat_foreign = v)} + > {formData.type_nat_foreign === 'N' ? 'Nacional' @@ -403,7 +401,7 @@ Ubicación fiscal y datos de contacto. -
+
-
+
-
+
@@ -488,7 +486,7 @@ -
+
@@ -597,7 +595,7 @@
-
+
@@ -606,7 +604,7 @@

-
+
-
+
diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index ebde6753..5a0af337 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -16,7 +16,11 @@ fa_class_id?: number; depreciation_rate?: number | null; fda_code?: string | null; + eccn_code?: string | null; class_enabled?: boolean | null; + // Virtual fields for form compatibility + annual_depreciation_rate?: number | string | null; + fda_key?: string | null; } // Estado de la lista de clases @@ -42,6 +46,9 @@ fraction: '', us_fraction: '', unit_measure_trade: '', + depreciation_rate: null as number | null, + fda_code: '', + eccn_code: '', bom: '' }); @@ -117,227 +124,13 @@ fraction: cls.fraction || '', us_fraction: cls.us_fraction || '', unit_measure_trade: '', + depreciation_rate: (cls as FixedAssetClassExtended).depreciation_rate || null, + fda_code: (cls as FixedAssetClassExtended).fda_code || '', + eccn_code: (cls as FixedAssetClassExtended).eccn_code || '', bom: '' }; } - async function saveFixedAssetClass(formData: any) { - const companyId = companyStore.activeCompany?.id; - - // CAMBIO: Usar $state.snapshot para obtener una copia real, no reactiva - const data = $state.snapshot(formData); - - if (!companyId) { - toast.error('No hay empresa seleccionada'); - throw new Error('No hay empresa seleccionada'); - } - - // Validar campos obligatorios - const missingFields: string[] = []; - - if (!data.class_code?.trim()) { - missingFields.push('Código de clase'); - } - if (!data.description_es?.trim()) { - missingFields.push('Descripción en español'); - } - if (!data.material_key?.trim()) { - missingFields.push('Tipo de activo fijo'); - } - if (!data.unit_of_measure?.trim()) { - missingFields.push('Unidad de medida comercial'); - } - if (!data.fraction?.trim()) { - missingFields.push('Fracción arancelaria'); - } - - if (missingFields.length > 0) { - const fieldsList = missingFields.join(', '); - validationError = `Debe completar los siguientes campos obligatorios: ${fieldsList}`; - toast.error(validationError, { - duration: 8000 - }); - throw new Error(`Campos obligatorios faltantes: ${fieldsList}`); - } - - // Limpiar error de validación si todo está bien - validationError = ''; - - try { - // Usar el endpoint combinado /fa que crea ambos registros en una transacción - const payload = { - class_code: data.class_code.trim(), - description_es: data.description_es.trim(), - description_en: data.description_en?.trim() || '', - material_key: data.material_key.trim(), - unit_of_measure: data.unit_of_measure.trim(), - fraction: data.fraction.trim(), - us_fraction: data.us_fraction?.trim() || '', - sub_key: data.sub_key || '', - physical_review: data.physical_review ? 1 : 0, - iva_exempt_fraction: data.iva_exempt_fraction || '', - // FA-specific fields - import_tariff_code: data.import_tariff_code || null, - import_tariff_type: data.import_tariff_type || null, - export_tariff_code: data.export_tariff_code || null, - export_tariff_type: data.export_tariff_type || null, - depreciation_rate: data.annual_depreciation_rate || null, - fda_code: data.fda_key || null, - eccn_code: data.eccn_code || null, - class_enabled: true - }; - - const response = await classesApi.createFA(payload, companyId); - - if (response.error) { - console.error('Server error:', response.error); - - // Manejar diferentes formatos de error - let errorMessage = response.error; - let isDuplicateError = false; - - // Detectar si es un error de código duplicado - if (errorMessage.includes('código') && errorMessage.includes('ya está en uso')) { - isDuplicateError = true; - } - - // Mensaje más específico para errores de duplicado - if (isDuplicateError) { - validationError = `⚠️ ${errorMessage}\n\nPor favor, cambie el código de clase a uno diferente.`; - } else { - validationError = `⚠️ ${errorMessage}`; - } - - toast.error(errorMessage, { duration: 8000 }); - throw new Error(errorMessage); - } - - validationError = ''; - toast.success('✅ Clase de activo fijo creada correctamente'); - return response.data; - } catch (error: any) { - console.error('Error saving fixed asset class:', error); - // El toast ya se mostró arriba, solo re-lanzar el error - throw error; - } - } - - async function updateFixedAssetClass(formData: any) { - const companyId = companyStore.activeCompany?.id; - - // CAMBIO 1: Usar $state.snapshot para obtener una copia real, no reactiva - // Esto garantiza que aunque el hijo borre el formulario, 'data' mantenga los valores - const data = $state.snapshot(formData); - - if (!companyId || !selectedClass) { - toast.error('No hay empresa o clase seleccionada'); - return; - } - - // CAMBIO 2: Validar sobre 'data' (la copia muerta) - const missingFields: string[] = []; - if (!data.class_code?.trim()) missingFields.push('Código de clase'); - if (!data.description_es?.trim()) missingFields.push('Descripción en español'); - if (!data.material_key?.trim()) missingFields.push('Tipo de activo fijo'); - if (!data.unit_of_measure?.trim()) missingFields.push('Unidad de medida comercial'); - if (!data.fraction?.trim()) missingFields.push('Fracción arancelaria'); - - if (missingFields.length > 0) { - const errorMsg = `Campos obligatorios faltantes: ${missingFields.join(', ')}`; - validationError = `⚠️ ${errorMsg}`; - toast.error(errorMsg); - // Lanzamos el error para que el 'onSave' del Dialog no cierre la ventana - throw new Error(errorMsg); - } - - validationError = ''; - - try { - // CAMBIO 3: Usar siempre 'data' para los payloads - const a76Response = await classesApi.update( - selectedClass.id, - { - class_code: data.class_code.trim(), - description_es: data.description_es.trim(), - description_en: data.description_en?.trim() || '', - material_key: data.material_key.trim(), - unit_of_measure: data.unit_of_measure.trim(), - fraction: data.fraction.trim(), - us_fraction: data.us_fraction || '', - physical_review: data.physical_review ? 1 : 0, - iva_exempt_fraction: data.iva_exempt_fraction || '' - }, - companyId - ); - - if (selectedClass.fa_class_id) { - await faClassesApi.update( - selectedClass.fa_class_id, - { - depreciation_rate: data.annual_depreciation_rate || null, - fda_code: data.fda_key || null - }, - companyId - ); - } else { - await faClassesApi.create( - { - class_id: selectedClass.id, - depreciation_rate: data.annual_depreciation_rate || null, - fda_code: data.fda_key || null, - class_enabled: true - }, - companyId - ); - } - - toast.success('Clase actualizada correctamente'); - return { a76: a76Response.data }; - } catch (error: any) { - console.error('Error updating fixed asset class:', error); - console.error('Error response:', error?.response); - console.error('Error response data:', error?.response?.data); - console.error('Error response detail:', error?.response?.data?.detail); - console.error('Error type:', typeof error?.response?.data?.detail); - - let errorMessage = 'Error al actualizar la clase'; - let isDuplicateError = false; - - // Extract error message from response - if (error?.response?.data?.detail) { - if (Array.isArray(error.response.data.detail)) { - errorMessage = error.response.data.detail - .map((e: any) => `${e.loc ? e.loc.join(' → ') : ''}: ${e.msg || e}`) - .join(', '); - } else if (typeof error.response.data.detail === 'string') { - errorMessage = error.response.data.detail; - // Detectar si es un error de código duplicado - if (errorMessage.includes('código') && errorMessage.includes('ya está en uso')) { - isDuplicateError = true; - } - } else { - errorMessage = JSON.stringify(error.response.data.detail); - } - } else if (error?.message) { - errorMessage = error.message; - } - - console.error('Final error message:', errorMessage); - console.error('Is duplicate error:', isDuplicateError); - - // Mensaje más específico para errores de duplicado - if (isDuplicateError) { - validationError = `⚠️ ${errorMessage}\n\nPor favor, cambie el código de clase a uno diferente.`; - } else { - validationError = `⚠️ ${errorMessage}`; - } - - toast.error(errorMessage, { duration: 8000 }); - - console.error('Toast shown, about to throw error'); - throw error; - } - } function handleNew() { selectedClass = null; formData = { @@ -349,6 +142,9 @@ fraction: '', us_fraction: '', unit_measure_trade: '', + depreciation_rate: null as number | null, + fda_code: '', + eccn_code: '', bom: '' }; } @@ -397,6 +193,9 @@ fraction: '', us_fraction: '', unit_measure_trade: '', + depreciation_rate: null as number | null, + fda_code: '', + eccn_code: '', bom: '' }; } catch (error) { @@ -629,6 +428,18 @@ {formData.fraction || '0000.00.00'}

+ + +
+ +

+ {formData.eccn_code || '---'} +

+
@@ -752,6 +563,48 @@ companyId ); + // También actualizar la extensión FA + if (selectedClass.fa_class_id) { + await faClassesApi.update( + selectedClass.fa_class_id, + { + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null + }, + companyId + ); + } else { + await faClassesApi.create( + { + class_id: selectedClass.id, + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null, + class_enabled: true + }, + companyId + ); + } + // ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error, status } if (response.error) { console.error('❌ Error en respuesta de actualización:', response); @@ -770,8 +623,18 @@ sub_key: cleanData.sub_key || '', physical_review: cleanData.physical_review ? 1 : 0, iva_exempt_fraction: cleanData.iva_exempt_fraction || '', - depreciation_rate: cleanData.depreciation_rate || null, - fda_code: cleanData.fda_code || null, + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null, class_enabled: true }; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index c09cd9e3..e6a1f74c 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -190,7 +190,7 @@ selectedInvoiceId = null; } else { selectedInvoiceId = invoice.id; - } + } } const selectedInvoice = $derived( @@ -584,6 +584,36 @@ reloadData(); } + async function handleUpdateStatus(status: boolean) { + if (!selectedInvoice || !companyStore.activeCompany) { + toast.info('Seleccione una factura para cambiar su estatus'); + return; + } + + loading = true; + try { + const companyId = companyStore.activeCompany.id; + const response = await invoicesApi.update(selectedInvoice.id, companyId, { + id: selectedInvoice.id, + is_updated: status + }); + + if (response.error) { + toast.error( + `Error al ${status ? 'actualizar' : 'desactualizar'} factura: ${response.error}` + ); + } else { + toast.success(`Factura ${status ? 'actualizada' : 'desactualizada'} correctamente`); + reloadData(); + } + } catch (e) { + console.error('Error updating status:', e); + toast.error('Error inesperado al cambiar el estatus'); + } finally { + loading = false; + } + } + // Opciones de tipo de operación para el filtro const operationTypeOptions = [ { value: '', label: 'Todas' }, @@ -785,11 +815,21 @@
- - diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 506ec342..df9e4fec 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -225,7 +225,7 @@ // Función para mapear la factura existente a los formData function mapInvoiceToTopFields(invoice: any) { if (!invoice) return topFieldsSkeleton; - + let operationType: string | null = null; if (invoice.operation_type) { operationType = invoice.operation_type; @@ -251,7 +251,7 @@ function mapInvoiceToGeneral(invoice: any) { if (!invoice) return generalSkeleton; - + return { provider_header: invoice.compliance_mx?.provider_header || 'proveedor', provider_id: invoice.compliance_mx?.provider_id || null, @@ -278,7 +278,7 @@ function mapInvoiceToObservations(invoice: any) { if (!invoice) return observationSkeleton; - + return { observation_es: invoice.observation_es || '', observation_en: invoice.observation_en || '', @@ -300,7 +300,7 @@ function mapInvoiceToItems(invoice: any) { if (!invoice) return ensureItemsFormData(null); - + return { items: invoice.items || [] }; @@ -308,7 +308,7 @@ function mapInvoiceToOthers(invoice: any) { if (!invoice) return othersSkeleton; - + return { comments_status: invoice.comments_status || '', transport_mode: invoice.logistics?.transport_mode || 'TRUCK', @@ -337,7 +337,7 @@ function mapInvoiceToContinuation(invoice: any) { if (!invoice) return continuationSkeleton; - + return { numero_tipo_transporte: invoice.logistics?.numero_tipo_transporte || '', es_ferrocarril: invoice.logistics?.es_ferrocarril || 'no', @@ -351,10 +351,13 @@ funge_como_cd: invoice.logistics?.acts_as_cd || false, llego_pedimento: invoice.compliance_mx?.llego_pedimento || false, errores_facturacion: invoice.errores_facturacion || [], - semaforo_verde_aduana_mexicana: invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, - semaforo_verde_aduana_americana: invoice.compliance_mx?.semaforo_verde_aduana_americana || false, + semaforo_verde_aduana_mexicana: + invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, + semaforo_verde_aduana_americana: + invoice.compliance_mx?.semaforo_verde_aduana_americana || false, semaforo_rojo_aduana_mexicana: invoice.compliance_mx?.semaforo_rojo_aduana_mexicana || false, - semaforo_rojo_aduana_americana: invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, + semaforo_rojo_aduana_americana: + invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, is_mixed: invoice.compliance_mx?.is_mixed || false, reason_export: invoice.compliance_mx?.reason_export || '1', purchase_order: invoice.purchase_order || '', @@ -405,8 +408,8 @@ !data.isCreate ? !!data.invoice : !!data.defaultSettings?.observationFormData ); let itemsExists = $state( - !data.isCreate - ? !!(data.invoice?.items && data.invoice.items.length > 0) + !data.isCreate + ? !!(data.invoice?.items && data.invoice.items.length > 0) : !!data.defaultSettings?.itemsFormData?.items?.length ); let othersExists = $state( @@ -484,7 +487,7 @@ const actualResponse = response as any; const items = actualResponse.data?.items || []; - if (items.length === 0) { + if (items.length === 0) { if (!uiStore.isExchangeRateDialogOpen) { missingExchangeRateDate = date; showExchangeRateDialog = true; diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index dd9613e9..6898d88d 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -85,7 +85,7 @@ function handleRowClick(pedimento: Pedimento) { // Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar - selectedId = selectedId === pedimento.id ? null : pedimento.id; + selectedId = selectedId === pedimento.id ? null : pedimento.id; } function handleEditSelected() { @@ -149,6 +149,11 @@ try { const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -194,7 +199,12 @@ error = null; try { - const companyId = companyStore.activeCompany?.id || 1; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -248,7 +258,12 @@ error = null; try { - const companyId = companyStore.activeCompany.id; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -334,17 +349,17 @@ -
+
{ + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + /> +
+
+ + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + /> +
+
+ + + +
+ +
+ +
+ {#each Object.keys(types.import) as key} +
+ + +
+ {/each} +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#each Object.keys(types.export.additional) as key} +
+ + +
+ {/each} +
+
+
+ + +
+ +
+ {#each Object.keys(types.other) as key} +
+ { + if (key === 'TODAS') handleTodasChange(v as boolean); + }} + /> + +
+ {/each} +
+
+
+ + + + + + + + Filtros e Identificadores + + + +
+ {#each [{ label: 'Proveedor', key: 'provider' as const }, { label: 'Vendido a', key: 'soldTo' as const }, { label: 'Clave de Pedimento', key: 'pedimentoKey' as const }] as item} +
+ +
+ + +
+
+ {/each} +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + Configuración Final + + + +
+
+ + +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+
+ + +
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + +
+
+ + + { + if (!v) showResults = false; + }} + > + + +
+
+ + + {reportTitle} + + + {results.length} registros encontrados • {currencyLabel} + +
+
+ + + + Cerrar + +
+
+
+ +
+
+
+ + + {#each config.reportType === 'normal' ? ['PEDIMENTO', 'CLAVE', 'FACTURA', 'FECHA FACT', 'VALOR COM.', 'TIPO OPER.', 'ESTATUS', 'PROYECTO'] : ['PEDIMENTO', 'FACTURA', 'FECHA FACT', 'PROVEEDOR', 'CLIENTE', 'CANTIDAD', 'DESC. ESPAÑOL', 'TIPO OPER.'] as header} + + {/each} + + + + {#each results as row} + + {#if config.reportType === 'normal'} + + + + + + + + + {:else} + {@const detailRow = row as MovementItemDetailed} + + + + + + + + + {/if} + + {/each} + +
+ {header} +
{row.Pedimento || '-'}{row.ClavePed || '-'}{row.Factura}{formatDateFromYYYYMMDD(row.FechaFactura)}${formatCurrency(row.ValorComercialMN)} + + {row.TipoMovTemDef} + + + + {row.Estatus || 'A'} + + {row.BaseDeDatos}{detailRow.Pedimento || '-'}{detailRow.Factura}{formatDateFromYYYYMMDD(detailRow.FechaFactura)}{detailRow.Proveedor || '-'}{detailRow.VendidoA || '-'}{detailRow.CantidadIE || '0'}{detailRow.DescripcionE || '-'} + + {detailRow.TipoMovTemDef} + +
+
+
+ + +
+ + + + + + + Seleccionar {dialogType === 'pedimentoKey' + ? 'Clave de Pedimento' + : dialogType === 'provider' + ? 'Proveedor' + : 'Cliente'} + + + Busca y selecciona {dialogType === 'pedimentoKey' + ? 'una clave de pedimento' + : dialogType === 'provider' + ? 'un proveedor' + : 'un cliente'} de la lista + + + +
+
+ + +
+ +
+ {#if dialogType === 'pedimentoKey'} + + + + + + + + + + {#if filteredItems.length === 0} + + + + {:else} + {#each filteredItems as item} + selectItem(item)} + > + + + + + {/each} + {/if} + +
CódigoDescripciónAcción
+ No se encontraron resultados +
{item.code || '-'}{item.description || '-'} + +
+ {:else} + + + + + + + + + + + + + + + + + {#if filteredItems.length === 0} + + + + {:else} + {#each filteredItems as item} + selectItem(item)} + > + + + + + + + + + + + + {/each} + {/if} + +
ClaveNombreTipoRFCCallesNúm. ExtCPColoniaCiudadAcción
+ No se encontraron resultados +
{item.id || '-'}{item.name || '-'} + + {item.client_or_provider === 'provider' + ? 'P' + : item.client_or_provider === 'client' + ? 'C' + : 'A'} + + {item.rfc || '-'}{item.address?.streets || '-'}{item.address?.exterior_number || '-'}{item.address?.postal_code || '-'}{item.address?.neighborhood || '-'}{item.address?.city || '-'} + +
+ {/if} +
+
+ + + + +
+
+ + + + +{#if contextMenu.open} +
+ +
+{/if} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 47270527..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,157 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - lucide-svelte: - specifier: ^0.552.0 - version: 0.552.0(svelte@5.43.2) - -packages: - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@sveltejs/acorn-typescript@1.0.6': - resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} - peerDependencies: - acorn: ^8.9.0 - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - - esrap@2.1.2: - resolution: {integrity: sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - - lucide-svelte@0.552.0: - resolution: {integrity: sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w==} - peerDependencies: - svelte: ^3 || ^4 || ^5.0.0-next.42 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - svelte@5.43.2: - resolution: {integrity: sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==} - engines: {node: '>=18'} - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - -snapshots: - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': - dependencies: - acorn: 8.15.0 - - '@types/estree@1.0.8': {} - - acorn@8.15.0: {} - - aria-query@5.3.2: {} - - axobject-query@4.1.0: {} - - clsx@2.1.1: {} - - esm-env@1.2.2: {} - - esrap@2.1.2: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - locate-character@3.0.0: {} - - lucide-svelte@0.552.0(svelte@5.43.2): - dependencies: - svelte: 5.43.2 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - svelte@5.43.2: - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@types/estree': 1.0.8 - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - clsx: 2.1.1 - esm-env: 1.2.2 - esrap: 2.1.2 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.21 - zimmerframe: 1.1.4 - - zimmerframe@1.1.4: {}