Refactor code structure for improved readability and maintainability

This commit is contained in:
Galindo97
2026-01-30 10:55:29 -06:00
parent e689d1f3a2
commit b8e25cd2e1
28 changed files with 9878 additions and 170 deletions

View File

@@ -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,
)

View File

@@ -113,17 +113,17 @@ class InvoiceComplianceMxBase(BaseModel):
manifest_number: Optional[str] = Field(
None, max_length=15, description="Manifest number"
)
provider_header: str = Field(None, max_length=20, description="Provider header")
provider_id: int = Field(None, description="Provider ID")
sold_to_header: str = Field(None, max_length=20, description="Sold to header")
sold_to_id: int = Field(None, description="Sold to ID")
shipped_to_header: str = Field(None, max_length=20, description="Shipped to header")
shipped_to_id: int = Field(None, description="Shipped to ID")
shipped_by_header: Optional[int] = Field(
provider_header: Optional[str] = Field(None, max_length=20, description="Provider header")
provider_id: Optional[int] = Field(None, description="Provider ID")
sold_to_header: Optional[str] = Field(None, max_length=20, description="Sold to header")
sold_to_id: Optional[int] = Field(None, description="Sold to ID")
shipped_to_header: Optional[str] = Field(None, max_length=20, description="Shipped to header")
shipped_to_id: Optional[int] = Field(None, description="Shipped to ID")
shipped_by_header: Optional[str] = Field(
None, max_length=20, description="Shipped by header"
)
shipped_by_id: Optional[int] = Field(None, description="Shipped by ID")
customs_broker_id: int = Field(None, description="Customs broker ID")
customs_broker_id: Optional[int] = Field(None, description="Customs broker ID")
customs_broker_us_id: Optional[int] = Field(
None, description="US customs broker ID"
)
@@ -141,7 +141,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(

View File

@@ -0,0 +1,309 @@
"""
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
)
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 operation_type filter
should_fetch_imports = filters.operation_type in [None, 'imp']
should_fetch_exports = filters.operation_type in [None, 'exp']
logger.info(f"Operation type filter: {filters.operation_type}")
logger.info(f"Should fetch imports: {should_fetch_imports}")
logger.info(f"Should fetch exports: {should_fetch_exports}")
# 1. Temporary Imports
if should_fetch_imports:
try:
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='Normal', # Always use normal mode for combined report
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)
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_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}")
# 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_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}")
# 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_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}")
# 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_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}")
# 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()

View File

@@ -0,0 +1,632 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
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 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 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=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.
"""
)
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"
)
movements = movement_service.get_all_movements(
db=db,
filters=filters
)
logger.info(f"Successfully retrieved {len(movements)} total movements")
return movements
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)}"
)

View File

@@ -0,0 +1,564 @@
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)"
)
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"
)
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"
)
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"
)
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"
)
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"
)
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"
)
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"
}
}
}

View File

@@ -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()

View File

@@ -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',
]

View File

