feat: Implement detailed and normal report types for invoice movements, enhancing number formatting and API request handling.

This commit is contained in:
Galindo97
2026-02-05 16:00:22 -06:00
parent e3cf5dc8f5
commit 264100e2ea
21 changed files with 2276 additions and 1752 deletions

View File

@@ -67,12 +67,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:

View File

@@ -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:

View File

@@ -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"}

View File

@@ -95,10 +95,13 @@ class FacturaImportacionUsaService:
return pdfkit.configuration(wkhtmltopdf=path)
def formatear_numero(self, valor, decimales: int = 2):
if valor is None: return 0.0
if valor is None:
valor = 0.0
try:
return round(float(valor), decimales)
except: return 0.0
num = round(float(valor), 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:

View File

@@ -67,10 +67,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:

View File

@@ -15,7 +15,8 @@ from .schemas import (
ExportRepairFilter,
AllMovementsFilter,
MovementItem,
MovementItemDetailed
MovementItemDetailed,
ReportType
)
from .services.temporary import TemporaryImportService
from .services.definitive import DefinitiveImportService
@@ -163,7 +164,6 @@ class MovementService:
# 1. Temporary Imports
if should_fetch_imports:
try:
temp_filter = ImportTemporaryFilter(
range_type=filters.range_type,
start_date=filters.start_date,
@@ -172,121 +172,119 @@ class MovementService:
provider=filters.provider,
buyer=filters.buyer,
pedimento_code=filters.pedimento_code,
report_type='Normal', # Always use normal mode for combined report
report_type=filters.report_type, # Use filter's report_type
currency_type=filters.currency_type,
exchange_rate_type=filters.exchange_rate_type,
is_shelter=filters.is_shelter,
database_name='default' # Required field
)
temp_movements = self.temporary_service.get_movements(db, temp_filter)
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")
except Exception as e:
db.rollback() # Rollback failed transaction
logger.warning(f"Error fetching temporary imports: {e}")
# 2. Definitive Imports
if should_fetch_imports:
try:
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='Normal',
currency_type=filters.currency_type,
exchange_rate_type=filters.exchange_rate_type,
is_shelter=filters.is_shelter,
database_name='default',
movement_type='ALL' # Required field - include all definitive types
)
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")
except Exception as e:
db.rollback() # Rollback failed transaction
logger.warning(f"Error fetching definitive imports: {e}")
all_movements.extend(def_movements)
logger.info(f"Added {len(def_movements)} definitive import movements")
# 3. Repair Imports
if should_fetch_imports:
try:
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='Normal',
currency_type=filters.currency_type,
exchange_rate_type=filters.exchange_rate_type,
is_shelter=filters.is_shelter,
database_name='default',
discharge_filter='ALL' # Required field - include all discharge statuses
)
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")
except Exception as e:
db.rollback() # Rollback failed transaction
logger.warning(f"Error fetching repair imports: {e}")
all_movements.extend(repair_movements)
logger.info(f"Added {len(repair_movements)} repair import movements")
# 4. Exports (Definitive)
if should_fetch_exports:
try:
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='Normal',
currency_type=filters.currency_type,
exchange_rate_type=filters.exchange_rate_type,
is_shelter=filters.is_shelter,
database_name='default',
movement_type='ALL', # Required field
discharge_filter='ALL', # Required field
use_transport_method=False # Required field
)
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")
except Exception as e:
db.rollback() # Rollback failed transaction
logger.warning(f"Error fetching exports: {e}")
all_movements.extend(export_movements)
logger.info(f"Added {len(export_movements)} export movements")
# 5. Export Repairs
if should_fetch_exports:
try:
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='Normal',
currency_type=filters.currency_type,
exchange_rate_type=filters.exchange_rate_type,
is_shelter=filters.is_shelter,
database_name='default',
movement_type='ALL', # Required field
discharge_filter='ALL' # Required field
)
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")
except Exception as e:
db.rollback() # Rollback failed transaction
logger.warning(f"Error fetching export repairs: {e}")
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

View File

