Merge pull request 'feature/Invoice-movements' (#158) from feature/Invoice-movements into development
Reviewed-on: ADUANASOFT/anexo76#158
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
# ==========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -28,6 +28,7 @@ class PedimentoRectificationDestinationService:
|
||||
.filter(
|
||||
PedimentoRectificationDestination.pedimento_id == pedimento_id,
|
||||
PedimentoRectificationDestination.tenant_id == tenant_id,
|
||||
PedimentoRectificationDestination.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ class PedimentoRectificationOriginService:
|
||||
.filter(
|
||||
PedimentoRectificationOrigin.pedimento_id == pedimento_id,
|
||||
PedimentoRectificationOrigin.tenant_id == tenant_id,
|
||||
PedimentoRectificationOrigin.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ''
|
||||
@@ -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()
|
||||
748
backend/api/v1/modules/a76/reports/movements/invoices/routes.py
Normal file
748
backend/api/v1/modules/a76/reports/movements/invoices/routes.py
Normal file
@@ -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
|
||||
609
backend/api/v1/modules/a76/reports/movements/invoices/schemas.py
Normal file
609
backend/api/v1/modules/a76/reports/movements/invoices/schemas.py
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
@@ -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 = '<factura>'
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
108
backend/api/v1/modules/a76/reports/movements/invoices/tasks.py
Normal file
108
backend/api/v1/modules/a76/reports/movements/invoices/tasks.py
Normal file
@@ -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()
|
||||
@@ -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"])
|
||||
router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"])
|
||||
|
||||
Reference in New Issue
Block a user