@@ -0,0 +1,76 @@
"""
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(',', '')
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

View File

@@ -0,0 +1,500 @@
"""
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"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}."
)
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 name, rfc, tax_id
FROM a76.clients_and_providers
WHERE id = :client_code AND 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}")
return {"name": None, "rfc": None, "tax_id": None}
@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}")
return {"name": None, "license": None}
@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}")
return None
@staticmethod
def get_series_info(
db: Session,
db_name: str,
consecutivo: str,
linea: str,
is_shelter: bool
) -> Optional[str]:
"""
Get series information for import items.
Args:
db: Database session
db_name: Legacy database name
consecutivo: Consecutivo value
linea: LineaImpo value
is_shelter: Shelter flag
Returns:
Formatted series string or None
"""
if not consecutivo 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.items i ON il.item_id = i.id
WHERE i.consecutivo = :consecutivo
AND il.line_number = :linea
ORDER BY ils.id
LIMIT 1
""")
result = db.execute(query, {
"consecutivo": consecutivo,
"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}")
return None
@staticmethod
def get_series_info_export(
db: Session,
db_name: str,
consecutivo: str,
linea: str,
is_shelter: bool
) -> Optional[str]:
"""
Get series information for export items.
Args:
db: Database session
db_name: Legacy database name
consecutivo: Consecutivo value
linea: LineaExpo value
is_shelter: Shelter flag
Returns:
Formatted series string or None
"""
if not consecutivo or not linea:
return None
try:
query = text("""
SELECT serial_numbers, model, expo_brand
FROM a76.item_line_series ils
INNER JOIN a76.item_lines il ON ils.item_line_id = il.id
INNER JOIN a76.items i ON il.item_id = i.id
WHERE i.consecutivo = :consecutivo
AND il.line_number = :linea
ORDER BY ils.id
LIMIT 1
""")
result = db.execute(query, {
"consecutivo": consecutivo,
"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}")
return None
@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
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
return ped_rectifica
else:
# Non-Shelter: implement BuscarRectificacion logic
return DatabaseHelper._buscar_rectificacion(db, pedimento, ped_rectifica)
@staticmethod
def _buscar_rectificacion(
db: Session,
pedimento_orig: str,
ped_rec: Optional[str]
) -> Optional[str]:
"""
BUSCA ULTIMO PEDIMENTO DE RECTIFICACION
Follows the rectification chain recursively until finding the final pedimento.
Clarion logic:
- If PPedRec is empty, return ''
- Otherwise, follow the chain using BUSCA_PEDIMENTO_R1 recursively
- Return the last Pedimento2 in the chain if no circular reference
- Return Pedimento1 if error (circular reference detected)
Args:
db: Database session
pedimento_orig: Original pedimento number
ped_rec: Initial rectification pedimento
Returns:
Final pedimento in rectification chain or empty string
"""
if not ped_rec:
return ''
try:
# Track visited pedimentos to detect circular references
visited = set()
visited.add(pedimento_orig)
# Start recursive search
final_pedimento = DatabaseHelper._busca_pedimento_r1(
db, ped_rec, visited
)
# If successful, return final pedimento; otherwise return original rectification
return final_pedimento if final_pedimento else ped_rec
except Exception as e:
logger.error(f"Error in BuscarRectificacion for {pedimento_orig}: {e}")
return pedimento_orig
@staticmethod
def _busca_pedimento_r1(
db: Session,
pedimento: str,
visited: set
) -> Optional[str]:
"""
BUSCA_PEDIMENTO_R1 ROUTINE - Recursive search for final rectification pedimento.
Clarion logic:
- Fetch pedimento from QPedimentos table
- If it has PedRectifica:
- Check if already visited (circular reference = error)
- Add to visited set and recurse with PedRectifica
- Return the deepest pedimento found
Args:
db: Database session
pedimento: Current pedimento 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 pedimentos table for ped_rectifica
sql = text("""
SELECT ped_rectifica
FROM a76.pedimentos
WHERE pedimento_number = :pedimento
LIMIT 1
""")
result = db.execute(sql, {"pedimento": pedimento}).fetchone()
if result and result[0]:
ped_rectifica_next = result[0]
# Add current pedimento to visited set
visited.add(pedimento)
# Recurse with next rectification
final_ped = DatabaseHelper._busca_pedimento_r1(
db, ped_rectifica_next, visited
)
# If recursion failed (circular ref), return None
# Otherwise return the final pedimento found
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 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}")
return None

View File