@@ -1,7 +1,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from typing import List, Union
from core.database import get_core_db
from core.security import get_current_user
@@ -65,6 +65,12 @@ def get_temporary_import_movements(
)
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(
@@ -121,6 +127,12 @@ def get_temporary_import_movements_detailed(
)
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(
@@ -584,7 +596,7 @@ def get_export_repair_movements_detailed(
@router.post(
"/all",
response_model=List[MovementItem],
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.
@@ -624,6 +636,12 @@ def get_all_movements(
)
logger.info(f"Successfully retrieved {len(movements)} total 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 Exception as e:
logger.error(f"Error fetching all movements: {str(e)}", exc_info=True)
raise HTTPException(

View File

@@ -83,6 +83,10 @@ class AllMovementsFilter(BaseModel):
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"

View File

@@ -40,6 +40,15 @@ class StringHelper:
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:

View File

@@ -75,8 +75,8 @@ class DatabaseHelper:
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"El Tipo de Cambio para la Fecha de Pago: {fecha_str}{ped_info} no está capturado. "
f"Solución: Capturar el Tipo de Cambio para la Fecha: {fecha_str}."
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
@@ -108,11 +108,11 @@ class DatabaseHelper:
if not client_code:
return {"name": None, "rfc": None, "tax_id": None}
client_type = 'provider' if is_supplier else 'client'
client_type = 'PROVIDER' if is_supplier else 'CLIENT'
try:
sql = text("""
SELECT name, rfc, tax_id
SELECT name, rfc
FROM a76.clients_and_providers
WHERE id = :client_code AND client_or_provider = :client_type
""")
@@ -122,14 +122,14 @@ class DatabaseHelper:
return {
"name": result[0],
"rfc": result[1],
"tax_id": result[2]
"tax_id": None # Column does not exist in this table
}
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}")
return {"name": None, "rfc": None, "tax_id": None}
raise
@staticmethod
def get_customs_agent_info(
@@ -170,7 +170,7 @@ class DatabaseHelper:
return {"name": None, "license": None}
except Exception as e:
logger.error(f"Error fetching customs agent info for {agent_code}: {e}")
return {"name": None, "license": None}
raise
@staticmethod
def get_aduana_seccion_nombre(
@@ -202,13 +202,13 @@ class DatabaseHelper:
return result[0] if result else None
except Exception as e:
logger.error(f"Error fetching customs section name: {e}")
return None
raise
@staticmethod
def get_series_info(
db: Session,
db_name: str,
consecutivo: str,
invoice_id: int,
linea: str,
is_shelter: bool
) -> Optional[str]:
@@ -217,30 +217,30 @@ class DatabaseHelper:
Args:
db: Database session
db_name: Legacy database name
consecutivo: Consecutivo value
linea: LineaImpo value
is_shelter: Shelter flag
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 consecutivo or not linea:
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.item_line_id = il.id
INNER JOIN a76.item_lines il ON ils.line_item_id = il.id
INNER JOIN a76.items i ON il.item_id = i.id
WHERE i.consecutivo = :consecutivo
WHERE i.invoice_id = :invoice_id
AND il.line_number = :linea
ORDER BY ils.id
LIMIT 1
""")
result = db.execute(query, {
"consecutivo": consecutivo,
"invoice_id": invoice_id,
"linea": linea
}).fetchone()
@@ -257,13 +257,13 @@ class DatabaseHelper:
return None
except Exception as e:
logger.error(f"Error fetching series info: {e}")
return None
raise
@staticmethod
def get_series_info_export(
db: Session,
db_name: str,
consecutivo: str,
invoice_id: int,
linea: str,
is_shelter: bool
) -> Optional[str]:
@@ -273,29 +273,31 @@ class DatabaseHelper:
Args:
db: Database session
db_name: Legacy database name
consecutivo: Consecutivo value
invoice_id: Invoice header ID
linea: LineaExpo value
is_shelter: Shelter flag
Returns:
Formatted series string or None
"""
if not consecutivo or not linea:
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_brand
SELECT serial_numbers, model, expo_brad
FROM a76.item_line_series ils
INNER JOIN a76.item_lines il ON ils.item_line_id = il.id
INNER JOIN a76.item_lines il ON ils.line_item_id = il.id
INNER JOIN a76.items i ON il.item_id = i.id
WHERE i.consecutivo = :consecutivo
WHERE i.invoice_id = :invoice_id
AND il.line_number = :linea
ORDER BY ils.id
LIMIT 1
""")
result = db.execute(query, {
"consecutivo": consecutivo,
"invoice_id": invoice_id,
"linea": linea
}).fetchone()
@@ -308,11 +310,11 @@ class DatabaseHelper:
parts.append(model)
if expo_brand:
parts.append(expo_brand)
return " / ".join(parts) if parts else None
return " | ".join(parts) if parts else None
return None
except Exception as e:
logger.error(f"Error fetching export series info: {e}")
return None
raise
@staticmethod
def get_rectification_pedimento(
@@ -497,4 +499,39 @@ class DatabaseHelper:
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

View File

@@ -77,9 +77,18 @@ class DefinitiveImportService:
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 = row[28] # total_me from SUM aggregation
total_mn = row[29] # total_mn from SUM aggregation
total_me = to_float(row[28]) # total_me from SUM aggregation
total_mn = to_float(row[29]) # total_mn from SUM aggregation
# Calculate exchange rate and value
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
@@ -87,10 +96,10 @@ class DefinitiveImportService:
db_name=filters.database_name,
valor_me=total_me,
valor_mn=total_mn,
tipo_cambio_db=row[20], # C51 - TipoCambio
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[27], # C59 - TIPOPEDIMENTOTRANSPORTEE
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,
@@ -134,7 +143,7 @@ class DefinitiveImportService:
UsuarioAcr=row[23], # C54 - UsuarioAct
Fecha_Pago=parse_yyyymmdd_date(row[8]), # C13 - Fecha_Pago
NumCaja=row[24], # C56 - Transporte + NumTrasporte
tipo_pedimento=row[25], # C57 - Pedimento18
tipo_pedimento=row[25], # C57 - Pedimento18 (Note: Schema doesn't have tipo_pedimento field, this might be extra)
AduanaCru=row[15], # C39 - Aduana_Cruce
Lote=row[26] # C58 - LOTE
)
@@ -186,7 +195,9 @@ class DefinitiveImportService:
if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus
continue
# Get all detailed information (same as temporary imports)
# 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
)
@@ -225,13 +236,13 @@ class DefinitiveImportService:
peso_bruto = 0.0
series_info = DatabaseHelper.get_series_info(
db, filters.database_name, row[40], row[44], filters.is_shelter
db, filters.database_name, row[34], row[44], filters.is_shelter
)
simbolo_ex = None
if row[49]:
if row[19]:
simbolo_ex = DatabaseHelper.get_part_export_symbol(
db, filters.database_name, row[49], filters.is_shelter
db, filters.database_name, row[19], filters.is_shelter
)
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
@@ -256,10 +267,10 @@ class DefinitiveImportService:
Fecha_Fin=row[11],
Fecha_Pago=row[12],
Remesa=row[13],
Proveedor=proveedor_info.get('name'),
Proveedor=row[7], # C8 - Provider name (from JOIN)
RFCProveedor=proveedor_info.get('rfc'),
ProveedorTaxID=proveedor_info.get('tax_id'),
VendidoA=vendido_info.get('name'),
VendidoA=row[8], # C9 - Client name (from JOIN)
VendidoARFC=vendido_info.get('rfc'),
VendidoATaxID=vendido_info.get('tax_id'),
AgenteAduanal=agente_info.get('name'),
@@ -276,7 +287,7 @@ class DefinitiveImportService:
OrdenCompraVenta=row[30],
FraccionArancelaria=row[31],
Preferencia=row[32],
Sector=row[34],
Sector=None, # row[34] is invoice ID, Sector not in query
PaisOrigen=row[37],
Aduana=aduana_nombre,
Advalorem=row[39],
@@ -319,12 +330,8 @@ class DefinitiveImportService:
where_conditions.append("ih.operation_type = 'imp'")
# GOLDEN RULE: If movement_type is ALL, only filter by operation_type
if hasattr(filters, 'movement_type') and filters.movement_type == 'ALL':
# ALL mode: bring all imports without filtering by specific invoice_type
pass
else:
# Specific mode: Filter by definitive invoice types only
where_conditions.append("ih.invoice_type IN ('DEF', 'MATDE', 'EXDEF')")
# 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":
@@ -337,11 +344,11 @@ class DefinitiveImportService:
# Provider filter
if filters.provider:
where_conditions.append(f"cmp.provider_id = {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 = {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:

View File

@@ -226,8 +226,8 @@ class ExportService:
peso_bruto_final = row[25] if row[37] == 'P' else 0 # C26 - PesoBruto
# Get series information
series_info = self._get_series_info(
db, filters.database_name, row[34], row[41] # C35 - Consecutivo, C42 - LineaExpo
series_info = DatabaseHelper.get_series_info_export(
db, filters.database_name, row[34], row[41], filters.is_shelter
)
# Get pedimento rectification
@@ -348,9 +348,9 @@ class ExportService:
# Optional filters
if filters.provider:
conditions.append(f"cmp.provider_id = {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 = {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}'")
@@ -383,44 +383,6 @@ class ExportService:
logger.error(f"Error calculating export totals for consecutivo {consecutivo}: {e}")
return (0, 0)
def _get_series_info(
self,
db: Session,
db_name: str,
consecutivo: int,
linea: int
) -> str:
"""Get series information for export partida."""
if not consecutivo or not linea:
return None
try:
sql = text(ExportQueries.build_series_query(db_name))
results = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall()
if not results:
return None
series_list = []
for idx, row in enumerate(results, 1):
serie = row[0]
modelo = row[1]
parte = row[2]
serie_str = f"{idx}) {serie}"
if modelo:
serie_str += f". Modelo: {modelo}"
if parte:
serie_str += f". Parte: {parte}"
series_list.append(serie_str)
return " | ".join(series_list) if series_list else None
except Exception as e:
logger.debug(f"Error fetching export series info for consecutivo {consecutivo}, linea {linea}: {e}")
return None
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:

View File

@@ -350,11 +350,11 @@ class ExportRepairService:
# Provider filter
if filters.provider:
where_conditions.append(f"cmp.provider_id = {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 = {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:

View File

@@ -235,7 +235,10 @@ class DefinitiveImportQueries:
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]
'' AS C6, -- [5]
'' AS C7, -- [6]
COALESCE(prov.name, '') AS C8, -- [7] Provider name
COALESCE(client.name, '') AS C9, -- [8] Client name
ped.regime AS C10, -- [9]
log.entry_exit_date AS C11, -- [10]
log.delivery_date AS C12, -- [11]
@@ -290,6 +293,8 @@ class DefinitiveImportQueries:
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.clients_and_providers prov ON prov.id = cmp.provider_id
LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id
LEFT JOIN a76.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
@@ -653,7 +658,8 @@ class ExportQueries:
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
LEFT JOIN a24.fa_item_lines fil ON il.id = lf.item_line_id
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}
"""

View File

@@ -81,9 +81,18 @@ class RepairImportService:
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 = row[24] # total_me from SUM aggregation
total_mn = row[25] # total_mn from SUM aggregation
total_me = to_float(row[24]) # total_me from SUM aggregation
total_mn = to_float(row[25]) # total_mn from SUM aggregation
# Calculate exchange rate and value
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
@@ -91,7 +100,7 @@ class RepairImportService:
db_name=filters.database_name,
valor_me=total_me,
valor_mn=total_mn,
tipo_cambio_db=row[17], # C40 - TipoCambio
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)
@@ -197,79 +206,79 @@ class RepairImportService:
for row in results:
# Skip cancelled if not included
if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus
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[15], is_supplier=True
db, filters.database_name, row[11], is_supplier=True
)
vendido_info = DatabaseHelper.get_client_info(
db, filters.database_name, row[16], is_supplier=False
db, filters.database_name, row[12], is_supplier=False
)
agente_info = DatabaseHelper.get_customs_agent_info(
db, filters.database_name, row[17]
db, filters.database_name, row[13]
)
aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre(
db, filters.database_name, row[38]
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[39], # EsSubPartida
valor_me=row[26],
valor_mn_direct=row[24],
fecha_pago=row[12],
fecha_inicio=row[10],
clave_ped=row[58],
tipo_cambio_partida=row[50],
es_subpartida=row[30], # 'P' or 'S'
valor_me=row[20],
valor_mn_direct=row[19],
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[39] == 'P':
peso_neto = float(row[28]) if row[28] else 0.0
peso_bruto = float(row[29]) if row[29] else 0.0
if row[30] == 'P':
peso_neto = float(row[21]) if row[21] else 0.0
peso_bruto = float(row[22]) if row[22] else 0.0
else:
peso_neto = 0.0
peso_bruto = 0.0
series_info = DatabaseHelper.get_series_info(
db, filters.database_name, row[40], row[44], filters.is_shelter
db, filters.database_name, row[29], row[0], filters.is_shelter
)
simbolo_ex = None
if row[49]:
if row[14]:
simbolo_ex = DatabaseHelper.get_part_export_symbol(
db, filters.database_name, row[49], filters.is_shelter
db, filters.database_name, row[14], filters.is_shelter
)
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
db, row[1], row[41], filters.is_shelter
db, row[2], row[41], filters.is_shelter
)
num_gaf_uni = DatabaseHelper.get_driver_badge(
db, filters.database_name, row[0]
db, filters.database_name, row[1]
)
movement = MovementItemDetailed(
Linea=row[44],
Factura=row[0],
Pedimento=row[1],
FechaFactura=row[2],
Estatus=row[3],
ClavePed=row[4],
Linea=row[0],
Factura=row[1],
Pedimento=row[2],
FechaFactura=row[3],
Estatus=row[4],
ClavePed=row[5],
TipoMovTemDef='IMPRE',
EsCambioRegimen='N',
Regimen=row[9],
Fecha_Inicio=row[10],
Fecha_Fin=row[11],
Fecha_Pago=row[12],
Remesa=row[13],
Regimen=row[6],
Fecha_Inicio=row[7],
Fecha_Fin=row[7], # Using same valid column or empty
Fecha_Pago=row[8],
Remesa=row[9],
Proveedor=proveedor_info.get('name'),
RFCProveedor=proveedor_info.get('rfc'),
ProveedorTaxID=proveedor_info.get('tax_id'),
@@ -278,42 +287,42 @@ class RepairImportService:
VendidoATaxID=vendido_info.get('tax_id'),
AgenteAduanal=agente_info.get('name'),
Patente=agente_info.get('license'),
NumParte=row[19],
DescripcionE=StringHelper.clean_text(row[20]),
DescripcionI=StringHelper.clean_text(row[21]),
CantidadIE=float(row[22]) if row[22] else 0.0,
UniMed=row[23],
NumParte=row[14],
DescripcionE=StringHelper.clean_text(row[15]),
DescripcionI=StringHelper.clean_text(row[16]),
CantidadIE=float(row[17]) if row[17] else 0.0,
UniMed=row[18],
ValorComercialMN=valor_comercial,
TipoCambio=tipo_cambio,
PesoNeto=peso_neto,
PesoBruto=peso_bruto,
OrdenCompraVenta=row[30],
FraccionArancelaria=row[31],
Preferencia=row[32],
Sector=row[34],
PaisOrigen=row[37],
OrdenCompraVenta=row[23],
FraccionArancelaria=row[24],
Preferencia=row[25],
Sector=row[26],
PaisOrigen=row[27],
Aduana=aduana_nombre,
Advalorem=row[39],
Advalorem=row[30],
TipoExpo='',
PedimentoR1=pedimento_r1,
EDocument=row[42],
NumOperacionVU=row[43],
EDocument=row[32],
NumOperacionVU=row[33],
Series=series_info,
Marca=StringHelper.clean_text(row[45]),
Modelo=StringHelper.clean_text(row[46]),
FraccionAmericana=row[47],
ECCN=row[48],
Marca=StringHelper.clean_text(row[34]),
Modelo=StringHelper.clean_text(row[35]),
FraccionAmericana=row[36],
ECCN=row[37],
SimboloEx=simbolo_ex,
FechaEmision=row[51],
FechaEmision=row[39],
BaseDeDatos=filters.database_name,
NumGafUni=num_gaf_uni,
UsuarioCap=row[52],
UsuarioAcr=row[53],
Transportista=row[54],
NumCaja=row[55],
Pedimento18=row[56],
AduanaCru=row[38],
Lote=row[57]
UsuarioCap=row[40],
UsuarioAcr=row[41],
Transportista=row[42],
NumCaja=row[43],
Pedimento18=row[44],
AduanaCru=row[28],
Lote='' # Not in query
)
movements.append(movement)
@@ -345,11 +354,11 @@ class RepairImportService:
# Provider filter
if filters.provider:
where_conditions.append(f"cmp.provider_id = {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 = {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:

View File

@@ -77,9 +77,23 @@ class TemporaryImportService:
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)
total_me = row[27] # total_me from SUM aggregation
total_mn = row[28] # total_mn from SUM aggregation
# 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)
# Calculate exchange rate and value
valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated(
@@ -87,10 +101,10 @@ class TemporaryImportService:
db_name=filters.database_name,
valor_me=total_me,
valor_mn=total_mn,
tipo_cambio_db=row[19], # C50 - TipoCambio
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[26], # C58 - TIPOPEDIMENTOTRANSPORTEE
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,
@@ -111,10 +125,6 @@ class TemporaryImportService:
db, filters.database_name, factura
)
# Helper to convert empty strings to None
def none_if_empty(val):
return None if val == '' else val
# Build movement item
movement = MovementItem(
Factura=factura,
@@ -138,9 +148,9 @@ class TemporaryImportService:
UsuarioAcr=row[22], # C53 - UsuarioAct
Fecha_Pago=parse_yyyymmdd_date(none_if_empty(row[8])), # C13 - Fecha_Pago
NumCaja=row[23], # C55 - Transporte + NumTrasporte
Pedimento18=row[24], # C56 - Pedimento18
Pedimento18=row[24], # C56 - Pedimento18 (Actually empty in query, but safe to keep)
AduanaCru=row[14], # C38 - Aduana_Cruce
Lote=row[25] # C57 - LOTE
Lote=row[25] # C57 - LOTE (Actually C55 is index 24. C56 is 25)
)
movements.append(movement)
@@ -220,6 +230,14 @@ class TemporaryImportService:
met_trans=met_trans
)
# ValorComercialMN should always be in MXN
# If currency_type is ME, valor_comercial is in USD, so multiply by tipo_cambio
if filters.currency_type.value == "ME" and tipo_cambio:
valor_comercial_mn = valor_comercial * tipo_cambio
else:
# If currency_type is MN, valor_comercial is already in MXN
valor_comercial_mn = valor_comercial
# 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
@@ -253,21 +271,35 @@ class TemporaryImportService:
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)
# Build detailed movement item
movement = MovementItemDetailed(
Linea=row[43], # C44 - LineaImpo
Factura=row[0], # C1 - FacturaImpo
Pedimento=row[1], # C2 - PedimentoImpo
FechaFactura=row[2], # C3 - FechaFactura
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=row[10], # C11 - Fecha_Inicio
Fecha_Fin=row[11], # C12 - Fecha_Fin
Fecha_Pago=row[12], # C13 - Fecha_Pago
Remesa=row[13], # C14 - Remesa
Fecha_Inicio=none_if_empty(row[10]), # C11 - Fecha_Inicio
Fecha_Fin=none_if_empty(row[11]), # C12 - Fecha_Fin
Fecha_Pago=none_if_empty(row[12]), # C13 - Fecha_Pago
Remesa=to_str(row[13]), # C14 - Remesa
Proveedor=row[7], # C8 - Provider name (from JOIN)
RFCProveedor=None, # RFC not in detailed query
ProveedorTaxID=None, # Tax ID not in detailed query
@@ -281,7 +313,7 @@ class TemporaryImportService:
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,
ValorComercialMN=valor_comercial_mn,
TipoCambio=tipo_cambio,
PesoNeto=peso_neto,
PesoBruto=peso_bruto,
@@ -291,7 +323,7 @@ class TemporaryImportService:
Sector=row[34], # C35 - Sector
PaisOrigen=row[36], # C37 - PaisOrigen
Aduana=aduana_nombre,
Advalorem=row[39], # C40 - EsSubPartida
Advalorem=to_str(row[39]), # C40 - EsSubPartida (convert bool to str)
TipoExpo='',
PedimentoR1=pedimento_r1,
EDocument=row[41], # C42 - EDocument
@@ -302,7 +334,7 @@ class TemporaryImportService:
FraccionAmericana=row[46], # C47 - FraccionAme
ECCN=row[47], # C48 - ECCN
SimboloEx=simbolo_ex,
FechaEmision=row[50], # C51 - FechaEmision
FechaEmision=parse_yyyymmdd_date(row[50]), # C51 - FechaEmision (convert to datetime)
BaseDeDatos=filters.database_name,
NumGafUni=num_gaf_uni,
UsuarioCap=row[51], # C52 - UsuarioCap
@@ -329,10 +361,8 @@ class TemporaryImportService:
# STRICT SEPARATION: Only temporary imports
where_conditions.append("ih.operation_type = 'imp'")
# GOLDEN RULE: If coming from /all, only filter by operation_type
# otherwise, apply specific invoice_type filter
if not hasattr(filters, 'from_all_endpoint') or not filters.from_all_endpoint:
where_conditions.append("ih.invoice_type IN ('TEM', 'MATTEM')")
# 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":
@@ -345,11 +375,11 @@ class TemporaryImportService:
# Provider filter
if filters.provider:
where_conditions.append(f"cmp.provider_id::text = '{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::text = '{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:

View File

@@ -53,6 +53,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
@@ -145,4 +146,10 @@ router.include_router(
aviso_consolidado_export_router,
prefix="/a76/reports/exportacion/aviso_consolidado",
tags=["a76 / reports"]
)
router.include_router(
movement_invoices_router,
prefix="/a76/reports/movements/invoices",
tags=["a76 / reports"]
)