From b8e25cd2e15d72a8121fef82f88a8c48db74d329 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 30 Jan 2026 10:55:29 -0600 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .../a76/clients_and_providers/routes.py | 7 +- .../api/v1/modules/a76/invoices/schemas.py | 18 +- .../reports/movements/invoices/__init__.py | 0 .../movements/invoices/movement_service.py | 309 +++ .../a76/reports/movements/invoices/routes.py | 632 +++++ .../a76/reports/movements/invoices/schemas.py | 564 +++++ .../reports/movements/invoices/services.py | 112 + .../movements/invoices/services/__init__.py | 26 + .../movements/invoices/services/base.py | 76 + .../invoices/services/database_helpers.py | 500 ++++ .../movements/invoices/services/definitive.py | 372 +++ .../invoices/services/exchange_rate.py | 172 ++ .../movements/invoices/services/export.py | 435 ++++ .../invoices/services/export_repair.py | 400 ++++ .../invoices/services/query_builders.py | 877 +++++++ .../invoices/services/query_builders.py.bak | 877 +++++++ .../movements/invoices/services/repair.py | 391 +++ .../movements/invoices/services/temporary.py | 386 +++ .../movements/invoices/services_old.py | 2115 +++++++++++++++++ backend/api/v1/modules/a76/router.py | 7 + .../api/dashboard/a76/invoice-movements.ts | 184 ++ .../src/lib/components/sidebar/modules.ts | 12 + .../lib/components/ui/icons/FolderIcon.svelte | 6 + frontend/src/routes/dashboard/+layout.svelte | 2 +- .../edit/[[id]]/+page.svelte | 2 +- .../reports/invoices/+page.server.ts | 15 + .../dashboard/reports/invoices/+page.svelte | 1394 +++++++++++ pnpm-lock.yaml | 157 -- 28 files changed, 9878 insertions(+), 170 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/routes.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/base.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/export.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/services_old.py create mode 100644 frontend/src/lib/api/dashboard/a76/invoice-movements.ts create mode 100644 frontend/src/lib/components/ui/icons/FolderIcon.svelte create mode 100644 frontend/src/routes/dashboard/reports/invoices/+page.server.ts create mode 100644 frontend/src/routes/dashboard/reports/invoices/+page.svelte delete mode 100644 pnpm-lock.yaml diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py index e1db85d7..b96c7442 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py @@ -7,7 +7,7 @@ from typing import List, Optional from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from .models import ClientOrProviderEnum @@ -40,7 +40,10 @@ async def get_clients_and_providers( """Get clients and providers""" tenant_id = validate_access_to_resource(db, company_id, current_user) - query = db.query(ClientProvider).filter( + query = db.query(ClientProvider).options( + joinedload(ClientProvider.address), + joinedload(ClientProvider.programs) + ).filter( ClientProvider.tenant_id == tenant_id, ClientProvider.company_id == company_id, ) diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index af111dc8..88b7cf0c 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -113,17 +113,17 @@ class InvoiceComplianceMxBase(BaseModel): manifest_number: Optional[str] = Field( None, max_length=15, description="Manifest number" ) - provider_header: str = Field(None, max_length=20, description="Provider header") - provider_id: int = Field(None, description="Provider ID") - sold_to_header: str = Field(None, max_length=20, description="Sold to header") - sold_to_id: int = Field(None, description="Sold to ID") - shipped_to_header: str = Field(None, max_length=20, description="Shipped to header") - shipped_to_id: int = Field(None, description="Shipped to ID") - 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( diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/__init__.py b/backend/api/v1/modules/a76/reports/movements/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py new file mode 100644 index 00000000..303dd6e8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py @@ -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() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py new file mode 100644 index 00000000..e6b8a37e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -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)}" + ) diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py new file mode 100644 index 00000000..966868ed --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py @@ -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" + } + } + } \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services.py b/backend/api/v1/modules/a76/reports/movements/invoices/services.py new file mode 100644 index 00000000..f218bd71 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services.py @@ -0,0 +1,112 @@ +""" +Unified service for invoice movement operations. +This service delegates to specialized handlers for each import type. +""" + +import logging +from sqlalchemy.orm import Session +from typing import List + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + ExportFilter, + MovementItem, + MovementItemDetailed +) +from .services.temporary import TemporaryImportService +from .services.definitive import DefinitiveImportService +from .services.repair import RepairImportService +from .services.export import ExportService + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Unified service for handling all types of movements. + Delegates to specialized services for each movement type. + """ + + def __init__(self): + self.temporary_service = TemporaryImportService() + self.definitive_service = DefinitiveImportService() + self.repair_service = RepairImportService() + self.export_service = ExportService() + + # ===== TEMPORARY IMPORTS ===== + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """Get temporary import movements (normal mode - grouped by invoice).""" + return self.temporary_service.get_movements(db, filters) + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """Get temporary import movements (detailed mode - line by line).""" + return self.temporary_service.get_movements_detailed(db, filters) + + # ===== DEFINITIVE IMPORTS ===== + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """Get definitive import movements (normal mode - grouped by invoice).""" + return self.definitive_service.get_movements(db, filters) + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """Get definitive import movements (detailed mode - line by line).""" + return self.definitive_service.get_movements_detailed(db, filters) + + # ===== REPAIR IMPORTS ===== + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """Get repair import movements (normal mode - grouped by invoice).""" + return self.repair_service.get_movements(db, filters) + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """Get repair import movements (detailed mode - line by line).""" + return self.repair_service.get_movements_detailed(db, filters) + + # ===== EXPORTS ===== + + def get_export_movements( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItem]: + """Get export movements (normal mode - grouped by invoice).""" + return self.export_service.get_movements(db, filters) + + def get_export_movements_detailed( + self, + db: Session, + filters: ExportFilter + ) -> List[MovementItemDetailed]: + """Get export movements (detailed mode - line by line).""" + return self.export_service.get_movements_detailed(db, filters) + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py new file mode 100644 index 00000000..fdde7fed --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/__init__.py @@ -0,0 +1,26 @@ +""" +Invoice Movement Services Module + +This package contains the business logic for handling different types of movements: +- Temporary imports (IMTEM) +- Definitive imports (COMEX/IMPDF) +- Repair imports (IMPRE) +- Exports (EXPO DEF) +- Export repairs (EXPO REP) + +The services are organized into specialized modules for better maintainability. +""" + +from .temporary import TemporaryImportService +from .definitive import DefinitiveImportService +from .repair import RepairImportService +from .export import ExportService +from .export_repair import ExportRepairService + +__all__ = [ + 'TemporaryImportService', + 'DefinitiveImportService', + 'RepairImportService', + 'ExportService', + 'ExportRepairService', +] diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py new file mode 100644 index 00000000..14b62a88 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py new file mode 100644 index 00000000..cfe08acb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/database_helpers.py @@ -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 = '' + + 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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py new file mode 100644 index 00000000..5a2c1ee3 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py new file mode 100644 index 00000000..656c8869 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/exchange_rate.py @@ -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) diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py new file mode 100644 index 00000000..cfb594f7 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py new file mode 100644 index 00000000..51044161 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export_repair.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py new file mode 100644 index 00000000..d141392c --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py @@ -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 + """ diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak new file mode 100644 index 00000000..f74e0da8 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak @@ -0,0 +1,877 @@ +""" +SQL Query builders for invoice movement services. +Centralizes all SQL query construction logic. +""" + + +class TemporaryImportQueries: + """SQL queries for temporary imports using PostgreSQL tables.""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: db_name parameter kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + COALESCE(ped_r1.pedimento_number, '') AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1 + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, cmp.remesa, fin.exchange_rate, + cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, cmp.aduana, ped_r1.pedimento_number, + cmp.edocument, cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.carrier_id, log.transport_num, log.license_plate + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for DETAILED mode (all partidas) from PostgreSQL.""" + # Note: db_name parameter is kept for compatibility but not used in PostgreSQL + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(fin.value_me, 0) AS C6, + COALESCE(fin.value_mn, 0) AS C7, + COALESCE(cmp.provider_id::text, '') AS C8, + COALESCE(cmp.sold_to_id::text, '') AS C9, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(cmp.remesa, 0) AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + '' AS C19, + COALESCE(il.class_id::text, '') AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22, + COALESCE(lq.quantity, 0) AS C23, + COALESCE(il.unit_of_measure::text, '') AS C24, + COALESCE(lf.value_mxn, 0) AS C25, + COALESCE(lf.customs_value_mxn, 0) AS C26, + COALESCE(lf.value_usd, 0) AS C27, + COALESCE(lf.customs_value_usd, 0) AS C28, + COALESCE(lq.net_weight, 0) AS C29, + COALESCE(lq.gross_weight, 0) AS C30, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(lc.fraction, '') AS C32, + COALESCE(lc.fraction_type, '') AS C33, + COALESCE(lc.advalorem_numeric, 0) AS C34, + COALESCE(lc.sector, '') AS C35, + COALESCE(lf.igi_amount_usd, 0) AS C36, + COALESCE(lc.origin_country, '') AS C37, + COALESCE(cmp.aduana, '') AS C38, + ih.id AS C39, + FALSE AS C40, + '' AS C41, + COALESCE(cmp.edocument, '') AS C42, + COALESCE(cmp.vucem_operation_num, '') AS C43, + COALESCE(il.line_number, 0) AS C44, + '' AS C45, + '' AS C46, + COALESCE(cls.us_fraction, '') AS C47, + COALESCE(prt.eccn, '') AS C48, + COALESCE(il.part_number::text, '') AS C49, + COALESCE(fin.exchange_rate, 0) AS C50, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51, + COALESCE(ih.capture_user, '') AS C52, + COALESCE(ih.who_updated, '') AS C53, + COALESCE(log.carrier_id, '') AS C54, + COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.item_lines il ON il.item_id = ( + SELECT id FROM a76.items WHERE invoice_id = ih.id LIMIT 1 + ) + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'TEM' + AND {where_str} + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for an invoice.""" + return f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information.""" + return f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number.""" + return f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """ + + +class DefinitiveImportQueries: + """SQL queries for definitive imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4, + COALESCE(ped.pedimento_code, '') AS C5, + COALESCE(ped.regime, '') AS C10, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13, + COALESCE(log.payment_receipt_num, '') AS C14, + COALESCE(fin.exchange_rate, 0) AS C15, + COALESCE(cmp.provider_id::text, '') AS C16, + COALESCE(cmp.sold_to_id::text, '') AS C17, + COALESCE(cmp.customs_broker_id::text, '') AS C18, + COALESCE(ih.purchase_order, '') AS C31, + COALESCE(cmp.aduana, '') AS C39, + ih.id AS C35, + COALESCE(ped_r1.pedimento_number, '') AS C42, + COALESCE(cmp.edocument, '') AS C43, + COALESCE(cmp.vucem_operation_num, '') AS C44, + COALESCE(fin.exchange_rate, 0) AS C51, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52, + COALESCE(ih.capture_user, '') AS C53, + COALESCE(ih.who_updated, '') AS C54, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, + '' AS C57, + '' AS C58, + '' AS C59, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1 + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE') + AND {where_clause} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num, + fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, + ih.purchase_order, cmp.aduana, ped_r1.pedimento_number, cmp.edocument, + cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.transport_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + ped.status AS C4, -- [3] + ped.pedimento_code AS C5, -- [4] + '' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8] + ped.regime AS C10, -- [9] + log.entry_exit_date AS C11, -- [10] + log.delivery_date AS C12, -- [11] + log.payment_date AS C13, -- [12] + log.payment_receipt_num AS C14, -- [13] + '' AS C15, -- [14] + cmp.provider_id AS C16, -- [15] + cmp.sold_to_id AS C17, -- [16] + cmp.customs_broker_id AS C18, -- [17] + '' AS C19, -- [18] + prt.part_number AS C20, -- [19] + ld.description_spanish AS C21, -- [20] + ld.description_english AS C22, -- [21] + lq.quantity AS C23, -- [22] + um.code AS C24, -- [23] + lf.value_mxn AS C25, -- [24] + '' AS C26, -- [25] + lf.value_usd AS C27, -- [26] + '' AS C28, -- [27] + lq.net_weight AS C29, -- [28] + lq.gross_weight AS C30, -- [29] + ih.purchase_order AS C31, -- [30] + lc.fraction AS C32, -- [31] + '' AS C33, '' AS C34, -- [32-33] + ih.id AS C35, -- [34] + '' AS C36, '' AS C37, -- [35-36] + lc.origin_country AS C38, -- [37] + cmp.aduana AS C39, -- [38] + il.material_type AS C40, -- [39] + il.id AS C41, -- [40] + '' AS C42, -- [41] rectification_id + cmp.edocument AS C43, -- [42] + cmp.vucem_operation_num AS C44, -- [43] + il.line_number AS C45, -- [44] + ld.brand AS C46, -- [45] + ld.model AS C47, -- [46] + prt.us_fraction AS C48, -- [47] + prt.eccn AS C49, -- [48] + prt.id AS C50, -- [49] + fin.exchange_rate AS C51, -- [50] + ih.emission_date AS C52, -- [51] + ih.capture_user AS C53, -- [52] + ih.who_updated AS C54, -- [53] + '' AS C55, -- [54] + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, -- [55] + '' AS C57, -- [56] Pedimento18 (row[56]) + COALESCE(ld.lot, '') AS C58, -- [57] Lote (row[57]) + '' AS C59, -- [58] TipoPed (row[58]) + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str) -> str: + """Build query to get totals for a definitive import invoice.""" + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for definitive imports.""" + # TODO: QSeriesDef table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for definitive imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class RepairImportQueries: + """SQL queries for repair imports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + ih.invoice_number AS C2, + COALESCE(ped.pedimento_number, '') AS C3, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5, + COALESCE(ped.pedimento_code, '') AS C6, + COALESCE(ped.regime, '') AS C7, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C9, + COALESCE(cmp.remesa::text, '') AS C10, + COALESCE(fin.exchange_rate, 0) AS C11, + COALESCE(cmp.provider_id::text, '') AS C12, + COALESCE(cmp.sold_to_id::text, '') AS C13, + COALESCE(cmp.customs_broker_id::text, '') AS C14, + COALESCE(ih.purchase_order, '') AS C24, + COALESCE(ped.customs_office, '') AS C29, + ih.id AS C30, + COALESCE(cmp.edocument, '') AS C33, + COALESCE(cmp.vucem_operation_num, '') AS C34, + COALESCE(fin.exchange_rate, 0) AS C40, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41, + COALESCE(ih.capture_user, '') AS C42, + COALESCE(ih.who_updated, '') AS C43, + COALESCE(log.carrier_id, '') AS C44, + COALESCE(log.transport_num, '') AS C45, + COALESCE(ped.pedimento_code, '') AS C47, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'REP' + AND COALESCE(cmp.is_regime_change, false) = false + {"AND " + where_str if where_str else ""} + {discharge_filter} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, + cmp.customs_broker_id, ih.purchase_order, ped.customs_office, cmp.edocument, + cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated, + log.carrier_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build main SQL query for repair import data.""" + # Note: is_discharged field not yet migrated to PostgreSQL schema + # discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + il.line_number, + ih.invoice_number, + COALESCE(ped.pedimento_number, ''), + TO_CHAR(ih.invoice_date, 'YYYYMMDD'), + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END, + COALESCE(ped.pedimento_code, ''), + COALESCE(ped.regime, ''), + '', + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), ''), + COALESCE(cmp.remesa::text, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(cmp.provider_id::text, ''), + COALESCE(cmp.sold_to_id::text, ''), + COALESCE(cmp.customs_broker_id::text, ''), + COALESCE(il.part_number::text, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lq.quantity, 0), + COALESCE(il.unit_of_measure, 0), + COALESCE(lf.value_mxn, 0), + COALESCE(lf.value_usd, 0), + COALESCE(lq.net_weight, 0), + COALESCE(lq.gross_weight, 0), + COALESCE(ih.purchase_order, ''), + COALESCE(lc.fraction, ''), + '', + COALESCE(lc.sector, ''), + COALESCE(lc.origin_country, ''), + COALESCE(ped.customs_office, ''), + ih.id, + 'P', + '', + COALESCE(cmp.edocument, ''), + COALESCE(cmp.vucem_operation_num, ''), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '), + COALESCE(lc.american_fraction, ''), + COALESCE(prt.eccn, ''), + COALESCE(fin.exchange_rate, 0), + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''), + COALESCE(ih.capture_user, ''), + COALESCE(ih.who_updated, ''), + COALESCE(log.carrier_id, ''), + COALESCE(log.transport_num, ''), + '', + COALESCE(ped.pedimento_code, '') + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE ih.operation_type = 'imp' + AND ih.invoice_type = 'REP' + AND COALESCE(cmp.is_regime_change, false) = false + {"AND " + where_str if where_str else ""} + {discharge_filter} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for a repair import invoice.""" + # Note: is_discharged field not yet migrated to PostgreSQL schema + # discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + discharge_filter = "" # Temporarily disabled until schema migration + return f""" + SELECT + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for repair imports.""" + # TODO: QSeriesImpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for repair imports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportQueries: + """SQL queries for exports (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_clause: str) -> str: + """ + Build optimized query for NORMAL mode (grouped by invoice with totals). + + Args: + db_name: Database name (not used in PostgreSQL version) + where_clause: Additional WHERE conditions (without WHERE keyword) + """ + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C9, + COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C10, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(log.payment_receipt_num, '') AS C12, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(cmp.aduana, '') AS C33, + COALESCE(ih.invoice_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE {where_clause} + GROUP BY ih.id, ih.invoice_number, ih.is_updated, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num, + cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, ih.purchase_order, cmp.aduana, + ih.invoice_type, cmp.edocument, cmp.vucem_operation_num, fin.exchange_rate, + ih.emission_date, ih.capture_user, ih.who_updated, log.transport_id, log.transport_num, + ih.invoice_date + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_clause: str) -> str: + return f""" + SELECT + ih.invoice_number AS C1, -- [0] + ped.pedimento_number AS C2, -- [1] + ih.invoice_date AS C3, -- [2] + '' AS C4, -- [3] + '' AS C5, -- [4] + ped.status AS C6, -- [5] + ped.pedimento_code AS C7, -- [6] + ped.regime AS C8, -- [7] + log.entry_exit_date AS C9, -- [8] + log.delivery_date AS C10, -- [9] + log.payment_date AS C11, -- [10] + log.payment_receipt_num AS C12, -- [11] + '' AS C13, -- [12] + cmp.provider_id AS C14, -- [13] + cmp.sold_to_id AS C15, -- [14] + cmp.customs_broker_id AS C16, -- [15] + '' AS C17, -- [16] + prt.part_number AS C18, -- [17] + ld.description_spanish AS C19, -- [18] + ld.description_english AS C20, -- [19] + lq.quantity AS C21, -- [20] + um.code AS C22, -- [21] + '' AS C23, -- [22] + '' AS C24, -- [23] + lq.net_weight AS C25, -- [24] + lq.gross_weight AS C26, -- [25] + ih.purchase_order AS C27, -- [26] + lc.fraction AS C28, -- [27] + '' AS C29, -- [28] + '' AS C30, -- [29] + '' AS C31, -- [30] + '' AS C32, -- [31] + cmp.aduana AS C33, -- [32] + ih.invoice_type AS C34, -- [33] + ih.id AS C35, -- [34] + lf.value_mxn AS C36, -- [35] + lf.value_usd AS C37, -- [36] + il.material_type AS C38, -- [37] + '' AS C39, -- [38] rectification_id + cmp.edocument AS C40, -- [39] + cmp.vucem_operation_num AS C41, -- [40] + il.line_number AS C42, -- [41] + ld.brand AS C43, -- [42] + ld.model AS C44, -- [43] + prt.us_fraction AS C45, -- [44] + prt.eccn AS C46, -- [45] + prt.id AS C47, -- [46] + fin.exchange_rate AS C48, -- [47] + ih.emission_date AS C49, -- [48] + ih.capture_user AS C50, -- [49] + ih.who_updated AS C51, -- [50] + '' AS C52, -- [51] + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, -- [52] NumCaja + '' AS C54, -- [53] Pedimento18 + COALESCE(ld.lot, '') AS C55, -- [54] Lote + '' AS C56, -- [55] TipoPedimentoTransporte + '' AS C57, -- [56] + '' AS C58, -- [57] + '' AS C59, -- [58] + '' AS C60 -- [59] Relleno final + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE {where_clause} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export invoice. + + Only sums partidas where is_subitem is false (main partidas, not sub-items). + """ + discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + return f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for exports.""" + # TODO: QSeriesExpo table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for exports.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ + + +class ExportRepairQueries: + """SQL queries for export repairs (PostgreSQL schema).""" + + @staticmethod + def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: + """Build optimized query for NORMAL mode (grouped by invoice with totals).""" + # Note: discharge_clause temporarily disabled until is_discharged field migrated + discharge_filter = "" # Will be: " AND il.is_discharged = true/false" when ready + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me, + COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + WHERE ih.operation_type = 'exp' + AND ih.invoice_type = 'REP' + {"AND " + where_str if where_str else ""} + GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime, + log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, + cmp.customs_broker_id, ih.purchase_order, ped.customs_office, ih.document_type, + cmp.edocument, cmp.vucem_operation_num, ih.invoice_date, ih.capture_user, + ih.who_updated, log.carrier_id, log.transport_id, log.transport_num + ORDER BY ih.invoice_number + """ + + @staticmethod + def build_main_query(db_name: str, where_str: str) -> str: + """Build main SQL query for export repair data.""" + return f""" + SELECT + ih.invoice_number AS C1, + COALESCE(ped.pedimento_number, '') AS C2, + TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3, + COALESCE(fin.value_me, 0) AS C4, + COALESCE(fin.value_mn, 0) AS C5, + CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6, + COALESCE(ped.pedimento_code, '') AS C7, + COALESCE(ped.regime, '') AS C8, + '' AS C9, + '' AS C10, + COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, + COALESCE(cmp.remesa::text, '') AS C12, + COALESCE(fin.exchange_rate, 0) AS C13, + COALESCE(cmp.provider_id::text, '') AS C14, + COALESCE(cmp.sold_to_id::text, '') AS C15, + COALESCE(cmp.customs_broker_id::text, '') AS C16, + '' AS C17, + COALESCE(cls.class_code, '') AS C18, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20, + COALESCE(lq.quantity, 0) AS C21, + COALESCE(il.unit_of_measure, 0) AS C22, + COALESCE(lf.customs_value_mxn, 0) AS C23, + COALESCE(lf.customs_value_usd, 0) AS C24, + COALESCE(lq.net_weight, 0) AS C25, + COALESCE(lq.gross_weight, 0) AS C26, + COALESCE(ih.purchase_order, '') AS C27, + COALESCE(lc.fraction, '') AS C28, + COALESCE(lc.fraction_type, '') AS C29, + COALESCE(lc.advalorem_numeric, 0) AS C30, + COALESCE(lc.sector, '') AS C31, + COALESCE(lc.origin_country, '') AS C32, + COALESCE(ped.customs_office, '') AS C33, + COALESCE(ih.document_type, '') AS C34, + ih.id AS C35, + COALESCE(lf.value_mxn, 0) AS C36, + COALESCE(lf.value_usd, 0) AS C37, + 'P' AS C38, + '' AS C39, + COALESCE(cmp.edocument, '') AS C40, + COALESCE(cmp.vucem_operation_num, '') AS C41, + COALESCE(il.line_number, 0) AS C42, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43, + REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44, + COALESCE(cls.us_fraction, '') AS C45, + COALESCE(prt.eccn, '') AS C46, + COALESCE(il.part_number::text, '') AS C47, + COALESCE(fin.exchange_rate, 0) AS C48, + COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49, + COALESCE(ih.capture_user, '') AS C50, + COALESCE(ih.who_updated, '') AS C51, + COALESCE(log.carrier_id, '') AS C52, + COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, + '' AS C54, + COALESCE(ld.lot, '') AS C55, + '' AS C56, + '' AS C57, + '' AS C58, + '' AS C59 + FROM a76.invoice_header ih + LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id + LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id + LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id + LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.items itm ON itm.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = itm.id + LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id + LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id + LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id + LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a76.classes cls ON cls.id = il.class_id + LEFT JOIN a76.parts prt ON prt.id = il.part_number + LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure + WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET') + AND UPPER(ih.invoice_type) IN ('DEF', 'REP', 'EXDEF', 'MATDE') + AND {where_str} + ORDER BY ih.invoice_number, il.line_number + """ + + @staticmethod + def build_totals_query(db_name: str, discharge_clause: str = "") -> str: + """Build query to get totals for an export repair invoice.""" + discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else "" + return f""" + SELECT + COALESCE(SUM(lf.value_usd), 0), + COALESCE(SUM(lf.value_mxn), 0) + FROM a76.item_line_financials lf + INNER JOIN a76.item_lines il ON il.id = lf.item_line_id + INNER JOIN a76.items itm ON itm.id = il.item_id + WHERE itm.invoice_id = :consecutivo + + {discharge_filter} + """ + + @staticmethod + def build_series_query(db_name: str) -> str: + """Build query to get series information for export repairs.""" + # TODO: QSeriesExpoRep table not migrated to PostgreSQL yet + return """ + SELECT '' as serie, '' as modelo, '' as parte + WHERE 1=0 + """ + + @staticmethod + def build_driver_badge_query(db_name: str) -> str: + """Build query to get driver badge number for export repairs.""" + # TODO: GConductor table not migrated to PostgreSQL yet + return """ + SELECT '' as badge + WHERE 1=0 + """ diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py new file mode 100644 index 00000000..0a4619ab --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py new file mode 100644 index 00000000..1e4ae9b2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py b/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py new file mode 100644 index 00000000..66c888df --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services_old.py @@ -0,0 +1,2115 @@ +import logging +from sqlalchemy import text +from sqlalchemy.orm import Session +import configparser +import os +from typing import List, Optional +from datetime import datetime +from decimal import Decimal + +from .schemas import ( + ImportTemporaryFilter, + ImportDefinitiveFilter, + ImportRepairFilter, + MovementItem, + MovementItemDetailed, + RangeType +) + +logger = logging.getLogger(__name__) + + +class MovementService: + """ + Service for handling movement operations, particularly temporary import movements. + Integrates with legacy SQL Server databases for data extraction. + """ + + def _get_met_trans_config(self) -> int: + """ + Read MetTrans configuration from Scaii.ini file. + + Returns: + MetTrans value (0 or 1) + """ + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + return met_trans + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + return 0 + + def _build_where_clause_temporary(self, filters: ImportTemporaryFilter) -> tuple: + """ + Build WHERE clause and parameters for temporary imports query. + + Returns: + Tuple of (where_string, params_dict) + """ + where_clauses = [] + params = {} + + # Date range filter + if filters.range_type.value == 'FF': + where_clauses.append("EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date") + else: + where_clauses.append("EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date") + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + # Status filter + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + # Buyer filter + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + # Pedimento code filter + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + return " AND ".join(where_clauses), params + + def _build_where_clause_definitive(self, filters: ImportDefinitiveFilter) -> str: + """ + Build WHERE clause for definitive imports query. + + Returns: + WHERE clause string + """ + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + + return " AND ".join(where_conditions) + + def _calculate_exchange_rate_and_value( + self, + db: Session, + db_name: str, + valor_me: float, + valor_mn: float, + tipo_cambio_db: float, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + currency_type: str, + exchange_rate_type: str, + is_shelter: bool, + use_transport_method: bool, + met_trans: int + ) -> tuple: + """ + Unified method to calculate exchange rate and commercial value. + Eliminates duplicated logic across temporary and definitive imports. + + Args: + db: Database session + db_name: Database name + valor_me: Value in foreign currency + valor_mn: Value in local currency + tipo_cambio_db: Exchange rate from database + fecha_pago: Payment date + fecha_inicio: Start date + tipo_pedimento: Pedimento type + currency_type: "ME" or "MN" + exchange_rate_type: "FP" or "FF" + is_shelter: Shelter company flag + use_transport_method: Use transport method flag + met_trans: MetTrans value from config + + Returns: + Tuple of (valor_comercial_mn, tipo_cambio) + """ + # Foreign currency case - simpler + if currency_type == "ME": + valor_comercial = valor_me + tipo_cambio = tipo_cambio_db + + # Try to get exchange rate if using payment date + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = self._get_fecha_tipo_cambio( + fecha_pago, fecha_inicio, tipo_pedimento, use_transport_method, met_trans + ) + tc_value = self._obtener_tipo_cambio(db, db_name, fecha_tc, is_shelter) + if tc_value: + tipo_cambio = tc_value + + return valor_comercial, tipo_cambio + + # Local currency case - more complex + if exchange_rate_type == "FP" and fecha_pago: + fecha_tc = self._get_fecha_tipo_cambio( + fecha_pago, fecha_inicio, tipo_pedimento, use_transport_method, met_trans + ) + tc_value = self._obtener_tipo_cambio(db, db_name, fecha_tc, is_shelter) + + if tc_value: + return valor_me * tc_value, tc_value + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using DB values") + return valor_mn, tipo_cambio_db + else: + return valor_mn, tipo_cambio_db + + def _get_fecha_tipo_cambio( + self, + fecha_pago, + fecha_inicio, + tipo_pedimento: str, + use_transport_method: bool, + met_trans: int + ): + """ + Determine which date to use for exchange rate lookup. + + Returns: + Date to use for exchange rate + """ + fecha = fecha_pago + if use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha = fecha_inicio + return fecha + + def get_temporary_import_movements( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItem]: + """ + Retrieve temporary import movements from legacy database. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching temporary import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause dynamically based on filters + where_clauses = [] + params = {} + + # Date range filter + if filters.range_type == 'FF': + where_clauses.append( + "EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date" + ) + else: + where_clauses.append( + "EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date" + ) + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + # Status filter + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + # Buyer filter + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + # Pedimento code filter + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + where_str = " AND ".join(where_clauses) + db_name = filters.database_name + + logger.debug(f"WHERE clause: {where_str}") + logger.debug(f"Query params: {params}") + + # Main Query (using shared query builder) + sql_query = text(self._build_main_query(db_name, where_str)) + + try: + result = db.execute(sql_query, params) + rows = result.fetchall() + logger.info(f"Query returned {len(rows)} rows") + except Exception as e: + logger.error(f"Error executing main query: {e}") + raise Exception(f"Database query failed: {str(e)}") + + movements = [] + processed_facturas = set() + + for row in rows: + if filters.report_type == 'Normal': + C1_Factura = row[0] + + if C1_Factura in processed_facturas: + continue + + qcsv_ie = {} + processed_facturas.add(C1_Factura) + + C39_Consecutivo = row[38] + + # Totalize Items (sum values for main partidas only) + sql_total = text(f""" + SELECT + COALESCE(SUM(EqiPim.ValorImpoME), 0), + COALESCE(SUM(EqiPim.ValorImpoMN), 0) + FROM [{db_name}].dbo.QEqiMaq EqiPim + WHERE EqiPim.Consecutivo = :consecutivo + AND EqiPim.EsSubpartida = 'P' + """) + + try: + res_total = db.execute(sql_total, {"consecutivo": C39_Consecutivo}).fetchone() + val_total_me = float(res_total[0]) if res_total and res_total[0] is not None else 0.0 + val_total_mn = float(res_total[1]) if res_total and res_total[1] is not None else 0.0 + except Exception as e: + logger.warning(f"Error calculating totals for consecutivo {C39_Consecutivo}: {e}") + val_total_me = 0.0 + val_total_mn = 0.0 + + qcsv_ie['Factura'] = row[0] + qcsv_ie['Pedimento'] = row[1] + qcsv_ie['FechaFactura'] = row[2] + qcsv_ie['Estatus'] = row[3] + qcsv_ie['ClavePed'] = row[4] + qcsv_ie['TipoMovTemDef'] = 'IMTEM' + qcsv_ie['EsCambioRegimen'] = 'N' + + row_c13 = row[12] # Fecha_Pago + row_c11 = row[10] # Fecha_Inicio + row_c58 = row[57] # TIPOPEDIMENTOTRANSPORTEE + row_c50 = row[49] # TipoCambio + row_c41 = row[40] # PedRectifica + row_c2 = row[1] # PedimentoImpo + + calculated_tc = row_c50 + + # Calculate values based on currency type + if filters.currency_type == 'ME': + qcsv_ie['ValorMPTemp'] = val_total_me + qcsv_ie['ValorComercialMN'] = val_total_me + + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio( + db, db_name, row_c13, row_c11, row_c58, met_trans + ) + qcsv_ie['TipoCambio'] = calculated_tc if calculated_tc > 0 else row_c50 + else: + qcsv_ie['TipoCambio'] = row_c50 + else: + if filters.is_shelter: + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio_mn(db, db_name, row_c13, row_c11, row_c58, met_trans) + qcsv_ie['ValorMPTemp'] = val_total_me * calculated_tc + qcsv_ie['ValorComercialMN'] = val_total_me * calculated_tc + qcsv_ie['TipoCambio'] = calculated_tc + else: + qcsv_ie['ValorMPTemp'] = val_total_mn + qcsv_ie['ValorComercialMN'] = val_total_mn + qcsv_ie['TipoCambio'] = row_c50 + else: + if filters.exchange_rate_type == 'FP' and row_c13: + calculated_tc = self._obtener_tipo_cambio(db, db_name, row_c13, row_c11, row_c58, met_trans) + qcsv_ie['ValorMPTemp'] = val_total_me * calculated_tc + qcsv_ie['ValorComercialMN'] = val_total_me * calculated_tc + qcsv_ie['TipoCambio'] = calculated_tc + else: + qcsv_ie['ValorMPTemp'] = val_total_mn + qcsv_ie['ValorComercialMN'] = val_total_mn + qcsv_ie['TipoCambio'] = row_c50 + + qcsv_ie['ValorAgre'] = 0.0 + qcsv_ie['TipoExpo'] = '' + + if filters.is_shelter: + qcsv_ie['PedimentoR1'] = row_c41 + else: + qcsv_ie['PedimentoR1'] = self._buscar_rectificacion(db, db_name, row_c2, row_c41) + + qcsv_ie['EDocument'] = row[41] + qcsv_ie['NumOperacionVU'] = row[42] + qcsv_ie['BaseDeDatos'] = db_name + + # Get driver badge number (gafete) + sql_gafete = text(f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + try: + res_gafete = db.execute(sql_gafete, {"factura": row[0]}).fetchone() + qcsv_ie['NumGafUni'] = res_gafete[0] if res_gafete and res_gafete[0] else None + except Exception as e: + logger.debug(f"Could not retrieve badge for invoice {row[0]}: {e}") + qcsv_ie['NumGafUni'] = None + + qcsv_ie['UsuarioCap'] = row[51] + qcsv_ie['UsuarioAcr'] = row[52] + qcsv_ie['Fecha_Pago'] = row[12] + qcsv_ie['NumCaja'] = row[54] + qcsv_ie['Pedimento18'] = row[55] + qcsv_ie['AduanaCru'] = row[37] + qcsv_ie['Lote'] = row[56] + + movements.append(MovementItem(**qcsv_ie)) + + return movements + + def _obtener_tipo_cambio( + self, + db: Session, + db_name: str, + fecha, + is_shelter: bool + ) -> Optional[float]: + """ + Get exchange rate for the given date. + Simplified version that works with both shelter and non-shelter logic. + + Args: + db: Database session + db_name: Legacy database name + fecha: Date for exchange rate lookup + is_shelter: Shelter company flag (currently not used but kept for compatibility) + + Returns: + Exchange rate as float, or None if not found + """ + if not fecha: + return None + + try: + sql_tc = text(f""" + SELECT TOP 1 Valor + FROM [{db_name}].dbo.GTipoCambio + WHERE Fecha = :fecha + ORDER BY Fecha DESC + """) + res = db.execute(sql_tc, {"fecha": fecha}).fetchone() + if res and res[0]: + return float(res[0]) + else: + logger.warning(f"Exchange rate not found for date {fecha}") + return None + except Exception as e: + logger.error(f"Error fetching exchange rate for date {fecha}: {e}") + return None + + def _buscar_rectificacion( + self, + pedimento: str, + ped_rectifica: Optional[str] + ) -> Optional[str]: + """ + Search for pedimento rectification. + Simplified version - returns the rectification value from database. + + Args: + pedimento: Original pedimento number + ped_rectifica: Rectification pedimento from query + + Returns: + Rectification pedimento number or None + """ + # TODO: Implement full rectification search logic if needed + # For now, returning the value from the query + return ped_rectifica + + def get_temporary_import_movements_detailed( + self, + db: Session, + filters: ImportTemporaryFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed temporary import movements (line by line) from legacy database. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of detailed movement items (one per line/partida) + """ + logger.info(f"Fetching DETAILED temporary import movements with filters: {filters.model_dump()}") + + # Read INI configuration + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause (same as normal report) + where_clauses = [] + params = {} + + if filters.range_type == 'FF': + where_clauses.append( + "EqiFim.FechaFactura >= :start_date AND EqiFim.FechaFactura <= :end_date" + ) + else: + where_clauses.append( + "EqiPed.Fecha_Pago >= :start_date AND EqiPed.Fecha_Pago <= :end_date" + ) + + params['start_date'] = filters.start_date + params['end_date'] = filters.end_date + + if not filters.include_cancelled: + where_clauses.append("EqiFim.Estatus = 'AC'") + + if filters.provider: + where_clauses.append("EqiFim.Proveedor = :provider") + params['provider'] = filters.provider + + if filters.buyer: + where_clauses.append("EqiFim.VendidoA = :buyer") + params['buyer'] = filters.buyer + + if filters.pedimento_code: + where_clauses.append("EqiPed.ClavePed = :pedimento_code") + params['pedimento_code'] = filters.pedimento_code + + where_str = " AND ".join(where_clauses) + db_name = filters.database_name + + logger.debug(f"WHERE clause: {where_str}") + logger.debug(f"Query params: {params}") + + # Same main query as normal report + sql_query = text(self._build_main_query(db_name, where_str)) + + try: + result = db.execute(sql_query, params) + rows = result.fetchall() + logger.info(f"Query returned {len(rows)} rows for detailed processing") + except Exception as e: + logger.error(f"Error executing main query: {e}") + raise Exception(f"Database query failed: {str(e)}") + + movements = [] + + # Process each row individually (detailed mode) + for row in rows: + item = {} + + # Basic invoice info + item['Linea'] = row[43] # C44 - LineaImpo + item['Factura'] = row[0] # C1 + item['Pedimento'] = row[1] # C2 + item['FechaFactura'] = row[2] # C3 + item['Estatus'] = row[3] # C4 + item['ClavePed'] = row[4] # C5 + item['TipoMovTemDef'] = 'IMTEM' + item['EsCambioRegimen'] = 'N' + item['Regimen'] = row[9] # C10 + item['Fecha_Inicio'] = row[10] # C11 + item['Fecha_Fin'] = row[11] # C12 + item['Fecha_Pago'] = row[12] # C13 + item['Remesa'] = row[13] # C14 + + # Get provider information + provider_code = row[15] # C16 - Proveedor + provider_info = self._get_client_provider_info(db, db_name, provider_code, filters.is_shelter) + item['Proveedor'] = provider_info.get('nombre') + item['RFCProveedor'] = provider_info.get('rfc') + item['ProveedorTaxID'] = provider_info.get('tax_id') + + # Get buyer information + buyer_code = row[16] # C17 - VendidoA + buyer_info = self._get_client_buyer_info(db, db_name, buyer_code, filters.is_shelter) + item['VendidoA'] = buyer_info.get('nombre') + item['VendidoARFC'] = buyer_info.get('rfc') + item['VendidoATaxID'] = buyer_info.get('tax_id') + + # Get customs broker info + customs_broker_code = row[17] # C18 - AAduanal + broker_info = self._get_customs_broker_info(db, db_name, customs_broker_code) + item['AgenteAduanal'] = broker_info.get('nombre') + item['Patente'] = broker_info.get('patente') + + # Item details + item['NumParte'] = row[19] # C20 - Clase (NumParte) + item['DescripcionE'] = self._clean_text(row[20]) # C21 - Already cleaned in query + item['DescripcionI'] = self._clean_text(row[21]) # C22 - Already cleaned in query + item['CantidadIE'] = float(row[22]) if row[22] else 0.0 # C23 + item['UniMed'] = row[23] # C24 + + # Values and exchange rate (only for main partidas, not subpartidas) + if row[39] == 'P': # C40 - EsSubPartida == 'P' + # Calculate value based on currency type + if filters.currency_type == 'MN': + if filters.is_shelter: + if filters.exchange_rate_type == 'FP' and row[12]: # C13 - Fecha_Pago + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['ValorComercialMN'] = float(row[26]) * tc if row[26] else 0.0 # C27 * TC + item['TipoCambio'] = tc + else: + item['ValorComercialMN'] = float(row[24]) if row[24] else 0.0 # C25 + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + else: + if filters.exchange_rate_type == 'FP' and row[12]: + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['ValorComercialMN'] = float(row[26]) * tc if row[26] else 0.0 # C27 * TC + item['TipoCambio'] = tc + else: + item['ValorComercialMN'] = float(row[24]) if row[24] else 0.0 # C25 + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + elif filters.currency_type == 'ME': + item['ValorComercialMN'] = float(row[26]) if row[26] else 0.0 # C27 + if filters.exchange_rate_type == 'FP' and row[12]: + tc = self._obtener_tipo_cambio(db, db_name, row[12], row[10], row[57], met_trans) + item['TipoCambio'] = tc if tc > 0 else float(row[49]) if row[49] else 0.0 + else: + item['TipoCambio'] = float(row[49]) if row[49] else 0.0 # C50 + + item['PesoNeto'] = float(row[28]) if row[28] else 0.0 # C29 + item['PesoBruto'] = float(row[29]) if row[29] else 0.0 # C30 + elif row[39] == 'S': # Subpartida + item['ValorComercialMN'] = 0.0 + item['PesoNeto'] = 0.0 + item['PesoBruto'] = 0.0 + item['TipoCambio'] = 0.0 + + # Additional fields + item['OrdenCompraVenta'] = row[30] # C31 + item['FraccionArancelaria'] = row[31] # C32 + item['Preferencia'] = row[32] # C33 + item['Sector'] = row[34] # C35 + item['PaisOrigen'] = row[36] # C37 + + # Get customs office name + aduana_code = row[37] # C38 - Aduana_Cruce + aduana_name = self._get_customs_office_name(db, db_name, aduana_code) + item['Aduana'] = aduana_name + + item['Advalorem'] = row[39] # C40 + item['TipoExpo'] = '' + + # Rectification + if filters.is_shelter: + item['PedimentoR1'] = row[40] # C41 + else: + item['PedimentoR1'] = self._buscar_rectificacion(db, db_name, row[1], row[40]) + + item['EDocument'] = row[41] # C42 + item['NumOperacionVU'] = row[42] # C43 + + # Get series information + consecutivo = row[38] # C39 + linea_impo = row[43] # C44 + series_info = self._get_series_info(db, db_name, consecutivo, linea_impo, filters.is_shelter) + item['Series'] = series_info + + item['Marca'] = row[44] # C45 + item['Modelo'] = row[45] # C46 + item['FraccionAmericana'] = row[46] # C47 + item['ECCN'] = row[47] # C48 + + # Get export symbol from parts + num_parte = row[48] # C49 + if num_parte: + simbolo_ex = self._get_part_export_symbol(db, db_name, num_parte, filters.is_shelter) + item['SimboloEx'] = simbolo_ex + else: + item['SimboloEx'] = None + + item['FechaEmision'] = row[50] # C51 + item['BaseDeDatos'] = db_name + + # Get driver badge + factura = row[0] # C1 + try: + sql_gafete = text(f""" + SELECT TOP 1 NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImp + ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + res_gafete = db.execute(sql_gafete, {"factura": factura}).fetchone() + item['NumGafUni'] = res_gafete[0] if res_gafete and res_gafete[0] else None + except Exception as e: + logger.debug(f"Could not retrieve badge for invoice {factura}: {e}") + item['NumGafUni'] = None + + item['UsuarioCap'] = row[51] # C52 + item['UsuarioAcr'] = row[52] # C53 + item['Transportista'] = row[53] # C54 + item['NumCaja'] = row[54] # C55 + item['Pedimento18'] = row[55] # C56 + item['AduanaCru'] = row[37] # C38 + item['Lote'] = row[56] # C57 + + movements.append(MovementItemDetailed(**item)) + + logger.info(f"Processed {len(movements)} detailed movement items") + return movements + + def _build_main_query(self, db_name: str, where_str: str) -> str: + """Build the main SQL query for fetching invoice data""" + return f""" + SELECT + EqiFim.FacturaImpo AS C1, + EqiFim.PedimentoImpo AS C2, + EqiFim.FechaFactura AS C3, + EqiFim.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiFim.ValorImpoME AS C6, + EqiFim.ValorImpoMN AS C7, + EqiFim.Proveedor AS C8, + EqiFim.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFim.Remesa AS C14, + EqiFim.TipoCambio AS C15, + EqiFim.Proveedor AS C16, + EqiFim.VendidoA AS C17, + EqiFim.AAduanal AS C18, + '' AS C19, + EqiPim.Clase AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.DescripcionE, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(ClaAct.DescripcionI, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C22, + EqiPim.CantImpo AS C23, + EqiPim.UnidadMedida AS C24, + EqiPim.ValorImpoMN AS C25, + EqiPim.ValorAduanasMN AS C26, + EqiPim.ValorImpoME AS C27, + EqiPim.ValorAduanasME AS C28, + EqiPim.PesoNeto AS C29, + EqiPim.PesoBruto AS C30, + EqiPim.OrdenCompra AS C31, + EqiPim.Fraccion AS C32, + EqiPim.TipoFraccion AS C33, + EqiPim.AdvImpo AS C34, + EqiPim.Sector AS C35, + EqiPim.MontoIgi AS C36, + EqiPim.PaisOrigen AS C37, + EqiPed.Aduana_Cruce AS C38, + EqiFim.Consecutivo AS C39, + EqiPim.EsSubPartida AS C40, + EqiPed.PedRectifica AS C41, + EqiFim.EDocument AS C42, + EqiFim.NumOperacionVU AS C43, + EqiPim.LineaImpo AS C44, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.Marca, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(EqiPim.Modelo, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C46, + ClaAct.FraccionAme AS C47, + ClaAct.ECCN AS C48, + EqiPim.NumParte AS C49, + EqiFim.TipoCambio AS C50, + EqiFim.FechaEmision AS C51, + EqiFim.UsuarioCap AS C52, + EqiFim.UsuarioAct AS C53, + EqiFim.Transportista AS C54, + EqiFim.Transporte + ' ' + EqiFim.NumTrasporte AS C55, + EqiPed.Pedimento18 AS C56, + EqiPim.LOTE AS C57, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C58 + FROM [{db_name}].dbo.QFacImp EqiFim + LEFT JOIN [{db_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFim.PedimentoImpo + LEFT JOIN [{db_name}].dbo.QEqiMaq EqiPim + ON EqiPim.Consecutivo = EqiFim.Consecutivo + LEFT JOIN [{db_name}].dbo.QClaAct ClaAct + ON ClaAct.Clase = EqiPim.Clase + WHERE {where_str} + """ + + def _clean_text(self, text: Optional[str]) -> Optional[str]: + """Clean text by removing special characters""" + if not text: + return None + return text.strip() + + def _get_client_provider_info(self, db: Session, db_name: str, client_code: str, is_shelter: bool) -> dict: + """Get provider/client information""" + if not client_code: + return {"nombre": None, "rfc": None, "tax_id": None} + + try: + sql = text(f""" + SELECT TOP 1 Nombre, RFC, TaxID + FROM [{db_name}].dbo.GClientesPro + WHERE Cliente = :cliente + """) + result = db.execute(sql, {"cliente": client_code}).fetchone() + + if result: + return { + "nombre": self._clean_text(result[0]), + "rfc": result[1], + "tax_id": result[2] + } + except Exception as e: + logger.warning(f"Error fetching provider info for {client_code}: {e}") + + return {"nombre": None, "rfc": None, "tax_id": None} + + def _get_client_buyer_info(self, db: Session, db_name: str, client_code: str, is_shelter: bool) -> dict: + """Get buyer information (same structure as provider)""" + return self._get_client_provider_info(db, db_name, client_code, is_shelter) + + def _get_customs_broker_info(self, db: Session, db_name: str, broker_code: str) -> dict: + """Get customs broker information""" + if not broker_code: + return {"nombre": None, "patente": None} + + try: + sql = text(f""" + SELECT TOP 1 Nombre, Patente + FROM [{db_name}].dbo.GAAduanal + WHERE ClaveAA = :clave + """) + result = db.execute(sql, {"clave": broker_code}).fetchone() + + if result: + return { + "nombre": result[0], + "patente": result[1] + } + except Exception as e: + logger.warning(f"Error fetching customs broker info for {broker_code}: {e}") + + return {"nombre": None, "patente": None} + + def _get_customs_office_name(self, db: Session, db_name: str, aduana_code: str) -> Optional[str]: + """Get customs office name""" + if not aduana_code: + return None + + try: + sql = text(f""" + SELECT TOP 1 REPLACE(REPLACE(REPLACE(REPLACE(Nombre, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') + FROM [{db_name}].dbo.GAduanaSec + WHERE AduanaSeccion = :aduana + """) + result = db.execute(sql, {"aduana": aduana_code}).fetchone() + return result[0] if result else None + except Exception as e: + logger.warning(f"Error fetching customs office name for {aduana_code}: {e}") + return None + + def _get_series_info(self, db: Session, db_name: str, consecutivo: str, linea_impo: str, is_shelter: bool) -> Optional[str]: + """Get series information for an item""" + if not consecutivo or not linea_impo: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpo + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY RenImpo + """) + result = db.execute(sql, {"consecutivo": consecutivo, "linea": linea_impo}).fetchall() + + if not result: + return None + + # Build series string + series_parts = [] + for idx, row in enumerate(result, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_parts.append(serie_str) + + return " | ".join(series_parts) if series_parts else None + + except Exception as e: + logger.warning(f"Error fetching series for {consecutivo}/{linea_impo}: {e}") + return None + + def _get_part_export_symbol(self, db: Session, db_name: str, num_parte: str, is_shelter: bool) -> Optional[str]: + """Get export symbol/license for a part number""" + if not num_parte: + return None + + try: + sql = text(f""" + SELECT TOP 1 SimboloExcLic + FROM [{db_name}].dbo.QPartes + WHERE NumParte = :num_parte + """) + result = db.execute(sql, {"num_parte": num_parte}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching export symbol for part {num_parte}: {e}") + return None + + def get_definitive_import_movements( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItem]: + """ + Retrieve definitive import movements from legacy database (LLENADODEFINITIVO - NORMAL). + Aggregates movements by invoice number. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching definitive import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: # FP + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Movement type filter + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + # If ALL, no filter added + + where_clause = " AND ".join(where_conditions) + + # Build main SQL query + sql_query = text(f""" + SELECT + EqiFid.FacturaImpoDef AS C1, + EqiFid.PedimentoImpoDef AS C2, + EqiFid.FechaFactura AS C3, + EqiFid.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiFid.ValorImpoME AS C6, + EqiFid.ValorImpoMN AS C7, + EqiFid.Proveedor AS C8, + EqiFid.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFid.Remesa AS C14, + EqiFid.TipoCambio AS C15, + EqiFid.AAduanal AS C18, + EqiFid.Consecutivo AS C40, + EqiPed.PedRectifica AS C42, + EqiFid.EDocument AS C43, + EqiFid.NumOperacionVU AS C44, + EqiFid.TipoCambio AS C51, + EqiFid.FechaEmision AS C52, + EqiFid.UsuarioCap AS C53, + EqiFid.UsuarioAct AS C54, + EqiFid.Transportista AS C55, + EqiFid.Transporte + ' ' + EqiFid.NumTrasporte AS C56, + EqiPed.Pedimento18 AS C57, + EqiPed.Aduana_Cruce AS C38, + EqiFid.ProvImpoDefCR AS C39, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C59 + FROM [{filters.database_name}].dbo.QFacImpDef EqiFid + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFid.PedimentoImpoDef + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements_dict = {} + + for row in result: + factura = row[0] # C1 + prov_impo_def_cr = row[28] # C39 + + # Determine movement type + tipo_mov = "COMEX" if prov_impo_def_cr == 'P' else "IMPDF" + + # Use factura + tipo_mov as key + key = (factura, tipo_mov) + + # If already exists, skip (we only want one entry per invoice in Normal mode) + if key not in movements_dict: + consecutivo = row[16] # C40 + fecha_pago = row[12] # C13 + tipo_pedimento = row[29] # C59 + fecha_inicio = row[10] # C11 + + # Calculate total values for this invoice + valor_me, valor_mn = self._calculate_definitive_totals( + db, filters.database_name, consecutivo + ) + + # Calculate exchange rate and values + tipo_cambio = row[20] # C51 + valor_mp_temp = 0.0 + valor_comercial_mn = 0.0 + + if filters.currency_type.value == "ME": + valor_mp_temp = float(valor_me or 0) + valor_comercial_mn = float(valor_me or 0) + tipo_cambio = float(tipo_cambio or 1.0) + else: # MN + if filters.is_shelter: + # Shelter logic with exchange rate calculation + if filters.exchange_rate_type.value == "FP" and fecha_pago: + # Check if special transport method logic applies + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_mp_temp = float(valor_me or 0) * tc_value + valor_comercial_mn = float(valor_me or 0) * tc_value + tipo_cambio = tc_value + else: + logger.warning(f"Exchange rate not found for date {fecha_tc}, using values from DB") + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + # Non-shelter logic + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_mp_temp = float(valor_me or 0) * tc_value + valor_comercial_mn = float(valor_me or 0) * tc_value + tipo_cambio = tc_value + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + else: + valor_mp_temp = float(valor_mn or 0) + valor_comercial_mn = float(valor_mn or 0) + + # Get driver badge number + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Get rectification pedimento + pedimento = row[1] # C2 + ped_rectifica = row[17] # C42 + pedimento_r1 = self._buscar_rectificacion(pedimento, ped_rectifica) + + # Create movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_mp_temp, + ValorComercialMN=valor_comercial_mn, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C43 + NumOperacionVU=row[19], # C44 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C53 + UsuarioAcr=row[23], # C54 + Fecha_Pago=row[12], # C13 + NumCaja=row[25], # C56 + Pedimento18=row[26], # C57 + AduanaCru=row[27], # C38 + Lote=None # Lote comes from EpiDef table, not available in main query + ) + + movements_dict[key] = movement + + movements = list(movements_dict.values()) + logger.info(f"Successfully retrieved {len(movements)} definitive import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching definitive import movements: {e}", exc_info=True) + raise + + def _calculate_definitive_totals(self, db: Session, db_name: str, consecutivo: int) -> tuple: + """ + Calculate total values for a definitive import invoice. + Sums up all partidas (items) excluding sub-partidas. + + Returns: + tuple: (total_valor_me, total_valor_mn) + """ + try: + sql = text(f""" + SELECT SUM(EqiPdf.ValorME), SUM(EqiPdf.ValorMN) + FROM [{db_name}].dbo.QEqiDef EqiPdf + WHERE EqiPdf.Consecutivo = :consecutivo + AND EqiPdf.EsSubpartida = 'P' + """) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> Optional[str]: + """Get driver's unique badge number (NUMGAFETEUNICO) for a definitive import invoice""" + if not factura: + return None + + try: + sql = text(f""" + SELECT NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImpDef + ON QFacImpDef.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpoDef = :factura + """) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for invoice {factura}: {e}") + return None + + def get_definitive_import_movements_detailed( + self, + db: Session, + filters: ImportDefinitiveFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed definitive import movements from legacy database (LLENADODEFINITIVO - DETALLADO). + Returns each line/partida as a separate record with full details. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of detailed movement items (one per partida/line) + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching DETAILED definitive import movements with filters: {filters.model_dump()}") + + # Read INI configuration for exchange rate logic + met_trans = 0 + try: + config = configparser.ConfigParser() + config.read('Scaii.ini') + met_trans = config.getint('METTRANS', 'TipoCambio', fallback=0) + logger.debug(f"INI met_trans value: {met_trans}") + except Exception as e: + logger.warning(f"Could not read Scaii.ini, using default met_trans=0: {e}") + + # Build WHERE clause (same as normal mode) + where_conditions = [] + + if filters.range_type.value == "FF": + where_conditions.append(f"EqiFid.FechaFactura >= '{filters.start_date}' AND EqiFid.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + if not filters.include_cancelled: + where_conditions.append("EqiFid.Estatus = 'AC'") + + if filters.provider: + where_conditions.append(f"EqiFid.Proveedor = '{filters.provider}'") + + if filters.buyer: + where_conditions.append(f"EqiFid.VendidoA = '{filters.buyer}'") + + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + if filters.movement_type.value == "COMEX": + where_conditions.append("EqiFid.ProvImpoDefCR = 'P'") + elif filters.movement_type.value == "IMPDF": + where_conditions.append("EqiFid.ProvImpoDefCR != 'P'") + + where_clause = " AND ".join(where_conditions) + + # Build detailed SQL query (includes partida/line details) + sql_query = text(f""" + SELECT + EqiFid.FacturaImpoDef AS C1, + EqiFid.PedimentoImpoDef AS C2, + EqiFid.FechaFactura AS C3, + EqiFid.Estatus AS C4, + EqiPed.ClavePed AS C5, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + EqiFid.Remesa AS C14, + EqiFid.TipoCambio AS C15, + EqiFid.Proveedor AS C16, + EqiFid.VendidoA AS C17, + EqiFid.AAduanal AS C18, + EpiDef.Clase AS C20, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.DescripcionE, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C21, + REPLACE(REPLACE(REPLACE(REPLACE(EqiCla.DescripcionI, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C22, + EpiDef.CantImpoDef AS C23, + EpiDef.UnidadMedida AS C24, + EpiDef.ValorMN AS C25, + EpiDef.ValorME AS C27, + EpiDef.PesoNeto AS C29, + EpiDef.PesoBruto AS C30, + EpiDef.OrdenCompra AS C31, + EpiDef.Fraccion AS C32, + EpiDef.TipoFraccion AS C33, + EpiDef.Sector AS C35, + EpiDef.PaisOrigen AS C37, + EqiPed.Aduana_Cruce AS C38, + EqiFid.ProvImpoDefCR AS C39, + EqiFid.Consecutivo AS C40, + EpiDef.EsSubPartida AS C41, + EqiPed.PedRectifica AS C42, + EqiFid.EDocument AS C43, + EqiFid.NumOperacionVU AS C44, + EpiDef.LineaImpoDef AS C45, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.Marca, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C46, + REPLACE(REPLACE(REPLACE(REPLACE(EpiDef.Modelo, CHAR(44), ' '), CHAR(9), ' '), CHAR(10), ' '), CHAR(13), ' ') AS C47, + EqiCla.FraccionAme AS C48, + EqiCla.ECCN AS C49, + EpiDef.NumParte AS C50, + EqiFid.TipoCambio AS C51, + EqiFid.FechaEmision AS C52, + EqiFid.UsuarioCap AS C53, + EqiFid.UsuarioAct AS C54, + EqiFid.Transportista AS C55, + EqiFid.Transporte + ' ' + EqiFid.NumTrasporte AS C56, + EqiPed.Pedimento18 AS C57, + EpiDef.Lote AS C58, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C59 + FROM [{filters.database_name}].dbo.QFacImpDef EqiFid + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = EqiFid.PedimentoImpoDef + LEFT JOIN [{filters.database_name}].dbo.QEqiDef EpiDef + ON EpiDef.Consecutivo = EqiFid.Consecutivo + LEFT JOIN [{filters.database_name}].dbo.QClaAct EqiCla + ON EqiCla.Clase = EpiDef.Clase + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements = [] + + for row in result: + linea = row[31] # C45 + factura = row[0] # C1 + pedimento = row[1] # C2 + prov_impo_def_cr = row[25] # C39 + es_subpartida = row[27] # C41 + fecha_pago = row[8] # C13 + tipo_pedimento = row[44] # C59 + fecha_inicio = row[6] # C11 + consecutivo = row[26] # C40 + + # Determine movement type + tipo_mov = "COMEX" if prov_impo_def_cr == 'P' else "IMPDF" + + # Get provider info + proveedor_info = self._get_client_provider_info( + db, filters.database_name, row[11], filters.is_shelter # C16 + ) + + # Get buyer info + buyer_info = self._get_client_buyer_info( + db, filters.database_name, row[12], filters.is_shelter # C17 + ) + + # Get customs broker info + broker_info = self._get_customs_broker_info( + db, filters.database_name, row[13] # C18 + ) + + # Calculate commercial value and exchange rate + tipo_cambio = float(row[10] or 1.0) # C15 + valor_comercial_mn = 0.0 + peso_neto = 0.0 + peso_bruto = 0.0 + + # Only process values if it's a Partida (not Subpartida) + if es_subpartida == 'P': + if filters.currency_type.value == "MN": + if filters.is_shelter: + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_comercial_mn = float(row[20] or 0) * tc_value # C27 * TC + tipo_cambio = tc_value + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + valor_comercial_mn = float(row[20] or 0) * tc_value # C27 * TC + tipo_cambio = tc_value + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: + valor_comercial_mn = float(row[19] or 0) # C25 + tipo_cambio = float(row[37] or 1.0) # C51 + else: # ME + valor_comercial_mn = float(row[20] or 0) # C27 + if filters.exchange_rate_type.value == "FP" and fecha_pago: + fecha_tc = fecha_pago + if filters.use_transport_method and met_trans == 1: + if tipo_pedimento in ('1', '4', '98E'): + fecha_tc = fecha_inicio + + tc_value = self._obtener_tipo_cambio( + db, filters.database_name, fecha_tc, filters.is_shelter + ) + if tc_value: + tipo_cambio = tc_value + else: + tipo_cambio = float(row[37] or 1.0) # C51 + else: + tipo_cambio = float(row[37] or 1.0) # C51 + + peso_neto = float(row[21] or 0) # C29 + peso_bruto = float(row[22] or 0) # C30 + # If es_subpartida == 'S', values remain 0 + + # Get customs office name + aduana_nombre = self._get_customs_office_name( + db, filters.database_name, row[24] # C38 + ) + + # Get series information + series_info = self._get_definitive_series_info( + db, filters.database_name, consecutivo, linea, filters.is_shelter + ) + + # Get export symbol + symbolo_ex = self._get_part_export_symbol( + db, filters.database_name, row[36], filters.is_shelter # C50 + ) + + # Get rectification pedimento + pedimento_r1 = self._buscar_rectificacion(pedimento, row[28]) # C42 + + # Get driver badge + num_gaf_uni = self._get_driver_badge(db, filters.database_name, factura) + + # Create detailed movement item + movement = MovementItemDetailed( + Linea=linea, + Factura=factura, + Pedimento=pedimento, + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + Regimen=row[5], # C10 + Fecha_Inicio=row[6], # C11 + Fecha_Fin=row[7], # C12 + Fecha_Pago=row[8], # C13 + Remesa=row[9], # C14, + Proveedor=proveedor_info.get("nombre"), + RFCProveedor=proveedor_info.get("rfc"), + ProveedorTaxID=proveedor_info.get("tax_id"), + VendidoA=buyer_info.get("nombre"), + VendidoARFC=buyer_info.get("rfc"), + VendidoATaxID=buyer_info.get("tax_id"), + AgenteAduanal=broker_info.get("nombre"), + Patente=broker_info.get("patente"), + NumParte=row[14], # C20 + DescripcionE=row[15], # C21 + DescripcionI=row[16], # C22 + CantidadIE=float(row[17] or 0), # C23 + UniMed=row[18], # C24 + ValorComercialMN=valor_comercial_mn, + TipoCambio=tipo_cambio, + PesoNeto=peso_neto, + PesoBruto=peso_bruto, + OrdenCompraVenta=row[23], # C31 + FraccionArancelaria=row[29], # C32 + Preferencia=row[30], # C33 + Sector=row[32], # C35 + PaisOrigen=row[33], # C37 + Aduana=aduana_nombre, + Advalorem=row[27], # C41 + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[34], # C43 + NumOperacionVU=row[35], # C44 + Series=series_info, + Marca=row[40], # C46 + Modelo=row[41], # C47 + FraccionAmericana=row[42], # C48 + ECCN=row[43], # C49 + SimboloEx=symbolo_ex, + FechaEmision=row[38], # C52 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[39], # C53 + UsuarioAcr=row[40], # C54 + Transportista=row[41], # C55 + NumCaja=row[42], # C56 + Pedimento18=row[43], # C57 + AduanaCru=row[24], # C38 + Lote=row[44] # C58 + ) + + movements.append(movement) + + logger.info(f"Successfully retrieved {len(movements)} detailed definitive movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed definitive movements: {e}", exc_info=True) + raise + + def _get_definitive_series_info( + self, + db: Session, + db_name: str, + consecutivo: int, + linea: int, + is_shelter: bool + ) -> Optional[str]: + """ + Get series information for a definitive import partida from QSeriesDef table. + Returns formatted string with series, model, and part info. + """ + if not consecutivo or not linea: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesDef + WHERE Consecutivo = :consecutivo + AND LineaImpoDef = :linea + """) + result = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() + + if not result: + return None + + series_list = [] + for idx, row in enumerate(result, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_list.append(serie_str) + + return " | ".join(series_list) if series_list else None + + except Exception as e: + logger.debug(f"Error fetching definitive series info for consecutivo {consecutivo}, linea {linea}: {e}") + return None + + def get_repair_import_movements( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItem]: + """ + Retrieve repair import movements from legacy database (LLENADOIMP_REPARACION - NORMAL). + Aggregates movements by invoice number. + + Args: + db: Database session + filters: Filter criteria for the query + + Returns: + List of movement items matching the criteria + + Raises: + Exception: If database query fails + """ + logger.info(f"Fetching repair import movements with filters: {filters.model_dump()}") + + # Get configuration + met_trans = self._get_met_trans_config() + + # Build WHERE clause + where_conditions = [] + + # Date range filter + if filters.range_type.value == "FF": + where_conditions.append(f"FimRep.FechaFactura >= '{filters.start_date}' AND FimRep.FechaFactura <= '{filters.end_date}'") + else: + where_conditions.append(f"EqiPed.Fecha_Pago >= '{filters.start_date}' AND EqiPed.Fecha_Pago <= '{filters.end_date}'") + + # Status filter + if not filters.include_cancelled: + where_conditions.append("FimRep.Estatus = 'AC'") + + # Provider filter + if filters.provider: + where_conditions.append(f"FimRep.Proveedor = '{filters.provider}'") + + # Buyer filter + if filters.buyer: + where_conditions.append(f"FimRep.VendidoA = '{filters.buyer}'") + + # Pedimento code filter + if filters.pedimento_code: + where_conditions.append(f"EqiPed.ClavePed = '{filters.pedimento_code}'") + + # Discharge filter for repair imports + if filters.discharge_filter.value == "SiDes": + where_conditions.append("RepPim.Descarga = 1") + elif filters.discharge_filter.value == "NoDes": + where_conditions.append("RepPim.Descarga = 0") + # If ALL, no filter added + + # Exclude regime changes + where_conditions.append("FimRep.EsCambioRegimen <> 'S'") + + where_clause = " AND ".join(where_conditions) + + # Build main SQL query for repair imports + sql_query = text(f""" + SELECT + FimRep.FacturaImpo AS C1, + FimRep.PedimentoImpo AS C2, + FimRep.FechaFactura AS C3, + FimRep.Estatus AS C4, + EqiPed.ClavePed AS C5, + FimRep.ValorImpoME AS C6, + FimRep.ValorImpoMN AS C7, + FimRep.Proveedor AS C8, + FimRep.VendidoA AS C9, + EqiPed.Regimen AS C10, + EqiPed.Fecha_Inicio AS C11, + EqiPed.Fecha_Fin AS C12, + EqiPed.Fecha_Pago AS C13, + FimRep.Remesa AS C14, + FimRep.TipoCambio AS C15, + FimRep.AAduanal AS C18, + FimRep.Consecutivo AS C39, + EqiPed.PedRectifica AS C41, + FimRep.EDocument AS C42, + FimRep.NumOperacionVU AS C43, + FimRep.TipoCambio AS C49, + FimRep.FechaEmision AS C50, + FimRep.UsuarioCap AS C51, + FimRep.UsuarioAct AS C52, + FimRep.Transportista AS C53, + FimRep.Transporte + ' ' + FimRep.NumTrasporte AS C54, + EqiPed.Pedimento18 AS C55, + EqiPed.Aduana_Cruce AS C38, + EqiPed.TIPOPEDIMENTOTRANSPORTEE AS C56 + FROM [{filters.database_name}].dbo.QFacImpRep FimRep + LEFT JOIN [{filters.database_name}].dbo.QPedimentos EqiPed + ON EqiPed.Pedimento = FimRep.PedimentoImpo + LEFT JOIN [{filters.database_name}].dbo.QEqiMaqRep RepPim + ON RepPim.Consecutivo = FimRep.Consecutivo + LEFT JOIN [{filters.database_name}].dbo.QClaAct ClaAct + ON ClaAct.Clase = RepPim.Clase + WHERE {where_clause} + """) + + try: + result = db.execute(sql_query) + movements_dict = {} + + for row in result: + factura = row[0] # C1 + + # Use factura + IMPRE as key + tipo_mov = "IMPRE" + key = (factura, tipo_mov) + + # If already exists, skip (we only want one entry per invoice in Normal mode) + if key not in movements_dict: + consecutivo = row[16] # C39 + fecha_pago = row[12] # C13 + tipo_pedimento = row[28] # C56 + fecha_inicio = row[10] # C11 + + # Calculate total values for this invoice (with discharge filter) + valor_me, valor_mn = self._calculate_repair_totals( + db, filters.database_name, consecutivo, filters.discharge_filter.value + ) + + # Calculate exchange rate and values using unified method + valor_comercial, tipo_cambio = self._calculate_exchange_rate_and_value( + db=db, + db_name=filters.database_name, + valor_me=valor_me, + valor_mn=valor_mn, + tipo_cambio_db=float(row[20] or 1.0), # C49 + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + tipo_pedimento=tipo_pedimento, + currency_type=filters.currency_type.value, + exchange_rate_type=filters.exchange_rate_type.value, + is_shelter=filters.is_shelter, + use_transport_method=filters.use_transport_method, + met_trans=met_trans + ) + + # Get driver badge number + num_gaf_uni = self._get_driver_badge_repair(db, filters.database_name, factura) + + # Get rectification pedimento + pedimento = row[1] # C2 + ped_rectifica = row[17] # C41 + pedimento_r1 = self._buscar_rectificacion(pedimento, ped_rectifica) + + # Create movement item + movement = MovementItem( + Factura=factura, + Pedimento=row[1], # C2 + FechaFactura=row[2], # C3 + Estatus=row[3], # C4 + ClavePed=row[4], # C5 + TipoMovTemDef=tipo_mov, + EsCambioRegimen='N', + ValorMPTemp=valor_comercial, + ValorComercialMN=valor_comercial, + TipoCambio=tipo_cambio, + ValorAgre=0.0, + TipoExpo='', + PedimentoR1=pedimento_r1, + EDocument=row[18], # C42 + NumOperacionVU=row[19], # C43 + BaseDeDatos=filters.database_name, + NumGafUni=num_gaf_uni, + UsuarioCap=row[22], # C51 + UsuarioAcr=row[23], # C52 + Fecha_Pago=row[12], # C13 + NumCaja=row[25], # C54 + Pedimento18=row[26], # C55 + AduanaCru=row[27], # C38 + Lote=None # Repair imports don't have Lote in main query + ) + + movements_dict[key] = movement + + movements = list(movements_dict.values()) + logger.info(f"Successfully retrieved {len(movements)} repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching repair import movements: {e}", exc_info=True) + raise + + def _calculate_repair_totals( + self, + db: Session, + db_name: str, + consecutivo: int, + discharge_filter: str + ) -> tuple: + """ + Calculate total values for a repair import invoice. + Sums up all partidas (items) excluding sub-partidas, with optional discharge filter. + + Args: + db: Database session + db_name: Database name + consecutivo: Invoice consecutive number + discharge_filter: "SiDes", "NoDes", or "ALL" + + Returns: + tuple: (total_valor_me, total_valor_mn) + """ + try: + # Build discharge filter clause + discharge_clause = "" + if discharge_filter == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif discharge_filter == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + sql = text(f""" + SELECT SUM(RepPim.ValorImpoME), SUM(RepPim.ValorImpoMN) + FROM [{db_name}].dbo.QEqiMaqRep RepPim + WHERE RepPim.Consecutivo = :consecutivo + AND RepPim.EsSubpartida = 'P' + {discharge_clause} + """) + result = db.execute(sql, {"consecutivo": consecutivo}).fetchone() + + if result: + return (result[0] or 0, result[1] or 0) + return (0, 0) + except Exception as e: + logger.error(f"Error calculating repair totals for consecutivo {consecutivo}: {e}") + return (0, 0) + + def _get_driver_badge_repair(self, db: Session, db_name: str, factura: str) -> Optional[str]: + """Get driver's unique badge number for a repair import invoice""" + if not factura: + return None + + try: + sql = text(f""" + SELECT NUMGAFETEUNICO + FROM [{db_name}].dbo.GConductor + LEFT JOIN [{db_name}].dbo.QFacImpRep + ON QFacImpRep.CONDUCTOR = GConductor.CONDUCTOR + WHERE FacturaImpo = :factura + """) + result = db.execute(sql, {"factura": factura}).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Error fetching driver badge for repair invoice {factura}: {e}") + return None + + def get_repair_import_movements_detailed( + self, + db: Session, + filters: ImportRepairFilter + ) -> List[MovementItemDetailed]: + """ + Retrieve detailed repair import movements from legacy database (LLENADOIMP_REPARACION - DETALLADO). + Returns individual partida lines with full detail. + + Args: + db: Database session + filters: Filter parameters including date range, discharge filter, etc. + + Returns: + List of detailed movement items + """ + try: + logger.info(f"Fetching detailed repair import movements with filters: {filters}") + + # Get database name + db_name = self._get_database_name(db) + if not db_name: + logger.error("Could not determine database name") + return [] + + # Get MetTrans configuration + met_trans = self._get_met_trans_config() + + # Build WHERE clause + where_clause = self._build_where_clause_repair(filters) + + # Build discharge filter for main query + discharge_clause = "" + if filters.discharge_filter == "SiDes": + discharge_clause = " AND RepPim.Descarga = 1" + elif filters.discharge_filter == "NoDes": + discharge_clause = " AND RepPim.Descarga = 0" + + # Build main SQL query with all required fields for detailed mode + sql = text(f""" + SELECT + RepPim.LineaImpo, -- C44: Linea + Rep.FacturaImpo, -- C1: Factura + Ped.PedNumero, -- C2: Pedimento + Rep.FechaFacImpo, -- C3: FechaFactura + Rep.Estatus, -- C4: Estatus + Ped.ClavePedImpo, -- C5: ClavePed + Ped.Regimen, -- C10: Regimen + Ped.FechaEntrada, -- C11: Fecha_Inicio + Ped.FechaPago, -- C13: Fecha_Pago + Rep.Remesa, -- C14: Remesa + Rep.TipoCambio, -- C15: TipoCambio (from header) + Rep.Cliente, -- C16: Cliente/Proveedor + Rep.VendidoA, -- C17: VendidoA + Rep.AgenteAduanal, -- C18: AgenteAduanal clave + RepPim.NumParteImpo, -- C20: NumParte + RepPim.DescripcionE, -- C21: DescripcionE + RepPim.DescripcionI, -- C22: DescripcionI + RepPim.CantidadImpo, -- C23: CantidadIE + RepPim.UnidadMedImpo, -- C24: UniMed + RepPim.ValorImpoMN, -- C25: ValorComercialMN (direct) + RepPim.ValorImpoME, -- C27: ValorComercialME + RepPim.PesoNetoImpo, -- C29: PesoNeto + RepPim.PesoBrutoImpo, -- C30: PesoBruto + RepPim.OrdenCompraVta, -- C31: OrdenCompraVenta + RepPim.FraccionImpo, -- C32: FraccionArancelaria + RepPim.Preferencia, -- C33: Preferencia + RepPim.Sector, -- C35: Sector + RepPim.PaisOrigenImpo, -- C37: PaisOrigen + RepPim.Aduana, -- C38: Aduana seccion + Rep.Consecutivo, -- C39: Consecutivo + RepPim.EsSubpartida, -- C40: Advalorem/EsSubpartida + Ped.PedRectifica, -- C41: PedRectifica + Rep.eDocument, -- C42: EDocument + Rep.NumOperacionVU, -- C43: NumOperacionVU + RepPim.Marca, -- C45: Marca + RepPim.Modelo, -- C46: Modelo + RepPim.FraccionAmericana, -- C47: FraccionAmericana + RepPim.ECCN, -- C48: ECCN + RepPim.TipoCambio AS TipoCambioPartida, -- C49: TipoCambio (from partida) + Rep.FechaEmbarque, -- C50: FechaEmision + Rep.UsuarioCap, -- C51: UsuarioCap + Rep.UsuarioAct, -- C52: UsuarioAcr + Rep.Transportista, -- C53: Transportista + Rep.NumCaja, -- C54: NumCaja + Ped.Pedimento18, -- C55: Pedimento18 + Ped.ClavePedImpo -- C56: ClavePedImpo (for MetTrans check) + FROM [{db_name}].dbo.QFacImpRep Rep + LEFT JOIN [{db_name}].dbo.QPedimentos Ped ON Rep.Pedimento = Ped.PedNumero + LEFT JOIN [{db_name}].dbo.QEqiMaqRep RepPim ON Rep.Consecutivo = RepPim.Consecutivo + WHERE Rep.EsCambioRegimen <> 'S' + {where_clause} + {discharge_clause} + ORDER BY Rep.FacturaImpo, RepPim.LineaImpo + """) + + results = db.execute(sql).fetchall() + logger.info(f"Found {len(results)} detailed repair import partidas") + + movements = [] + for row in results: + # Extract all fields from query + linea = row[0] + factura = row[1] + pedimento = row[2] + fecha_factura = row[3] + estatus = row[4] + clave_ped = row[5] + regimen = row[6] + fecha_inicio = row[7] + fecha_pago = row[8] + remesa = row[9] + tipo_cambio_header = row[10] + cliente = row[11] + vendido_a = row[12] + agente_aduanal_clave = row[13] + num_parte = row[14] + descripcion_e = row[15] + descripcion_i = row[16] + cantidad = row[17] + uni_med = row[18] + valor_mn_direct = row[19] + valor_me = row[20] + peso_neto = row[21] + peso_bruto = row[22] + orden_compra = row[23] + fraccion = row[24] + preferencia = row[25] + sector = row[26] + pais_origen = row[27] + aduana_seccion = row[28] + consecutivo = row[29] + es_subpartida = row[30] + ped_rectifica = row[31] + e_document = row[32] + num_operacion_vu = row[33] + marca = row[34] + modelo = row[35] + fraccion_americana = row[36] + eccn = row[37] + tipo_cambio_partida = row[38] + fecha_emision = row[39] + usuario_cap = row[40] + usuario_acr = row[41] + transportista = row[42] + num_caja = row[43] + pedimento_18 = row[44] + clave_ped_mettrans = row[45] + + # Get client/supplier information (Proveedor) + proveedor_info = self._get_client_info(db, db_name, cliente, is_supplier=True) + + # Get sold-to client information (VendidoA) + vendido_info = self._get_client_info(db, db_name, vendido_a, is_supplier=False) + + # Get customs agent information + agente_info = self._get_customs_agent_info(db, db_name, agente_aduanal_clave) + + # Get customs section name + aduana_nombre = self._get_aduana_seccion_nombre(db, db_name, aduana_seccion) + + # Calculate exchange rate and commercial value + valor_mn, tipo_cambio_final = self._calculate_exchange_rate_and_value( + db=db, + db_name=db_name, + es_subpartida=es_subpartida, + valor_me=valor_me, + valor_mn_direct=valor_mn_direct, + fecha_pago=fecha_pago, + fecha_inicio=fecha_inicio, + clave_ped=clave_ped_mettrans, + tipo_cambio_partida=tipo_cambio_partida, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + met_trans=met_trans + ) + + # Set peso values based on subpartida flag + peso_neto_final = peso_neto if es_subpartida == 'P' else 0 + peso_bruto_final = peso_bruto if es_subpartida == 'P' else 0 + + # Get series information for this partida + series = self._get_series_info_repair(db, db_name, consecutivo, linea) + + # Get rectification pedimento + pedimento_r1 = self._buscar_rectificacion(db, db_name, pedimento, ped_rectifica) + + # Get driver badge unique number + num_gaf_uni = self._get_driver_badge_repair(db, db_name, factura) + + # Build movement item + movement = MovementItemDetailed( + linea=linea, + factura=factura, + pedimento=pedimento, + fecha_factura=fecha_factura, + estatus=estatus, + clave_ped=clave_ped, + tipo_mov_tem_def="IMPRE", + es_cambio_regimen="N", + regimen=regimen, + fecha_inicio=fecha_inicio, + fecha_fin=None, # Not available for repair imports + fecha_pago=fecha_pago, + remesa=remesa, + tipo_cambio=tipo_cambio_final, + proveedor=proveedor_info.get("nombre"), + rfc_proveedor=proveedor_info.get("rfc"), + proveedor_tax_id=proveedor_info.get("tax_id"), + vendido_a=vendido_info.get("nombre"), + vendido_a_rfc=vendido_info.get("rfc"), + vendido_a_tax_id=vendido_info.get("tax_id"), + agente_aduanal=agente_info.get("nombre"), + patente=agente_info.get("patente"), + num_parte=num_parte, + descripcion_e=self._remove_commas(descripcion_e), + descripcion_i=self._remove_commas(descripcion_i), + cantidad_ie=cantidad, + uni_med=uni_med, + valor_comercial_mn=valor_mn, + peso_neto=peso_neto_final, + peso_bruto=peso_bruto_final, + orden_compra_venta=orden_compra, + fraccion_arancelaria=fraccion, + preferencia=preferencia, + sector=sector, + pais_origen=pais_origen, + aduana=aduana_nombre, + advalorem=es_subpartida, + tipo_expo=None, + pedimento_r1=pedimento_r1, + e_document=e_document, + num_operacion_vu=num_operacion_vu, + series=series, + marca=marca, + modelo=modelo, + fraccion_americana=fraccion_americana, + eccn=eccn, + fecha_emision=fecha_emision, + base_de_datos=db_name, + num_gaf_uni=num_gaf_uni, + usuario_cap=usuario_cap, + usuario_acr=usuario_acr, + transportista=transportista, + num_caja=num_caja, + pedimento_18=pedimento_18, + aduana_cru=aduana_seccion + ) + + movements.append(movement) + + logger.info(f"Successfully processed {len(movements)} detailed repair import movements") + return movements + + except Exception as e: + logger.error(f"Error fetching detailed repair import movements: {e}", exc_info=True) + raise + + def _get_series_info_repair(self, db: Session, db_name: str, consecutivo: int, linea: int) -> Optional[str]: + """Get series information for repair import partida""" + if not consecutivo or not linea: + return None + + try: + sql = text(f""" + SELECT SerieImpo, ModeloImpo, ParteImpo + FROM [{db_name}].dbo.QSeriesImpoRep + WHERE Consecutivo = :consecutivo + AND LineaImpo = :linea + ORDER BY Renglon + """) + results = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() + + if not results: + return None + + series_list = [] + for idx, row in enumerate(results, 1): + serie = row[0] + modelo = row[1] + parte = row[2] + + serie_str = f"{idx}) {serie}" + if modelo: + serie_str += f". Modelo: {modelo}" + if parte: + serie_str += f". Parte: {parte}" + + series_list.append(serie_str) + + return " | ".join(series_list) if series_list else None + + except Exception as e: + logger.debug(f"Error fetching repair series info for consecutivo {consecutivo}, linea {linea}: {e}") + return None + + def _build_where_clause_repair(self, filters: ImportRepairFilter) -> str: + """Build WHERE clause for repair imports query""" + conditions = [] + + # Date range filter + if filters.range_type == RangeType.INVOICE_DATE: + conditions.append(f"Rep.FechaFacImpo BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + elif filters.range_type == RangeType.PAYMENT_DATE: + conditions.append(f"Ped.FechaPago BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + elif filters.range_type == RangeType.ENTRY_DATE: + conditions.append(f"Ped.FechaEntrada BETWEEN '{filters.date_from}' AND '{filters.date_to}'") + + return " AND " + " AND ".join(conditions) if conditions else "" + + +# Singleton instance +movement_service = MovementService() diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 24220d6d..bc21783a 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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"] ) \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts new file mode 100644 index 00000000..a5b7dafe --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts @@ -0,0 +1,184 @@ +/** + * API Client para Reportes de Movimientos de Facturas + */ +import { api } from '$lib/api'; + +// ===== TYPES ===== + +export type RangeType = 'FF' | 'FP'; // FF = fecha factura, FP = fecha pago +export type ReportType = 'Normal' | 'Detallado'; +export type CurrencyType = 'ME' | 'MN'; // ME = moneda extranjera, MN = moneda nacional +export type ExchangeRateType = 'FP' | 'FF'; +export type MovementTypeFilter = 'COMEX' | 'IMPDF' | 'ALL'; +export type DischargeFilter = 'SiDes' | 'NoDes' | 'ALL'; +export type ExportMovementType = 'AFIJO' | 'NODES' | 'SCRAP' | 'REEXP' | 'DONAC' | 'VEMEX' | 'ALL'; + +export interface BaseFilter { + range_type: RangeType; + start_date: string; // YYYYMMDD format + end_date: string; // YYYYMMDD format + include_cancelled: boolean; + provider?: string | null; + buyer?: string | null; + pedimento_code?: string | null; + report_type: ReportType; + currency_type: CurrencyType; + exchange_rate_type: ExchangeRateType; + is_shelter: boolean; + database_name: string; +} + +export interface ImportTemporaryFilter extends BaseFilter {} + +export interface ImportDefinitiveFilter extends BaseFilter { + movement_type: MovementTypeFilter; +} + +export interface ImportRepairFilter extends BaseFilter { + discharge_filter: DischargeFilter; +} + +export interface ExportFilter extends BaseFilter { + movement_type: ExportMovementType; + discharge_filter: DischargeFilter; + use_transport_method: boolean; +} + +export interface ExportRepairFilter extends Omit { + movement_type: ExportMovementType; + discharge_filter: DischargeFilter; +} + +export interface AllMovementsFilter { + range_type: RangeType; + start_date: string; // YYYYMMDD format + end_date: string; // YYYYMMDD format + include_cancelled: boolean; + provider?: string | null; + buyer?: string | null; + pedimento_code?: string | null; + currency_type: CurrencyType; + exchange_rate_type: ExchangeRateType; + is_shelter: boolean; + operation_type?: 'imp' | 'exp' | null; +} + +export interface MovementItem { + Factura: string; + Pedimento: string; + FechaFactura: string | null; + Estatus: string; + ClavePed: string; + TipoMovTemDef: string; + EsCambioRegimen: string; + ValorMPTemp: number; + ValorComercialMN: number; + TipoCambio: number; + ValorAgre: number; + TipoExpo: string; + PedimentoR1: string | null; + EDocument: string | null; + NumOperacionVU: string | null; + BaseDeDatos: string; + NumGafUni: string | null; + UsuarioCap: string | null; + UsuarioAcr: string | null; + Fecha_Pago: string | null; + NumCaja: string | null; + Pedimento18: string | null; + AduanaCru: string | null; + Lote: string | null; +} + +export interface MovementItemDetailed extends MovementItem { + Linea: number; + Regimen: string | null; + Fecha_Inicio: string | null; + Fecha_Fin: string | null; + Remesa: string | null; + Proveedor: string | null; + RFCProveedor: string | null; + ProveedorTaxID: string | null; + VendidoA: string | null; + VendidoARFC: string | null; + VendidoATaxID: string | null; + AgenteAduanal: string | null; + Patente: string | null; + NumParte: string | null; + DescripcionE: string | null; + DescripcionI: string | null; + CantidadIE: number; + UniMed: string | null; + PesoNeto: number; + PesoBruto: number; + OrdenCompraVenta: string | null; + FraccionArancelaria: string | null; + Preferencia: string | null; + Sector: string | null; + PaisOrigen: string | null; + Aduana: string | null; + Advalorem: string | null; + Series: string | null; + Marca: string | null; + Modelo: string | null; + FraccionAmericana: string | null; + ECCN: string | null; + SimboloEx: string | null; + FechaEmision: string | null; + Transportista: string | null; +} + +// ===== API METHODS ===== + +export const invoiceMovementsApi = { + // Temporary Imports + getTemporaryImports: (filters: ImportTemporaryFilter) => + api.post('/v1/a76/reports/movements/invoices/temporary', filters), + + getTemporaryImportsDetailed: (filters: ImportTemporaryFilter) => + api.post( + '/v1/a76/reports/movements/invoices/temporary-detailed', + filters + ), + + // Definitive Imports + getDefinitiveImports: (filters: ImportDefinitiveFilter) => + api.post('/v1/a76/reports/movements/invoices/definitive', filters), + + getDefinitiveImportsDetailed: (filters: ImportDefinitiveFilter) => + api.post( + '/v1/a76/reports/movements/invoices/definitive-detailed', + filters + ), + + // Repair Imports + getRepairImports: (filters: ImportRepairFilter) => + api.post('/v1/a76/reports/movements/invoices/repair', filters), + + getRepairImportsDetailed: (filters: ImportRepairFilter) => + api.post( + '/v1/a76/reports/movements/invoices/repair-detailed', + filters + ), + + // Exports + getExports: (filters: ExportFilter) => + api.post('/v1/a76/reports/movements/invoices/export', filters), + + getExportsDetailed: (filters: ExportFilter) => + api.post('/v1/a76/reports/movements/invoices/export-detailed', filters), + + // Export Repairs + getExportRepairs: (filters: ExportRepairFilter) => + api.post('/v1/a76/reports/movements/invoices/export-repair', filters), + + getExportRepairsDetailed: (filters: ExportRepairFilter) => + api.post( + '/v1/a76/reports/movements/invoices/export-repair-detailed', + filters + ), + + // All Movements + getAllMovements: (filters: AllMovementsFilter) => + api.post('/v1/a76/reports/movements/invoices/all', filters) +}; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index e6dbde48..17421dc6 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -5,6 +5,7 @@ import { BadgeCheck, ChartPie, Database, + FileSearch, FileText, Frame, GalleryVerticalEnd, @@ -384,6 +385,17 @@ export function getSidebarData(): SidebarData { icon: BadgeCheck, items: [], }, + { + title: "Reportes", + url: "#", + icon: FileSearch, + items: [ + { + title: "Facturas Impo/Expo", + url: "/dashboard/reports/invoices", + }, + ], + }, { title: m["sidebar.reference_data.configuracion"](), url: "#", diff --git a/frontend/src/lib/components/ui/icons/FolderIcon.svelte b/frontend/src/lib/components/ui/icons/FolderIcon.svelte new file mode 100644 index 00000000..beada7c8 --- /dev/null +++ b/frontend/src/lib/components/ui/icons/FolderIcon.svelte @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 60b5d95c..13ca79ea 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -57,7 +57,7 @@
- {@render children()} + {@render children?.()}
diff --git a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte index 30b1ecd6..248805b0 100644 --- a/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/clients_and_providers/edit/[[id]]/+page.svelte @@ -203,7 +203,7 @@ } catch (e: any) { console.error(e); error = e.message || "Error al guardar"; - toast.error(error); + toast.error(error || "Error al guardar"); } finally { loading = false; } diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.server.ts b/frontend/src/routes/dashboard/reports/invoices/+page.server.ts new file mode 100644 index 00000000..d86c1b74 --- /dev/null +++ b/frontend/src/routes/dashboard/reports/invoices/+page.server.ts @@ -0,0 +1,15 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { getAuthTokens } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ cookies }) => { + const { accessToken } = getAuthTokens(cookies); + + if (!accessToken) { + throw redirect(302, '/login'); + } + + return { + title: 'Reportes de Facturas' + }; +}; diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte new file mode 100644 index 00000000..b2060ede --- /dev/null +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -0,0 +1,1394 @@ + + +
+ + +
+
+