@@ -0,0 +1,372 @@
"""
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
# 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
# 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[20], # C51 - TipoCambio
fecha_pago=row[8], # C13 - Fecha_Pago
fecha_inicio=row[6], # C11 - Fecha_Inicio
tipo_pedimento=row[27], # C59 - 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
)
# 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
tipo_pedimento=row[25], # C57 - Pedimento18
AduanaCru=row[15], # C39 - Aduana_Cruce
Lote=row[26] # C58 - LOTE
)
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 all detailed information (same as temporary imports)
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], # 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],
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
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
)
simbolo_ex = None
if row[49]:
simbolo_ex = DatabaseHelper.get_part_export_symbol(
db, filters.database_name, row[49], filters.is_shelter
)
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
db, row[1], row[41], filters.is_shelter
)
num_gaf_uni = DatabaseHelper.get_driver_badge(
db, filters.database_name, row[0]
)
movement = MovementItemDetailed(
Linea=row[44],
Factura=row[0],
Pedimento=row[1],
FechaFactura=row[2],
Estatus=row[3],
ClavePed=row[4],
TipoMovTemDef='IMPDF',
EsCambioRegimen='N',
Regimen=row[9],
Fecha_Inicio=row[10],
Fecha_Fin=row[11],
Fecha_Pago=row[12],
Remesa=row[13],
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[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],
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],
Aduana=aduana_nombre,
Advalorem=row[39],
TipoExpo='',
PedimentoR1=pedimento_r1,
EDocument=row[42],
NumOperacionVU=row[43],
Series=series_info,
Marca=StringHelper.clean_text(row[45]),
Modelo=StringHelper.clean_text(row[46]),
FraccionAmericana=row[47],
ECCN=row[48],
SimboloEx=simbolo_ex,
FechaEmision=row[51],
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]
)
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
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')")
# 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"log.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND log.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 = {filters.provider}")
# Buyer filter
if filters.buyer:
where_conditions.append(f"cmp.sold_to_id = {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
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.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

View File

@@ -0,0 +1,172 @@
"""
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, # Always use for detailed calculations
met_trans=met_trans
)
tc_value = DatabaseHelper.get_exchange_rate(db, db_name, fecha_tc)
if tc_value:
tipo_cambio_final = tc_value
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 * exchange rate
return (valor_me * tc_value, tc_value)
else:
# Fall back to direct MN value and partida exchange rate
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:
# 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
)
# For Shelter + FP: validate exchange rate exists (Clarion logic)
tc_value = DatabaseHelper.get_exchange_rate(
db, db_name, fecha_tc,
is_shelter=is_shelter,
raise_on_missing=is_shelter # Raise error if shelter and not found
)
if tc_value:
return (valor_me * tc_value, tc_value)
else:
logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values")
return (valor_mn, tipo_cambio_db)
else:
# FT or no payment date: use DB values
return (valor_comercial, tipo_cambio)
# Local currency case
else:
return (valor_mn, tipo_cambio_db)

View File

@@ -0,0 +1,435 @@
"""
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[15] # C34 - TipoFactura
# Skip cancelled if not included
if not filters.include_cancelled and row[3] != 'AC': # C6 - Estatus
continue
consecutivo = row[16] # C35 - Consecutivo
# 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
# 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[19], # C48 - TipoCambio
fecha_pago=row[8], # C11 - Fecha_Pago
fecha_inicio=row[6], # C9 - Fecha_Inicio
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
'', # PedRectifica not in aggregated query
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=0.0,
TipoExpo='EXPO DEF',
PedimentoR1=rectified_pedimento,
EDocument=row[17], # C40 - EDocument
NumOperacionVU=row[18], # C41 - NumOperacionVU
BaseDeDatos=filters.database_name,
NumGafUni=driver_badge,
UsuarioCap=row[21], # C50 - UsuarioCap
UsuarioAcr=row[22], # C51 - UsuarioAct
Fecha_Pago=parse_yyyymmdd_date(row[8]), # C11 - Fecha_Pago
NumCaja=row[23], # C53 - Transporte + NumTrasporte
Pedimento18='', # Not in aggregated query
AduanaCru=row[14], # 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 based on subpartida flag
peso_neto_final = row[24] if row[37] == 'P' else 0 # C25 - PesoNeto
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
)
# 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[6], # C7 - ClavePed
TipoMovTemDef=row[33], # C34 - TipoFactura
EsCambioRegimen='N',
Regimen=row[7], # C8 - Regimen
Fecha_Inicio=row[8], # C9 - Fecha_Inicio
Fecha_Fin=row[9], # C10 - Fecha_Fin
Fecha_Pago=row[10], # C11 - Fecha_Pago
Remesa=row[11], # 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 - Clase
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[37], # C38 - EsSubPartida
TipoExpo='EXPO DEF',
PedimentoR1=rectified_pedimento,
EDocument=row[39], # C40 - EDocument
NumOperacionVU=row[40], # C41 - NumOperacionVU
Series=series_info,
Marca=row[42], # C43 - Marca
Modelo=row[43], # C44 - Modelo
FraccionAmericana=row[44], # C45 - FraccionAme
ECCN=row[45], # C46 - ECCN
FechaEmision=row[48], # C49 - FechaEmision
BaseDeDatos=filters.database_name,
NumGafUni=driver_badge,
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] # 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 "log.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 = {filters.provider}")
if filters.buyer:
conditions.append(f"cmp.sold_to_id = {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_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:
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

View File

@@ -0,0 +1,400 @@
"""
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).
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
# 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] # C35 - Consecutivo
# 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
# 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='', # Not in aggregated query
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=False,
met_trans=met_trans
)
# Get pedimento rectification
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
db,
row[1], # C2 - PedimentoExpo
'', # PedRectifica not in aggregated query
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,
EsCambioRegimen='N',
ValorMPTemp=valor_comercial,
ValorComercialMN=valor_comercial,
TipoCambio=tipo_cambio,
ValorAgre=0.0,
TipoExpo='EXPO REP',
PedimentoR1=pedimento_r1,
EDocument=row[16], # C40 - EDocument
NumOperacionVU=row[17], # C41 - NumOperacionVU
BaseDeDatos=filters.database_name,
NumGafUni=num_gaf_uni,
UsuarioCap=row[20], # C50 - UsuarioCap
UsuarioAcr=row[21], # C51 - UsuarioAct
Fecha_Pago=parse_yyyymmdd_date(row[6]), # C11 - Fecha_Pago
NumCaja=row[23], # C53 - Transporte + NumTrasporte
Pedimento18='', # Not in aggregated query
AduanaCru=row[13], # C33 - customs_office
Lote='' # Not in aggregated query
)
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
)
# Calculate values (only for main partidas 'P')
if row[37] == 'P': # 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=row[8], # C9 - Fecha_Inicio
Fecha_Fin=row[9], # C10 - Fecha_Fin
Fecha_Pago=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[17], # C18 - Clase (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=row[48], # 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] # 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":
# ALL mode: bring all exports without filtering by specific invoice_type
pass
else:
where_conditions.append("ih.invoice_type IN ('EXREP', 'MATEXREP')")
# 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"log.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND log.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 = {filters.provider}")
# Buyer filter
if filters.buyer:
where_conditions.append(f"cmp.sold_to_id = {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)
def _calculate_totals(self, db: Session, db_name: str, consecutivo: int, discharge_filter: str) -> tuple:
"""Calculate totals for main partidas with discharge filter."""
discharge_clause = ""
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
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.invoice_id = :consecutivo
AND il.is_subpart = 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
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

View File

@@ -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(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
LEFT JOIN a24.fa_item_lines fil ON il.id = lf.item_line_id
WHERE 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(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
LEFT JOIN a24.fa_item_lines fil ON il.id = lf.item_line_id
WHERE 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
"""

View File

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

View File

@@ -0,0 +1,391 @@
"""
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
# 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
# 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[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
'', # PedRectifica not in aggregated query
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[3] != 'AC': # C4 - Estatus
continue
# Get all detailed information
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], # 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],
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
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
)
simbolo_ex = None
if row[49]:
simbolo_ex = DatabaseHelper.get_part_export_symbol(
db, filters.database_name, row[49], filters.is_shelter
)
pedimento_r1 = DatabaseHelper.get_rectification_pedimento(
db, row[1], row[41], filters.is_shelter
)
num_gaf_uni = DatabaseHelper.get_driver_badge(
db, filters.database_name, row[0]
)
movement = MovementItemDetailed(
Linea=row[44],
Factura=row[0],
Pedimento=row[1],
FechaFactura=row[2],
Estatus=row[3],
ClavePed=row[4],
TipoMovTemDef='IMPRE',
EsCambioRegimen='N',
Regimen=row[9],
Fecha_Inicio=row[10],
Fecha_Fin=row[11],
Fecha_Pago=row[12],
Remesa=row[13],
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[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],
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],
Aduana=aduana_nombre,
Advalorem=row[39],
TipoExpo='',
PedimentoR1=pedimento_r1,
EDocument=row[42],
NumOperacionVU=row[43],
Series=series_info,
Marca=StringHelper.clean_text(row[45]),
Modelo=StringHelper.clean_text(row[46]),
FraccionAmericana=row[47],
ECCN=row[48],
SimboloEx=simbolo_ex,
FechaEmision=row[51],
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]
)
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'")
# GOLDEN RULE: If coming from /all, only filter by operation_type
if hasattr(filters, 'discharge_filter') and filters.discharge_filter == 'ALL':
# ALL mode: bring all imports without filtering by specific invoice_type
pass
else:
where_conditions.append("ih.invoice_type IN ('REP', 'MATREP')")
# 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"log.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND log.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 = {filters.provider}")
# Buyer filter
if filters.buyer:
where_conditions.append(f"cmp.sold_to_id = {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
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.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

View File

@@ -0,0 +1,386 @@
"""
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
# 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
# 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[19], # C50 - TipoCambio
fecha_pago=row[8], # C13 - Fecha_Pago
fecha_inicio=row[6], # C11 - Fecha_Inicio
tipo_pedimento=row[26], # C58 - TIPOPEDIMENTOTRANSPORTEE
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
)
# 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,
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_comercial,
ValorComercialMN=valor_comercial,
TipoCambio=tipo_cambio,
ValorAgre=0.0,
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[23], # C55 - Transporte + NumTrasporte
Pedimento18=row[24], # C56 - Pedimento18
AduanaCru=row[14], # C38 - Aduana_Cruce
Lote=row[25] # C57 - LOTE
)
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
# Get provider information
proveedor_info = DatabaseHelper.get_client_info(
db, filters.database_name, row[15], is_supplier=True # C16 - Proveedor
)
# Get buyer information
vendido_info = DatabaseHelper.get_client_info(
db, filters.database_name, row[16], is_supplier=False # C17 - VendidoA
)
# Get customs agent information
agente_info = DatabaseHelper.get_customs_agent_info(
db, filters.database_name, row[17] # C18 - AAduanal
)
# 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
)
# 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
# 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
)
# 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
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
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[19], # C20 - Clase (NumParte)
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 - OrdenCompra
FraccionArancelaria=row[31], # C32 - Fraccion
Preferencia=row[32], # C33 - TipoFraccion
Sector=row[34], # C35 - Sector
PaisOrigen=row[36], # C37 - PaisOrigen
Aduana=aduana_nombre,
Advalorem=row[39], # C40 - EsSubPartida
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=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 - Transportista
NumCaja=row[54], # C55 - Transporte + NumTrasporte
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'")
# 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')")
# 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"log.payment_date >= TO_DATE('{filters.start_date}', 'YYYYMMDD') AND log.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::text = '{filters.provider}'")
# Buyer filter
if filters.buyer:
where_conditions.append(f"cmp.sold_to_id::text = '{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
JOIN a76.items i ON i.id = il.item_id
WHERE i.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

View File

@@ -51,6 +51,7 @@ from api.v1.modules.public.reference_data.material_types.routes import router as
# --- NUEVO IMPORT PARA REPORTES DE FACTURAS ---
from .reports.importacion.facturas.routes import router as invoices_reports_router
from .reports.importacion.consolidados.routes import router as consolidated_reports_router
from .reports.movements.invoices.routes import router as invoice_movements_router
# Router principal
@@ -130,4 +131,10 @@ router.include_router(
consolidated_reports_router,
prefix="/a76/reports/importacion/consolidados",
tags=["a76 / reports"]
)
router.include_router(
invoice_movements_router,
prefix="/a76/reports/movements/invoices",
tags=["a76 / reports / movements"]
)