+ + Reporte de Facturas +

+ + V2.0 + +
+
+ Sistema Fiscal +
+
+ + + + +
+ {#each menuOptions as item} + {#if item.items.length > 0} + + + {#snippet child({ props })} + + {/snippet} + + + {item.label} + +
+ {#each item.items as subItem} + toast.info(`Seleccionado: ${subItem}`)}> + {subItem} + + {/each} +
+
+
+ {:else} + + {/if} + {/each} +
+ + + + +
+ + + + + + Periodo y Clasificación + + + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ +
+ {#each Object.keys(types.import) as key} +
+ + +
+ {/each} +
+
+ + +
+ +
+ {#each Object.keys(types.other) as key} +
+ { if (key === 'TODAS') handleTodasChange(v as boolean); }} + /> + +
+ {/each} +
+
+
+ + +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ {#each Object.keys(types.export.additional) as key} +
+ + +
+ {/each} +
+
+
+
+
+
+ + + + + + Filtros e Identificadores + + + +
+ {#each [ + { label: 'Proveedor', key: 'provider' as const }, + { label: 'Vendido a', key: 'soldTo' as const }, + { label: 'Clave de Pedimento', key: 'pedimentoKey' as const } + ] as item} +
+ +
+ + +
+
+ {/each} +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + Configuración Final + + + + +
+
+ + +
+ + +
+
+ + +
+
+
+ +
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+
+ + +
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + +
+
+ + + { if (!v) showResults = false; }}> + + +
+
+ + + {reportTitle} + + + {results.length} registros encontrados • {currencyLabel} + +
+
+ + + + Cerrar + +
+
+
+ +
+
+ + + + {#each (config.reportType === 'normal' ? + ['PEDIMENTO', 'CLAVE', 'FACTURA', 'FECHA FACT', 'VALOR COM.', 'TIPO OPER.', 'ESTATUS', 'PROYECTO'] : + ['PEDIMENTO', 'FACTURA', 'FECHA FACT', 'PROVEEDOR', 'CLIENTE', 'CANTIDAD', 'DESC. ESPAÑOL', 'TIPO OPER.']) as header} + + {/each} + + + + {#each results as row} + + {#if config.reportType === 'normal'} + + + + + + + + + {:else} + {@const detailRow = row as MovementItemDetailed} + + + + + + + + + {/if} + + {/each} + +
+ {header} +
{row.Pedimento || '-'}{row.ClavePed || '-'}{row.Factura}{formatDateFromYYYYMMDD(row.FechaFactura)}${row.ValorComercialMN} + + {row.TipoMovTemDef} + + + + {row.Estatus || 'A'} + + {row.BaseDeDatos}{detailRow.Pedimento || '-'}{detailRow.Factura}{formatDateFromYYYYMMDD(detailRow.FechaFactura)}{detailRow.Proveedor || '-'}{detailRow.VendidoA || '-'}{detailRow.CantidadIE || '0'}{detailRow.DescripcionE || '-'} + + {detailRow.TipoMovTemDef} + +
+
+
+
+
+
+ + + + + + + Seleccionar {dialogType === 'pedimentoKey' ? 'Clave de Pedimento' : dialogType === 'provider' ? 'Proveedor' : 'Cliente'} + + + Busca y selecciona {dialogType === 'pedimentoKey' ? 'una clave de pedimento' : dialogType === 'provider' ? 'un proveedor' : 'un cliente'} de la lista + + + +
+
+ + +
+ +
+ {#if dialogType === 'pedimentoKey'} + + + + + + + + + + {#if filteredItems.length === 0} + + + + {:else} + {#each filteredItems as item} + selectItem(item)}> + + + + + {/each} + {/if} + +
CódigoDescripciónAcción
+ No se encontraron resultados +
{item.code || '-'}{item.description || '-'} + +
+ {:else} + + + + + + + + + + + + + + + + + {#if filteredItems.length === 0} + + + + {:else} + {#each filteredItems as item} + selectItem(item)}> + + + + + + + + + + + + {/each} + {/if} + +
ClaveNombreTipoRFCCallesNúm. ExtCPColoniaCiudadAcción
+ No se encontraron resultados +
{item.id || '-'}{item.name || '-'} + + {item.client_or_provider === 'provider' ? 'P' : + item.client_or_provider === 'client' ? 'C' : + 'A'} + + {item.rfc || '-'}{item.address?.streets || '-'}{item.address?.exterior_number || '-'}{item.address?.postal_code || '-'}{item.address?.neighborhood || '-'}{item.address?.city || '-'} + +
+ {/if} +
+
+ + + + +
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 47270527..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,157 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - lucide-svelte: - specifier: ^0.552.0 - version: 0.552.0(svelte@5.43.2) - -packages: - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@sveltejs/acorn-typescript@1.0.6': - resolution: {integrity: sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==} - peerDependencies: - acorn: ^8.9.0 - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - - esrap@2.1.2: - resolution: {integrity: sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - - lucide-svelte@0.552.0: - resolution: {integrity: sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w==} - peerDependencies: - svelte: ^3 || ^4 || ^5.0.0-next.42 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - svelte@5.43.2: - resolution: {integrity: sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==} - engines: {node: '>=18'} - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - -snapshots: - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@sveltejs/acorn-typescript@1.0.6(acorn@8.15.0)': - dependencies: - acorn: 8.15.0 - - '@types/estree@1.0.8': {} - - acorn@8.15.0: {} - - aria-query@5.3.2: {} - - axobject-query@4.1.0: {} - - clsx@2.1.1: {} - - esm-env@1.2.2: {} - - esrap@2.1.2: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - locate-character@3.0.0: {} - - lucide-svelte@0.552.0(svelte@5.43.2): - dependencies: - svelte: 5.43.2 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - svelte@5.43.2: - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.6(acorn@8.15.0) - '@types/estree': 1.0.8 - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - clsx: 2.1.1 - esm-env: 1.2.2 - esrap: 2.1.2 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.21 - zimmerframe: 1.1.4 - - zimmerframe@1.1.4: {}