From 2905e90a72701bb3459ef8f52df7ea3a220f6682 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 30 Jan 2026 10:12:48 -0600 Subject: [PATCH 01/52] fix(pedimentos): change not_affect_usd_value and not_affect_customs_value types from bool to int in DTOs fix(main): enable redirect_slashes in FastAPI app configuration --- .../a76/pedmientos/dtos/pedimento_decrementables.py | 8 ++++---- .../a76/pedmientos/dtos/pedimento_incrementables.py | 8 ++++---- backend/main.py | 1 + 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py index 796bfee1..c9718994 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_decrementables.py @@ -17,10 +17,10 @@ class PedimentoDecrementablesBase(BaseModel): others: Optional[Decimal] = Field(None, description="Others") currency: Optional[str] = Field(None, max_length=3, description="Currency") currency_factor: Optional[Decimal] = Field(None, description="Currency factor") - not_affect_usd_value: Optional[bool] = Field( + not_affect_usd_value: Optional[int] = Field( None, description="Not affect USD value" ) - not_affect_customs_value: Optional[bool] = Field( + not_affect_customs_value: Optional[int] = Field( None, description="Not affect customs value" ) @@ -41,8 +41,8 @@ class PedimentoDecrementablesUpdate(BaseModel): others: Optional[Decimal] = None currency: Optional[str] = Field(None, max_length=3) currency_factor: Optional[Decimal] = None - not_affect_usd_value: Optional[bool] = None - not_affect_customs_value: Optional[bool] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None class PedimentoDecrementablesResponse(PedimentoDecrementablesBase): diff --git a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py index c8100d01..acc6312e 100644 --- a/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py +++ b/backend/api/v1/modules/a76/pedmientos/dtos/pedimento_incrementables.py @@ -18,10 +18,10 @@ class PedimentoIncrementablesBase(BaseModel): deductibles: Optional[Decimal] = Field(None, description="Deductibles") currency: Optional[str] = Field(None, max_length=3, description="Currency") currency_factor: Optional[Decimal] = Field(None, description="Currency factor") - not_affect_usd_value: Optional[bool] = Field( + not_affect_usd_value: Optional[int] = Field( None, description="Not affect USD value" ) - not_affect_customs_value: Optional[bool] = Field( + not_affect_customs_value: Optional[int] = Field( None, description="Not affect customs value" ) @@ -43,8 +43,8 @@ class PedimentoIncrementablesUpdate(BaseModel): deductibles: Optional[Decimal] = None currency: Optional[str] = Field(None, max_length=3) currency_factor: Optional[Decimal] = None - not_affect_usd_value: Optional[bool] = None - not_affect_customs_value: Optional[bool] = None + not_affect_usd_value: Optional[int] = None + not_affect_customs_value: Optional[int] = None class PedimentoIncrementablesResponse(PedimentoIncrementablesBase): diff --git a/backend/main.py b/backend/main.py index b7ed6b69..b5a0c59a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -45,6 +45,7 @@ app = FastAPI( docs_url="/api/docs" if settings.DEBUG else None, redoc_url="/api/redoc" if settings.DEBUG else None, openapi_url="/api/openapi.json" if settings.DEBUG else None, + redirect_slashes=True, ) # Registrar manejadores de excepciones From d96b851c06efd56aa1988cc0d1f3e027b115fe6b Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 30 Jan 2026 10:24:48 -0600 Subject: [PATCH 02/52] fix(pedimentos): remove trailing slash from delete API endpoint URL --- backend/main.py | 1 - frontend/src/lib/api/dashboard/a76/pedimentos.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/main.py b/backend/main.py index b5a0c59a..b7ed6b69 100644 --- a/backend/main.py +++ b/backend/main.py @@ -45,7 +45,6 @@ app = FastAPI( docs_url="/api/docs" if settings.DEBUG else None, redoc_url="/api/redoc" if settings.DEBUG else None, openapi_url="/api/openapi.json" if settings.DEBUG else None, - redirect_slashes=True, ) # Registrar manejadores de excepciones diff --git a/frontend/src/lib/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts index 574c447f..a5069c9f 100644 --- a/frontend/src/lib/api/dashboard/a76/pedimentos.ts +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -324,5 +324,5 @@ export const pedimentosApi = { * @param id - ID del pedimento a eliminar * @param companyId - ID de la compañía (por defecto 1) */ - delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}/?company_id=${companyId}`) + delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`) }; From e689d1f3a2484563cca2bde53e1358d57d62b1b8 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 30 Jan 2026 10:44:29 -0600 Subject: [PATCH 03/52] Refactor code structure for improved readability and maintainability --- frontend/src/lib/date-utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/date-utils.ts b/frontend/src/lib/date-utils.ts index 20991a30..e743ee02 100644 --- a/frontend/src/lib/date-utils.ts +++ b/frontend/src/lib/date-utils.ts @@ -22,7 +22,9 @@ export function prepareDateForBackend(dateStr: string, timeStr: string = '00:00' // Combinar fecha y hora usando la zona horaria local const dateTime = toCalendarDateTime(date, time); const zonedDateTime = dateTime.toDate(localTimeZone); - return zonedDateTime.toISOString(); + // Convertir a objeto Date nativo de JavaScript para obtener ISO string correcto + const jsDate = new Date(zonedDateTime.toString()); + return jsDate.toISOString(); } catch (e) { console.error('Error parsing date:', e); return null; From b8e25cd2e15d72a8121fef82f88a8c48db74d329 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 30 Jan 2026 10:55:29 -0600 Subject: [PATCH 04/52] 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: {} From 461f8ececbdfbe772d5f6405153dcd91052cda0d Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 30 Jan 2026 18:03:41 -0600 Subject: [PATCH 05/52] fix: update invoice type filtering for repair imports and exports --- .../invoices/services/export_repair.py | 2 +- .../invoices/services/query_builders.py | 92 +++++---------- .../movements/invoices/services/repair.py | 8 +- .../dashboard/reports/invoices/+page.svelte | 106 ++++++++++++------ 4 files changed, 104 insertions(+), 104 deletions(-) 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 index 51044161..13143ef6 100644 --- 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 @@ -337,7 +337,7 @@ class ExportRepairService: # ALL mode: bring all exports without filtering by specific invoice_type pass else: - where_conditions.append("ih.invoice_type IN ('EXREP', 'MATEXREP')") + where_conditions.append("ih.invoice_type = 'REPAR'") # Date range filter if filters.range_type.value == "FF": 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 index d141392c..7e6417c1 100644 --- 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 @@ -41,26 +41,17 @@ class TemporaryImportQueries: '' 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 + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 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 """ @@ -133,9 +124,8 @@ class TemporaryImportQueries: 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.items i ON i.invoice_id = ih.id + LEFT JOIN a76.item_lines il ON il.item_id = i.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 @@ -146,6 +136,7 @@ class TemporaryImportQueries: WHERE ih.operation_type = 'imp' AND ih.invoice_type = 'TEM' AND {where_str} + ORDER BY ih.invoice_number, il.line_number """ @staticmethod @@ -219,27 +210,17 @@ class DefinitiveImportQueries: '' 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 + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 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 """ @@ -355,7 +336,12 @@ class DefinitiveImportQueries: class RepairImportQueries: - """SQL queries for repair imports (PostgreSQL schema).""" + """SQL queries for repair imports (PostgreSQL schema). + + Note: Repair imports are NOT identified by invoice_type, but by having + cross-references (search_invoice field) that link them to export invoices. + These are regular import invoices (TEM, DEF, etc.) that were imported for repair. + """ @staticmethod def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: @@ -387,27 +373,23 @@ class RepairImportQueries: 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 + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 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 EXISTS ( + SELECT 1 FROM a76.item_lines il2 + INNER JOIN a24.fa_item_lines fil2 ON fil2.id = il2.id + WHERE il2.item_id = i.id AND fil2.search_invoice IS NOT NULL + ) {"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 """ @@ -476,11 +458,12 @@ class RepairImportQueries: LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id LEFT JOIN a76.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 fil.search_invoice IS NOT NULL {"AND " + where_str if where_str else ""} {discharge_filter} ORDER BY ih.invoice_number, il.line_number @@ -562,24 +545,14 @@ class ExportQueries: 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 + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 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 """ @@ -736,25 +709,16 @@ class ExportRepairQueries: 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 + COALESCE(fin.value_me, 0) AS total_me, + COALESCE(fin.value_mn, 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 ih.invoice_type = 'REPAR' {"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 """ @@ -837,7 +801,7 @@ class ExportRepairQueries: 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 UPPER(ih.invoice_type) IN ('DEF', 'REPAR', 'EXDEF', 'MATDE') AND {where_str} ORDER BY ih.invoice_number, il.line_number """ 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 index 0a4619ab..889d3185 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -331,12 +331,8 @@ class RepairImportService: # 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')") + # Repair imports are identified by cross-references, not invoice_type + where_conditions.append("COALESCE(cmp.is_regime_change, false) = false") # Date range filter if filters.range_type.value == "FF": diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index b2060ede..a1f33b08 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -83,7 +83,6 @@ let filters = $state({ includeNA: false, - includePartial: false, downloaded: 'all' as 'all' | 'downloaded' | 'not_downloaded' }); @@ -291,30 +290,6 @@ return { valid: true }; } - function getSelectedMovementTypes(): string[] { - const selected: string[] = []; - - // Imports - if (types.import.TEM) selected.push('IMTEM'); - if (types.import.DEF) selected.push('IMPDF'); - if (types.import.REP) selected.push('IMPRE'); - - // Exports - if (types.export.main.DEF || types.export.additional.TODAS) selected.push('EXPO_DEF'); - if (types.export.main.REP || types.export.additional.TODAS) selected.push('EXPO_REP'); - if (types.export.additional.AFIJO) selected.push('AFIJO'); - if (types.export.additional.REEXP) selected.push('REEXP'); - if (types.export.additional.NODES) selected.push('NODES'); - if (types.export.additional.DONAC) selected.push('DONAC'); - if (types.export.additional.SCRAP) selected.push('SCRAP'); - - // Others - if (types.other.VEMEX) selected.push('VEMEX'); - if (types.other.COMEX) selected.push('COMEX'); - - return selected; - } - // --- LÓGICA --- async function handleGenerateReport() { @@ -408,7 +383,7 @@ if (response.data) allResults.push(...response.data); } - // Importaciones Definitivas (IMPDF) + // Importaciones Definitivas (IMPDF) o COMEX if (types.import.DEF) { toast.info('Obteniendo importaciones definitivas...'); const movementType = types.other.COMEX ? 'COMEX' : 'IMPDF'; @@ -416,12 +391,12 @@ ? await invoiceMovementsApi.getDefinitiveImports({ ...baseFilter, database_name: 'default', - movement_type: movementType === 'COMEX' ? 'COMEX' : 'IMPDF' + movement_type: movementType }) : await invoiceMovementsApi.getDefinitiveImportsDetailed({ ...baseFilter, database_name: 'default', - movement_type: movementType === 'COMEX' ? 'COMEX' : 'IMPDF' + movement_type: movementType }); if (response.data) allResults.push(...response.data); } @@ -445,10 +420,11 @@ if (response.data) allResults.push(...response.data); } - // Exportaciones Definitivas - if (types.export.main.DEF || types.export.additional.TODAS || Object.values(types.export.additional).some(v => v)) { + // Exportaciones Definitivas (incluye VEMEX) + if (types.export.main.DEF || types.other.VEMEX || types.export.additional.TODAS || Object.values(types.export.additional).some(v => v)) { toast.info('Obteniendo exportaciones...'); + // Determinar tipo de movimiento basado en checkboxes adicionales let movementType: any = 'ALL'; if (types.export.additional.AFIJO) movementType = 'AFIJO'; else if (types.export.additional.NODES) movementType = 'NODES'; @@ -466,7 +442,7 @@ database_name: 'default', movement_type: movementType, discharge_filter: dischargeFilter, - use_transport_method: false // Por defecto, ajustar según necesidad + use_transport_method: false }) : await invoiceMovementsApi.getExportsDetailed({ ...baseFilter, @@ -482,6 +458,7 @@ if (types.export.main.REP || types.export.additional.TODAS) { toast.info('Obteniendo exportaciones de reparación...'); + // Para reparaciones solo aplican AFIJO y NODES let movementType: any = 'ALL'; if (types.export.additional.AFIJO) movementType = 'AFIJO'; else if (types.export.additional.NODES) movementType = 'NODES'; @@ -505,6 +482,61 @@ if (response.data) allResults.push(...response.data); } + // Cambio de Régimen (CREG) - Exportaciones con cambio de régimen + if (types.export.main.CREG) { + toast.info('Obteniendo cambios de régimen...'); + + // Para cambio de régimen solo aplican AFIJO y SCRAP + let movementType: any = 'ALL'; + if (types.export.additional.AFIJO) movementType = 'AFIJO'; + else if (types.export.additional.SCRAP) movementType = 'SCRAP'; + + const dischargeFilter = filters.downloaded === 'downloaded' ? 'SiDes' : + filters.downloaded === 'not_downloaded' ? 'NoDes' : 'ALL'; + + const response = config.reportType === 'normal' + ? await invoiceMovementsApi.getExports({ + ...baseFilter, + database_name: 'default', + movement_type: movementType, + discharge_filter: dischargeFilter, + use_transport_method: false + }) + : await invoiceMovementsApi.getExportsDetailed({ + ...baseFilter, + database_name: 'default', + movement_type: movementType, + discharge_filter: dischargeFilter, + use_transport_method: false + }); + if (response.data) allResults.push(...response.data); + } + + // CREGEXP (Cambio Régimen Export) - Caso especial + if (types.other.CREGEXP) { + toast.info('Obteniendo cambios de régimen export...'); + + const dischargeFilter = filters.downloaded === 'downloaded' ? 'SiDes' : + filters.downloaded === 'not_downloaded' ? 'NoDes' : 'ALL'; + + const response = config.reportType === 'normal' + ? await invoiceMovementsApi.getExports({ + ...baseFilter, + database_name: 'default', + movement_type: 'ALL', + discharge_filter: dischargeFilter, + use_transport_method: false + }) + : await invoiceMovementsApi.getExportsDetailed({ + ...baseFilter, + database_name: 'default', + movement_type: 'ALL', + discharge_filter: dischargeFilter, + use_transport_method: false + }); + if (response.data) allResults.push(...response.data); + } + results = allResults; showResults = true; @@ -760,6 +792,14 @@ return dateStr; } + function formatCurrency(value: number | null | undefined): string { + if (value === null || value === undefined) return '-'; + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(value); + } + function handleClose() { // Reset form instead of navigating back showResults = false; @@ -938,7 +978,7 @@ bind:checked={types.import[key as keyof typeof types.import]} /> {/each} @@ -1237,7 +1277,7 @@ {row.ClavePed || '-'} {row.Factura} {formatDateFromYYYYMMDD(row.FechaFactura)} - ${row.ValorComercialMN} + ${formatCurrency(row.ValorComercialMN)} {row.TipoMovTemDef} From 5bf757e08d5284d771aadba6edb6965aa61efced Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 3 Feb 2026 09:20:24 -0600 Subject: [PATCH 06/52] fix: update date handling to convert ISO datetime to YYYY-MM-DD format --- .../pedimentos/edit/[id]/+page.svelte | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte index 1138594e..39107639 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte @@ -319,44 +319,42 @@ exchange_rate: generalFormData?.exchange_rate || undefined }; - // Helper function para convertir fecha YYYY-MM-DD a ISO datetime - function dateToISO(dateString: string | null | undefined): string | null { - if (!dateString) return null; - // Si ya tiene formato ISO completo, retornarlo - if (dateString.includes('T')) return dateString; - // Si es solo fecha (YYYY-MM-DD), agregar tiempo medianoche - return `${dateString}T00:00:00`; + // Helper function para convertir ISO datetime a YYYY-MM-DD + function isoToDate(isoString: string | null | undefined): string | null { + if (!isoString) return null; + // Extraer solo la parte de fecha (YYYY-MM-DD) + return isoString.split('T')[0]; } // Fechas - enviar si hay al menos un campo con valor if (generalFormData) { - const hasDatesValue = generalFormData.entry_date || generalFormData.pedimento_date || - generalFormData.extraction_date || generalFormData.rectification_payment_date || - generalFormData.original_date || generalFormData.payment_date || generalFormData.end_date; - if (hasDatesValue) { - payload.pedimento_dates = { - entry_date: generalFormData.entry_date || null, - pedimento_date: generalFormData.pedimento_date || null, - payment_date: generalFormData.payment_date || null, - rectification_payment_date: generalFormData.rectification_payment_date || null, - extraction_date: generalFormData.extraction_date || null, - original_date: generalFormData.original_date || null, - end_date: generalFormData.end_date || null - }; - } - } + const hasDatesValue = generalFormData.entry_date || generalFormData.pedimento_date || + generalFormData.extraction_date || generalFormData.rectification_payment_date || + generalFormData.original_date || generalFormData.payment_date || generalFormData.end_date; + if (hasDatesValue) { + payload.pedimento_dates = { + entry_date: isoToDate(generalFormData.entry_date), + pedimento_date: isoToDate(generalFormData.pedimento_date), + payment_date: isoToDate(generalFormData.payment_date), + rectification_payment_date: isoToDate(generalFormData.rectification_payment_date), + extraction_date: isoToDate(generalFormData.extraction_date), + original_date: isoToDate(generalFormData.original_date), + end_date: isoToDate(generalFormData.end_date) + }; + } + } - // Solo agregar sub-recursos en modo UPDATE (no en CREATE) - // Y solo si tienen valores reales (no enviar objetos vacíos/null) - - // Observaciones - siempre enviar (puede ser string vacío para borrar) - payload.observations = observacionesFormData?.observaciones || ''; - - // Incrementables - solo enviar si hay al menos un campo con valor - if (generalFormData) { - const hasIncrementablesValue = generalFormData.valor_seguro || generalFormData.embalajes || - generalFormData.fletes || generalFormData.deducibles || generalFormData.moneda_incrementables || - generalFormData.no_afectar_valor_dolares_inc !== undefined || generalFormData.no_afectar_valor_aduana !== undefined; + // Solo agregar sub-recursos en modo UPDATE (no en CREATE) + // Y solo si tienen valores reales (no enviar objetos vacíos/null) + + // Observaciones - siempre enviar (puede ser string vacío para borrar) + payload.observations = observacionesFormData?.observaciones || ''; + + // Incrementables - solo enviar si hay al menos un campo con valor + if (generalFormData) { + const hasIncrementablesValue = generalFormData.valor_seguro || generalFormData.embalajes || + generalFormData.fletes || generalFormData.deducibles || generalFormData.moneda_incrementables || + generalFormData.no_afectar_valor_dolares_inc !== undefined || generalFormData.no_afectar_valor_aduana !== undefined; if (hasIncrementablesValue) { payload.pedimento_incrementables = { insured_value: generalFormData.valor_seguro || null, From e7971d0d1d9cfdbd1b5e1a9f9b17a4bbdc1f4796 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 3 Feb 2026 10:38:36 -0600 Subject: [PATCH 07/52] refactor: optimize provider and client information retrieval in invoice services --- .../movements/invoices/services/definitive.py | 21 ++++++--------- .../movements/invoices/services/export.py | 21 ++++++--------- .../invoices/services/export_repair.py | 23 ++++++---------- .../invoices/services/query_builders.py | 27 ++++++++++++------- .../movements/invoices/services/repair.py | 25 +++++++---------- .../movements/invoices/services/temporary.py | 23 ++++++---------- 6 files changed, 60 insertions(+), 80 deletions(-) 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 index 5a2c1ee3..ee2f603e 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py @@ -186,13 +186,8 @@ class DefinitiveImportService: 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 - ) + # Provider and client names now come directly from query (C8, C9) + # No need for additional database lookups agente_info = DatabaseHelper.get_customs_agent_info( db, filters.database_name, row[17] ) @@ -256,12 +251,12 @@ class DefinitiveImportService: 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'), + Proveedor=row[7], # C8 - Provider name (from JOIN) + RFCProveedor=None, # RFC not in detailed query + ProveedorTaxID=None, # Tax ID not in detailed query + VendidoA=row[8], # C9 - Client name (from JOIN) + VendidoARFC=None, # RFC not in detailed query + VendidoATaxID=None, # Tax ID not in detailed query AgenteAduanal=agente_info.get('name'), Patente=agente_info.get('license'), NumParte=row[19], 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 index cfb594f7..d7513f1f 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -187,13 +187,8 @@ class ExportService: 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 - ) + # Provider and client names now come directly from query (C14, C15) + # No need for additional database lookups # Get customs agent information agente_info = DatabaseHelper.get_customs_agent_info( @@ -257,12 +252,12 @@ class ExportService: 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"), + Proveedor=row[13], # C14 - Provider name (from JOIN) + RFCProveedor=None, # RFC not in detailed query + ProveedorTaxID=None, # Tax ID not in detailed query + VendidoA=row[14], # C15 - Client name (from JOIN) + VendidoARFC=None, # RFC not in detailed query + VendidoATaxID=None, # Tax ID not in detailed query AgenteAduanal=agente_info.get("name"), Patente=agente_info.get("license"), NumParte=row[17], # C18 - Clase 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 index 13143ef6..c3b7f088 100644 --- 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 @@ -188,15 +188,8 @@ class ExportRepairService: 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 - ) + # Provider and client names now come directly from query (C14, C15) + # No need for additional database lookups # Get customs agent information agente_info = DatabaseHelper.get_customs_agent_info( @@ -271,12 +264,12 @@ class ExportRepairService: 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'), + Proveedor=row[13], # C14 - Provider name (from JOIN) + RFCProveedor=None, # RFC not in detailed query + ProveedorTaxID=None, # Tax ID not in detailed query + VendidoA=row[14], # C15 - Client name (from JOIN) + VendidoARFC=None, # RFC not in detailed query + VendidoATaxID=None, # Tax ID not in detailed query AgenteAduanal=agente_info.get('name'), Patente=agente_info.get('license'), NumParte=row[17], # C18 - Clase (NumParte) 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 index 7e6417c1..4851aebf 100644 --- 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 @@ -68,8 +68,8 @@ class TemporaryImportQueries: 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(prov.name, '') AS C8, + COALESCE(client.name, '') 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, @@ -124,6 +124,8 @@ class TemporaryImportQueries: LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id LEFT JOIN a76.items i ON i.invoice_id = ih.id LEFT JOIN a76.item_lines il ON il.item_id = i.id LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id @@ -233,7 +235,10 @@ class DefinitiveImportQueries: ih.invoice_date AS C3, -- [2] ped.status AS C4, -- [3] ped.pedimento_code AS C5, -- [4] - '' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8] + '' AS C6, -- [5] + '' AS C7, -- [6] + COALESCE(prov.name, '') AS C8, -- [7] Provider name + COALESCE(client.name, '') AS C9, -- [8] Client name ped.regime AS C10, -- [9] log.entry_exit_date AS C11, -- [10] log.delivery_date AS C12, -- [11] @@ -412,8 +417,8 @@ class RepairImportQueries: 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(prov.name, ''), + COALESCE(client.name, ''), 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), ' '), @@ -452,6 +457,8 @@ class RepairImportQueries: LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id LEFT JOIN a76.items itm ON itm.invoice_id = ih.id LEFT JOIN a76.item_lines il ON il.item_id = itm.id LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id @@ -573,8 +580,8 @@ class ExportQueries: 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] + COALESCE(prov.name, '') AS C14, -- [13] Provider name + COALESCE(client.name, '') AS C15, -- [14] Client name cmp.customs_broker_id AS C16, -- [15] '' AS C17, -- [16] prt.part_number AS C18, -- [17] @@ -740,8 +747,8 @@ class ExportRepairQueries: 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(prov.name, '') AS C14, + COALESCE(client.name, '') AS C15, COALESCE(cmp.customs_broker_id::text, '') AS C16, '' AS C17, COALESCE(cls.class_code, '') AS C18, @@ -791,6 +798,8 @@ class ExportRepairQueries: LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id LEFT JOIN a76.items itm ON itm.invoice_id = ih.id LEFT JOIN a76.item_lines il ON il.item_id = itm.id LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id 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 index 889d3185..26ceb14f 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -200,13 +200,8 @@ class RepairImportService: 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 - ) + # Provider and client names now come directly from query + # No need for additional database lookups agente_info = DatabaseHelper.get_customs_agent_info( db, filters.database_name, row[17] ) @@ -268,14 +263,14 @@ class RepairImportService: 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'), + Fecha_Pago=row[8], + Remesa=row[9], + Proveedor=row[11], # Provider name (from JOIN) + RFCProveedor=None, # RFC not in detailed query + ProveedorTaxID=None, # Tax ID not in detailed query + VendidoA=row[12], # Client name (from JOIN) + VendidoARFC=None, # RFC not in detailed query + VendidoATaxID=None, # Tax ID not in detailed query AgenteAduanal=agente_info.get('name'), Patente=agente_info.get('license'), NumParte=row[19], 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 index 1e4ae9b2..b4d1ea2c 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -190,15 +190,8 @@ class TemporaryImportService: 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 - ) + # Provider and client names now come directly from query (C8, C9) + # No need for additional database lookups # Get customs agent information agente_info = DatabaseHelper.get_customs_agent_info( @@ -274,12 +267,12 @@ class TemporaryImportService: 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'), + Proveedor=row[7], # C8 - Provider name (from JOIN) + RFCProveedor=None, # RFC not in detailed query + ProveedorTaxID=None, # Tax ID not in detailed query + VendidoA=row[8], # C9 - Client name (from JOIN) + VendidoARFC=None, # RFC not in detailed query + VendidoATaxID=None, # Tax ID not in detailed query AgenteAduanal=agente_info.get('name'), Patente=agente_info.get('license'), NumParte=row[19], # C20 - Clase (NumParte) From 7920b216540652941c0ec1d614e2efb3ead11549 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 3 Feb 2026 15:49:40 -0600 Subject: [PATCH 08/52] refactor: streamline client and supplier information retrieval across invoice services --- .../movements/invoices/services/definitive.py | 21 ++++++++++------ .../movements/invoices/services/export.py | 21 ++++++++++------ .../invoices/services/export_repair.py | 23 +++++++++++------ .../invoices/services/query_builders.py | 21 ++++++---------- .../movements/invoices/services/repair.py | 25 +++++++++++-------- .../movements/invoices/services/temporary.py | 1 + 6 files changed, 64 insertions(+), 48 deletions(-) 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 index ee2f603e..5a2c1ee3 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py @@ -186,8 +186,13 @@ class DefinitiveImportService: if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus continue - # Provider and client names now come directly from query (C8, C9) - # No need for additional database lookups + # 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] ) @@ -251,12 +256,12 @@ class DefinitiveImportService: Fecha_Fin=row[11], Fecha_Pago=row[12], Remesa=row[13], - Proveedor=row[7], # C8 - Provider name (from JOIN) - RFCProveedor=None, # RFC not in detailed query - ProveedorTaxID=None, # Tax ID not in detailed query - VendidoA=row[8], # C9 - Client name (from JOIN) - VendidoARFC=None, # RFC not in detailed query - VendidoATaxID=None, # Tax ID not in detailed query + 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], 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 index d7513f1f..cfb594f7 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -187,8 +187,13 @@ class ExportService: if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus continue - # Provider and client names now come directly from query (C14, C15) - # No need for additional database lookups + # 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( @@ -252,12 +257,12 @@ class ExportService: Fecha_Pago=row[10], # C11 - Fecha_Pago Remesa=row[11], # C12 - Remesa TipoCambio=tipo_cambio_final, - Proveedor=row[13], # C14 - Provider name (from JOIN) - RFCProveedor=None, # RFC not in detailed query - ProveedorTaxID=None, # Tax ID not in detailed query - VendidoA=row[14], # C15 - Client name (from JOIN) - VendidoARFC=None, # RFC not in detailed query - VendidoATaxID=None, # Tax ID not in detailed query + 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 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 index c3b7f088..13143ef6 100644 --- 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 @@ -188,8 +188,15 @@ class ExportRepairService: if not filters.include_cancelled and row[5] == 'NA': # C6 - Estatus continue - # Provider and client names now come directly from query (C14, C15) - # No need for additional database lookups + # 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( @@ -264,12 +271,12 @@ class ExportRepairService: Fecha_Fin=row[9], # C10 - Fecha_Fin Fecha_Pago=row[10], # C11 - Fecha_Pago Remesa=row[11], # C12 - Remesa - Proveedor=row[13], # C14 - Provider name (from JOIN) - RFCProveedor=None, # RFC not in detailed query - ProveedorTaxID=None, # Tax ID not in detailed query - VendidoA=row[14], # C15 - Client name (from JOIN) - VendidoARFC=None, # RFC not in detailed query - VendidoATaxID=None, # Tax ID not in detailed query + 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) 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 index 4851aebf..d4d2b765 100644 --- 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 @@ -235,10 +235,7 @@ class DefinitiveImportQueries: ih.invoice_date AS C3, -- [2] ped.status AS C4, -- [3] ped.pedimento_code AS C5, -- [4] - '' AS C6, -- [5] - '' AS C7, -- [6] - COALESCE(prov.name, '') AS C8, -- [7] Provider name - COALESCE(client.name, '') AS C9, -- [8] Client name + '' 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] @@ -417,8 +414,8 @@ class RepairImportQueries: COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), ''), COALESCE(cmp.remesa::text, ''), COALESCE(fin.exchange_rate, 0), - COALESCE(prov.name, ''), - COALESCE(client.name, ''), + 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), ' '), @@ -457,8 +454,6 @@ class RepairImportQueries: LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id - LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id - LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id LEFT JOIN a76.items itm ON itm.invoice_id = ih.id LEFT JOIN a76.item_lines il ON il.item_id = itm.id LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id @@ -580,8 +575,8 @@ class ExportQueries: log.payment_date AS C11, -- [10] log.payment_receipt_num AS C12, -- [11] '' AS C13, -- [12] - COALESCE(prov.name, '') AS C14, -- [13] Provider name - COALESCE(client.name, '') AS C15, -- [14] Client name + 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] @@ -747,8 +742,8 @@ class ExportRepairQueries: COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11, COALESCE(cmp.remesa::text, '') AS C12, COALESCE(fin.exchange_rate, 0) AS C13, - COALESCE(prov.name, '') AS C14, - COALESCE(client.name, '') AS C15, + 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, @@ -798,8 +793,6 @@ class ExportRepairQueries: LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id - LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id - LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id LEFT JOIN a76.items itm ON itm.invoice_id = ih.id LEFT JOIN a76.item_lines il ON il.item_id = itm.id LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id 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 index 26ceb14f..889d3185 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -200,8 +200,13 @@ class RepairImportService: if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus continue - # Provider and client names now come directly from query - # No need for additional database lookups + # 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] ) @@ -263,14 +268,14 @@ class RepairImportService: Regimen=row[9], Fecha_Inicio=row[10], Fecha_Fin=row[11], - Fecha_Pago=row[8], - Remesa=row[9], - Proveedor=row[11], # Provider name (from JOIN) - RFCProveedor=None, # RFC not in detailed query - ProveedorTaxID=None, # Tax ID not in detailed query - VendidoA=row[12], # Client name (from JOIN) - VendidoARFC=None, # RFC not in detailed query - VendidoATaxID=None, # Tax ID not in detailed query + 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], 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 index b4d1ea2c..b249ebdf 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -192,6 +192,7 @@ class TemporaryImportService: # Provider and client names now come directly from query (C8, C9) # No need for additional database lookups + logger.info(f"Processing invoice {row[0]}: Proveedor='{row[7]}', VendidoA='{row[8]}', CantidadIE={row[22]}, DescripcionE='{row[20][:50] if row[20] else None}'") # Get customs agent information agente_info = DatabaseHelper.get_customs_agent_info( From 264100e2ea251f55cc6f7a1e20b90ee7ae6e5af6 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 5 Feb 2026 16:00:22 -0600 Subject: [PATCH 09/52] feat: Implement detailed and normal report types for invoice movements, enhancing number formatting and API request handling. --- .../importacion/consolidados/mex/service.py | 12 +- .../importacion/facturas/mex/service.py | 12 +- .../reports/importacion/facturas/routes.py | 3 +- .../importacion/facturas/usa/service.py | 9 +- .../importacion/packing_list/service.py | 14 +- .../movements/invoices/movement_service.py | 186 +- .../a76/reports/movements/invoices/routes.py | 22 +- .../a76/reports/movements/invoices/schemas.py | 4 + .../movements/invoices/services/base.py | 9 + .../invoices/services/database_helpers.py | 91 +- .../movements/invoices/services/definitive.py | 47 +- .../movements/invoices/services/export.py | 46 +- .../invoices/services/export_repair.py | 4 +- .../invoices/services/query_builders.py | 10 +- .../movements/invoices/services/repair.py | 131 +- .../movements/invoices/services/temporary.py | 78 +- backend/api/v1/modules/a76/router.py | 7 + .../api/dashboard/a76/invoice-movements.ts | 3 +- .../dashboard/a76/reports/reports-invoices.ts | 5 - .../exchange_rate/exchange-rate-guard.svelte | 89 +- .../dashboard/reports/invoices/+page.svelte | 3246 ++++++++++------- 21 files changed, 2276 insertions(+), 1752 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 34cbc9ef..4836c0ad 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -67,12 +67,18 @@ class ConsolidadoImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 619ae2f5..a7163ed1 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -103,12 +103,18 @@ class FacturaImportacionMexService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ if valor is None: - return 0.0 + valor = 0.0 try: - return round(float(valor), decimales) + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" except: - return 0.0 + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py index 324bb91a..12595fda 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/routes.py @@ -41,9 +41,10 @@ async def trigger_descarga_factura( invoice_id: int, company_id: int = Query(..., description="ID de la empresa"), invoice_type: str = Query('mexican', description="Tipo de factura: 'mexican' o 'american'"), + currency_code: str = Query('ORIGINAL', description="Moneda: 'MXN', 'USD', o 'ORIGINAL'"), current_user: Dict[str, Any] = Depends(get_current_user), db: Session = Depends(get_core_db) ): validate_access_to_resource(db, company_id, current_user) - task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type) + task = generar_pdf_factura_async.delay(invoice_id, company_id, invoice_type, currency_code) return {"task_id": task.id, "message": "Generación iniciada"} \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index d5ec1d10..98eb0284 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -95,10 +95,13 @@ class FacturaImportacionUsaService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + if valor is None: + valor = 0.0 try: - return round(float(valor), decimales) - except: return 0.0 + num = round(float(valor), decimales) + return f"{num:,.{decimales}f}" + except: + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index a6163592..55e1a1dc 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -67,10 +67,18 @@ class PackingListService: return pdfkit.configuration(wkhtmltopdf=path) def formatear_numero(self, valor, decimales: int = 2): - if valor is None: return 0.0 + """ + Formatea un número con separadores de miles y decimales especificados. + Retorna una cadena formateada para mostrar en reportes. + """ + if valor is None: + valor = 0.0 try: - return round(float(valor), decimales) - except: return 0.0 + num = round(float(valor), decimales) + # Formatear con separadores de miles y decimales + return f"{num:,.{decimales}f}" + except: + return f"0.{'0' * decimales}" def _format_fraccion_fallback(self, fraccion_raw: str) -> str: if not fraccion_raw or len(fraccion_raw) < 8: 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 index 303dd6e8..1d3cc18b 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py @@ -15,7 +15,8 @@ from .schemas import ( ExportRepairFilter, AllMovementsFilter, MovementItem, - MovementItemDetailed + MovementItemDetailed, + ReportType ) from .services.temporary import TemporaryImportService from .services.definitive import DefinitiveImportService @@ -163,7 +164,6 @@ class MovementService: # 1. Temporary Imports if should_fetch_imports: - try: temp_filter = ImportTemporaryFilter( range_type=filters.range_type, start_date=filters.start_date, @@ -172,121 +172,119 @@ class MovementService: provider=filters.provider, buyer=filters.buyer, pedimento_code=filters.pedimento_code, - report_type='Normal', # Always use normal mode for combined report + report_type=filters.report_type, # Use filter's report_type currency_type=filters.currency_type, exchange_rate_type=filters.exchange_rate_type, is_shelter=filters.is_shelter, database_name='default' # Required field ) - temp_movements = self.temporary_service.get_movements(db, temp_filter) + if filters.report_type == ReportType.DETAILED: + temp_movements = self.temporary_service.get_movements_detailed(db, temp_filter) + else: + temp_movements = self.temporary_service.get_movements(db, temp_filter) all_movements.extend(temp_movements) logger.info(f"Added {len(temp_movements)} temporary import movements") - except Exception as e: - db.rollback() # Rollback failed transaction - logger.warning(f"Error fetching temporary imports: {e}") # 2. Definitive Imports if should_fetch_imports: - try: - def_filter = ImportDefinitiveFilter( - range_type=filters.range_type, - start_date=filters.start_date, - end_date=filters.end_date, - include_cancelled=filters.include_cancelled, - provider=filters.provider, - buyer=filters.buyer, - pedimento_code=filters.pedimento_code, - report_type='Normal', - currency_type=filters.currency_type, - exchange_rate_type=filters.exchange_rate_type, - is_shelter=filters.is_shelter, - database_name='default', - movement_type='ALL' # Required field - include all definitive types - ) + def_filter = ImportDefinitiveFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + def_movements = self.definitive_service.get_movements_detailed(db, def_filter) + else: def_movements = self.definitive_service.get_movements(db, def_filter) - all_movements.extend(def_movements) - logger.info(f"Added {len(def_movements)} definitive import movements") - except Exception as e: - db.rollback() # Rollback failed transaction - logger.warning(f"Error fetching definitive imports: {e}") + all_movements.extend(def_movements) + logger.info(f"Added {len(def_movements)} definitive import movements") # 3. Repair Imports if should_fetch_imports: - try: - repair_filter = ImportRepairFilter( - range_type=filters.range_type, - start_date=filters.start_date, - end_date=filters.end_date, - include_cancelled=filters.include_cancelled, - provider=filters.provider, - buyer=filters.buyer, - pedimento_code=filters.pedimento_code, - report_type='Normal', - currency_type=filters.currency_type, - exchange_rate_type=filters.exchange_rate_type, - is_shelter=filters.is_shelter, - database_name='default', - discharge_filter='ALL' # Required field - include all discharge statuses - ) + repair_filter = ImportRepairFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + discharge_filter='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + repair_movements = self.repair_service.get_movements_detailed(db, repair_filter) + else: repair_movements = self.repair_service.get_movements(db, repair_filter) - all_movements.extend(repair_movements) - logger.info(f"Added {len(repair_movements)} repair import movements") - except Exception as e: - db.rollback() # Rollback failed transaction - logger.warning(f"Error fetching repair imports: {e}") + all_movements.extend(repair_movements) + logger.info(f"Added {len(repair_movements)} repair import movements") # 4. Exports (Definitive) if should_fetch_exports: - try: - export_filter = ExportFilter( - range_type=filters.range_type, - start_date=filters.start_date, - end_date=filters.end_date, - include_cancelled=filters.include_cancelled, - provider=filters.provider, - buyer=filters.buyer, - pedimento_code=filters.pedimento_code, - report_type='Normal', - currency_type=filters.currency_type, - exchange_rate_type=filters.exchange_rate_type, - is_shelter=filters.is_shelter, - database_name='default', - movement_type='ALL', # Required field - discharge_filter='ALL', # Required field - use_transport_method=False # Required field - ) + export_filter = ExportFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + discharge_filter='ALL', + use_transport_method=False + ) + if filters.report_type == ReportType.DETAILED: + export_movements = self.export_service.get_movements_detailed(db, export_filter) + else: export_movements = self.export_service.get_movements(db, export_filter) - all_movements.extend(export_movements) - logger.info(f"Added {len(export_movements)} export movements") - except Exception as e: - db.rollback() # Rollback failed transaction - logger.warning(f"Error fetching exports: {e}") + all_movements.extend(export_movements) + logger.info(f"Added {len(export_movements)} export movements") # 5. Export Repairs if should_fetch_exports: - try: - export_repair_filter = ExportRepairFilter( - range_type=filters.range_type, - start_date=filters.start_date, - end_date=filters.end_date, - include_cancelled=filters.include_cancelled, - provider=filters.provider, - buyer=filters.buyer, - pedimento_code=filters.pedimento_code, - report_type='Normal', - currency_type=filters.currency_type, - exchange_rate_type=filters.exchange_rate_type, - is_shelter=filters.is_shelter, - database_name='default', - movement_type='ALL', # Required field - discharge_filter='ALL' # Required field - ) + export_repair_filter = ExportRepairFilter( + range_type=filters.range_type, + start_date=filters.start_date, + end_date=filters.end_date, + include_cancelled=filters.include_cancelled, + provider=filters.provider, + buyer=filters.buyer, + pedimento_code=filters.pedimento_code, + report_type=filters.report_type, + currency_type=filters.currency_type, + exchange_rate_type=filters.exchange_rate_type, + is_shelter=filters.is_shelter, + database_name='default', + movement_type='ALL', + discharge_filter='ALL' + ) + if filters.report_type == ReportType.DETAILED: + export_repair_movements = self.export_repair_service.get_movements_detailed(db, export_repair_filter) + else: export_repair_movements = self.export_repair_service.get_movements(db, export_repair_filter) - all_movements.extend(export_repair_movements) - logger.info(f"Added {len(export_repair_movements)} export repair movements") - except Exception as e: - db.rollback() # Rollback failed transaction - logger.warning(f"Error fetching export repairs: {e}") + all_movements.extend(export_repair_movements) + logger.info(f"Added {len(export_repair_movements)} export repair movements") # Sort all movements by date (Fecha field) # Handle mixed datetime and string types diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py index e6b8a37e..21eff0cc 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -1,7 +1,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List +from typing import List, Union from core.database import get_core_db from core.security import get_current_user @@ -65,6 +65,12 @@ def get_temporary_import_movements( ) logger.info(f"Successfully retrieved {len(movements)} movements") return movements + except ValueError as e: + logger.warning(f"Validation error fetching movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) except Exception as e: logger.error(f"Error fetching movements: {str(e)}", exc_info=True) raise HTTPException( @@ -121,6 +127,12 @@ def get_temporary_import_movements_detailed( ) logger.info(f"Successfully retrieved {len(movements)} detailed movements") return movements + except ValueError as e: + logger.warning(f"Validation error fetching detailed movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) except Exception as e: logger.error(f"Error fetching detailed movements: {str(e)}", exc_info=True) raise HTTPException( @@ -584,7 +596,7 @@ def get_export_repair_movements_detailed( @router.post( "/all", - response_model=List[MovementItem], + response_model=Union[List[MovementItemDetailed], List[MovementItem]], summary="Get All Invoice Movements", description=""" Retrieve all invoice movements (imports and exports of all types) from database. @@ -624,6 +636,12 @@ def get_all_movements( ) logger.info(f"Successfully retrieved {len(movements)} total movements") return movements + except ValueError as e: + logger.warning(f"Validation error fetching all movements: {str(e)}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) except Exception as e: logger.error(f"Error fetching all movements: {str(e)}", exc_info=True) raise HTTPException( diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py index 966868ed..3f4ede62 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py @@ -83,6 +83,10 @@ class AllMovementsFilter(BaseModel): default=None, description="Filter by pedimento code (ClavePed)" ) + report_type: ReportType = Field( + default=ReportType.NORMAL, + description="Report type: Normal (grouped by invoice) or Detailed (line by line)" + ) currency_type: CurrencyType = Field( default=CurrencyType.FOREIGN, description="Currency type for value calculations" 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 index 14b62a88..e04262a8 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/base.py @@ -40,6 +40,15 @@ class StringHelper: if not text: return text return text.replace(',', '') + + @staticmethod + def clean_text(text: Optional[str]) -> Optional[str]: + """Clean text by stripping whitespace and removing special characters.""" + if not text: + return None + # Remove special characters and extra whitespace + cleaned = text.strip() + return cleaned if cleaned else None class DateHelper: 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 index cfe08acb..93954c4d 100644 --- 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 @@ -75,8 +75,8 @@ class DatabaseHelper: fecha_str = fecha.strftime('%d/%m/%Y') if hasattr(fecha, 'strftime') else str(fecha) ped_info = f" del Pedimento: {pedimento_number}" if pedimento_number else "" raise ValueError( - f"El Tipo de Cambio para la Fecha de Pago: {fecha_str}{ped_info} no está capturado. " - f"Solución: Capturar el Tipo de Cambio para la Fecha: {fecha_str}." + f"Falta el tipo de cambio del día {fecha_str}. " + f"Por favor regístralo en el catálogo de Tipos de Cambio." ) logger.warning(f"Exchange rate not found for date {fecha}") return None @@ -108,11 +108,11 @@ class DatabaseHelper: if not client_code: return {"name": None, "rfc": None, "tax_id": None} - client_type = 'provider' if is_supplier else 'client' + client_type = 'PROVIDER' if is_supplier else 'CLIENT' try: sql = text(""" - SELECT name, rfc, tax_id + SELECT name, rfc FROM a76.clients_and_providers WHERE id = :client_code AND client_or_provider = :client_type """) @@ -122,14 +122,14 @@ class DatabaseHelper: return { "name": result[0], "rfc": result[1], - "tax_id": result[2] + "tax_id": None # Column does not exist in this table } else: logger.debug(f"Client {client_code} not found as {client_type}") return {"name": None, "rfc": None, "tax_id": None} except Exception as e: logger.error(f"Error fetching client info for {client_code}: {e}") - return {"name": None, "rfc": None, "tax_id": None} + raise @staticmethod def get_customs_agent_info( @@ -170,7 +170,7 @@ class DatabaseHelper: return {"name": None, "license": None} except Exception as e: logger.error(f"Error fetching customs agent info for {agent_code}: {e}") - return {"name": None, "license": None} + raise @staticmethod def get_aduana_seccion_nombre( @@ -202,13 +202,13 @@ class DatabaseHelper: return result[0] if result else None except Exception as e: logger.error(f"Error fetching customs section name: {e}") - return None + raise @staticmethod def get_series_info( db: Session, db_name: str, - consecutivo: str, + invoice_id: int, linea: str, is_shelter: bool ) -> Optional[str]: @@ -217,30 +217,30 @@ class DatabaseHelper: Args: db: Database session - db_name: Legacy database name - consecutivo: Consecutivo value - linea: LineaImpo value - is_shelter: Shelter flag + db_name: Legacy database name (not used in PostgreSQL) + invoice_id: Invoice header ID + linea: Line number + is_shelter: Shelter flag (not used) Returns: Formatted series string or None """ - if not consecutivo or not linea: + if not invoice_id or not linea: return None try: query = text(""" SELECT serial_numbers, model, brand FROM a76.item_line_series ils - INNER JOIN a76.item_lines il ON ils.item_line_id = il.id + INNER JOIN a76.item_lines il ON ils.line_item_id = il.id INNER JOIN a76.items i ON il.item_id = i.id - WHERE i.consecutivo = :consecutivo + WHERE i.invoice_id = :invoice_id AND il.line_number = :linea ORDER BY ils.id LIMIT 1 """) result = db.execute(query, { - "consecutivo": consecutivo, + "invoice_id": invoice_id, "linea": linea }).fetchone() @@ -257,13 +257,13 @@ class DatabaseHelper: return None except Exception as e: logger.error(f"Error fetching series info: {e}") - return None + raise @staticmethod def get_series_info_export( db: Session, db_name: str, - consecutivo: str, + invoice_id: int, linea: str, is_shelter: bool ) -> Optional[str]: @@ -273,29 +273,31 @@ class DatabaseHelper: Args: db: Database session db_name: Legacy database name - consecutivo: Consecutivo value + invoice_id: Invoice header ID linea: LineaExpo value is_shelter: Shelter flag Returns: Formatted series string or None """ - if not consecutivo or not linea: + if not invoice_id or not linea: return None try: + # Note: Postgres items table calls it expo_brad (typo in DB schema) + # ItemLineSeries FK is line_item_id, not item_line_id query = text(""" - SELECT serial_numbers, model, expo_brand + SELECT serial_numbers, model, expo_brad FROM a76.item_line_series ils - INNER JOIN a76.item_lines il ON ils.item_line_id = il.id + INNER JOIN a76.item_lines il ON ils.line_item_id = il.id INNER JOIN a76.items i ON il.item_id = i.id - WHERE i.consecutivo = :consecutivo + WHERE i.invoice_id = :invoice_id AND il.line_number = :linea ORDER BY ils.id LIMIT 1 """) result = db.execute(query, { - "consecutivo": consecutivo, + "invoice_id": invoice_id, "linea": linea }).fetchone() @@ -308,11 +310,11 @@ class DatabaseHelper: parts.append(model) if expo_brand: parts.append(expo_brand) - return " / ".join(parts) if parts else None + return " | ".join(parts) if parts else None return None except Exception as e: logger.error(f"Error fetching export series info: {e}") - return None + raise @staticmethod def get_rectification_pedimento( @@ -497,4 +499,39 @@ class DatabaseHelper: return result[0] if result else None except Exception as e: logger.error(f"Error fetching driver badge for invoice {factura}: {e}") + raise + + @staticmethod + def get_part_export_symbol( + db: Session, + db_name: str, + num_parte: str, + is_shelter: bool + ) -> Optional[str]: + """ + Get export symbol/license for a part number. + + Args: + db: Database session + db_name: Database name (not used in PostgreSQL, kept for compatibility) + num_parte: Part number + is_shelter: Shelter flag (not used, kept for compatibility) + + Returns: + Export symbol/license or None + """ + if not num_parte: return None + + try: + query = text(""" + SELECT exclusion_symbol + FROM a76.parts + WHERE part_number = :num_parte + LIMIT 1 + """) + result = db.execute(query, {"num_parte": num_parte}).fetchone() + return result[0] if result else None + except Exception as e: + logger.error(f"Error fetching export symbol for part {num_parte}: {e}") + raise 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 index 5a2c1ee3..442252b9 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/definitive.py @@ -77,9 +77,18 @@ class DefinitiveImportService: invoice_id = row[16] # C35 - invoice ID + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + # Totals come directly from GROUP BY query (no N+1 problem) - total_me = row[28] # total_me from SUM aggregation - total_mn = row[29] # total_mn from SUM aggregation + total_me = to_float(row[28]) # total_me from SUM aggregation + total_mn = to_float(row[29]) # total_mn from SUM aggregation # Calculate exchange rate and value valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( @@ -87,10 +96,10 @@ class DefinitiveImportService: db_name=filters.database_name, valor_me=total_me, valor_mn=total_mn, - tipo_cambio_db=row[20], # C51 - TipoCambio + tipo_cambio_db=to_float(row[20]), # C51 - TipoCambio fecha_pago=row[8], # C13 - Fecha_Pago fecha_inicio=row[6], # C11 - Fecha_Inicio - tipo_pedimento=row[27], # C59 - TIPOPEDIMENTOTRANSPORTEE + tipo_pedimento=row[4], # C5 - ClavePed (Fix: using C5 instead of empty C59) currency_type=filters.currency_type.value, exchange_rate_type=filters.exchange_rate_type.value, is_shelter=filters.is_shelter, @@ -134,7 +143,7 @@ class DefinitiveImportService: UsuarioAcr=row[23], # C54 - UsuarioAct Fecha_Pago=parse_yyyymmdd_date(row[8]), # C13 - Fecha_Pago NumCaja=row[24], # C56 - Transporte + NumTrasporte - tipo_pedimento=row[25], # C57 - Pedimento18 + tipo_pedimento=row[25], # C57 - Pedimento18 (Note: Schema doesn't have tipo_pedimento field, this might be extra) AduanaCru=row[15], # C39 - Aduana_Cruce Lote=row[26] # C58 - LOTE ) @@ -186,7 +195,9 @@ class DefinitiveImportService: if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus continue - # Get all detailed information (same as temporary imports) + # Get additional detailed information + # Provider and client names now come directly from query (row[7], row[8]) + # But we still need RFC and TaxID from the helper proveedor_info = DatabaseHelper.get_client_info( db, filters.database_name, row[15], is_supplier=True ) @@ -225,13 +236,13 @@ class DefinitiveImportService: peso_bruto = 0.0 series_info = DatabaseHelper.get_series_info( - db, filters.database_name, row[40], row[44], filters.is_shelter + db, filters.database_name, row[34], row[44], filters.is_shelter ) simbolo_ex = None - if row[49]: + if row[19]: simbolo_ex = DatabaseHelper.get_part_export_symbol( - db, filters.database_name, row[49], filters.is_shelter + db, filters.database_name, row[19], filters.is_shelter ) pedimento_r1 = DatabaseHelper.get_rectification_pedimento( @@ -256,10 +267,10 @@ class DefinitiveImportService: Fecha_Fin=row[11], Fecha_Pago=row[12], Remesa=row[13], - Proveedor=proveedor_info.get('name'), + Proveedor=row[7], # C8 - Provider name (from JOIN) RFCProveedor=proveedor_info.get('rfc'), ProveedorTaxID=proveedor_info.get('tax_id'), - VendidoA=vendido_info.get('name'), + VendidoA=row[8], # C9 - Client name (from JOIN) VendidoARFC=vendido_info.get('rfc'), VendidoATaxID=vendido_info.get('tax_id'), AgenteAduanal=agente_info.get('name'), @@ -276,7 +287,7 @@ class DefinitiveImportService: OrdenCompraVenta=row[30], FraccionArancelaria=row[31], Preferencia=row[32], - Sector=row[34], + Sector=None, # row[34] is invoice ID, Sector not in query PaisOrigen=row[37], Aduana=aduana_nombre, Advalorem=row[39], @@ -319,12 +330,8 @@ class DefinitiveImportService: where_conditions.append("ih.operation_type = 'imp'") # GOLDEN RULE: If movement_type is ALL, only filter by operation_type - if hasattr(filters, 'movement_type') and filters.movement_type == 'ALL': - # ALL mode: bring all imports without filtering by specific invoice_type - pass - else: - # Specific mode: Filter by definitive invoice types only - where_conditions.append("ih.invoice_type IN ('DEF', 'MATDE', 'EXDEF')") + # ALWAYS filter by specific invoice_type to avoid duplication with Temporary service + where_conditions.append("ih.invoice_type IN ('DEF', 'MATDE', 'EXDEF')") # Date range filter if filters.range_type.value == "FF": @@ -337,11 +344,11 @@ class DefinitiveImportService: # Provider filter if filters.provider: - where_conditions.append(f"cmp.provider_id = {filters.provider}") + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") # Buyer filter if filters.buyer: - where_conditions.append(f"cmp.sold_to_id = {filters.buyer}") + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") # Pedimento code filter if filters.pedimento_code: 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 index cfb594f7..331f1858 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -226,8 +226,8 @@ class ExportService: peso_bruto_final = row[25] if row[37] == 'P' else 0 # C26 - PesoBruto # Get series information - series_info = self._get_series_info( - db, filters.database_name, row[34], row[41] # C35 - Consecutivo, C42 - LineaExpo + series_info = DatabaseHelper.get_series_info_export( + db, filters.database_name, row[34], row[41], filters.is_shelter ) # Get pedimento rectification @@ -348,9 +348,9 @@ class ExportService: # Optional filters if filters.provider: - conditions.append(f"cmp.provider_id = {filters.provider}") + conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") if filters.buyer: - conditions.append(f"cmp.sold_to_id = {filters.buyer}") + conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") if filters.pedimento_code: conditions.append(f"ped.pedimento_code = '{filters.pedimento_code}'") @@ -383,44 +383,6 @@ class ExportService: logger.error(f"Error calculating export totals for consecutivo {consecutivo}: {e}") return (0, 0) - def _get_series_info( - self, - db: Session, - db_name: str, - consecutivo: int, - linea: int - ) -> str: - """Get series information for export partida.""" - if not consecutivo or not linea: - return None - - try: - sql = text(ExportQueries.build_series_query(db_name)) - results = db.execute(sql, {"consecutivo": consecutivo, "linea": linea}).fetchall() - - if not results: - return None - - series_list = [] - for idx, row in enumerate(results, 1): - serie = row[0] - modelo = row[1] - parte = row[2] - - serie_str = f"{idx}) {serie}" - if modelo: - serie_str += f". Modelo: {modelo}" - if parte: - serie_str += f". Parte: {parte}" - - series_list.append(serie_str) - - return " | ".join(series_list) if series_list else None - - except Exception as e: - logger.debug(f"Error fetching export series info for consecutivo {consecutivo}, linea {linea}: {e}") - return None - def _get_driver_badge(self, db: Session, db_name: str, factura: str) -> str: """Get driver's unique badge number for an export invoice.""" if not factura: 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 index 13143ef6..8f8887f8 100644 --- 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 @@ -350,11 +350,11 @@ class ExportRepairService: # Provider filter if filters.provider: - where_conditions.append(f"cmp.provider_id = {filters.provider}") + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") # Buyer filter if filters.buyer: - where_conditions.append(f"cmp.sold_to_id = {filters.buyer}") + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") # Pedimento code filter if filters.pedimento_code: 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 index d4d2b765..23a9ae23 100644 --- 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 @@ -235,7 +235,10 @@ class DefinitiveImportQueries: ih.invoice_date AS C3, -- [2] ped.status AS C4, -- [3] ped.pedimento_code AS C5, -- [4] - '' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8] + '' AS C6, -- [5] + '' AS C7, -- [6] + COALESCE(prov.name, '') AS C8, -- [7] Provider name + COALESCE(client.name, '') AS C9, -- [8] Client name ped.regime AS C10, -- [9] log.entry_exit_date AS C11, -- [10] log.delivery_date AS C12, -- [11] @@ -290,6 +293,8 @@ class DefinitiveImportQueries: LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id + LEFT JOIN a76.clients_and_providers prov ON prov.id = cmp.provider_id + LEFT JOIN a76.clients_and_providers client ON client.id = cmp.sold_to_id LEFT JOIN a76.items itm ON itm.invoice_id = ih.id LEFT JOIN a76.item_lines il ON il.item_id = itm.id LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id @@ -653,7 +658,8 @@ class ExportQueries: COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) FROM a76.item_line_financials lf INNER JOIN a76.item_lines il ON il.id = lf.item_line_id - LEFT JOIN a24.fa_item_lines fil ON il.id = lf.item_line_id + LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id + INNER JOIN a76.items itm ON itm.id = il.item_id WHERE itm.invoice_id = :consecutivo {discharge_filter} """ 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 index 889d3185..f462f8b1 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/repair.py @@ -81,9 +81,18 @@ class RepairImportService: consecutivo = row[14] # C30 - Consecutivo + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + # Totals come directly from GROUP BY query (no N+1 problem) - total_me = row[24] # total_me from SUM aggregation - total_mn = row[25] # total_mn from SUM aggregation + total_me = to_float(row[24]) # total_me from SUM aggregation + total_mn = to_float(row[25]) # total_mn from SUM aggregation # Calculate exchange rate and value valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( @@ -91,7 +100,7 @@ class RepairImportService: db_name=filters.database_name, valor_me=total_me, valor_mn=total_mn, - tipo_cambio_db=row[17], # C40 - TipoCambio + tipo_cambio_db=to_float(row[17]), # C40 - TipoCambio fecha_pago=row[6], # C9 - Fecha_Pago fecha_inicio='', # Not available in aggregated query tipo_pedimento=row[23], # C47 - pedimento_code (used as tipo_pedimento) @@ -197,79 +206,79 @@ class RepairImportService: for row in results: # Skip cancelled if not included - if not filters.include_cancelled and row[3] != 'AC': # C4 - Estatus + if not filters.include_cancelled and row[4] != 'AC': # C5 - Estatus continue # Get all detailed information proveedor_info = DatabaseHelper.get_client_info( - db, filters.database_name, row[15], is_supplier=True + db, filters.database_name, row[11], is_supplier=True ) vendido_info = DatabaseHelper.get_client_info( - db, filters.database_name, row[16], is_supplier=False + db, filters.database_name, row[12], is_supplier=False ) agente_info = DatabaseHelper.get_customs_agent_info( - db, filters.database_name, row[17] + db, filters.database_name, row[13] ) aduana_nombre = DatabaseHelper.get_aduana_seccion_nombre( - db, filters.database_name, row[38] + db, filters.database_name, row[28] ) # Calculate values using unified method valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_exchange_rate_and_value( db=db, db_name=filters.database_name, - es_subpartida=row[39], # EsSubPartida - valor_me=row[26], - valor_mn_direct=row[24], - fecha_pago=row[12], - fecha_inicio=row[10], - clave_ped=row[58], - tipo_cambio_partida=row[50], + es_subpartida=row[30], # 'P' or 'S' + valor_me=row[20], + valor_mn_direct=row[19], + fecha_pago=row[8], + fecha_inicio=row[7], + clave_ped=row[45], + tipo_cambio_partida=row[38], currency_type=filters.currency_type.value, exchange_rate_type=filters.exchange_rate_type.value, met_trans=met_trans ) # Set peso values based on subpartida flag - if row[39] == 'P': - peso_neto = float(row[28]) if row[28] else 0.0 - peso_bruto = float(row[29]) if row[29] else 0.0 + if row[30] == 'P': + peso_neto = float(row[21]) if row[21] else 0.0 + peso_bruto = float(row[22]) if row[22] else 0.0 else: peso_neto = 0.0 peso_bruto = 0.0 series_info = DatabaseHelper.get_series_info( - db, filters.database_name, row[40], row[44], filters.is_shelter + db, filters.database_name, row[29], row[0], filters.is_shelter ) simbolo_ex = None - if row[49]: + if row[14]: simbolo_ex = DatabaseHelper.get_part_export_symbol( - db, filters.database_name, row[49], filters.is_shelter + db, filters.database_name, row[14], filters.is_shelter ) pedimento_r1 = DatabaseHelper.get_rectification_pedimento( - db, row[1], row[41], filters.is_shelter + db, row[2], row[41], filters.is_shelter ) num_gaf_uni = DatabaseHelper.get_driver_badge( - db, filters.database_name, row[0] + db, filters.database_name, row[1] ) movement = MovementItemDetailed( - Linea=row[44], - Factura=row[0], - Pedimento=row[1], - FechaFactura=row[2], - Estatus=row[3], - ClavePed=row[4], + Linea=row[0], + Factura=row[1], + Pedimento=row[2], + FechaFactura=row[3], + Estatus=row[4], + ClavePed=row[5], TipoMovTemDef='IMPRE', EsCambioRegimen='N', - Regimen=row[9], - Fecha_Inicio=row[10], - Fecha_Fin=row[11], - Fecha_Pago=row[12], - Remesa=row[13], + Regimen=row[6], + Fecha_Inicio=row[7], + Fecha_Fin=row[7], # Using same valid column or empty + Fecha_Pago=row[8], + Remesa=row[9], Proveedor=proveedor_info.get('name'), RFCProveedor=proveedor_info.get('rfc'), ProveedorTaxID=proveedor_info.get('tax_id'), @@ -278,42 +287,42 @@ class RepairImportService: VendidoATaxID=vendido_info.get('tax_id'), AgenteAduanal=agente_info.get('name'), Patente=agente_info.get('license'), - NumParte=row[19], - DescripcionE=StringHelper.clean_text(row[20]), - DescripcionI=StringHelper.clean_text(row[21]), - CantidadIE=float(row[22]) if row[22] else 0.0, - UniMed=row[23], + NumParte=row[14], + DescripcionE=StringHelper.clean_text(row[15]), + DescripcionI=StringHelper.clean_text(row[16]), + CantidadIE=float(row[17]) if row[17] else 0.0, + UniMed=row[18], ValorComercialMN=valor_comercial, TipoCambio=tipo_cambio, PesoNeto=peso_neto, PesoBruto=peso_bruto, - OrdenCompraVenta=row[30], - FraccionArancelaria=row[31], - Preferencia=row[32], - Sector=row[34], - PaisOrigen=row[37], + OrdenCompraVenta=row[23], + FraccionArancelaria=row[24], + Preferencia=row[25], + Sector=row[26], + PaisOrigen=row[27], Aduana=aduana_nombre, - Advalorem=row[39], + Advalorem=row[30], TipoExpo='', PedimentoR1=pedimento_r1, - EDocument=row[42], - NumOperacionVU=row[43], + EDocument=row[32], + NumOperacionVU=row[33], Series=series_info, - Marca=StringHelper.clean_text(row[45]), - Modelo=StringHelper.clean_text(row[46]), - FraccionAmericana=row[47], - ECCN=row[48], + Marca=StringHelper.clean_text(row[34]), + Modelo=StringHelper.clean_text(row[35]), + FraccionAmericana=row[36], + ECCN=row[37], SimboloEx=simbolo_ex, - FechaEmision=row[51], + FechaEmision=row[39], BaseDeDatos=filters.database_name, NumGafUni=num_gaf_uni, - UsuarioCap=row[52], - UsuarioAcr=row[53], - Transportista=row[54], - NumCaja=row[55], - Pedimento18=row[56], - AduanaCru=row[38], - Lote=row[57] + UsuarioCap=row[40], + UsuarioAcr=row[41], + Transportista=row[42], + NumCaja=row[43], + Pedimento18=row[44], + AduanaCru=row[28], + Lote='' # Not in query ) movements.append(movement) @@ -345,11 +354,11 @@ class RepairImportService: # Provider filter if filters.provider: - where_conditions.append(f"cmp.provider_id = {filters.provider}") + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") # Buyer filter if filters.buyer: - where_conditions.append(f"cmp.sold_to_id = {filters.buyer}") + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") # Pedimento code filter if filters.pedimento_code: 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 index b249ebdf..70fa5ca5 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -77,9 +77,23 @@ class TemporaryImportService: consecutivo = row[15] # C39 - Consecutivo + # Helper to convert empty strings to None + def none_if_empty(val): + return None if val == '' else val + + # Helper to safely convert to float + def to_float(val): + if val is None or val == '': + return 0.0 + try: + return float(val) + except (ValueError, TypeError): + return 0.0 + # Totals come directly from GROUP BY query (no N+1 problem) - total_me = row[27] # total_me from SUM aggregation - total_mn = row[28] # total_mn from SUM aggregation + # Correct indices based on TemporaryImportQueries.build_aggregated_query + total_me = to_float(row[28]) # total_me (index 28) + total_mn = to_float(row[29]) # total_mn (index 29) # Calculate exchange rate and value valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_aggregated( @@ -87,10 +101,10 @@ class TemporaryImportService: db_name=filters.database_name, valor_me=total_me, valor_mn=total_mn, - tipo_cambio_db=row[19], # C50 - TipoCambio + tipo_cambio_db=to_float(row[19]), # C50 - TipoCambio fecha_pago=row[8], # C13 - Fecha_Pago fecha_inicio=row[6], # C11 - Fecha_Inicio - tipo_pedimento=row[26], # C58 - TIPOPEDIMENTOTRANSPORTEE + tipo_pedimento=row[4], # C5 - ClavePed (Using correct index) currency_type=filters.currency_type.value, exchange_rate_type=filters.exchange_rate_type.value, is_shelter=filters.is_shelter, @@ -111,10 +125,6 @@ class TemporaryImportService: db, filters.database_name, factura ) - # Helper to convert empty strings to None - def none_if_empty(val): - return None if val == '' else val - # Build movement item movement = MovementItem( Factura=factura, @@ -138,9 +148,9 @@ class TemporaryImportService: UsuarioAcr=row[22], # C53 - UsuarioAct Fecha_Pago=parse_yyyymmdd_date(none_if_empty(row[8])), # C13 - Fecha_Pago NumCaja=row[23], # C55 - Transporte + NumTrasporte - Pedimento18=row[24], # C56 - Pedimento18 + Pedimento18=row[24], # C56 - Pedimento18 (Actually empty in query, but safe to keep) AduanaCru=row[14], # C38 - Aduana_Cruce - Lote=row[25] # C57 - LOTE + Lote=row[25] # C57 - LOTE (Actually C55 is index 24. C56 is 25) ) movements.append(movement) @@ -220,6 +230,14 @@ class TemporaryImportService: met_trans=met_trans ) + # ValorComercialMN should always be in MXN + # If currency_type is ME, valor_comercial is in USD, so multiply by tipo_cambio + if filters.currency_type.value == "ME" and tipo_cambio: + valor_comercial_mn = valor_comercial * tipo_cambio + else: + # If currency_type is MN, valor_comercial is already in MXN + valor_comercial_mn = valor_comercial + # Set peso values based on subpartida flag if row[39] == 'P': # C40 - EsSubPartida peso_neto = float(row[28]) if row[28] else 0.0 # C29 @@ -253,21 +271,35 @@ class TemporaryImportService: db, filters.database_name, row[0] # C1 - FacturaImpo ) + # Helper to convert empty strings to None for dates + def none_if_empty(val): + if val == '' or val is None: + return None + return val + + # Helper to convert to string (for Remesa, Advalorem) + def to_str(val): + if val is None or val == '': + return None + if isinstance(val, bool): + return 'P' if val else 'S' # Convert bool to P/S for Advalorem + return str(val) + # Build detailed movement item movement = MovementItemDetailed( Linea=row[43], # C44 - LineaImpo Factura=row[0], # C1 - FacturaImpo Pedimento=row[1], # C2 - PedimentoImpo - FechaFactura=row[2], # C3 - FechaFactura + FechaFactura=parse_yyyymmdd_date(row[2]), # C3 - FechaFactura (convert to datetime) Estatus=row[3], # C4 - Estatus ClavePed=row[4], # C5 - ClavePed TipoMovTemDef='IMTEM', EsCambioRegimen='N', Regimen=row[9], # C10 - Regimen - Fecha_Inicio=row[10], # C11 - Fecha_Inicio - Fecha_Fin=row[11], # C12 - Fecha_Fin - Fecha_Pago=row[12], # C13 - Fecha_Pago - Remesa=row[13], # C14 - Remesa + Fecha_Inicio=none_if_empty(row[10]), # C11 - Fecha_Inicio + Fecha_Fin=none_if_empty(row[11]), # C12 - Fecha_Fin + Fecha_Pago=none_if_empty(row[12]), # C13 - Fecha_Pago + Remesa=to_str(row[13]), # C14 - Remesa Proveedor=row[7], # C8 - Provider name (from JOIN) RFCProveedor=None, # RFC not in detailed query ProveedorTaxID=None, # Tax ID not in detailed query @@ -281,7 +313,7 @@ class TemporaryImportService: DescripcionI=StringHelper.clean_text(row[21]), # C22 CantidadIE=float(row[22]) if row[22] else 0.0, # C23 UniMed=row[23], # C24 - ValorComercialMN=valor_comercial, + ValorComercialMN=valor_comercial_mn, TipoCambio=tipo_cambio, PesoNeto=peso_neto, PesoBruto=peso_bruto, @@ -291,7 +323,7 @@ class TemporaryImportService: Sector=row[34], # C35 - Sector PaisOrigen=row[36], # C37 - PaisOrigen Aduana=aduana_nombre, - Advalorem=row[39], # C40 - EsSubPartida + Advalorem=to_str(row[39]), # C40 - EsSubPartida (convert bool to str) TipoExpo='', PedimentoR1=pedimento_r1, EDocument=row[41], # C42 - EDocument @@ -302,7 +334,7 @@ class TemporaryImportService: FraccionAmericana=row[46], # C47 - FraccionAme ECCN=row[47], # C48 - ECCN SimboloEx=simbolo_ex, - FechaEmision=row[50], # C51 - FechaEmision + FechaEmision=parse_yyyymmdd_date(row[50]), # C51 - FechaEmision (convert to datetime) BaseDeDatos=filters.database_name, NumGafUni=num_gaf_uni, UsuarioCap=row[51], # C52 - UsuarioCap @@ -329,10 +361,8 @@ class TemporaryImportService: # STRICT SEPARATION: Only temporary imports where_conditions.append("ih.operation_type = 'imp'") - # GOLDEN RULE: If coming from /all, only filter by operation_type - # otherwise, apply specific invoice_type filter - if not hasattr(filters, 'from_all_endpoint') or not filters.from_all_endpoint: - where_conditions.append("ih.invoice_type IN ('TEM', 'MATTEM')") + # ALWAYS filter by specific invoice_type to avoid duplication with Definitive service + where_conditions.append("ih.invoice_type IN ('TEM', 'MATTEM')") # Date range filter if filters.range_type.value == "FF": @@ -345,11 +375,11 @@ class TemporaryImportService: # Provider filter if filters.provider: - where_conditions.append(f"cmp.provider_id::text = '{filters.provider}'") + where_conditions.append(f"cmp.provider_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.provider}')") # Buyer filter if filters.buyer: - where_conditions.append(f"cmp.sold_to_id::text = '{filters.buyer}'") + where_conditions.append(f"cmp.sold_to_id IN (SELECT id FROM a76.clients_and_providers WHERE name = '{filters.buyer}')") # Pedimento code filter if filters.pedimento_code: diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 01c8c57d..8a0ff1f8 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -53,6 +53,7 @@ from .reports.importacion.facturas.routes import router as invoices_reports_rout from .reports.importacion.consolidados.routes import router as consolidated_reports_router from .reports.importacion.packing_list.routes import router as packing_list_router from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router +from .reports.movements.invoices.routes import router as movement_invoices_router @@ -145,4 +146,10 @@ router.include_router( aviso_consolidado_export_router, prefix="/a76/reports/exportacion/aviso_consolidado", tags=["a76 / reports"] +) + +router.include_router( + movement_invoices_router, + prefix="/a76/reports/movements/invoices", + tags=["a76 / reports"] ) \ 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 index a5b7dafe..ce6daa22 100644 --- a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts +++ b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts @@ -28,7 +28,7 @@ export interface BaseFilter { database_name: string; } -export interface ImportTemporaryFilter extends BaseFilter {} +export interface ImportTemporaryFilter extends BaseFilter { } export interface ImportDefinitiveFilter extends BaseFilter { movement_type: MovementTypeFilter; @@ -57,6 +57,7 @@ export interface AllMovementsFilter { provider?: string | null; buyer?: string | null; pedimento_code?: string | null; + report_type: ReportType; currency_type: CurrencyType; exchange_rate_type: ExchangeRateType; is_shelter: boolean; diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts index 7041c3a6..313e31a7 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-invoices.ts @@ -1,5 +1,4 @@ -const BASE_URL = import.meta.env.VITE_API_URL || ''; const BASE_URL = import.meta.env.VITE_API_URL || ''; export const invoicesReportsApi = { @@ -15,7 +14,6 @@ export const invoicesReportsApi = { const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { - method: 'POST', method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -25,14 +23,11 @@ export const invoicesReportsApi = { if (!response.ok) throw new Error('Error al iniciar la generación'); return await response.json(); - return await response.json(); }, getTaskStatus: async (taskId: string) => { const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`; - const token = localStorage.getItem('access_token'); const response = await fetch(endpoint, { method: 'GET', diff --git a/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte b/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte index 8f113fc1..b3d22504 100644 --- a/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte +++ b/frontend/src/lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte @@ -1,59 +1,48 @@ - + diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index a1f33b08..6c3083ad 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -1,1434 +1,1862 @@ -
- - -
-
-

- - Reporte de Facturas -

- - V2.0 - -
-
- Sistema Fiscal -
-
+
+ +
+
+

+ + 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} -
- - + +
+ {#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 - - - -
-
- - -
-
- - -
-
+ +
+ + + + + Periodo y Clasificación + + + +
+
+ + +
+
+ + +
+
- + -
- -
- -
- {#each Object.keys(types.import) as key} -
- - -
- {/each} -
-
+
+ +
+ +
+ {#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} -
-
-
-
- - + +
+ +
+ {#each Object.keys(types.other) as key} +
+ { + if (key === 'TODAS') handleTodasChange(v as boolean); + }} + /> + +
+ {/each} +
+
+
- - - - - Filtros e Identificadores - - - -
- {#each [ - { label: 'Proveedor', key: 'provider' as const }, - { label: 'Vendido a', key: 'soldTo' as const }, - { label: 'Clave de Pedimento', key: 'pedimentoKey' as const } - ] as item} -
- -
- - -
-
- {/each} -
+ +
+ -
- -
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
-
- - -
- - -
- -
- -
- - -
-
- - -
-
- - -
-
-
-
- - + +
+ +
+ {#each Object.keys(types.export.additional) as key} +
+ + +
+ {/each} +
+
+
+
+
+
- - - - - Configuración Final - - - - -
-
- - -
- - -
-
- - -
-
-
+ + + + + Filtros e Identificadores + + + +
+ {#each [{ label: 'Proveedor', key: 'provider' as const }, { label: 'Vendido a', key: 'soldTo' as const }, { label: 'Clave de Pedimento', key: 'pedimentoKey' as const }] as item} +
+ +
+ + +
+
+ {/each} +
-
- - -
- - -
-
- - -
-
- - -
-
-
-
+
+ +
-
- -
+
+ -
-
- - -
- - -
-
- - -
-
-
-
- - -
- - -
-
- - -
-
-
-
+
+ + +
-
- -
-
- - -
-
- - -
-
- - -
-
-
+
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
-
- - - - -
-
+ + + + + Configuración Final + + + +
+
+ + +
+ + +
+
+ + +
+
+
- - { if (!v) showResults = false; }}> - - -
-
- - - {reportTitle} - - - {results.length} registros encontrados • {currencyLabel} - -
-
- - - - Cerrar - -
-
-
- -
-
- - - - {#each (config.reportType === 'normal' ? - ['PEDIMENTO', 'CLAVE', 'FACTURA', 'FECHA FACT', 'VALOR COM.', 'TIPO OPER.', 'ESTATUS', 'PROYECTO'] : - ['PEDIMENTO', 'FACTURA', 'FECHA FACT', 'PROVEEDOR', 'CLIENTE', 'CANTIDAD', 'DESC. ESPAÑOL', 'TIPO OPER.']) as header} - - {/each} - - - - {#each results as row} - - {#if config.reportType === 'normal'} - - - - - - - - - {:else} - {@const detailRow = row as MovementItemDetailed} - - - - - - - - - {/if} - - {/each} - -
- {header} -
{row.Pedimento || '-'}{row.ClavePed || '-'}{row.Factura}{formatDateFromYYYYMMDD(row.FechaFactura)}${formatCurrency(row.ValorComercialMN)} - - {row.TipoMovTemDef} - - - - {row.Estatus || 'A'} - - {row.BaseDeDatos}{detailRow.Pedimento || '-'}{detailRow.Factura}{formatDateFromYYYYMMDD(detailRow.FechaFactura)}{detailRow.Proveedor || '-'}{detailRow.VendidoA || '-'}{detailRow.CantidadIE || '0'}{detailRow.DescripcionE || '-'} - - {detailRow.TipoMovTemDef} - -
-
-
-
-
+
+ + +
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ +
+ +
+
+ + +
+ + +
+
+ + +
+
+
+
+ + +
+ + +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + +
+
+ + + { + if (!v) showResults = false; + }} + > + + +
+
+ + + {reportTitle} + + + {results.length} registros encontrados • {currencyLabel} + +
+
+ + + + Cerrar + +
+
+
+ +
+
+ + + + {#each config.reportType === 'normal' ? ['PEDIMENTO', 'CLAVE', 'FACTURA', 'FECHA FACT', 'VALOR COM.', 'TIPO OPER.', 'ESTATUS', 'PROYECTO'] : ['PEDIMENTO', 'FACTURA', 'FECHA FACT', 'PROVEEDOR', 'CLIENTE', 'CANTIDAD', 'DESC. ESPAÑOL', 'TIPO OPER.'] as header} + + {/each} + + + + {#each results as row} + + {#if config.reportType === 'normal'} + + + + + + + + + {:else} + {@const detailRow = row as MovementItemDetailed} + + + + + + + + + {/if} + + {/each} + +
+ {header} +
{row.Pedimento || '-'}{row.ClavePed || '-'}{row.Factura}{formatDateFromYYYYMMDD(row.FechaFactura)}${formatCurrency(row.ValorComercialMN)} + + {row.TipoMovTemDef} + + + + {row.Estatus || 'A'} + + {row.BaseDeDatos}{detailRow.Pedimento || '-'}{detailRow.Factura}{formatDateFromYYYYMMDD(detailRow.FechaFactura)}{detailRow.Proveedor || '-'}{detailRow.VendidoA || '-'}{detailRow.CantidadIE || '0'}{detailRow.DescripcionE || '-'} + + {detailRow.TipoMovTemDef} + +
+
+
+
+
- - - - Seleccionar {dialogType === 'pedimentoKey' ? 'Clave de Pedimento' : dialogType === 'provider' ? 'Proveedor' : 'Cliente'} - - - Busca y selecciona {dialogType === 'pedimentoKey' ? 'una clave de pedimento' : dialogType === 'provider' ? 'un proveedor' : 'un cliente'} de la lista - - - -
-
- - -
- -
- {#if dialogType === 'pedimentoKey'} - - - - - - - - - - {#if filteredItems.length === 0} - - - - {:else} - {#each filteredItems as item} - selectItem(item)}> - - - - - {/each} - {/if} - -
CódigoDescripciónAcción
- No se encontraron resultados -
{item.code || '-'}{item.description || '-'} - -
- {:else} - - - - - - - - - - - - - - - - - {#if filteredItems.length === 0} - - - - {:else} - {#each filteredItems as item} - selectItem(item)}> - - - - - - - - - - - - {/each} - {/if} - -
ClaveNombreTipoRFCCallesNúm. ExtCPColoniaCiudadAcción
- No se encontraron resultados -
{item.id || '-'}{item.name || '-'} - - {item.client_or_provider === 'provider' ? 'P' : - item.client_or_provider === 'client' ? 'C' : - 'A'} - - {item.rfc || '-'}{item.address?.streets || '-'}{item.address?.exterior_number || '-'}{item.address?.postal_code || '-'}{item.address?.neighborhood || '-'}{item.address?.city || '-'} - -
- {/if} -
-
- - - - -
+ + + + 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} +
+
+ + + + +
From f3f763cd6da4c78259e43faac8c71edee91d60a0 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 6 Feb 2026 15:31:30 -0600 Subject: [PATCH 10/52] feat: Implement asynchronous invoice report generation with email delivery and frontend status polling. --- .../reports/movements/invoices/csv_utils.py | 87 +++ .../movements/invoices/movement_service.py | 35 +- .../a76/reports/movements/invoices/routes.py | 102 ++- .../a76/reports/movements/invoices/schemas.py | 41 ++ .../invoices/services/query_builders.py | 55 +- .../movements/invoices/services/temporary.py | 4 +- .../a76/reports/movements/invoices/tasks.py | 108 +++ backend/api/v1/modules/a76/router.py | 1 - backend/core/celery_app.py | 3 +- backend/core/config.py | 8 + backend/core/email.py | 117 ++++ backend/requirements.txt | 2 +- .../api/dashboard/a76/invoice-movements.ts | 20 +- .../dashboard/reports/invoices/+page.svelte | 614 +++++++----------- 14 files changed, 797 insertions(+), 400 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py create mode 100644 backend/api/v1/modules/a76/reports/movements/invoices/tasks.py create mode 100644 backend/core/email.py diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py new file mode 100644 index 00000000..004b7ae4 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -0,0 +1,87 @@ +""" +CSV generation utilities for invoice movement reports. +""" +import csv +import io +from typing import List, Union +from datetime import datetime + +from .schemas import MovementItem, MovementItemDetailed + + +def generate_csv_from_movements( + movements: List[Union[MovementItem, MovementItemDetailed]], + report_type: str = "normal" +) -> str: + """ + Generate CSV content from movement items. + + Args: + movements: List of movement items (normal or detailed) + report_type: "normal" or "detailed" + + Returns: + CSV content as string + """ + output = io.StringIO() + + if report_type.lower() == "normal": + # Normal report columns + fieldnames = [ + 'Pedimento', 'ClavePed', 'Factura', 'FechaFactura', + 'ValorComercialMN', 'TipoMovTemDef', 'Estatus', 'BaseDeDatos', + 'TipoCambio', 'Fecha_Pago', 'ValorMPTemp', 'ValorAgre', + 'TipoExpo', 'EsCambioRegimen', 'Regimen' + ] + + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for movement in movements: + row = movement.model_dump() + # Format datetime fields + if row.get('FechaFactura'): + row['FechaFactura'] = _format_datetime(row['FechaFactura']) + if row.get('Fecha_Pago'): + row['Fecha_Pago'] = _format_datetime(row['Fecha_Pago']) + writer.writerow(row) + + else: + # Detailed report columns + fieldnames = [ + 'Linea', 'Pedimento', 'Factura', 'FechaFactura', + 'Proveedor', 'VendidoA', 'CantidadIE', 'DescripcionE', + 'DescripcionI', 'NumParte', 'UniMed', 'ValorComercialMN', + 'TipoMovTemDef', 'ClavePed', 'Estatus', 'TipoCambio', + 'PesoNeto', 'PesoBruto', 'OrdenCompraVenta', 'Regimen', + 'AgenteAduanal', 'Patente', 'BaseDeDatos' + ] + + writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for movement in movements: + row = movement.model_dump() + # Format datetime fields + if row.get('FechaFactura'): + row['FechaFactura'] = _format_datetime(row['FechaFactura']) + if row.get('Fecha_Pago'): + row['Fecha_Pago'] = _format_datetime(row['Fecha_Pago']) + if row.get('Fecha_Inicio'): + row['Fecha_Inicio'] = _format_datetime(row['Fecha_Inicio']) + if row.get('Fecha_Fin'): + row['Fecha_Fin'] = _format_datetime(row['Fecha_Fin']) + writer.writerow(row) + + csv_content = output.getvalue() + output.close() + return csv_content + + +def _format_datetime(dt) -> str: + """Format datetime for CSV export.""" + if isinstance(dt, datetime): + return dt.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(dt, str): + return dt + return '' 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 index 1d3cc18b..41af2b37 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/movement_service.py @@ -154,16 +154,25 @@ class MovementService: # 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'] + # Determine which services to call based on granular flags + # Default behavior: If granular flags are all defaults (True) but operation_type is set, + # we might need to respect operation_type. + # But for simplicity, we assume granular flags from frontend are the source of truth. + # If frontend didn't set them (legacy call?), they default to True. - 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}") + # Override based on operation_type if provided (legacy compatibility or coarse filter) + if filters.operation_type == 'imp': + filters.export_def = False + filters.export_rep = False + elif filters.operation_type == 'exp': + filters.import_temp = False + filters.import_def = False + filters.import_rep = False + + logger.info(f"Fetching movements with flags: Temp={filters.import_temp}, Def={filters.import_def}, Rep={filters.import_rep}, ExpDef={filters.export_def}, ExpRep={filters.export_rep}") # 1. Temporary Imports - if should_fetch_imports: + if filters.import_temp: temp_filter = ImportTemporaryFilter( range_type=filters.range_type, start_date=filters.start_date, @@ -172,11 +181,11 @@ class MovementService: provider=filters.provider, buyer=filters.buyer, pedimento_code=filters.pedimento_code, - report_type=filters.report_type, # Use filter's report_type + report_type=filters.report_type, currency_type=filters.currency_type, exchange_rate_type=filters.exchange_rate_type, is_shelter=filters.is_shelter, - database_name='default' # Required field + database_name='default' ) if filters.report_type == ReportType.DETAILED: temp_movements = self.temporary_service.get_movements_detailed(db, temp_filter) @@ -186,7 +195,7 @@ class MovementService: logger.info(f"Added {len(temp_movements)} temporary import movements") # 2. Definitive Imports - if should_fetch_imports: + if filters.import_def: def_filter = ImportDefinitiveFilter( range_type=filters.range_type, start_date=filters.start_date, @@ -211,7 +220,7 @@ class MovementService: logger.info(f"Added {len(def_movements)} definitive import movements") # 3. Repair Imports - if should_fetch_imports: + if filters.import_rep: repair_filter = ImportRepairFilter( range_type=filters.range_type, start_date=filters.start_date, @@ -236,7 +245,7 @@ class MovementService: logger.info(f"Added {len(repair_movements)} repair import movements") # 4. Exports (Definitive) - if should_fetch_exports: + if filters.export_def: export_filter = ExportFilter( range_type=filters.range_type, start_date=filters.start_date, @@ -262,7 +271,7 @@ class MovementService: logger.info(f"Added {len(export_movements)} export movements") # 5. Export Repairs - if should_fetch_exports: + if filters.export_rep: export_repair_filter = ExportRepairFilter( range_type=filters.range_type, start_date=filters.start_date, diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py index 21eff0cc..0d406f07 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -604,9 +604,11 @@ def get_export_repair_movements_detailed( Use this when "TODAS" checkbox is selected to get a comprehensive view of all movements regardless of their specific type. + + If send_email is True, the report will be sent to the authenticated user's email address. """ ) -def get_all_movements( +async def get_all_movements( filters: AllMovementsFilter, db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user) @@ -628,13 +630,52 @@ def get_all_movements( try: logger.info( f"User {current_user.get('preferred_username', 'unknown')} " - f"requesting all invoice movements" + f"requesting all invoice movements (send_email={filters.send_email})" ) movements = movement_service.get_all_movements( db=db, filters=filters ) logger.info(f"Successfully retrieved {len(movements)} total movements") + + # Send email if requested + if filters.send_email: + user_email = current_user.get('email') + if not user_email: + logger.warning(f"User {current_user.get('sub')} has no email address - skipping email") + else: + try: + from core.email import EmailService + from .csv_utils import generate_csv_from_movements + from datetime import datetime + + # Generate CSV + csv_content = generate_csv_from_movements( + movements=movements, + report_type=filters.report_type.value.lower() + ) + + # Generate filename + filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Send email + email_sent = await EmailService.send_report_email( + recipient_email=user_email, + subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}", + body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.", + csv_content=csv_content, + filename=filename + ) + + if email_sent: + logger.info(f"Report emailed successfully to {user_email}") + else: + logger.warning(f"Failed to send email to {user_email} - SMTP may not be configured correctly") + + except Exception as email_error: + logger.warning(f"Email sending failed: {str(email_error)} - continuing with report generation") + + return movements return movements except ValueError as e: logger.warning(f"Validation error fetching all movements: {str(e)}") @@ -642,9 +683,66 @@ def get_all_movements( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) ) + except HTTPException: + raise except Exception as e: logger.error(f"Error fetching all movements: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error processing all movements: {str(e)}" ) + + +@router.post( + "/generate", + summary="Generate Invoice Report (Async)", + description="Trigger background generation of invoice report." +) +def generate_invoice_report_async( + filters: AllMovementsFilter, + current_user: dict = Depends(get_current_user) +): + """ + Trigger background generation of invoice report. + Returns task_id to poll status. + """ + from .tasks import generate_invoice_movements_async + + logger.info(f"User {current_user.get('preferred_username', 'unknown')} triggering async report generation") + + # Serialize filters to dict for Celery + filter_data = filters.model_dump() + user_email = current_user.get('email') + + # Trigger task + task = generate_invoice_movements_async.delay(filter_data, user_email) + + return {"task_id": task.id} + + +@router.get( + "/task/{task_id}", + summary="Get Async Task Status", + description="Check status of background report generation task." +) +def get_task_status(task_id: str): + """ + Get status of background task. + """ + from celery.result import AsyncResult + from core.celery_app import celery_app + + task_result = AsyncResult(task_id, app=celery_app) + + response = { + "task_id": task_id, + "status": task_result.status, + } + + if task_result.state == 'PROCESSING': + response["meta"] = task_result.info + + if task_result.ready(): + response["result"] = task_result.result + + return response diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py index 3f4ede62..002331cf 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/schemas.py @@ -103,6 +103,27 @@ class AllMovementsFilter(BaseModel): default=None, description="Filter by operation type: 'imp' for imports only, 'exp' for exports only, None for all" ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) + + # Granular movement selection + import_temp: bool = Field(default=True, description="Include temporary imports (IMTEM)") + import_def: bool = Field(default=True, description="Include definitive imports (IMPDF/COMEX)") + import_rep: bool = Field(default=True, description="Include repair imports (IMPRE)") + export_def: bool = Field(default=True, description="Include definitive exports") + export_rep: bool = Field(default=True, description="Include repair exports") + + # Specific filters + export_types: Optional[list[str]] = Field( + default=None, + description="Specific export legacy codes to include (AFIJO, NODES, etc)" + ) + discharge_filter: DischargeFilter = Field( + default=DischargeFilter.ALL, + description="Global discharge filter for repair movements" + ) class ImportTemporaryFilter(BaseModel): @@ -155,6 +176,10 @@ class ImportTemporaryFilter(BaseModel): ..., description="Legacy database name to query from" ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) class ImportDefinitiveFilter(BaseModel): @@ -215,6 +240,10 @@ class ImportDefinitiveFilter(BaseModel): ..., description="Legacy database name to query from" ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) class MovementItem(BaseModel): """Movement item representing a temporary import invoice""" @@ -317,6 +346,10 @@ class ImportRepairFilter(BaseModel): ..., description="Legacy database name to query from" ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) class MovementItemDetailed(BaseModel): @@ -464,6 +497,10 @@ class ExportFilter(BaseModel): ..., description="Legacy database name" ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) model_config = { "json_schema_extra": { @@ -545,6 +582,10 @@ class ExportRepairFilter(BaseModel): ..., description="Legacy database name" ) + send_email: bool = Field( + default=False, + description="Send report via email to current user" + ) model_config = { "json_schema_extra": { 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 index 23a9ae23..86bf2f1a 100644 --- 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 @@ -353,7 +353,14 @@ class RepairImportQueries: @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 + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" SELECT ih.invoice_number AS C2, @@ -403,9 +410,13 @@ class RepairImportQueries: @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 + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" return f""" SELECT il.line_number, @@ -479,9 +490,13 @@ class RepairImportQueries: @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 + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" return f""" SELECT COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), @@ -651,7 +666,13 @@ class ExportQueries: 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 "" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" return f""" SELECT COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), @@ -689,8 +710,14 @@ class ExportRepairQueries: @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 + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" + return f""" SELECT ih.invoice_number AS C1, @@ -817,7 +844,13 @@ class ExportRepairQueries: @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 "" + # Use fa_item_lines.download field (true = discharged, false = not discharged) + if "SiDes" in discharge_clause: + discharge_filter = "AND fil.download = true" + elif "NoDes" in discharge_clause: + discharge_filter = "AND fil.download = false" + else: + discharge_filter = "" return f""" SELECT COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0), 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 index 70fa5ca5..474f6d07 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -233,10 +233,10 @@ class TemporaryImportService: # ValorComercialMN should always be in MXN # If currency_type is ME, valor_comercial is in USD, so multiply by tipo_cambio if filters.currency_type.value == "ME" and tipo_cambio: - valor_comercial_mn = valor_comercial * tipo_cambio + valor_comercial_mn = float(valor_comercial) * float(tipo_cambio) else: # If currency_type is MN, valor_comercial is already in MXN - valor_comercial_mn = valor_comercial + valor_comercial_mn = float(valor_comercial) # Set peso values based on subpartida flag if row[39] == 'P': # C40 - EsSubPartida diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py new file mode 100644 index 00000000..a2de6aef --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py @@ -0,0 +1,108 @@ + +import base64 +import logging +import traceback +from typing import Dict, Any + +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from core.email import EmailService +from datetime import datetime + +from .movement_service import movement_service +from .schemas import AllMovementsFilter +from .csv_utils import generate_csv_from_movements + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True, name="generate_invoice_movements_async") +def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_email: str = None): + """ + Async task to generate invoice movements report. + FETCHES data -> GENERATES CSV -> SENDS EMAIL (optional) -> RETURNS CSV (base64) + """ + db = CoreSessionLocal() + try: + # 1. Update Progress + self.update_state(state='PROCESSING', meta={'current': 10, 'total': 100, 'status': 'Inicializando reporte...'}) + + # 2. Reconstruct Filter + filters = AllMovementsFilter(**filter_data) + + # 3. Fetch Data + self.update_state(state='PROCESSING', meta={'current': 30, 'total': 100, 'status': 'Obteniendo movimientos de base de datos...'}) + logger.info(f"Async Task: Fetching movements for {filters}") + + movements = movement_service.get_all_movements(db=db, filters=filters) + + self.update_state(state='PROCESSING', meta={'current': 70, 'total': 100, 'status': f'Procesando {len(movements)} registros...'}) + + # 4. Generate CSV + csv_content = generate_csv_from_movements( + movements=movements, + report_type=filters.report_type.value.lower() + ) + + # 5. Send Email if requested + email_sent = False + if filters.send_email and user_email: + self.update_state(state='PROCESSING', meta={'current': 90, 'total': 100, 'status': 'Enviando correo electrónico...'}) + try: + # Generate filename + filename = f"reporte_facturas_{filters.start_date}_{filters.end_date}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + # Send email (using the new async wrapper or run_until_complete if needed, + # but since we are in a sync celery task we might need to be careful with async/await. + # Actually EmailService.send_report_email is async. + # We need to run it synchronously here or make the task async. + # Celery tasks are sync by default. We can use asgiref.sync.async_to_sync + + import asyncio + from asgiref.sync import async_to_sync + + # Helper to run async method + result = async_to_sync(EmailService.send_report_email)( + recipient_email=user_email, + subject=f"Reporte de Facturas - {filters.start_date} al {filters.end_date}", + body_text=f"Se ha generado el reporte de facturas solicitado con {len(movements)} registros.", + csv_content=csv_content, + filename=filename + ) + + if result: + email_sent = True + logger.info(f"Async Task: Email sent to {user_email}") + else: + logger.warning(f"Async Task: Failed to send email to {user_email}") + + except Exception as e: + logger.error(f"Async Task: Email error: {str(e)}") + + # 6. Encode and Return + self.update_state(state='PROCESSING', meta={'current': 95, 'total': 100, 'status': 'Finalizando...'}) + + # Convert string csv to bytes then base64 + pdf_b64 = base64.b64encode(csv_content.encode('utf-8')).decode('utf-8') + + return { + 'status': 'success', + 'file_name': f"reporte_facturas_{datetime.now().strftime('%Y%m%d')}.csv", + 'content': pdf_b64, + 'media_type': 'text/csv', + 'email_sent': email_sent, + 'total_records': len(movements) + } + + except Exception as e: + logger.error(f"Error in generate_invoice_movements_async: {str(e)}", exc_info=True) + self.update_state( + state='FAILURE', + meta={ + 'exc_type': type(e).__name__, + 'exc_message': str(e), + 'custom': 'Error generating report' + } + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 8a0ff1f8..7b89d8b8 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -56,7 +56,6 @@ from .reports.exportacion.aviso_consolidado.routes import router as aviso_consol from .reports.movements.invoices.routes import router as movement_invoices_router - # Router principal router = APIRouter() diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 3e41aa5f..235149f8 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -12,7 +12,8 @@ celery_app = Celery( "api.v1.modules.a76.reports.importacion.facturas.task", "api.v1.modules.a76.reports.importacion.consolidados.task", "api.v1.modules.a76.reports.importacion.packing_list.task", - "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task" + "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", + "api.v1.modules.a76.reports.movements.invoices.tasks" ] # Ruta al módulo donde están las tareas ) diff --git a/backend/core/config.py b/backend/core/config.py index 37c5d52f..b44417d4 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -47,6 +47,14 @@ class Settings(BaseSettings): SITAR_API_USER: str = "" SITAR_API_PASSWORD: str = "" + # SMTP Email Configuration + SMTP_HOST: str = "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM_NAME: str = "Sistema Anexo76" + SMTP_USE_TLS: bool = True + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, diff --git a/backend/core/email.py b/backend/core/email.py new file mode 100644 index 00000000..41b81a67 --- /dev/null +++ b/backend/core/email.py @@ -0,0 +1,117 @@ +""" +Email service for sending reports via SMTP. +""" +import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from typing import List +import logging +from datetime import datetime + +from core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailService: + """Service for sending emails with attachments.""" + + @staticmethod + async def send_report_email( + recipient_email: str, + subject: str, + body_text: str, + csv_content: str, + filename: str + ) -> bool: + """ + Send a report email with CSV attachment. + + Args: + recipient_email: Email address of recipient + subject: Email subject line + body_text: Plain text email body + csv_content: CSV file content as string + filename: Name for the CSV attachment + + Returns: + bool: True if email sent successfully, False otherwise + """ + try: + # Create message + msg = MIMEMultipart() + msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>" + msg['To'] = recipient_email + msg['Subject'] = subject + + # Email body + html_body = f""" + + +
+

+ Reporte de Facturas - Sistema Anexo76 +

+

{body_text}

+

+ El reporte se encuentra adjunto en formato CSV. +

+
+

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

+

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

+
+ + + """ + msg.attach(MIMEText(html_body, 'html')) + + # CSV attachment + attachment = MIMEBase('text', 'csv') + attachment.set_payload(csv_content.encode('utf-8')) + encoders.encode_base64(attachment) + attachment.add_header( + 'Content-Disposition', + f'attachment; filename="{filename}"' + ) + msg.attach(attachment) + + # Create SSL context that ignores certificate errors + import ssl + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + # Send email + if settings.SMTP_PORT == 465: + # Port 465 uses implicit SSL + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + use_tls=True, # Implicit SSL + tls_context=context + ) as smtp: + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + else: + # Port 587 uses STARTTLS + async with aiosmtplib.SMTP( + hostname=settings.SMTP_HOST, + port=settings.SMTP_PORT, + tls_context=context + ) as smtp: + await smtp.starttls(tls_context=context) + await smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + await smtp.send_message(msg) + + logger.info(f"Email sent successfully to {recipient_email}") + return True + + except Exception as e: + logger.error(f"Failed to send email to {recipient_email}: {str(e)}") + return False diff --git a/backend/requirements.txt b/backend/requirements.txt index c169f2ca..d21c8780 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,4 +50,4 @@ redis==5.0.1 flower==2.0.1 # Barcode -pdf417gen==0.8.1 \ No newline at end of file +pdf417gen==0.8.1asgiref==3.8.1 diff --git a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts index ce6daa22..5424bc4b 100644 --- a/frontend/src/lib/api/dashboard/a76/invoice-movements.ts +++ b/frontend/src/lib/api/dashboard/a76/invoice-movements.ts @@ -62,6 +62,15 @@ export interface AllMovementsFilter { exchange_rate_type: ExchangeRateType; is_shelter: boolean; operation_type?: 'imp' | 'exp' | null; + send_email?: boolean; + // Granular flags + import_temp?: boolean; + import_def?: boolean; + import_rep?: boolean; + export_def?: boolean; + export_rep?: boolean; + export_types?: string[]; + discharge_filter?: DischargeFilter; } export interface MovementItem { @@ -181,5 +190,14 @@ export const invoiceMovementsApi = { // All Movements getAllMovements: (filters: AllMovementsFilter) => - api.post('/v1/a76/reports/movements/invoices/all', filters) + api.post('/v1/a76/reports/movements/invoices/all', filters), + + // Async Generation + generateReportAsync: (filters: AllMovementsFilter) => + api.post<{ task_id: string }>('/v1/a76/reports/movements/invoices/generate', filters), + + getTaskStatus: (taskId: string) => + api.get<{ task_id: string; status: string; result?: any; meta?: any }>( + `/v1/a76/reports/movements/invoices/task/${taskId}` + ) }; diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 6c3083ad..33bc86c2 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -28,7 +28,8 @@ ShieldCheck, Calculator, Download, - Folder + Folder, + Eye } from 'lucide-svelte'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Dialog from '$lib/components/ui/dialog'; @@ -346,395 +347,229 @@ // --- LÓGICA --- - async function handleGenerateReport() { - // Validar fechas - const dateValidation = validateDates(); - if (!dateValidation.valid) { - toast.error(`Error: ${dateValidation.message}`); - return; - } + // Estado del Context Menu + let contextMenu = $state({ + open: false, + x: 0, + y: 0 + }); - // Validar tipos de movimiento - const movementValidation = validateMovementTypes(); - if (!movementValidation.valid) { - toast.error(`Error: ${movementValidation.message}`); - return; - } + function handleContextMenu(e: MouseEvent) { + e.preventDefault(); + contextMenu = { + open: true, + x: e.clientX, + y: e.clientY + }; + } - // Validar reglas de negocio del Clarion - const businessValidation = validateBusinessRules(); - if (!businessValidation.valid) { - toast.error(`Error: ${businessValidation.message}`); - return; + function closeContextMenu() { + contextMenu.open = false; + } + + // Acción: Generar Reporte (Email / Background) - Click Izquierdo + async function generateReport() { + const filter = buildAllMovementsFilter(); + if (!filter) return; + + // Forzar envío de correo para esta acción + filter.send_email = true; + + loading = true; + // Initial toast + const toastId = toast.loading('Iniciando generación de reporte...'); + + try { + // 1. Trigger Async Generation + const response = await invoiceMovementsApi.generateReportAsync(filter); + + if (!response.data || !response.data.task_id) { + toast.error('Error al iniciar la generación del reporte', { id: toastId }); + loading = false; + return; + } + + const taskId = response.data.task_id; + + // 2. Poll for status + const pollInterval = setInterval(async () => { + try { + const statusResponse = await invoiceMovementsApi.getTaskStatus(taskId); + const statusData = statusResponse.data; + + if (!statusData) return; + + if (statusData.status === 'SUCCESS') { + clearInterval(pollInterval); + loading = false; + toast.success( + 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + { id: toastId } + ); + } else if (statusData.status === 'FAILURE') { + clearInterval(pollInterval); + loading = false; + // Try to extract specific error from meta or result + const errorMsg = + statusData.meta?.exc_message || + statusData.result?.exc_message || + statusData.result?.detail || + 'Error en la generación del reporte'; + + console.error('Task Failure Details:', statusData); + toast.error(errorMsg, { id: toastId }); + } else if (statusData.status === 'PROCESSING') { + // Update progress + if (statusData.meta) { + const { current, total, status } = statusData.meta; + const percentage = Math.round((current / total) * 100); + toast.loading(`${status} (${percentage}%)`, { id: toastId }); + } + } + } catch (err) { + console.error('Error polling status:', err); + // Don't stop polling on transient network errors, but maybe log it + } + }, 1000); // Poll every 1 second + } catch (error: any) { + console.error('Error generando reporte:', error); + toast.error(error.message || 'Error al generar el reporte', { id: toastId }); + loading = false; } + } + + // Acción: Vista Previa (Tabla) - Click Derecho -> Opción + async function previewReport() { + closeContextMenu(); + const filter = buildAllMovementsFilter(); + if (!filter) return; + + // Para preview, no enviamos correo (o respetamos config, pero generalmente preview es solo ver) + filter.send_email = false; loading = true; results = []; showResults = false; + toast.info('Cargando vista previa...'); + try { - // Si "TODAS" en otras opciones o exportaciones está marcado, usar el endpoint especial - if (types.other.TODAS || types.export.additional.TODAS) { - toast.info('Obteniendo todos los movimientos...'); + const response = await invoiceMovementsApi.getAllMovements(filter); - // Determinar el tipo de operación basado en cuál "TODAS" está marcado - let operation_type: 'imp' | 'exp' | null = null; - - if (types.other.TODAS) { - // "TODAS" de la sección Otras = traer TODO (importaciones + exportaciones) - operation_type = null; - } else if (types.export.additional.TODAS) { - // "TODAS" de exportaciones = solo exportaciones - operation_type = 'exp'; - } - - const allMovementsFilter: AllMovementsFilter = { - range_type: dates.type === 'invoice' ? 'FF' : 'FP', - start_date: formatDateToYYYYMMDD(dates.from), - end_date: formatDateToYYYYMMDD(dates.to), - include_cancelled: filters.includeNA, - provider: selectors.provider || null, - buyer: selectors.soldTo || null, - pedimento_code: selectors.pedimentoKey || null, - report_type: config.reportType === 'normal' ? 'Normal' : 'Detallado', - currency_type: config.currency === 'foreign' ? 'ME' : 'MN', - exchange_rate_type: config.exchangeRate === 'invoice_date' ? 'FF' : 'FP', - is_shelter: config.shelter, - operation_type - }; - - const response = await invoiceMovementsApi.getAllMovements(allMovementsFilter); - - if (response.error) { - toast.error(response.error); - return; - } - - if (response.data) { - results = response.data; - showResults = true; - } - - toast.success(`Se encontraron ${results.length} movimientos`); - return; // Salir temprano, no ejecutar la lógica individual + if (response.error) { + toast.error(response.error); + return; } - const baseFilter: Omit = { - range_type: dates.type === 'invoice' ? 'FF' : 'FP', - start_date: formatDateToYYYYMMDD(dates.from), - end_date: formatDateToYYYYMMDD(dates.to), - include_cancelled: filters.includeNA, - provider: selectors.provider || null, - buyer: selectors.soldTo || null, - pedimento_code: selectors.pedimentoKey || null, - report_type: config.reportType === 'normal' ? 'Normal' : 'Detallado', - currency_type: config.currency === 'foreign' ? 'ME' : 'MN', - exchange_rate_type: config.exchangeRate === 'invoice_date' ? 'FF' : 'FP', - is_shelter: config.shelter - }; + if (response.data) { + results = response.data; + showResults = true; - const allResults: (MovementItem | MovementItemDetailed)[] = []; - - // Importaciones Temporales (IMTEM) - if (types.import.TEM) { - toast.info('Obteniendo importaciones temporales...'); - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getTemporaryImports({ - ...baseFilter, - database_name: 'default' - }) - : await invoiceMovementsApi.getTemporaryImportsDetailed({ - ...baseFilter, - database_name: 'default' - }); - - if (response.error) { - toast.error(response.error); - return; + if (results.length === 0) { + toast.warning('No se encontraron resultados con los filtros seleccionados'); + } else { + toast.success(`${results.length} registros cargados en vista previa`); } - - if (response.data) allResults.push(...response.data); } + } catch (error: any) { + console.error('Error generando vista previa:', error); + toast.error(error.message || 'Error al generar la vista previa'); + } finally { + loading = false; + } + } - // Importaciones Definitivas (IMPDF) o COMEX - if (types.import.DEF) { - toast.info('Obteniendo importaciones definitivas...'); - const movementType = types.other.COMEX ? 'COMEX' : 'IMPDF'; - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getDefinitiveImports({ - ...baseFilter, - database_name: 'default', - movement_type: movementType - }) - : await invoiceMovementsApi.getDefinitiveImportsDetailed({ - ...baseFilter, - database_name: 'default', - movement_type: movementType - }); + // Helper para construir el filtro (extraído de la lógica anterior) + function buildAllMovementsFilter(): AllMovementsFilter | null { + // Validaciones + const dateValidation = validateDates(); + if (!dateValidation.valid) { + toast.error(`Error: ${dateValidation.message}`); + return null; + } + const movementValidation = validateMovementTypes(); + if (!movementValidation.valid) { + toast.error(`Error: ${movementValidation.message}`); + return null; + } + const businessValidation = validateBusinessRules(); + if (!businessValidation.valid) { + toast.error(`Error: ${businessValidation.message}`); + return null; + } - if (response.error) { - toast.error(response.error); - return; - } - - if (response.data) allResults.push(...response.data); - } - - // Importaciones de Reparación (IMPRE) - if (types.import.REP) { - toast.info('Obteniendo importaciones de reparación...'); - const dischargeFilter = - filters.downloaded === 'downloaded' - ? 'SiDes' - : filters.downloaded === 'not_downloaded' - ? 'NoDes' - : 'ALL'; - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getRepairImports({ - ...baseFilter, - database_name: 'default', - discharge_filter: dischargeFilter - }) - : await invoiceMovementsApi.getRepairImportsDetailed({ - ...baseFilter, - database_name: 'default', - discharge_filter: dischargeFilter - }); - - if (response.error) { - toast.error(response.error); - return; - } - - if (response.data) allResults.push(...response.data); - } - - // Exportaciones Definitivas (incluye VEMEX) - if ( + const allMovementsFilter: AllMovementsFilter = { + range_type: dates.type === 'invoice' ? 'FF' : 'FP', + start_date: formatDateToYYYYMMDD(dates.from), + end_date: formatDateToYYYYMMDD(dates.to), + include_cancelled: filters.includeNA, + provider: selectors.provider || null, + buyer: selectors.soldTo || null, + pedimento_code: selectors.pedimentoKey || null, + report_type: config.reportType === 'normal' ? 'Normal' : 'Detallado', + currency_type: config.currency === 'foreign' ? 'ME' : 'MN', + exchange_rate_type: config.exchangeRate === 'invoice_date' ? 'FF' : 'FP', + is_shelter: config.shelter, + // Granular flags + import_temp: !!types.import.TEM, + import_def: !!types.import.DEF, + import_rep: !!types.import.REP, + export_def: !!( types.export.main.DEF || types.other.VEMEX || types.export.additional.TODAS || Object.values(types.export.additional).some((v) => v) - ) { - toast.info('Obteniendo exportaciones...'); + ), + export_rep: !!types.export.main.REP, + send_email: config.sendEmail + }; - // Determinar tipo de movimiento basado en checkboxes adicionales - let movementType: any = 'ALL'; - if (types.export.additional.AFIJO) movementType = 'AFIJO'; - else if (types.export.additional.NODES) movementType = 'NODES'; - else if (types.export.additional.SCRAP) movementType = 'SCRAP'; - else if (types.export.additional.REEXP) movementType = 'REEXP'; - else if (types.export.additional.DONAC) movementType = 'DONAC'; - else if (types.other.VEMEX) movementType = 'VEMEX'; + // Override flags if "TODAS" is selected + if (types.other.TODAS) { + allMovementsFilter.import_temp = true; + allMovementsFilter.import_def = true; + allMovementsFilter.import_rep = true; + allMovementsFilter.export_def = true; + allMovementsFilter.export_rep = true; + allMovementsFilter.operation_type = null; + } else if (types.export.additional.TODAS) { + allMovementsFilter.import_temp = false; + allMovementsFilter.import_def = false; + allMovementsFilter.import_rep = false; + allMovementsFilter.export_def = true; + allMovementsFilter.export_rep = true; + allMovementsFilter.operation_type = 'exp'; + } else { + const hasImports = + allMovementsFilter.import_temp || + allMovementsFilter.import_def || + allMovementsFilter.import_rep; + const hasExports = allMovementsFilter.export_def || allMovementsFilter.export_rep; - const dischargeFilter = - filters.downloaded === 'downloaded' - ? 'SiDes' - : filters.downloaded === 'not_downloaded' - ? 'NoDes' - : 'ALL'; - - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getExports({ - ...baseFilter, - database_name: 'default', - movement_type: movementType, - discharge_filter: dischargeFilter, - use_transport_method: false - }) - : await invoiceMovementsApi.getExportsDetailed({ - ...baseFilter, - database_name: 'default', - movement_type: movementType, - discharge_filter: dischargeFilter, - use_transport_method: false - }); - - if (response.error) { - toast.error(response.error); - return; - } - - if (response.data) allResults.push(...response.data); + if (hasImports && hasExports) { + allMovementsFilter.operation_type = null; + } else if (hasImports) { + allMovementsFilter.operation_type = 'imp'; + } else if (hasExports) { + allMovementsFilter.operation_type = 'exp'; } - - // Exportaciones de Reparación - if (types.export.main.REP || types.export.additional.TODAS) { - toast.info('Obteniendo exportaciones de reparación...'); - - // Para reparaciones solo aplican AFIJO y NODES - let movementType: any = 'ALL'; - if (types.export.additional.AFIJO) movementType = 'AFIJO'; - else if (types.export.additional.NODES) movementType = 'NODES'; - - const dischargeFilter = - filters.downloaded === 'downloaded' - ? 'SiDes' - : filters.downloaded === 'not_downloaded' - ? 'NoDes' - : 'ALL'; - - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getExportRepairs({ - ...baseFilter, - database_name: 'default', - movement_type: movementType, - discharge_filter: dischargeFilter - }) - : await invoiceMovementsApi.getExportRepairsDetailed({ - ...baseFilter, - database_name: 'default', - movement_type: movementType, - discharge_filter: dischargeFilter - }); - - if (response.error) { - toast.error(response.error); - return; - } - - if (response.data) allResults.push(...response.data); - } - - // Cambio de Régimen (CREG) - Exportaciones con cambio de régimen - if (types.export.main.CREG) { - toast.info('Obteniendo cambios de régimen...'); - - // Para cambio de régimen solo aplican AFIJO y SCRAP - let movementType: any = 'ALL'; - if (types.export.additional.AFIJO) movementType = 'AFIJO'; - else if (types.export.additional.SCRAP) movementType = 'SCRAP'; - - const dischargeFilter = - filters.downloaded === 'downloaded' - ? 'SiDes' - : filters.downloaded === 'not_downloaded' - ? 'NoDes' - : 'ALL'; - - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getExports({ - ...baseFilter, - database_name: 'default', - movement_type: movementType, - discharge_filter: dischargeFilter, - use_transport_method: false - }) - : await invoiceMovementsApi.getExportsDetailed({ - ...baseFilter, - database_name: 'default', - movement_type: movementType, - discharge_filter: dischargeFilter, - use_transport_method: false - }); - if (response.data) allResults.push(...response.data); - } - - // CREGEXP (Cambio Régimen Export) - Caso especial - if (types.other.CREGEXP) { - toast.info('Obteniendo cambios de régimen export...'); - - const dischargeFilter = - filters.downloaded === 'downloaded' - ? 'SiDes' - : filters.downloaded === 'not_downloaded' - ? 'NoDes' - : 'ALL'; - - const response = - config.reportType === 'normal' - ? await invoiceMovementsApi.getExports({ - ...baseFilter, - database_name: 'default', - movement_type: 'ALL', - discharge_filter: dischargeFilter, - use_transport_method: false - }) - : await invoiceMovementsApi.getExportsDetailed({ - ...baseFilter, - database_name: 'default', - movement_type: 'ALL', - discharge_filter: dischargeFilter, - use_transport_method: false - }); - - if (response.error) { - toast.error(response.error); - return; - } - - if (response.data) allResults.push(...response.data); - } - - results = allResults; - showResults = true; - - // Ordenar resultados según configuración y modo de reporte - if (config.reportType === 'normal') { - // LLENADOCSVNORMAL - SORT con 3 campos - if (config.shelter) { - results.sort((a, b) => { - if (a.BaseDeDatos !== b.BaseDeDatos) return a.BaseDeDatos.localeCompare(b.BaseDeDatos); - if (a.TipoMovTemDef !== b.TipoMovTemDef) - return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef); - return (a.FechaFactura || '').localeCompare(b.FechaFactura || ''); - }); - } else { - results.sort((a, b) => { - if (a.TipoMovTemDef !== b.TipoMovTemDef) - return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef); - if ((a.FechaFactura || '') !== (b.FechaFactura || '')) - return (a.FechaFactura || '').localeCompare(b.FechaFactura || ''); - return a.BaseDeDatos.localeCompare(b.BaseDeDatos); - }); - } - } else { - // LLENADOCSVDETALLADO - SORT más simple - if (config.shelter) { - results.sort((a, b) => { - if (a.BaseDeDatos !== b.BaseDeDatos) return a.BaseDeDatos.localeCompare(b.BaseDeDatos); - if (a.TipoMovTemDef !== b.TipoMovTemDef) - return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef); - return (a.FechaFactura || '').localeCompare(b.FechaFactura || ''); - }); - } else { - results.sort((a, b) => { - if (a.TipoMovTemDef !== b.TipoMovTemDef) - return a.TipoMovTemDef.localeCompare(b.TipoMovTemDef); - return (a.FechaFactura || '').localeCompare(b.FechaFactura || ''); - }); - } - } - - // Configurar título del reporte - reportTitle = 'REPORTE DE FACTURAS'; - - // Configurar etiqueta de moneda - if (config.currency === 'foreign') { - currencyLabel = 'Moneda: Dólares'; - } else if (config.currency === 'national') { - currencyLabel = 'Moneda: Pesos'; - } else { - currencyLabel = 'Moneda: Captura'; - } - - if (allResults.length === 0) { - toast.warning('No se encontraron resultados con los filtros seleccionados'); - } else { - toast.success(`Reporte generado exitosamente: ${allResults.length} registros encontrados`); - } - } catch (error: any) { - console.error('Error generando reporte:', error); - toast.error(error.message || 'Error al generar el reporte'); - } finally { - loading = false; } + + // Set discharge filter (Global) + const dischargeFilter = + filters.downloaded === 'downloaded' + ? 'SiDes' + : filters.downloaded === 'not_downloaded' + ? 'NoDes' + : 'ALL'; + allMovementsFilter.discharge_filter = dischargeFilter; + + return allMovementsFilter; + } + + async function handleGenerateReport() { + // Wrapper for compatibility if button still calls this + await generateReport(); } function downloadCSV() { @@ -1171,11 +1006,31 @@
- + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + />
- + { + const input = e.currentTarget; + if (input && typeof input.showPicker === 'function') { + input.showPicker(); + } + }} + />
@@ -1517,7 +1372,8 @@ +
+{/if} From 84b507fbba5a708466839bd865bf93078f810971 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Fri, 6 Feb 2026 17:57:58 -0600 Subject: [PATCH 11/52] feat: Enhance invoice movement reports by refactoring CSV generation with a filter object, expanding exported fields, improving formatting, and enabling direct download in the frontend. --- .../modules/a76/reports/movements/__init__.py | 0 .../reports/movements/invoices/csv_utils.py | 105 +++++++++++++----- .../a76/reports/movements/invoices/routes.py | 2 +- .../a76/reports/movements/invoices/tasks.py | 2 +- backend/requirements.txt | 4 +- docker-compose.yml | 16 +++ .../dashboard/reports/invoices/+page.svelte | 43 ++++++- 7 files changed, 137 insertions(+), 35 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/movements/__init__.py diff --git a/backend/api/v1/modules/a76/reports/movements/__init__.py b/backend/api/v1/modules/a76/reports/movements/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py index 004b7ae4..2c91af6d 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -6,32 +6,42 @@ import io from typing import List, Union from datetime import datetime -from .schemas import MovementItem, MovementItemDetailed +from .schemas import MovementItem, MovementItemDetailed, AllMovementsFilter def generate_csv_from_movements( movements: List[Union[MovementItem, MovementItemDetailed]], - report_type: str = "normal" + filters: AllMovementsFilter ) -> str: """ Generate CSV content from movement items. Args: movements: List of movement items (normal or detailed) - report_type: "normal" or "detailed" + filters: Filter object containing report parameters Returns: CSV content as string """ output = io.StringIO() - if report_type.lower() == "normal": - # Normal report columns + if filters.report_type.value.lower() == "normal": + # Normal report - only fields that are actually populated fieldnames = [ - 'Pedimento', 'ClavePed', 'Factura', 'FechaFactura', - 'ValorComercialMN', 'TipoMovTemDef', 'Estatus', 'BaseDeDatos', - 'TipoCambio', 'Fecha_Pago', 'ValorMPTemp', 'ValorAgre', - 'TipoExpo', 'EsCambioRegimen', 'Regimen' + # Identification + 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Values + 'ValorComercialMN', 'ValorMPTemp', 'TipoCambio', 'ValorAgre', + # Classification + 'TipoMovTemDef', 'Estatus', 'TipoExpo', 'EsCambioRegimen', + # Dates + 'Fecha_Pago', + # References + 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Logistics + 'NumCaja', 'NumGafUni', 'AduanaCru', + # Metadata + 'BaseDeDatos', 'UsuarioCap', 'UsuarioAcr' ] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') @@ -40,21 +50,45 @@ def generate_csv_from_movements( for movement in movements: row = movement.model_dump() # Format datetime fields - if row.get('FechaFactura'): - row['FechaFactura'] = _format_datetime(row['FechaFactura']) - if row.get('Fecha_Pago'): - row['Fecha_Pago'] = _format_datetime(row['Fecha_Pago']) + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['ValorMPTemp'] = _format_decimal(row.get('ValorMPTemp')) + row['ValorAgre'] = _format_decimal(row.get('ValorAgre')) + writer.writerow(row) else: - # Detailed report columns + # Detailed report - only fields that are actually populated fieldnames = [ - 'Linea', 'Pedimento', 'Factura', 'FechaFactura', - 'Proveedor', 'VendidoA', 'CantidadIE', 'DescripcionE', - 'DescripcionI', 'NumParte', 'UniMed', 'ValorComercialMN', - 'TipoMovTemDef', 'ClavePed', 'Estatus', 'TipoCambio', - 'PesoNeto', 'PesoBruto', 'OrdenCompraVenta', 'Regimen', - 'AgenteAduanal', 'Patente', 'BaseDeDatos' + # Identification + 'Linea', 'Factura', 'Pedimento', 'FechaFactura', 'ClavePed', + # Parties (names only, no RFC/TaxID as they're not in queries) + 'Proveedor', 'VendidoA', + # Product + 'NumParte', 'DescripcionE', 'DescripcionI', 'CantidadIE', 'UniMed', + # Classification + 'FraccionArancelaria', 'FraccionAmericana', 'ECCN', 'Sector', 'PaisOrigen', + # Values + 'ValorComercialMN', 'TipoCambio', 'PesoNeto', 'PesoBruto', + # Customs + 'TipoMovTemDef', 'Regimen', 'Aduana', 'Advalorem', 'Preferencia', + # Customs Broker + 'AgenteAduanal', 'Patente', + # References + 'OrdenCompraVenta', 'Remesa', 'PedimentoR1', 'EDocument', 'NumOperacionVU', + # Identifiers + 'Series', 'Marca', 'Modelo', 'SimboloEx', + # Dates + 'Fecha_Pago', 'Fecha_Inicio', 'Fecha_Fin', 'FechaEmision', + # Logistics + 'Transportista', 'NumCaja', 'NumGafUni', 'AduanaCru', 'Lote', + # Metadata + 'Estatus', 'BaseDeDatos', 'TipoExpo', 'EsCambioRegimen', 'Pedimento18', + 'UsuarioCap', 'UsuarioAcr' ] writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore') @@ -63,14 +97,19 @@ def generate_csv_from_movements( for movement in movements: row = movement.model_dump() # Format datetime fields - if row.get('FechaFactura'): - row['FechaFactura'] = _format_datetime(row['FechaFactura']) - if row.get('Fecha_Pago'): - row['Fecha_Pago'] = _format_datetime(row['Fecha_Pago']) - if row.get('Fecha_Inicio'): - row['Fecha_Inicio'] = _format_datetime(row['Fecha_Inicio']) - if row.get('Fecha_Fin'): - row['Fecha_Fin'] = _format_datetime(row['Fecha_Fin']) + row['FechaFactura'] = _format_datetime(row.get('FechaFactura')) + row['Fecha_Pago'] = _format_datetime(row.get('Fecha_Pago')) + row['Fecha_Inicio'] = _format_datetime(row.get('Fecha_Inicio')) + row['Fecha_Fin'] = _format_datetime(row.get('Fecha_Fin')) + row['FechaEmision'] = _format_datetime(row.get('FechaEmision')) + + # Format numeric fields + row['ValorComercialMN'] = _format_decimal(row.get('ValorComercialMN')) + row['TipoCambio'] = _format_decimal(row.get('TipoCambio')) + row['CantidadIE'] = _format_decimal(row.get('CantidadIE')) + row['PesoNeto'] = _format_decimal(row.get('PesoNeto')) + row['PesoBruto'] = _format_decimal(row.get('PesoBruto')) + writer.writerow(row) csv_content = output.getvalue() @@ -85,3 +124,13 @@ def _format_datetime(dt) -> str: elif isinstance(dt, str): return dt return '' + + +def _format_decimal(value, decimals: int = 2) -> str: + """Format decimal values for CSV export.""" + if value is None: + return '' + try: + return f"{float(value):.{decimals}f}" + except (ValueError, TypeError): + return str(value) if value else '' diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py index 0d406f07..351e4d9e 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/routes.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/routes.py @@ -652,7 +652,7 @@ async def get_all_movements( # Generate CSV csv_content = generate_csv_from_movements( movements=movements, - report_type=filters.report_type.value.lower() + filters=filters ) # Generate filename diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py index a2de6aef..47114e98 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/tasks.py @@ -40,7 +40,7 @@ def generate_invoice_movements_async(self, filter_data: Dict[str, Any], user_ema # 4. Generate CSV csv_content = generate_csv_from_movements( movements=movements, - report_type=filters.report_type.value.lower() + filters=filters ) # 5. Send Email if requested diff --git a/backend/requirements.txt b/backend/requirements.txt index d21c8780..7480be9f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -50,4 +50,6 @@ redis==5.0.1 flower==2.0.1 # Barcode -pdf417gen==0.8.1asgiref==3.8.1 +pdf417gen==0.8.1 +asgiref==3.8.1 +aiosmtplib==3.0.1 diff --git a/docker-compose.yml b/docker-compose.yml index 041c1bdf..a6dc68de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -272,12 +272,28 @@ services: container_name: worker command: celery -A core.celery_app worker --loglevel=info environment: + - DEBUG=${DEBUG:-True} + - ENVIRONMENT=${ENVIRONMENT:-development} + - PYTHONUNBUFFERED=1 + - PYTHONDONTWRITEBYTECODE=1 + - CORE_DB_HOST=${CORE_DB_HOST:-postgres-a76} + - CORE_DB_PORT=${CORE_DB_PORT:-5432} + - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} + - CORE_DB_USER=${CORE_DB_USER:-postgres} + - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - KEYCLOAK_SERVER_URL=${KEYCLOAK_SERVER_URL:-http://keycloak:8080/kcauth} + - KEYCLOAK_REALM=${KEYCLOAK_REALM:-master} + - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend} + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret} - VALKEY_URL=redis://valkey:6379/0 depends_on: - backend - valkey networks: - backend-net + volumes: + - ./backend:/app + valkey: image: valkey/valkey:7.2 diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 33bc86c2..97dff1c6 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -402,10 +402,45 @@ if (statusData.status === 'SUCCESS') { clearInterval(pollInterval); loading = false; - toast.success( - 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', - { id: toastId } - ); + + // Download the file automatically + if (statusData.result?.content) { + try { + // Decode base64 content + const base64Content = statusData.result.content; + const binaryString = atob(base64Content); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Create blob and download + const blob = new Blob([bytes], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = statusData.result.file_name || 'reporte_facturas.csv'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + toast.success('Reporte generado y descargado. También se ha enviado por correo.', { + id: toastId + }); + } catch (downloadErr) { + console.error('Error downloading file:', downloadErr); + toast.success( + 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + { id: toastId } + ); + } + } else { + toast.success( + 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + { id: toastId } + ); + } } else if (statusData.status === 'FAILURE') { clearInterval(pollInterval); loading = false; From 893c154b851ac6aba801a2f3fc07bd1a678e2940 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 9 Feb 2026 12:57:20 -0600 Subject: [PATCH 12/52] feat: Enhance invoice form data handling with new fields and improve invoice report export filter logic and UI. --- .../reports/movements/invoices/csv_utils.py | 2 +- .../invoices/edit/invoice-top-fields.svelte | 386 ++++++++++-------- .../invoices/edit/others-tab-form.svelte | 86 ++-- .../dashboard/invoices/edit/save-invoice.ts | 29 +- .../dashboard/reports/invoices/+page.svelte | 116 +++--- 5 files changed, 348 insertions(+), 271 deletions(-) diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py index 2c91af6d..4ac1a3c8 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/csv_utils.py @@ -120,7 +120,7 @@ def generate_csv_from_movements( def _format_datetime(dt) -> str: """Format datetime for CSV export.""" if isinstance(dt, datetime): - return dt.strftime('%Y-%m-%d %H:%M:%S') + return dt.strftime('%Y-%m-%d') elif isinstance(dt, str): return dt return '' diff --git a/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte b/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte index e3549f05..52de9150 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/invoice-top-fields.svelte @@ -1,196 +1,224 @@ -
-
- - { - formData.operation_type = v; - }} - > - - - {formData.operation_type - ? (formData.operation_type === 'exp' ? 'Exp' : 'Imp') - : '...'} - - - - Exportación - Importación - - -
-
- - { - formData.invoice_type = v ?? ''; - }} - > - - - {formData.invoice_type - ? `${formData.invoice_type}` - : '...'} - - - - {#each invoiceTypes as type} - - {type.key} - {type.description} - - {/each} - - -
+
+
+ + { + formData.operation_type = v; + }} + > + + + {formData.operation_type ? (formData.operation_type === 'exp' ? 'Exp' : 'Imp') : '...'} + + + + Exportación + Importación + + +
+
+ + { + formData.invoice_type = v ?? ''; + }} + > + + + {formData.invoice_type ? `${formData.invoice_type}` : '...'} + + + + {#each invoiceTypes as type} + + {type.key} - {type.description} + + {/each} + + +
-
- - { - formData.is_pedimento_pending = checked; - }} - /> -
-
- - { - formData.pedimento_id = v ? parseInt(v) : null; - if (v) { - handlePedimentoChange(v); - } - }} - > - - - {formData.pedimento || 'Selecciona pedimento...'} - - - - {#each pedimentos as pedimento} - - {pedimento.customs_office?.slice(0, 2)}-{pedimento.license}-{pedimento.pedimento_number} - - {/each} - - -
+
+ + { + formData.is_pedimento_pending = checked; + }} + /> +
+
+ + { + formData.pedimento_id = v ? parseInt(v) : null; + if (v) { + handlePedimentoChange(v); + } + }} + > + + + {formData.pedimento || 'Selecciona pedimento...'} + + + + {#each pedimentos as pedimento} + + {pedimento.customs_office?.slice(0, 2)}-{pedimento.license}-{pedimento.pedimento_number} + + {/each} + + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
+ +
+ + +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte index c08d1e4f..ca7219fe 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/others-tab-form.svelte @@ -10,12 +10,12 @@ import { Plus, Upload } from 'lucide-svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; - let { + let { invoice, formData = $bindable(), exists = $bindable(), transportModes = [] - }: { + }: { invoice: Invoice | null; formData?: any; exists?: boolean; @@ -36,11 +36,14 @@ electronic_signature: invoice.compliance_mx?.electronic_signature || '', mandatory_person: '', contingency_mode: invoice.compliance_mx?.contingency_mode || false, - cove: invoice.compliance_mx?.origin_destination_cove || '', + cove: invoice.compliance_mx?.edocument || '', operation_num: invoice.compliance_mx?.vucem_operation_num || '', adendas: invoice.compliance_mx?.addendum_vu || '', observations_vu: invoice.vu_observations || '', - certified_number: invoice.compliance_mx?.certificate_number || '', + certified_number: invoice.compliance_mx?.certificate_number || '', + entry_exit_date: invoice.logistics?.entry_exit_date || '', + payment_date: invoice.logistics?.payment_date || '', + delivery_date: invoice.logistics?.delivery_date || '' }; exists = true; } else if (!formData) { @@ -61,7 +64,10 @@ operation_num: '', adendas: '', observations_vu: '', - certified_number: '', + certified_number: '', + entry_exit_date: '', + payment_date: '', + delivery_date: '' }; exists = false; } @@ -70,7 +76,6 @@ let rfc = $state(''); let curp = $state(''); - function loadInfo() { // Función para cargar información console.log('Cargar información'); @@ -78,18 +83,18 @@
-
- +
- formData.transport_mode = value || 'TRUCK'} + (formData.transport_mode = value || 'TRUCK')} > - {transportModes.find(m => m.key === formData.transport_mode)?.name || 'Seleccionar modo'} + {transportModes.find((m) => m.key === formData.transport_mode)?.name || + 'Seleccionar modo'} {#each transportModes as mode} @@ -116,9 +121,9 @@
- formData.is_mixed = v === 'yes'} + (formData.is_mixed = v === 'yes')} class="flex gap-4" >
@@ -131,7 +136,7 @@
-
+
@@ -145,22 +150,18 @@
@@ -1641,8 +1654,8 @@
@@ -1659,7 +1672,7 @@ -
+
@@ -1668,16 +1681,16 @@ Comentario - + Fecha Hora - + {#if notasPedimento.length === 0} - + No hay notas registradas @@ -1696,7 +1709,7 @@ -
+
- + Página {currentNotasPage + 1} de {totalNotasPages || 1}
@@ -1829,7 +1842,7 @@
@@ -1873,7 +1886,7 @@
- +
-
+
# Descripción de Mercancía - Cantidad UMC - Cantidad UMT - Peso + Cantidad UMC + Cantidad UMT + Peso {#if mercancias.length === 0} - + No hay mercancías registradas @@ -2086,17 +2099,17 @@ -
- - - -
@@ -2109,7 +2122,7 @@ type="number" bind:value={currentEmbarque.cantidad_transportes} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2119,7 +2132,7 @@ type="number" bind:value={currentEmbarque.cantidad_partidas} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2130,7 +2143,7 @@ step="0.001" value={currentEmbarque.suma_cantidad_umc.toFixed(3)} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2140,7 +2153,7 @@ type="number" bind:value={currentEmbarque.cantidad_embarques} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2150,7 +2163,7 @@ type="number" bind:value={currentEmbarque.cantidad_mercancias} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> @@ -2161,13 +2174,13 @@ step="0.001" value={currentEmbarque.suma_cantidad_umc_embarques.toFixed(3)} readonly - class="text-right h-9 text-sm" + class="h-9 text-right text-sm" /> -
+
@@ -2190,7 +2203,7 @@ - + Mercancías del Embarque Parcial @@ -2201,7 +2214,7 @@
@@ -2311,7 +2324,7 @@
diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte index cd7288cb..35f52052 100644 --- a/frontend/src/lib/components/keyboard/KeyboardManager.svelte +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -117,6 +117,7 @@ const authenticated = isAuthenticated(); const { key, altKey, ctrlKey, metaKey, shiftKey } = event; + if (!key) return; const lowerKey = key.toLowerCase(); // Ignore standalone modifiers diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index f3ebcc7c..a38220bd 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -187,7 +187,7 @@ selectedInvoiceId = null; } else { selectedInvoiceId = invoice.id; - } + } } const selectedInvoice = $derived( @@ -581,6 +581,36 @@ reloadData(); } + async function handleUpdateStatus(status: boolean) { + if (!selectedInvoice || !companyStore.activeCompany) { + toast.info('Seleccione una factura para cambiar su estatus'); + return; + } + + loading = true; + try { + const companyId = companyStore.activeCompany.id; + const response = await invoicesApi.update(selectedInvoice.id, companyId, { + id: selectedInvoice.id, + is_updated: status + }); + + if (response.error) { + toast.error( + `Error al ${status ? 'actualizar' : 'desactualizar'} factura: ${response.error}` + ); + } else { + toast.success(`Factura ${status ? 'actualizada' : 'desactualizada'} correctamente`); + reloadData(); + } + } catch (e) { + console.error('Error updating status:', e); + toast.error('Error inesperado al cambiar el estatus'); + } finally { + loading = false; + } + } + // Opciones de tipo de operación para el filtro const operationTypeOptions = [ { value: '', label: 'Todas' }, @@ -782,11 +812,21 @@
- - diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 506ec342..df9e4fec 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -225,7 +225,7 @@ // Función para mapear la factura existente a los formData function mapInvoiceToTopFields(invoice: any) { if (!invoice) return topFieldsSkeleton; - + let operationType: string | null = null; if (invoice.operation_type) { operationType = invoice.operation_type; @@ -251,7 +251,7 @@ function mapInvoiceToGeneral(invoice: any) { if (!invoice) return generalSkeleton; - + return { provider_header: invoice.compliance_mx?.provider_header || 'proveedor', provider_id: invoice.compliance_mx?.provider_id || null, @@ -278,7 +278,7 @@ function mapInvoiceToObservations(invoice: any) { if (!invoice) return observationSkeleton; - + return { observation_es: invoice.observation_es || '', observation_en: invoice.observation_en || '', @@ -300,7 +300,7 @@ function mapInvoiceToItems(invoice: any) { if (!invoice) return ensureItemsFormData(null); - + return { items: invoice.items || [] }; @@ -308,7 +308,7 @@ function mapInvoiceToOthers(invoice: any) { if (!invoice) return othersSkeleton; - + return { comments_status: invoice.comments_status || '', transport_mode: invoice.logistics?.transport_mode || 'TRUCK', @@ -337,7 +337,7 @@ function mapInvoiceToContinuation(invoice: any) { if (!invoice) return continuationSkeleton; - + return { numero_tipo_transporte: invoice.logistics?.numero_tipo_transporte || '', es_ferrocarril: invoice.logistics?.es_ferrocarril || 'no', @@ -351,10 +351,13 @@ funge_como_cd: invoice.logistics?.acts_as_cd || false, llego_pedimento: invoice.compliance_mx?.llego_pedimento || false, errores_facturacion: invoice.errores_facturacion || [], - semaforo_verde_aduana_mexicana: invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, - semaforo_verde_aduana_americana: invoice.compliance_mx?.semaforo_verde_aduana_americana || false, + semaforo_verde_aduana_mexicana: + invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, + semaforo_verde_aduana_americana: + invoice.compliance_mx?.semaforo_verde_aduana_americana || false, semaforo_rojo_aduana_mexicana: invoice.compliance_mx?.semaforo_rojo_aduana_mexicana || false, - semaforo_rojo_aduana_americana: invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, + semaforo_rojo_aduana_americana: + invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, is_mixed: invoice.compliance_mx?.is_mixed || false, reason_export: invoice.compliance_mx?.reason_export || '1', purchase_order: invoice.purchase_order || '', @@ -405,8 +408,8 @@ !data.isCreate ? !!data.invoice : !!data.defaultSettings?.observationFormData ); let itemsExists = $state( - !data.isCreate - ? !!(data.invoice?.items && data.invoice.items.length > 0) + !data.isCreate + ? !!(data.invoice?.items && data.invoice.items.length > 0) : !!data.defaultSettings?.itemsFormData?.items?.length ); let othersExists = $state( @@ -484,7 +487,7 @@ const actualResponse = response as any; const items = actualResponse.data?.items || []; - if (items.length === 0) { + if (items.length === 0) { if (!uiStore.isExchangeRateDialogOpen) { missingExchangeRateDate = date; showExchangeRateDialog = true; diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index dd9613e9..6898d88d 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -85,7 +85,7 @@ function handleRowClick(pedimento: Pedimento) { // Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar - selectedId = selectedId === pedimento.id ? null : pedimento.id; + selectedId = selectedId === pedimento.id ? null : pedimento.id; } function handleEditSelected() { @@ -149,6 +149,11 @@ try { const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -194,7 +199,12 @@ error = null; try { - const companyId = companyStore.activeCompany?.id || 1; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -248,7 +258,12 @@ error = null; try { - const companyId = companyStore.activeCompany.id; + const companyId = companyStore.activeCompany?.id; + if (!companyId) { + error = 'No hay compañía seleccionada'; + loading = false; + return; + } const filterParams = { status: filters.status || undefined, @@ -334,17 +349,17 @@ -
+
- -
+ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index 8bc0f930..af2950b8 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -5,14 +5,14 @@ import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; import PartNumberDialog from './part-number-dialog.svelte'; - import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; + import type { Item, LineDescriptions } from '$lib/api/dashboard/a76/items'; - let { + let { lineItem = $bindable(), - descriptions = $bindable() - }: { + descriptions = $bindable() + }: { lineItem: Partial; - descriptions: LineDescriptions; + descriptions: LineDescriptions; } = $props(); let showPartDialog = $state(false); @@ -22,30 +22,30 @@ lineItem.fa_data = {}; } - // Helper to map boolean to string for RadioGroup - let isSubPartidaValue = $derived(lineItem.fa_data?.is_subitem ? 'subpartida' : 'partida'); - function setIsSubPartida(val: string) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.is_subitem = val === 'subpartida'; - } + // Helper to map boolean to string for RadioGroup + let isSubPartidaValue = $derived(lineItem.fa_data?.is_subitem ? 'subpartida' : 'partida'); + function setIsSubPartida(val: string) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.is_subitem = val === 'subpartida'; + } - let containsSubPartidasValue = $derived(lineItem.fa_data?.contains_subitems ? 'si' : 'no'); - function setContinueSubPartidas(val: string) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.contains_subitems = val === 'si'; - } + let containsSubPartidasValue = $derived(lineItem.fa_data?.contains_subitems ? 'si' : 'no'); + function setContinueSubPartidas(val: string) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.contains_subitems = val === 'si'; + } - // Helper for subitem_number binding - let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0); - function setSubitemNumber(val: number) { - if (!lineItem.fa_data) lineItem.fa_data = {}; - lineItem.fa_data.subitem_number = val; - } + // Helper for subitem_number binding + let subitemNumber = $derived.by(() => lineItem.fa_data?.subitem_number ?? 0); + function setSubitemNumber(val: number) { + if (!lineItem.fa_data) lineItem.fa_data = {}; + lineItem.fa_data.subitem_number = val; + } function handlePartSelect(part: any) { lineItem.part_number = part.id; // Store part number for display - (lineItem as any).part_number = part.part_number; + (lineItem as any).part_number_display = part.part_number; (lineItem as any).part_description_es = part.description_spanish; (lineItem as any).part_description_en = part.description_english; } @@ -53,15 +53,12 @@ -
+
-
- Is - +
+ Is +
@@ -73,12 +70,15 @@
{#if isSubPartidaValue === 'partida'} -
- Contains Sub-Items - + Contains Sub-Items + + class="flex gap-3" + >
@@ -90,31 +90,33 @@
{:else if isSubPartidaValue === 'subpartida'} -
- Main Item Number - + Main Item Number + setSubitemNumber(e.currentTarget.valueAsNumber || 0)} - class="h-7 text-xs" + class="h-7 text-xs" placeholder="Enter main item number" /> -
- {/if} +
+ {/if}
-
+
- (showPartDialog = true)} /> @@ -124,29 +126,33 @@ class="h-7 w-7 shrink-0" onclick={() => (showPartDialog = true)} > - +
{#if (lineItem as any).part_description_es} -

{(lineItem as any).part_description_es}

+

+ {(lineItem as any).part_description_es} +

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

Items de la Factura

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

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

No se encontraron plantillas

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

+ + +
+ +

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

+
@@ -752,6 +563,48 @@ companyId ); + // También actualizar la extensión FA + if (selectedClass.fa_class_id) { + await faClassesApi.update( + selectedClass.fa_class_id, + { + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null + }, + companyId + ); + } else { + await faClassesApi.create( + { + class_id: selectedClass.id, + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null, + class_enabled: true + }, + companyId + ); + } + // ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error, status } if (response.error) { console.error('❌ Error en respuesta de actualización:', response); @@ -770,8 +623,18 @@ sub_key: cleanData.sub_key || '', physical_review: cleanData.physical_review ? 1 : 0, iva_exempt_fraction: cleanData.iva_exempt_fraction || '', - depreciation_rate: cleanData.depreciation_rate || null, - fda_code: cleanData.fda_code || null, + depreciation_rate: + cleanData.annual_depreciation_rate !== undefined && + cleanData.annual_depreciation_rate !== null && + cleanData.annual_depreciation_rate !== '' + ? Number(cleanData.annual_depreciation_rate) + : cleanData.depreciation_rate !== undefined && + cleanData.depreciation_rate !== null && + cleanData.depreciation_rate != null + ? Number(cleanData.depreciation_rate) + : null, + fda_code: (cleanData.fda_key ?? cleanData.fda_code) || null, + eccn_code: cleanData.eccn_code || null, class_enabled: true }; diff --git a/frontend/src/routes/dashboard/reports/invoices/+page.svelte b/frontend/src/routes/dashboard/reports/invoices/+page.svelte index 07b043a8..2776ce68 100644 --- a/frontend/src/routes/dashboard/reports/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/reports/invoices/+page.svelte @@ -457,19 +457,22 @@ document.body.removeChild(link); window.URL.revokeObjectURL(url); - toast.success('Reporte generado y descargado. También se ha enviado por correo.', { - id: toastId - }); + toast.success( + `Reporte generado y descargado.${config.sendEmail ? ' También se ha enviado por correo.' : ''}`, + { + id: toastId + } + ); } catch (downloadErr) { console.error('Error downloading file:', downloadErr); toast.success( - 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + `Reporte generado correctamente.${config.sendEmail ? ' Se ha enviado un correo con los resultados.' : ''}`, { id: toastId } ); } } else { toast.success( - 'Reporte generado correctamente. Se ha enviado un correo con los resultados.', + `Reporte generado correctamente.${config.sendEmail ? ' Se ha enviado un correo con los resultados.' : ''}`, { id: toastId } ); } From af9be8d0b5f0c5b363619de9e671cc2c38f719f6 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 24 Feb 2026 09:23:07 -0600 Subject: [PATCH 47/52] feat: Refine invoice report queries to handle empty concatenated transport data and adjust subpartida logic for peso calculation. --- .../movements/invoices/services/export.py | 74 +++++++++---------- .../invoices/services/export_repair.py | 6 +- .../invoices/services/query_builders.py | 16 ++-- .../movements/invoices/services/temporary.py | 4 +- 4 files changed, 50 insertions(+), 50 deletions(-) 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 index 17ceb6f9..de65c9db 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/export.py @@ -64,13 +64,13 @@ class ExportService: for row in results: factura = row[0] # C1 - FacturaExpo - tipo_mov = row[15] # C34 - TipoFactura + tipo_mov = row[14] # C34 - TipoFactura # Skip cancelled if not included if not filters.include_cancelled and row[3] != 'AC': # C6 - Estatus continue - consecutivo = row[16] # C35 - Consecutivo + consecutivo = row[15] # C35 - Consecutivo # Totals come directly from GROUP BY query (no N+1 problem) def to_float(val): @@ -78,10 +78,10 @@ class ExportService: try: return float(val) except (ValueError, TypeError): return 0.0 - total_me = to_float(row[24]) # total_me from SUM aggregation - total_mn = to_float(row[25]) # total_mn from SUM aggregation - sum_value_usd = to_float(row[27]) - sum_value_mxn = to_float(row[28]) + total_me = to_float(row[23]) # total_me from SUM aggregation + total_mn = to_float(row[24]) # total_mn from SUM aggregation + sum_value_usd = to_float(row[26]) + sum_value_mxn = to_float(row[27]) total_me = sum_value_usd if sum_value_usd > 0 else total_me total_mn = sum_value_mxn if sum_value_mxn > 0 else total_mn @@ -92,9 +92,9 @@ class ExportService: 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_cambio_db=row[18], # C48 - TipoCambio + fecha_pago=row[7], # C11 - Fecha_Pago + fecha_inicio=row[6], # C10 - Fecha_Inicio (mapped previously to C9) tipo_pedimento='', # Not in aggregated query currency_type=filters.currency_type.value, exchange_rate_type=filters.exchange_rate_type.value, @@ -107,7 +107,7 @@ class ExportService: rectified_pedimento = DatabaseHelper.get_rectification_pedimento( db, row[1], # C2 - PedimentoExpo - row[26], # C54 - PedRectifica + row[25], # C54 - PedRectifica filters.is_shelter ) @@ -126,19 +126,19 @@ class ExportService: ValorMPTemp=valor_comercial, ValorComercialMN=valor_comercial, TipoCambio=tipo_cambio, - ValorAgre=0.0, + ValorAgre=to_float(row[28]), TipoExpo='EXPO DEF', PedimentoR1=rectified_pedimento, - EDocument=row[17], # C40 - EDocument - NumOperacionVU=row[18], # C41 - NumOperacionVU + EDocument=row[16], # C40 - EDocument + NumOperacionVU=row[17], # 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 + UsuarioCap=row[20], # C50 - UsuarioCap + UsuarioAcr=row[21], # C51 - UsuarioAct + Fecha_Pago=parse_yyyymmdd_date(row[7]), # C11 - Fecha_Pago + NumCaja=row[22], # C53 - Transporte + NumTrasporte Pedimento18='', # Not in aggregated query - AduanaCru=row[14], # C33 - Aduana_Cruce + AduanaCru=row[13], # C33 - Aduana_Cruce Lote='' # Not in aggregated query ) @@ -231,9 +231,9 @@ class ExportService: 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 + # Set peso values (material_type is typically 'PT' or 'MP', not just 'P') + peso_neto_final = row[24] if row[37] != 'S' else 0 # C25 - PesoNeto + peso_bruto_final = row[25] if row[37] != 'S' else 0 # C26 - PesoBruto # Get series information series_info = DatabaseHelper.get_series_info_export( @@ -253,7 +253,7 @@ class ExportService: # Build detailed movement item movement = MovementItemDetailed( - Linea=row[39], # C42 - LineaExpo + Linea=row[41], # C42 - LineaExpo Factura=row[0], # C1 - FacturaExpo Pedimento=row[1], # C2 - PedimentoExpo FechaFactura=row[2], # C3 - FechaFactura @@ -289,26 +289,26 @@ class ExportService: Sector=row[30], # C31 - Sector PaisOrigen=row[31], # C32 - PaisOrigen Aduana=customs_name, - Advalorem=row[27], # C30 - Advalorem + Advalorem=row[29], # C30 - Advalorem TipoExpo='EXPO DEF', PedimentoR1=rectified_pedimento, - EDocument=row[37], # C40 - EDocument - NumOperacionVU=row[38], # C41 - NumOperacionVU + EDocument=row[39], # C40 - EDocument + NumOperacionVU=row[40], # C41 - NumOperacionVU Series=series_info, - Marca=StringHelper.clean_text(row[40]), # C43 - Marca - Modelo=StringHelper.clean_text(row[41]), # C44 - Modelo - FraccionAmericana=row[42], # C45 - FraccionAme - ECCN=row[43], # C46 - ECCN - FechaEmision=parse_yyyymmdd_date(row[46]) if row[46] else None, # C49 - FechaEmision + Marca=StringHelper.clean_text(row[42]), # C43 - Marca + Modelo=StringHelper.clean_text(row[43]), # C44 - Modelo + FraccionAmericana=row[44], # C45 - FraccionAme + ECCN=row[45], # C46 - ECCN + FechaEmision=parse_yyyymmdd_date(row[48]) if row[48] else None, # C49 - FechaEmision BaseDeDatos=filters.database_name, NumGafUni=driver_badge, - UsuarioCap=row[47], # C50 - UsuarioCap - UsuarioAcr=row[48], # C51 - UsuarioAct - Transportista=row[49], # C52 - Carrier ID (derived from log.transport_id) - NumCaja=row[50], # C53 - log.transport_id || log.transport_num - Pedimento18=row[51], # C54 - empty - AduanaCru=row[30], # C33 - Aduana_Cruce - Lote=row[52] if len(row) > 52 else '' # C55 - Lote + UsuarioCap=row[49], # C50 - UsuarioCap + UsuarioAcr=row[50], # C51 - UsuarioAct + Transportista=row[51], # C52 - Carrier ID (derived from log.transport_id) + NumCaja=row[52], # C53 - log.transport_id || log.transport_num + Pedimento18=row[53], # C54 - empty + AduanaCru=row[32], # C33 - Aduana_Cruce + Lote=row[54] if len(row) > 54 else '' # C55 - Lote ) 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 index 98b33a0f..63704ddd 100644 --- 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 @@ -155,7 +155,7 @@ class ExportRepairService: ValorMPTemp=valor_comercial, ValorComercialMN=valor_comercial, TipoCambio=tipo_cambio, - ValorAgre=0.0, + ValorAgre=to_float(row[29]), TipoExpo='EXPO REP', PedimentoR1=pedimento_r1, EDocument=row[16], # C40 - EDocument ← FIXED (was 17) @@ -245,8 +245,8 @@ class ExportRepairService: db, filters.database_name, row[32] # C33 - Aduana_Cruce ) - # Calculate values (only for main partidas 'P') - if row[37] == 'P': # C38 - EsSubPartida + # Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S') + if row[37] != 'S': # C38 - EsSubPartida valor_comercial, tipo_cambio = ExchangeRateCalculator.calculate_for_partida( db=db, db_name=filters.database_name, 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 index 6e14c659..7b2d7fdb 100644 --- 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 @@ -41,7 +41,7 @@ class TemporaryImportQueries: 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, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_num, log.license_plate), ''), '') AS C55, '' AS C56, '' AS C57, '' AS C58, @@ -147,7 +147,7 @@ class TemporaryImportQueries: 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, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_num, log.license_plate), ''), '') AS C55, '' AS C56, COALESCE(ld.lot, '') AS C57, '' AS C58, @@ -255,7 +255,7 @@ class DefinitiveImportQueries: 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, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C56, '' AS C57, '' AS C58, '' AS C59, @@ -354,7 +354,7 @@ class DefinitiveImportQueries: COALESCE(ih.capture_user, '') AS C52, COALESCE(ih.who_updated, '') AS C53, COALESCE(log.carrier_id, '') AS C54, - COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C55, + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C55, '' AS C56, COALESCE(ld.lot, '') AS C57, '' AS C58, @@ -683,7 +683,7 @@ class ExportQueries: 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(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, COALESCE(fin.value_me, 0) AS total_me, COALESCE(fin.value_mn, 0) AS total_mn, COALESCE( @@ -777,7 +777,7 @@ class ExportQueries: 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 + COALESCE(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, -- [52] NumCaja '' AS C54, -- [53] Pedimento18 COALESCE(ld.lot, '') AS C55, -- [54] Lote '' AS C56, -- [55] TipoPedimentoTransporte @@ -894,7 +894,7 @@ class ExportRepairQueries: 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(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, COALESCE(fin.value_me, 0) AS total_me, COALESCE(fin.value_mn, 0) AS total_mn, COALESCE( @@ -994,7 +994,7 @@ class ExportRepairQueries: 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(NULLIF(CONCAT_WS(' ', log.transport_id, log.transport_num), ''), '') AS C53, '' AS C54, COALESCE(ld.lot, '') AS C55, '' AS C56, 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 index 40ae067c..1e3faa70 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/temporary.py @@ -285,8 +285,8 @@ class TemporaryImportService: # Assign the properly converted commercial value directly valor_comercial_mn = float(valor_comercial) - # Set peso values based on subpartida flag - if row[39] == 'P': # C40 - EsSubPartida + # Set peso values based on subpartida flag (allow 'PT', 'MP', etc. but block 'S') + if row[39] != 'S': # C40 - EsSubPartida peso_neto = float(row[28]) if row[28] else 0.0 # C29 peso_bruto = float(row[29]) if row[29] else 0.0 # C30 else: From a52602dedf3f9a6d45d40adacc215f9863bcd44e Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 24 Feb 2026 14:24:50 -0600 Subject: [PATCH 48/52] =?UTF-8?q?feat:=20Implement=20Ventanilla=20=C3=9Ani?= =?UTF-8?q?ca=20(VU)=20management=20for=20customs=20brokers,=20including?= =?UTF-8?q?=20API,=20UI,=20and=20data=20model=20updates,=20and=20add=20a?= =?UTF-8?q?=20new=20state=20selection=20dialog.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../7937209f9718_seed_initial_data.py | 3 +- .../api/v1/modules/a76/customs_brokers/dto.py | 55 +- .../v1/modules/a76/customs_brokers/models.py | 2 +- .../modules/a76/customs_brokers/services.py | 38 +- .../lib/api/dashboard/a76/customs-brokers.ts | 1 + .../dashboard/customs_brokers/columns.ts | 18 +- .../edit/items/fa/state-dialog.svelte | 153 ++++ .../dashboard/customs_brokers/edit.ts | 6 + .../customs_brokers/edit/[[id]]/+page.svelte | 673 +++++++++++++++++- 9 files changed, 871 insertions(+), 78 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 5bffa511..1efba5f4 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -94,9 +94,8 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" + """Upgrade schema.""" - # --- UTILIDAD DE FORMATEO --- def format_value(val): if val is None or str(val).strip() == "" or str(val).upper() == "NONE": return "NULL" diff --git a/backend/api/v1/modules/a76/customs_brokers/dto.py b/backend/api/v1/modules/a76/customs_brokers/dto.py index 7f65b264..e7f38cba 100644 --- a/backend/api/v1/modules/a76/customs_brokers/dto.py +++ b/backend/api/v1/modules/a76/customs_brokers/dto.py @@ -43,6 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO): broker_key: str tenant_id: int company_id: int + vu: Optional["CustomsBrokerVUCreateDTO"] = None class Config: from_attributes = True @@ -75,25 +76,25 @@ class CustomsBrokerDTO(BaseModel): class CustomsBrokerVUCreateDTO(BaseModel): - certificate_path: Optional[str] - key_path: Optional[str] - access_key: Optional[str] - fiel_format: Optional[str] - signature_read_path: Optional[str] - archive_path: Optional[str] - fiel_access_key: Optional[str] - web_service_user: Optional[str] - web_service_access_key: Optional[str] - vu_email: Optional[str] - vu_figure_type: Optional[str] - xml_files_path: Optional[str] - query_tax_id: Optional[str] - doda_certificate_path: Optional[str] - doda_key_path: Optional[str] - doda_web_service_user: Optional[str] - doda_web_service_access_key: Optional[str] - doda_fiel_access_key: Optional[str] - doda_xml_files_path: Optional[str] + certificate_path: Optional[str] = None + key_path: Optional[str] = None + access_key: Optional[str] = None + fiel_format: Optional[str] = None + signature_read_path: Optional[str] = None + archive_path: Optional[str] = None + fiel_access_key: Optional[str] = None + web_service_user: Optional[str] = None + web_service_access_key: Optional[str] = None + vu_email: Optional[str] = None + vu_figure_type: Optional[str] = None + xml_files_path: Optional[str] = None + query_tax_id: Optional[str] = None + doda_certificate_path: Optional[str] = None + doda_key_path: Optional[str] = None + doda_web_service_user: Optional[str] = None + doda_web_service_access_key: Optional[str] = None + doda_fiel_access_key: Optional[str] = None + doda_xml_files_path: Optional[str] = None class Config: from_attributes = True @@ -102,15 +103,15 @@ class CustomsBrokerVUCreateDTO(BaseModel): class CustomsBrokerPersonnelDTO(BaseModel): broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$") line: int - name: Optional[str] - tax_id: Optional[str] - personal_id: Optional[str] - position: Optional[str] + name: Optional[str] = None + tax_id: Optional[str] = None + personal_id: Optional[str] = None + position: Optional[str] = None license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$") - first_name: Optional[str] - last_name: Optional[str] - middle_name: Optional[str] - email: Optional[str] + first_name: Optional[str] = None + last_name: Optional[str] = None + middle_name: Optional[str] = None + email: Optional[str] = None class Config: from_attributes = True diff --git a/backend/api/v1/modules/a76/customs_brokers/models.py b/backend/api/v1/modules/a76/customs_brokers/models.py index a5925a62..11c29580 100644 --- a/backend/api/v1/modules/a76/customs_brokers/models.py +++ b/backend/api/v1/modules/a76/customs_brokers/models.py @@ -32,7 +32,7 @@ class CustomsBroker(Base, TenantScopedMixin, TimestampMixin): contact = Column(String(80), nullable=True) vu = relationship( - "CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete" + "CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete", uselist=False ) personnel = relationship( "CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete" diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index ea72abe5..c1c10d68 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -76,7 +76,8 @@ class CustomsBrokerVUService: def get_by_broker_key(db: Session, broker_key: str): return ( db.query(models.CustomsBrokerVU) - .filter(models.CustomsBrokerVU.broker_key == broker_key) + .join(models.CustomsBroker) + .filter(models.CustomsBroker.broker_key == broker_key) .first() ) @@ -90,13 +91,28 @@ class CustomsBrokerVUService: @staticmethod def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO): - vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key) + # We need the custom broker ID to insert a new VU + broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first() + if not broker: + return None + + vu = db.query(models.CustomsBrokerVU).filter(models.CustomsBrokerVU.customs_broker_id == broker.id).first() + if vu: - for key, value in vu_data.dict(exclude_unset=True).items(): + # Update existing + for key, value in vu_data.model_dump(exclude_unset=True).items(): setattr(vu, key, value) db.commit() db.refresh(vu) - return vu + return vu + else: + # Create new + new_vu_data = vu_data.model_dump() + new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data) + db.add(new_vu) + db.commit() + db.refresh(new_vu) + return new_vu @staticmethod def delete_vu(db: Session, broker_key: str): @@ -112,16 +128,22 @@ class CustomsBrokerPersonnelService: def get_by_broker_key_and_line(db: Session, broker_key: str, line: int): return ( db.query(models.CustomsBrokerPersonnel) + .join(models.CustomsBroker) .filter( - models.CustomsBrokerPersonnel.broker_key == broker_key, + models.CustomsBroker.broker_key == broker_key, models.CustomsBrokerPersonnel.line == line, ) .first() ) @staticmethod - def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO): - new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict()) + def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO): + broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first() + if not broker: + return None + + new_personnel_data = personnel_data.model_dump() + new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data) db.add(new_personnel) db.commit() db.refresh(new_personnel) @@ -138,7 +160,7 @@ class CustomsBrokerPersonnelService: db, broker_key, line ) if personnel: - for key, value in personnel_data.dict(exclude_unset=True).items(): + for key, value in personnel_data.model_dump(exclude_unset=True).items(): setattr(personnel, key, value) db.commit() db.refresh(personnel) diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index 981ef884..f08c1726 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -22,6 +22,7 @@ export interface CustomsBroker { contact?: string | null; tenant_id: string; company_id: string; + vu?: CustomsBrokerVU | null; } export interface CustomsBrokerVU { diff --git a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts index 24400b55..3ea92e34 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts +++ b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts @@ -28,8 +28,12 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ cell: ({ row }) => { const typeSnippet = createRawSnippet<[{ type: string | null | undefined }]>((getType) => { const { type } = getType(); + let display = type || '-'; + if (type === 'MEX') display = 'Agente Aduanal Mexicano'; + else if (type === 'USA') display = 'Agente Aduanal Americano (Broker)'; + return { - render: () => `
${type || '-'}
` + render: () => `
${display}
` }; }); return renderSnippet(typeSnippet, { type: row.original.type }); @@ -68,8 +72,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ const postalSnippet = createRawSnippet<[{ postal: string | null | undefined }]>((getPostal) => { const { postal } = getPostal(); return { - render: () => - postal + render: () => + postal ? `${postal}` : `-` }; @@ -136,8 +140,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ const licenseSnippet = createRawSnippet<[{ license: string | null | undefined }]>((getLicense) => { const { license } = getLicense(); return { - render: () => - license + render: () => + license ? `${license}` : `-` }; @@ -175,9 +179,9 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ id: "actions", header: "Acciones", cell: ({ row }) => { - return renderComponent(DataTableActions, { + return renderComponent(DataTableActions, { broker: row.original, - onSuccess + onSuccess }); } } diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte new file mode 100644 index 00000000..81f228e8 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte @@ -0,0 +1,153 @@ + + + + + + CATALOGO DE ESTADOS + + +
+
+ + +
+
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

{error}

+
+ {:else} +
+
+ + + + + + + + + + {#each filteredItems as item} + handleSelect(item)} + > + + + + + + {/each} + {#if filteredItems.length === 0} + + + + {/if} + +
Clave M3Clave MexClave AmeDescripción
{item.m3_key || ''}{item.mex_key || ''}{item.ame_key || ''}{item.description || ''}
+ No se encontraron resultados +
+
+ {/if} +
+ +
+ +
+ + diff --git a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts index dc716087..8902cccb 100644 --- a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts +++ b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts @@ -4,6 +4,7 @@ export const obtenerAtajosEdicionAgente = (acciones: { irGeneral: () => void; irContacto: () => void; irDireccion: () => void; + irVU: () => void; guardar: () => void; cancelar: () => void; }): ShortcutDef[] => [ @@ -22,6 +23,11 @@ export const obtenerAtajosEdicionAgente = (acciones: { description: 'Tab Dirección', action: acciones.irDireccion }, + { + key: 'Alt+Digit4', + description: 'Tab Ventanilla Única', + action: acciones.irVU + }, { key: 'Ctrl+S', description: 'Guardar', diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 264e7d8a..801303a2 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -8,6 +8,8 @@ } from '$lib/api/dashboard/a76/customs-brokers'; // UI Components + import CountryDialog from '$lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte'; + import StateDialog from '$lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; @@ -15,6 +17,7 @@ import { Badge } from '$lib/components/ui/badge'; import * as Tabs from '$lib/components/ui/tabs'; import * as Card from '$lib/components/ui/card'; + import * as Select from '$lib/components/ui/select'; import { ArrowLeft, Loader2, @@ -24,7 +27,18 @@ MapPin, Settings, FileText, - Hash + Hash, + FileKey, + Key, + Globe, + Folder, + Mail, + UserRound, + Fingerprint, + Lock, + Signature, + Archive, + ShieldCheck } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; @@ -40,6 +54,8 @@ let activeTab = $state('general'); let error = $state(null); let dataLoaded = $state(false); + let showCountryDialog = $state(false); + let showStateDialog = $state(false); let formData = $state({ broker_key: '', @@ -62,6 +78,28 @@ company_id: '' }); + let vuData = $state({ + certificate_path: '', + key_path: '', + xml_files_path: '', + fiel_access_key: '', + doda_web_service_user: '', + doda_web_service_access_key: '', + doda_certificate_path: '', + doda_key_path: '', + doda_fiel_access_key: '', + doda_xml_files_path: '', + web_service_user: '', + web_service_access_key: '', + query_tax_id: '', + vu_email: '', + vu_figure_type: '', + fiel_format: '', + access_key: '', + signature_read_path: '', + archive_path: '' + }); + let brokerKeyError = $state(false); let licenseError = $state(false); let brokerKeyTimeout: ReturnType; @@ -77,6 +115,16 @@ } }); + // --- 5. FUNCIONES --- + function handleLocalFileSelect(event: Event, targetKey: keyof typeof vuData) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + vuData[targetKey] = file.name; + toast.success(`Archivo ${file.name} seleccionado`); + } + } + async function loadBrokerData(key: string, cId: string) { if (!key || key === 'undefined') return; loading = true; @@ -106,9 +154,32 @@ company: d.company || '', company_id: cId }; + if (d.vu) { + vuData = { + certificate_path: d.vu.certificate_path || '', + key_path: d.vu.key_path || '', + xml_files_path: d.vu.xml_files_path || '', + fiel_access_key: d.vu.fiel_access_key || '', + doda_web_service_user: d.vu.doda_web_service_user || '', + doda_web_service_access_key: d.vu.doda_web_service_access_key || '', + doda_certificate_path: d.vu.doda_certificate_path || '', + doda_key_path: d.vu.doda_key_path || '', + doda_fiel_access_key: d.vu.doda_fiel_access_key || '', + doda_xml_files_path: d.vu.doda_xml_files_path || '', + web_service_user: d.vu.web_service_user || '', + web_service_access_key: d.vu.web_service_access_key || '', + query_tax_id: d.vu.query_tax_id || '', + vu_email: d.vu.vu_email || '', + vu_figure_type: d.vu.vu_figure_type || '', + fiel_format: d.vu.fiel_format || '', + access_key: d.vu.access_key || '', + signature_read_path: d.vu.signature_read_path || '', + archive_path: d.vu.archive_path || '' + }; + } dataLoaded = true; } else if (d.error) { - error = d.error; + error = d.error as string; toast.error(error); } } catch (e: any) { @@ -119,6 +190,18 @@ } } + function handleCountrySelect(country: any) { + const nextCountry = country.m3_key || country.mex_key || country.ame_key; + if (formData.country !== nextCountry) { + formData.state = ''; + } + formData.country = nextCountry; + } + + function handleStateSelect(state: any) { + formData.state = state.m3_key || state.mex_key || state.ame_key; + } + // --- 4. GUARDADO --- async function handleSave() { if (!companyStore.activeCompany) { @@ -151,15 +234,31 @@ formData.company_id = cId; const res = isEdit - ? await customsBrokersApi.update(routeId!, formData, cId) + ? await customsBrokersApi.update(routeId!, formData) : await customsBrokersApi.create(formData, cId); if ((res as any).error) throw new Error((res as any).error); + // UPSERT VU + try { + const vuRes = await customsBrokersApi.updateVU(formData.broker_key, vuData, cId); + if ((vuRes as any).error) { + toast.error( + 'Agente guardado, pero ocurrió un error guardando Ventanilla Única: ' + + (vuRes as any).error + ); + return; // Avoid triggering success redirection + } + } catch (vuErr: any) { + toast.error('Agente guardado, pero ocurrió un error guardando Ventanilla Única.'); + console.error(vuErr); + return; + } + toast.success(isEdit ? 'Agente actualizado' : 'Agente creado'); goto('/dashboard/customs_brokers'); } catch (e: any) { - error = e.message || 'Error al procesar la solicitud'; + error = (e.message || 'Error al procesar la solicitud') as string; toast.error(error); } finally { loading = false; @@ -176,6 +275,7 @@ irGeneral: () => (activeTab = 'general'), irContacto: () => (activeTab = 'contact'), irDireccion: () => (activeTab = 'address'), + irVU: () => (activeTab = 'vu'), guardar: handleSave, cancelar: handleCancel }) @@ -197,7 +297,7 @@ {isEdit ? 'Edición' : 'Nuevo'}
-

+

{isEdit ? 'Modifica la información del agente aduanal' : 'Registra un nuevo agente aduanal en el sistema'} @@ -224,7 +324,31 @@ Identificación oficial del agente y patente. -

+
+
+ + (formData.type = v)} + disabled={loading} + > + + {formData.type === 'MEX' + ? 'Agente Aduanal Mexicano' + : formData.type === 'USA' + ? 'Agente Aduanal Americano (Broker)' + : 'Selecciona un tipo...'} + + + Agente Aduanal Mexicano + Agente Aduanal Americano (Broker) + + +
+
+ +
{#if brokerKeyError} @@ -281,8 +405,8 @@ } }} placeholder="Ej. 3421" - maxlength="5" - class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''} + maxlength={5} + class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} disabled={loading} /> {#if licenseError} @@ -297,16 +421,22 @@
- +
-
+
@@ -315,6 +445,7 @@ bind:value={formData.personal_id} placeholder="CURP si aplica" disabled={loading} + class="h-10" />
@@ -330,13 +461,14 @@ Datos para comunicación con el agente. -
+
@@ -345,24 +477,26 @@ bind:value={formData.position} placeholder="Ej. Gerente Comercial" disabled={loading} + class="h-10" />
-
+
- +
@@ -371,6 +505,7 @@ bind:value={formData.email} placeholder="correo@empresa.com" disabled={loading} + class="h-10" />
@@ -392,26 +527,501 @@ bind:value={formData.address} placeholder="Dirección completa" disabled={loading} + class="h-10" />
-
+
- +
- +
-
+
- +
+ (showStateDialog = true)} + /> + +
- +
+ (showCountryDialog = true)} + /> + +
+
+
+ + + + + + + + + + Ventanilla Única / Web Services + Certificados y credenciales para integración con DODA/PITA. + + +
+
+ +
+ + handleLocalFileSelect(e, 'certificate_path')} + id="vu-cert-file" + /> + +
+
+
+ +
+ + handleLocalFileSelect(e, 'key_path')} + id="vu-key-file" + /> + +
+
+
+ +
+
+ + +
+
+ + + + {vuData.vu_figure_type || 'Seleccionar tipo de figura'} + + + AGENTE ADUANAL + APODERADO ADUANAL + MANDATARIO + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + handleLocalFileSelect(e, 'xml_files_path')} + id="vu-cove-file" + /> + +
+
+
+ + + +
+

+ Configuración Adicional +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + DODA-PITA + Configuración de servicios DODA / PITA. + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + handleLocalFileSelect(e, 'doda_certificate_path')} + id="doda-cert-file" + /> + +
+
+
+ +
+ + handleLocalFileSelect(e, 'doda_key_path')} + id="doda-key-file" + /> + +
+
+
+ +
+
+ + +
+
+ +
+ + handleLocalFileSelect(e, 'doda_xml_files_path')} + id="doda-xml-file" + /> + +
+
+
+
+
+
+ + + + ANAM + Configuración de acceso para ANAM. + + +
+
+ + +
+
+ +
@@ -424,22 +1034,19 @@
-
+
- - - General - - - Contacto - - - Dirección - + + General + Contacto + Domicilio + VU + DODA + ANAM
From 2028c8764533e512af39b3e2a9ee5716f4917d65 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 24 Feb 2026 16:57:22 -0600 Subject: [PATCH 49/52] feat: Add state and sector selector dialogs with search and infinite scroll capabilities, and update gitignore to include mypy cache. --- .gitignore | 1 + .../modals/sector-selector-dialog.svelte | 226 ++++++++++++++++++ .../modals/state-selector-dialog.svelte | 224 +++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte diff --git a/.gitignore b/.gitignore index 48bbc048..60db8bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Python __pycache__/ +.mypy_cache/ *.py[cod] *$py.class *.so diff --git a/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte new file mode 100644 index 00000000..dffe17af --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/sector-selector-dialog.svelte @@ -0,0 +1,226 @@ + + + + + + Seleccionar Sector PROSEC + + Seleccione el sector del catálogo. Escrolea para ver más. + + + +
+ + +
+ +
+ {#if loading && items.length === 0} +
+ +

Cargando catálogo...

+
+ {:else if items.length === 0} +
+

No se encontraron sectores.

+
+ {:else} + + + + Clave + Descripción + Autorizado + + + + {#each items as item} + handleSelect(item)} + > + +
+ + + {item.key} + +
+
+ + {item.description} + + + + {item.authorized ? 'Sí' : 'No'} + + +
+ {/each} +
+
+ +
+ {#if loadingMore} + + {/if} +
+ {/if} +
+ + +
+ {items.length} de {totalItems} registros +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte new file mode 100644 index 00000000..eb6f9c4c --- /dev/null +++ b/frontend/src/lib/components/dashboard/shared/modals/state-selector-dialog.svelte @@ -0,0 +1,224 @@ + + + + + + Seleccionar Estado + + Seleccione el estado del catálogo. Escrolea para ver más. + + + +
+ + +
+ +
+ {#if loading && items.length === 0} +
+ +

Cargando catálogo...

+
+ {:else if items.length === 0} +
+

No se encontraron estados.

+
+ {:else} + + + + Clave M3 + Descripción + MEX + + + + {#each items as item} + handleSelect(item)} + > + +
+ + + {item.m3_key} + +
+
+ + {item.description} + + + {item.mex_key || '-'} + +
+ {/each} +
+
+ +
+ {#if loadingMore} + + {/if} +
+ {/if} +
+ + +
+ {items.length} de {totalItems} registros +
+ +
+
+
From 980c913d4613a50e68b2c80d71eb34a73096eef8 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 24 Feb 2026 17:41:40 -0600 Subject: [PATCH 50/52] fix(customs-brokers): update VU and personnel for multi-tenancy support --- .gitignore | 1 + .../api/v1/modules/a76/customs_brokers/dto.py | 9 +- .../v1/modules/a76/customs_brokers/routes.py | 6 +- .../modules/a76/customs_brokers/services.py | 42 +++++++-- .../lib/api/dashboard/a76/customs-brokers.ts | 44 ++++++++-- .../dashboard/customs_brokers/+page.svelte | 87 +++++++++---------- 6 files changed, 124 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index 48bbc048..60db8bdd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Python __pycache__/ +.mypy_cache/ *.py[cod] *$py.class *.so diff --git a/backend/api/v1/modules/a76/customs_brokers/dto.py b/backend/api/v1/modules/a76/customs_brokers/dto.py index e7f38cba..9f1309c7 100644 --- a/backend/api/v1/modules/a76/customs_brokers/dto.py +++ b/backend/api/v1/modules/a76/customs_brokers/dto.py @@ -43,7 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO): broker_key: str tenant_id: int company_id: int - vu: Optional["CustomsBrokerVUCreateDTO"] = None + vu: Optional["CustomsBrokerVUResponseDTO"] = None class Config: from_attributes = True @@ -95,6 +95,11 @@ class CustomsBrokerVUCreateDTO(BaseModel): doda_web_service_access_key: Optional[str] = None doda_fiel_access_key: Optional[str] = None doda_xml_files_path: Optional[str] = None + tenant_id: Optional[int] = None + company_id: Optional[int] = None + +class CustomsBrokerVUResponseDTO(CustomsBrokerVUCreateDTO): + customs_broker_id: int class Config: from_attributes = True @@ -112,6 +117,8 @@ class CustomsBrokerPersonnelDTO(BaseModel): last_name: Optional[str] = None middle_name: Optional[str] = None email: Optional[str] = None + tenant_id: Optional[int] = None + company_id: Optional[int] = None class Config: from_attributes = True diff --git a/backend/api/v1/modules/a76/customs_brokers/routes.py b/backend/api/v1/modules/a76/customs_brokers/routes.py index e968ac78..541f41d9 100644 --- a/backend/api/v1/modules/a76/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/customs_brokers/routes.py @@ -62,7 +62,7 @@ def update_customs_broker( @router.put( "/customs-broker-vu/{broker_key}", - response_model=dto.CustomsBrokerVUCreateDTO, + response_model=dto.CustomsBrokerVUResponseDTO, ) def update_customs_broker_vu( broker_key: str, @@ -77,7 +77,7 @@ def update_customs_broker_vu( if not broker: raise HTTPException(status_code=404, detail="Customs Broker not found") - updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data) + updated_vu = services.CustomsBrokerVUService.update_vu(db, broker_key, vu_data, tenant_id, company_id) if not updated_vu: raise HTTPException(status_code=404, detail="Customs Broker VU not found") return updated_vu @@ -102,7 +102,7 @@ def update_customs_broker_personnel( raise HTTPException(status_code=404, detail="Customs Broker not found") updated_personnel = services.CustomsBrokerPersonnelService.update_personnel( - db, broker_key, line, personnel_data + db, broker_key, line, personnel_data, tenant_id, company_id ) if not updated_personnel: raise HTTPException( diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index c1c10d68..dc00f352 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -90,9 +90,17 @@ class CustomsBrokerVUService: return new_vu @staticmethod - def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO): + def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO, tenant_id: int, company_id: int): # We need the custom broker ID to insert a new VU - broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first() + broker = ( + db.query(models.CustomsBroker) + .filter( + models.CustomsBroker.broker_key == broker_key, + models.CustomsBroker.tenant_id == tenant_id, + models.CustomsBroker.company_id == company_id, + ) + .first() + ) if not broker: return None @@ -108,6 +116,8 @@ class CustomsBrokerVUService: else: # Create new new_vu_data = vu_data.model_dump() + new_vu_data["tenant_id"] = tenant_id + new_vu_data["company_id"] = company_id new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data) db.add(new_vu) db.commit() @@ -125,24 +135,36 @@ class CustomsBrokerVUService: class CustomsBrokerPersonnelService: @staticmethod - def get_by_broker_key_and_line(db: Session, broker_key: str, line: int): + def get_by_broker_key_and_line(db: Session, broker_key: str, line: int, tenant_id: int, company_id: int): return ( db.query(models.CustomsBrokerPersonnel) .join(models.CustomsBroker) .filter( models.CustomsBroker.broker_key == broker_key, models.CustomsBrokerPersonnel.line == line, + models.CustomsBroker.tenant_id == tenant_id, + models.CustomsBroker.company_id == company_id, ) .first() ) @staticmethod - def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO): - broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first() + def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO, tenant_id: int, company_id: int): + broker = ( + db.query(models.CustomsBroker) + .filter( + models.CustomsBroker.broker_key == broker_key, + models.CustomsBroker.tenant_id == tenant_id, + models.CustomsBroker.company_id == company_id, + ) + .first() + ) if not broker: return None new_personnel_data = personnel_data.model_dump() + new_personnel_data["tenant_id"] = tenant_id + new_personnel_data["company_id"] = company_id new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data) db.add(new_personnel) db.commit() @@ -155,16 +177,22 @@ class CustomsBrokerPersonnelService: broker_key: str, line: int, personnel_data: dto.CustomsBrokerPersonnelDTO, + tenant_id: int, + company_id: int, ): personnel = CustomsBrokerPersonnelService.get_by_broker_key_and_line( - db, broker_key, line + db, broker_key, line, tenant_id, company_id ) if personnel: for key, value in personnel_data.model_dump(exclude_unset=True).items(): setattr(personnel, key, value) db.commit() db.refresh(personnel) - return personnel + return personnel + else: + return CustomsBrokerPersonnelService.create_personnel( + db, broker_key, personnel_data, tenant_id, company_id + ) @staticmethod def delete_personnel(db: Session, broker_key: str, line: int): diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index f08c1726..1362a11a 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -1,4 +1,5 @@ import { api } from '$lib/api'; +import { companyStore } from '$lib/stores/company.svelte'; // <--- NUEVO: Importamos el store para el fallback import type { ApiResponse } from '$lib/api'; export interface CustomsBroker { @@ -26,6 +27,8 @@ export interface CustomsBroker { } export interface CustomsBrokerVU { + tenant_id?: string | null; + company_id?: string | null; certificate_path?: string | null; key_path?: string | null; access_key?: string | null; @@ -95,7 +98,9 @@ export interface CustomsBrokerListResponse { */ export const customsBrokersApi = { list: (companyId: string, page = 1, pageSize = 50) => { - return api.get(`/v1/a76/customs-brokers?company_id=${companyId}&page=${page}&page_size=${pageSize}`); + return api.get( + `/v1/a76/customs-brokers?company_id=${companyId}&page=${page}&page_size=${pageSize}` + ); }, get: (brokerKey: string, companyId: string) => { @@ -111,20 +116,47 @@ export const customsBrokersApi = { */ update: (brokerKey: string, data: CreateCustomsBrokerData) => { const companyId = data.company_id; - return api.put(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data); + return api.put( + `/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, + data + ); }, /** * Elimina un agente aduanal */ delete: (brokerKey: string, companyId: string) => { - return api.delete(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`); + return api.delete( + `/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}` + ); }, - - + /** + * Actualiza la información de Ventanilla Única (VU) + */ updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => { - return api.put(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data); + // LOGICA DE RESCATE: + // Si companyId llega nulo/undefined, intentamos obtenerlo del store global + let finalCompanyId = companyId; + + if (!finalCompanyId && companyStore.activeCompany?.id) { + finalCompanyId = companyStore.activeCompany.id.toString(); + console.warn("WARN: companyId no fue provisto a updateVU, usando companyStore:", finalCompanyId); + } + + // Aseguramos que el payload tenga los IDs + const payload = { + ...data, + company_id: finalCompanyId, + tenant_id: finalCompanyId + }; + + console.log('[DEBUG] Enviando payload VU:', payload); + + return api.put( + `/v1/a76/customs-broker-vu/${brokerKey}?company_id=${finalCompanyId}`, + payload + ); }, updatePersonnel: (brokerKey: string, line: number, data: CustomsBrokerPersonnel, companyId: string) => { diff --git a/frontend/src/routes/dashboard/customs_brokers/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/+page.svelte index 5a92b518..611ce839 100644 --- a/frontend/src/routes/dashboard/customs_brokers/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/+page.svelte @@ -187,8 +187,7 @@ const brokerColumns = createBrokerColumns(handleActionSuccess); -
- +

GESTIÓN ADUANAL

@@ -196,17 +195,17 @@
- - + + Agentes Aduanales Secciones Aduanales @@ -214,13 +213,11 @@ - -
- -
-
+
+
+

Filtros

Busque por nombre o patente @@ -244,23 +241,20 @@ oninput={handleSearch} />
-
- -
+
- -
-
+
+

Listado

{filteredItems.length} registros
@@ -275,9 +269,8 @@ idField="broker_key" />
- {#if totalItems > pageSize} -
+
- + Página {currentPage} de {Math.ceil(totalItems / pageSize)}
-
-
-

+

+

Detalles del Agente

{selectedItem?.name || '---'}

-
- + Patente: {selectedItem?.broker_key || ''}
-
+
{#if selectedItem}
@@ -338,21 +330,21 @@ {#if selectedItem.tax_id}
-

{selectedItem.tax_id}

+

{selectedItem.tax_id}

{/if} -
+
-
+

{selectedItem.address || ''}

{[selectedItem.city, selectedItem.state].filter(Boolean).join(', ')} @@ -363,9 +355,9 @@

-
+
@@ -391,9 +383,9 @@
{:else}
- +

Selecciona un agente

{/if} @@ -401,9 +393,8 @@
- - - + +
-
+
{#if activeTab === 'brokers'} - {:else} {/if} From 97d868e863d5289a7719a27f79ed9f59c020180a Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 25 Feb 2026 09:00:15 -0600 Subject: [PATCH 51/52] feat(clients-providers): improve Programs tab UI and fix certified company toggle --- .../modules/a76/clients_and_providers/dto.py | 2 +- .../a76/clients_and_providers/models.py | 2 +- .../edit/[[id]]/+page.svelte | 317 +++++++++++++----- 3 files changed, 242 insertions(+), 79 deletions(-) diff --git a/backend/api/v1/modules/a76/clients_and_providers/dto.py b/backend/api/v1/modules/a76/clients_and_providers/dto.py index 624c99f1..292d9e41 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/dto.py +++ b/backend/api/v1/modules/a76/clients_and_providers/dto.py @@ -46,7 +46,7 @@ class ClientProviderProgramsDTO(BaseModel): program_number: Optional[str] = Field( None, max_length=40, description="Program number" ) - prosec: Optional[int] = Field(None, description="PROSEC") + prosec: Optional[str] = Field(None, max_length=8, description="PROSEC") prosec_authorization: Optional[str] = Field( None, max_length=20, description="PROSEC authorization" ) diff --git a/backend/api/v1/modules/a76/clients_and_providers/models.py b/backend/api/v1/modules/a76/clients_and_providers/models.py index 4cf08fe0..db5544ca 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/models.py +++ b/backend/api/v1/modules/a76/clients_and_providers/models.py @@ -136,7 +136,7 @@ class ClientProviderPrograms(Base, TenantScopedMixin, TimestampMixin): # Program information program: Mapped[Optional[str]] = mapped_column(String(7)) program_number: Mapped[Optional[str]] = mapped_column(String(40)) - prosec: Mapped[Optional[int]] = mapped_column(SmallInteger) + prosec: Mapped[Optional[str]] = mapped_column(String(8)) prosec_authorization: Mapped[Optional[str]] = mapped_column(String(20)) secon_auth_date: Mapped[Optional[int]] = mapped_column(Integer) manufacturer_id: Mapped[Optional[str]] = mapped_column(String(25)) 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 8f5bbad8..991a2507 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 @@ -22,10 +22,29 @@ FileText, Settings, User, - Trash2 + Trash2, + Search, + Globe, + MapPin as MapPinIcon, + Factory, + Calendar, + Hash, + ShieldCheck, + Award, + Fingerprint, + Briefcase } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; + // Componentes Compartidos (Modales) + import CountrySelectorDialog from '$lib/components/dashboard/goods/modales/country-selector-dialog.svelte'; + import StateSelectorDialog from '$lib/components/dashboard/shared/modals/state-selector-dialog.svelte'; + import SectorSelectorDialog from '$lib/components/dashboard/shared/modals/sector-selector-dialog.svelte'; + + import { type Country } from '$lib/api/dashboard/reference_data/countries'; + import { type State } from '$lib/api/dashboard/reference_data/states'; + import { type Sector } from '$lib/api/dashboard/reference_data/sectors'; + // API & Stores import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; @@ -73,17 +92,30 @@ program: '', program_number: '', authorization_date_str: '', // String para input date - prosec: 0, + prosec: '', manufacturer_id: '', tax_id: '', ctpat_svi: '', - is_certified_company: '0' + is_certified_company: false }); let formData = $state(getEmptyForm()); let loading = $state(false); let error = $state(null); + // --- ESTADO PARA MODALES --- + let countryModalOpen = $state(false); + let stateModalOpen = $state(false); + let sectorModalOpen = $state(false); + + const scaiiPrograms = [ + { value: 'IMMEX', label: 'IMMEX' }, + { value: 'PROSEC', label: 'PROSEC' }, + { value: 'ALTEX', label: 'ALTEX' }, + { value: 'ECEX', label: 'ECEX' }, + { value: 'DRAWBACK', label: 'DRAWBACK' } + ]; + // --- UTILIDADES --- const intDateToString = (d?: number | null) => d @@ -138,11 +170,11 @@ program: prog.program || '', program_number: prog.program_number || '', authorization_date_str: intDateToString(prog.secon_auth_date), - prosec: prog.prosec || 0, + prosec: prog.prosec ? String(prog.prosec) : '', manufacturer_id: prog.manufacturer_id || '', tax_id: prog.tax_id || '', ctpat_svi: prog.ctpat_svi || '', - is_certified_company: prog.is_certified_company || '0' + is_certified_company: prog.is_certified_company === '1' }; } } catch (e: any) { @@ -202,11 +234,11 @@ program: clean(formData.program), program_number: clean(formData.program_number), secon_auth_date: stringDateToInt(formData.authorization_date_str), - prosec: Number(formData.prosec) || null, + prosec: clean(formData.prosec), manufacturer_id: clean(formData.manufacturer_id), tax_id: clean(formData.tax_id), ctpat_svi: clean(formData.ctpat_svi), - is_certified_company: clean(formData.is_certified_company) + is_certified_company: formData.is_certified_company ? '1' : '0' } }; @@ -470,17 +502,44 @@
- +
+ + +
- +
+ + +
@@ -509,77 +568,160 @@ >Información sobre IMMEX, PROSEC y otras certificaciones. - -
-
- - + + +
+
+ + Programas de Fomento
-
- - + +
+
+ + (formData.program = v)} + disabled={loading} + > + + {formData.program || 'Selecciona un programa'} + + + {#each scaiiPrograms as prog} + + {prog.label} + + {/each} + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
-
-
- - + +
+
+ + Identificación Industrial
-
- - -
-
- - + +
+
+ + +
+
+ + +
-
-
- - + +
+
+ + Certificaciones y Seguridad
-
- - + +
+
+ + +
+
+ +
+ +

+ Indica si cuenta con certificación de empresa +

+
+
@@ -670,3 +812,24 @@
+ + + (formData.country = country.m3_key)} +/> + + { + formData.state = state.description; + if (state.m3_key && !formData.country) { + formData.country = state.m3_key; + } + }} +/> + + (formData.prosec = sector.key)} +/> From 750130ac8e15238186ddcb5ba8f1f54726c52cc6 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Thu, 26 Feb 2026 11:40:55 -0600 Subject: [PATCH 52/52] feat: Implement CRUD and UI for transportation entities (vehicles, trailers, and transporters). --- .gitignore | 6 +- backend/api/v1/common/tenant_crud_routes.py | 26 +- .../a76/transportation/trailers/services.py | 6 +- .../a76/transportation/transporters/models.py | 4 +- .../transportation/transporters/services.py | 6 +- .../a76/transportation/vehicles/services.py | 6 +- backend/main.py | 141 ++++++++ frontend/src/app.d.ts | 2 +- frontend/src/lib/api/dashboard/a76/items.ts | 192 +++++------ .../src/lib/api/dashboard/a76/pedimentos.ts | 10 + .../src/lib/api/dashboard/a76/trailers.ts | 36 +- .../src/lib/api/dashboard/a76/transporters.ts | 43 ++- .../src/lib/api/dashboard/a76/vehicles.ts | 90 +++-- .../invoices/edit/items/items-tab-form.svelte | 18 +- .../transportation/trailers/columns.ts | 64 ++++ .../trailers/create-edit-dialog.svelte | 207 ++++++++++++ .../trailers/data-table-actions.svelte | 111 ++++++ .../transportation/trailers/data-table.svelte | 99 ++++++ .../transporters/create-edit-dialog.svelte | 306 +++++++++++++++++ .../transporters/data-table-actions.svelte | 114 +++++++ .../transporters/data-table.svelte | 99 ++++++ .../transporters/transporter-columns.ts | 64 ++++ .../transportation/vehicles/columns.ts | 64 ++++ .../vehicles/create-edit-dialog.svelte | 317 ++++++++++++++++++ .../vehicles/data-table-actions.svelte | 111 ++++++ .../transportation/vehicles/data-table.svelte | 99 ++++++ .../src/lib/components/sidebar/modules.ts | 23 +- .../general_catalogs/trailers/+page.svelte | 117 +++++++ .../transporters/+page.svelte | 117 +++++++ .../general_catalogs/vehicles/+page.svelte | 119 +++++++ frontend/src/svelte-shims.d.ts | 7 + 31 files changed, 2470 insertions(+), 154 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/transportation/trailers/columns.ts create mode 100644 frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts create mode 100644 frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts create mode 100644 frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte create mode 100644 frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte create mode 100644 frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte create mode 100644 frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte create mode 100644 frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte create mode 100644 frontend/src/svelte-shims.d.ts diff --git a/.gitignore b/.gitignore index 48bbc048..3bed79d4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,13 +48,15 @@ logs/ *.sqlite3 # Testing +backend/app_data/ +.mypy_cache/ .pytest_cache/ .coverage htmlcov/ -backend/app_data/ # Node (para frontend) -node_modules/ +**/node_modules/ +**/.svelte-kit/ .npm .yarn diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 91b23321..98f4b184 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -3,7 +3,7 @@ import logging from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request from pydantic import BaseModel from sqlalchemy.orm import Session @@ -137,6 +137,7 @@ class TenantCRUDRoutes( description=f"Get paginated list of {self.resource_name}s with optional filters", ) async def list_resources( + request: Request, company_id: int = Query(..., description="Company ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query( @@ -145,13 +146,6 @@ class TenantCRUDRoutes( le=self.max_page_size, description="Page size", ), - status: Optional[str] = Query(None, description="Filter by status"), - operation_type: Optional[str] = Query( - None, description="Filter by operation type" - ), - invoice_type: Optional[str] = Query( - None, description="Filter by invoice type" - ), db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): @@ -164,13 +158,15 @@ class TenantCRUDRoutes( ) skip = (page - 1) * page_size - filters = {} - if status: - filters["status"] = status - if operation_type: - filters["operation_type"] = operation_type - if invoice_type: - filters["invoice_type"] = invoice_type + + # Extraer todos los parámetros de búsqueda dinámicamente + # Excluimos los parámetros estándar de paginación y control + standard_params = {"company_id", "page", "page_size"} + filters = { + k: v + for k, v in request.query_params.items() + if k not in standard_params and v is not None and v != "" + } items, total = self.service.get_all( db, tenant_id, company_id, skip, page_size, filters diff --git a/backend/api/v1/modules/a76/transportation/trailers/services.py b/backend/api/v1/modules/a76/transportation/trailers/services.py index 43332b64..4ffb7c30 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/services.py +++ b/backend/api/v1/modules/a76/transportation/trailers/services.py @@ -25,6 +25,10 @@ class TrailerService: # Apply filters if provided if filters: + if filters.get("trailer_number"): + query = query.filter( + models.Trailer.trailer_number.ilike(f"%{filters['trailer_number']}%") + ) if filters.get("plate_number"): query = query.filter( models.Trailer.plate_number.ilike(f"%{filters['plate_number']}%") @@ -75,8 +79,8 @@ class TrailerService: db: Session, trailer_number: str, tenant_id: int, - company_id: int, trailer_data: dto.TrailerUpdateDTO, + company_id: int, ) -> Optional[models.Trailer]: """Update a trailer""" trailer = TrailerService.get_by_id(db, trailer_number, tenant_id, company_id) diff --git a/backend/api/v1/modules/a76/transportation/transporters/models.py b/backend/api/v1/modules/a76/transportation/transporters/models.py index cc355dbf..9f555c79 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/models.py +++ b/backend/api/v1/modules/a76/transportation/transporters/models.py @@ -9,7 +9,7 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin): {"schema": "a76"}, ) - transporter_key = Column(String(5), primary_key=True, nullable=False) + transporter_key = Column(String(23), primary_key=True, nullable=False) name = Column(String(256), nullable=True) short_name = Column(String(10), nullable=True) responsible = Column(String(100), nullable=True) @@ -27,4 +27,4 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin): ftp_user = Column(String(200), nullable=True) ftp_password = Column(String(100), nullable=True) ftp_directory = Column(String(1000), nullable=True) - filler_code = Column(String(4), nullable=True) + filler_code = Column(String(20), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py index 1af393e5..bbf76e69 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/services.py +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -25,6 +25,10 @@ class TransporterService: # Apply filters if provided if filters: + if filters.get("transporter_key"): + query = query.filter( + models.Transporter.transporter_key.ilike(f"%{filters['transporter_key']}%") + ) if filters.get("name"): query = query.filter( models.Transporter.name.ilike(f"%{filters['name']}%") @@ -75,8 +79,8 @@ class TransporterService: db: Session, transporter_key: str, tenant_id: int, - company_id: int, transporter_data: dto.TransporterUpdateDTO, + company_id: int, ) -> Optional[models.Transporter]: """Update a transporter""" transporter = TransporterService.get_by_id( diff --git a/backend/api/v1/modules/a76/transportation/vehicles/services.py b/backend/api/v1/modules/a76/transportation/vehicles/services.py index d21bd36f..2b9801ac 100644 --- a/backend/api/v1/modules/a76/transportation/vehicles/services.py +++ b/backend/api/v1/modules/a76/transportation/vehicles/services.py @@ -25,6 +25,10 @@ class VehicleService: # Apply filters if provided if filters: + if filters.get("vehicle_key"): + query = query.filter( + models.Vehicle.vehicle_key.ilike(f"%{filters['vehicle_key']}%") + ) if filters.get("plate_number"): query = query.filter( models.Vehicle.plate_number.ilike(f"%{filters['plate_number']}%") @@ -75,8 +79,8 @@ class VehicleService: db: Session, vehicle_key: str, tenant_id: int, - company_id: int, vehicle_data: dto.VehicleUpdateDTO, + company_id: int, ) -> Optional[models.Vehicle]: """Update a vehicle""" vehicle = VehicleService.get_by_id(db, vehicle_key, tenant_id, company_id) diff --git a/backend/main.py b/backend/main.py index b1740f07..d383b62c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -58,6 +58,11 @@ from api.v1.modules.a76.clients_and_providers.models import ClientProvider from api.v1.modules.a76.customs_brokers.models import CustomsBroker from api.v1.modules.a76.general_catalogs.company.models import Company +# Transportation Modules +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle + # Core Modules & Transactional Models from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.items.series.models import Serie @@ -145,6 +150,137 @@ def run_migrations(): subprocess.run(["alembic", "upgrade", "head"], check=True) +def create_transportation_tables(): + """Crea las tablas de transporte directamente si no existen. + Se usa en lugar de una migración Alembic para evitar gestionar versiones. + """ + from sqlalchemy import text + from core.database import core_engine + + ddl_statements = [ + """ + CREATE TABLE IF NOT EXISTS a76.transporter ( + transporter_key VARCHAR(23) PRIMARY KEY, + name VARCHAR(256), + short_name VARCHAR(10), + responsible VARCHAR(100), + rfc VARCHAR(30), + streets VARCHAR(100), + postal_code VARCHAR(15), + city VARCHAR(30), + state VARCHAR(30), + country VARCHAR(3), + loader_code VARCHAR(9), + caat_code VARCHAR(49), + transport_code VARCHAR(8), + transport_interface_type VARCHAR(20), + ftp_server VARCHAR(200), + ftp_user VARCHAR(200), + ftp_password VARCHAR(100), + ftp_directory VARCHAR(1000), + filler_code VARCHAR(20), + tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), + company_id INTEGER NOT NULL REFERENCES a76.company(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + deleted_at TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS a76.trailer ( + trailer_number VARCHAR(20) PRIMARY KEY, + ace_trailer_number VARCHAR(10), + trailer_type_key VARCHAR(2), + seal VARCHAR(15), + entity_code VARCHAR(1), + plate_number VARCHAR(17), + state VARCHAR(30), + country VARCHAR(3), + container_key VARCHAR(3), + tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), + company_id INTEGER NOT NULL REFERENCES a76.company(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + deleted_at TIMESTAMP + ); + """, + """ + CREATE TABLE IF NOT EXISTS a76.vehicle ( + vehicle_key VARCHAR(14) PRIMARY KEY, + ace_vehicle_key VARCHAR(10), + transporter_key VARCHAR(23), + transport_identifier VARCHAR(30), + transport_type VARCHAR(2), + entity_code VARCHAR(1), + transponder_number VARCHAR(16), + dot_number VARCHAR(8), + plate_number VARCHAR(17), + city VARCHAR(30), + state VARCHAR(30), + country VARCHAR(3), + seal VARCHAR(49), + insurance_company_name VARCHAR(30), + insurance_number VARCHAR(20), + insurance_amount NUMERIC(13, 2), + insurance_date INTEGER, + box_number VARCHAR(300), + brand VARCHAR(20), + year VARCHAR(4), + series VARCHAR(30), + description VARCHAR(100), + engine_number VARCHAR(50), + sct_permission VARCHAR(40), + color VARCHAR(20), + container_key VARCHAR(3), + tenant_id INTEGER NOT NULL REFERENCES core.tenants(id), + company_id INTEGER NOT NULL REFERENCES a76.company(id), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + deleted_at TIMESTAMP + ); + """, + ] + + with core_engine.connect() as conn: + for stmt in ddl_statements: + conn.execute(text(stmt)) + # Ampliar columnas que pudieron haberse creado con tamaño incorrecto + conn.execute(text(""" + DO $$ + BEGIN + -- Fix transporter_key si fue creada como VARCHAR(5) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema='a76' AND table_name='transporter' + AND column_name='transporter_key' + AND character_maximum_length < 23 + ) THEN + ALTER TABLE a76.transporter ALTER COLUMN transporter_key TYPE VARCHAR(23); + END IF; + -- Fix filler_code si fue creada como VARCHAR(4) + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema='a76' AND table_name='transporter' + AND column_name='filler_code' + AND character_maximum_length < 20 + ) THEN + ALTER TABLE a76.transporter ALTER COLUMN filler_code TYPE VARCHAR(20); + END IF; + + -- Eliminar constraint de trailer_type_key en trailer si existe + IF EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name='trailer_trailer_type_key_fkey' + AND table_schema='a76' AND table_name='trailer' + ) THEN + ALTER TABLE a76.trailer DROP CONSTRAINT trailer_trailer_type_key_fkey; + END IF; + END$$; + """)) + conn.commit() + logger.info("Tablas de transporte verificadas/creadas correctamente.") + + # Inicializar la base de datos @app.on_event("startup") async def on_startup(): @@ -152,6 +288,7 @@ async def on_startup(): logger.info("Iniciando la aplicación Anexo76...") init_db() run_migrations() + create_transportation_tables() logger.info("Base de datos inicializada correctamente.") @@ -265,6 +402,10 @@ def register_audit(): CustomsBroker, Part, Company, + # Transportation Modules + Trailer, + Transporter, + Vehicle, # Reference Data Country, CurrencyType, diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 7e328ea8..9cffaf74 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -13,4 +13,4 @@ declare global { } } -export {}; +export { }; diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 2b10b734..28e77fcd 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -18,7 +18,7 @@ export interface LineCustoms { destination_country?: string; advalorem?: string; advalorem_numeric?: number; - advalorem_american?: number; + advalorem_american?: number; advalorem_tlcan?: number; rate?: string; depreciation_rate?: number; @@ -33,7 +33,7 @@ export interface LineFinancials { unit_cost_mxn?: number; unit_cost_capture?: number; unit_cost_commercial_usd?: number; - + // Values value_mc?: number; value_usd?: number; @@ -49,7 +49,7 @@ export interface LineQuantities { line_item_id?: number; quantity?: number; unit_of_measure?: string; - + // Special quantities quantity_temp_export?: number; quantity_returned?: number; @@ -92,7 +92,7 @@ export interface FaLineItem { id?: number; tenant_id?: number; company_id?: number; - + // Asset information (SCAF specific) asset_number?: string; asset_photo?: string; @@ -101,44 +101,46 @@ export interface FaLineItem { return_import_invoice?: string; return_import_date?: number; movement_type_import?: string; - + // Cross-references for import repair search_invoice?: string; search_line?: number; - + // Search type search_type?: string; - - // Subitems - is_subitem?: boolean; - contains_subitems?: boolean; - subitem_number?: number; + + // Subitems + is_subitem?: boolean; + contains_subitems?: boolean; + subitem_number?: number; // Special flags download?: boolean; own_equipment?: boolean; - omit_annex31?: boolean; - + omit_annex31?: boolean; + // Timestamps created_at?: string; updated_at?: string; } export interface Item { - id?: number; - invoice_id: number; + id?: number; + invoice_id: number; line_number: number; - + // Identification part_number?: string; + part_number_id?: number; component_part_number?: string; + component_part_number_id?: number; class_id?: number; identifier?: string; // Unit of Measure unit_of_measure?: number; alternate_unit?: number; - + // Permits permit_number?: string; page_line?: string; @@ -151,29 +153,29 @@ export interface Item { includes_subitems?: boolean; tax_payment?: boolean; is_military_mcia?: boolean; - + // Payment payment_method?: string; igi_amount?: number; - + // Additional notes wildcard_field?: string; // Computed fields from class_info relation class_code?: string; class_description?: string; - + // Computed field from unit_of_measure_info relation - unit_of_measure_code?: string; - reference_number?: string; - order?: string; - guide_number?: string; - depreciation_date?: number; - rectification?: number; - warehouse?: string; - location?: string; - created_at?: string; - updated_at?: string; + unit_of_measure_code?: string; + reference_number?: string; + order?: string; + guide_number?: string; + depreciation_date?: number; + rectification?: number; + warehouse?: string; + location?: string; + created_at?: string; + updated_at?: string; // Nested relations (Singular names to match backend Pydantic models) customs?: LineCustoms; @@ -185,86 +187,86 @@ export interface Item { } export interface ItemListResponse { - items: Item[]; - total: number; - skip: number; - limit: number; + items: Item[]; + total: number; + skip: number; + limit: number; } export interface CreateItemData extends Omit { - invoice_id: number; + invoice_id: number; } -export interface UpdateItemData extends Partial> {} +export interface UpdateItemData extends Partial> { } /** * API para Items */ export const itemsApi = { - /** - * Lista todos los items con paginación - */ - list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => { - const params = new URLSearchParams({ - company_id: companyId.toString(), - skip: skip.toString(), - limit: limit.toString() - }); + /** + * Lista todos los items con paginación + */ + list: (companyId: number, skip = 0, limit = 100, invoiceId?: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString(), + skip: skip.toString(), + limit: limit.toString() + }); - if (invoiceId) { - params.append('invoice_id', invoiceId.toString()); - } + if (invoiceId) { + params.append('invoice_id', invoiceId.toString()); + } - return api.get(`/v1/a76/items/?${params.toString()}`); - }, + return api.get(`/v1/a76/items/?${params.toString()}`); + }, - /** - * Lista items por invoice ID - */ - listByInvoice: (invoiceId: number, companyId: number) => { - const params = new URLSearchParams({ - company_id: companyId.toString() - }); - return api.get(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`); - }, + /** + * Lista items por invoice ID + */ + listByInvoice: (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`); + }, - /** - * Obtiene un item por ID - */ - get: (itemId: number, companyId: number) => { - const params = new URLSearchParams({ - company_id: companyId.toString() - }); - return api.get(`/v1/a76/items/${itemId}/?${params.toString()}`); - }, + /** + * Obtiene un item por ID + */ + get: (itemId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`/v1/a76/items/${itemId}/?${params.toString()}`); + }, - /** - * Crea un nuevo item - */ - create: (companyId: number, data: CreateItemData) => { - const params = new URLSearchParams({ - company_id: companyId.toString() - }); - return api.post(`/v1/a76/items/?${params.toString()}`, data); - }, + /** + * Crea un nuevo item + */ + create: (companyId: number, data: CreateItemData) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`/v1/a76/items/?${params.toString()}`, data); + }, - /** - * Actualiza un item existente - */ - update: (itemId: number, companyId: number, data: UpdateItemData) => { - const params = new URLSearchParams({ - company_id: companyId.toString() - }); - return api.put(`/v1/a76/items/${itemId}/?${params.toString()}`, data); - }, + /** + * Actualiza un item existente + */ + update: (itemId: number, companyId: number, data: UpdateItemData) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`/v1/a76/items/${itemId}/?${params.toString()}`, data); + }, - /** - * Elimina un item - */ - delete: (itemId: number, companyId: number) => { - const params = new URLSearchParams({ - company_id: companyId.toString() - }); - return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); - } + /** + * Elimina un item + */ + delete: (itemId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); + } }; diff --git a/frontend/src/lib/api/dashboard/a76/pedimentos.ts b/frontend/src/lib/api/dashboard/a76/pedimentos.ts index 582e913d..f36ccc30 100644 --- a/frontend/src/lib/api/dashboard/a76/pedimentos.ts +++ b/frontend/src/lib/api/dashboard/a76/pedimentos.ts @@ -33,12 +33,19 @@ export interface PedimentoPayments { } export interface PedimentoTransportMeans { + id?: number; destination?: number | null; entry_exit?: string | null; arrival?: string | null; departure?: string | null; } +export interface PedimentoCustomsOffices { + id?: number; + dispatch_customs?: string | null; + entry_exit_customs?: string | null; +} + export interface PedimentoValidation { validator?: string | null; validation_ack?: string | null; @@ -180,6 +187,7 @@ export interface Pedimento { pedimento_incrementables?: PedimentoIncrementables | null; pedimento_decrementables?: PedimentoDecrementables | null; pedimento_indexes?: PedimentoIndexes | null; + pedimento_customs_offices?: PedimentoCustomsOffices | null; pedimento_config_additional?: PedimentoConfigAdditional | null; pedimento_config_calculations?: PedimentoConfigCalculations | null; pedimento_config_surcharges?: PedimentoConfigSurcharges | null; @@ -220,6 +228,7 @@ export interface CreatePedimentoData { pedimento_incrementables?: PedimentoIncrementables | null; pedimento_decrementables?: PedimentoDecrementables | null; pedimento_indexes?: PedimentoIndexes | null; + pedimento_customs_offices?: PedimentoCustomsOffices | null; pedimento_config_additional?: PedimentoConfigAdditional | null; pedimento_config_calculations?: PedimentoConfigCalculations | null; pedimento_config_surcharges?: PedimentoConfigSurcharges | null; @@ -253,6 +262,7 @@ export interface UpdatePedimentoData { pedimento_incrementables?: PedimentoIncrementables | null; pedimento_decrementables?: PedimentoDecrementables | null; pedimento_indexes?: PedimentoIndexes | null; + pedimento_customs_offices?: PedimentoCustomsOffices | null; pedimento_config_additional?: PedimentoConfigAdditional | null; pedimento_config_calculations?: PedimentoConfigCalculations | null; pedimento_config_surcharges?: PedimentoConfigSurcharges | null; diff --git a/frontend/src/lib/api/dashboard/a76/trailers.ts b/frontend/src/lib/api/dashboard/a76/trailers.ts index df739414..5d74aa2d 100644 --- a/frontend/src/lib/api/dashboard/a76/trailers.ts +++ b/frontend/src/lib/api/dashboard/a76/trailers.ts @@ -2,10 +2,17 @@ import { api, type ApiResponse } from '$lib/api'; export interface Trailer { trailer_number: string; - plate_number?: string; + ace_trailer_number?: string; trailer_type_key?: string; - is_active: boolean; - tenant_id?: string; + seal?: string; + entity_code?: string; + plate_number?: string; + state?: string; + country?: string; + container_key?: string; + is_active?: boolean; + company_id?: number | string; + tenant_id?: number | string; } export interface TrailerResponse { @@ -16,7 +23,7 @@ export interface TrailerResponse { } class TrailersApi { - private baseUrl = '/v1/a76/trailers'; + private baseUrl = '/v1/a76/transportation/trailers'; async list( companyId: string | number, @@ -35,6 +42,27 @@ class TrailersApi { }); return api.get(`${this.baseUrl}/${id}?${queryParams.toString()}`); } + + async create(data: Trailer, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`${this.baseUrl}?${queryParams.toString()}`, data); + } + + async update(id: string, data: Trailer, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data); + } + + async delete(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } } export const trailersApi = new TrailersApi(); diff --git a/frontend/src/lib/api/dashboard/a76/transporters.ts b/frontend/src/lib/api/dashboard/a76/transporters.ts index 6383828b..70ecbcdd 100644 --- a/frontend/src/lib/api/dashboard/a76/transporters.ts +++ b/frontend/src/lib/api/dashboard/a76/transporters.ts @@ -2,10 +2,26 @@ import { api, type ApiResponse } from '$lib/api'; export interface Transporter { transporter_key: string; - name: string; + name?: string; + short_name?: string; + responsible?: string; rfc?: string; - is_active: boolean; - tenant_id?: string; + streets?: string; + postal_code?: string; + city?: string; + state?: string; + country?: string; + loader_code?: string; + caat_code?: string; + transport_code?: string; + transport_interface_type?: string; + ftp_server?: string; + ftp_user?: string; + ftp_password?: string; + ftp_directory?: string; + filler_code?: string; + company_id?: number | string; + tenant_id?: number | string; } export interface TransporterResponse { @@ -35,6 +51,27 @@ class TransportersApi { }); return api.get(`${this.baseUrl}/${id}?${queryParams.toString()}`); } + + async create(data: Transporter, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`${this.baseUrl}?${queryParams.toString()}`, data); + } + + async update(id: string, data: Transporter, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data); + } + + async delete(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } } export const transportersApi = new TransportersApi(); diff --git a/frontend/src/lib/api/dashboard/a76/vehicles.ts b/frontend/src/lib/api/dashboard/a76/vehicles.ts index a0c864e4..3fd7d67a 100644 --- a/frontend/src/lib/api/dashboard/a76/vehicles.ts +++ b/frontend/src/lib/api/dashboard/a76/vehicles.ts @@ -1,32 +1,84 @@ -import { api } from '$lib/api'; +import { api, type ApiResponse } from '$lib/api'; export interface Vehicle { vehicle_key: string; - brand?: string; - plate_number?: string; - description?: string; + ace_vehicle_key?: string; + transporter_key?: string; + transport_identifier?: string; transport_type?: string; + entity_code?: string; + transponder_number?: string; + dot_number?: string; + plate_number?: string; + city?: string; + state?: string; + country?: string; + seal?: string; + insurance_company_name?: string; + insurance_number?: string; + insurance_amount?: number; + insurance_date?: number; + box_number?: string; + brand?: string; year?: string; + series?: string; + description?: string; + engine_number?: string; + sct_permission?: string; + color?: string; + container_key?: string; + company_id?: number | string; + tenant_id?: number | string; } -export interface VehicleListResponse { +export interface VehicleResponse { items: Vehicle[]; total: number; + page: number; + page_size: number; } -/** - * API para Vehículos - */ -export const vehiclesApi = { - list: (companyId: string, page = 1, pageSize = 50) => { - return api.get( - `/v1/a76/transportation/vehicles?company_id=${companyId}&page=${page}&page_size=${pageSize}` - ); - }, +class VehiclesApi { + private baseUrl = '/v1/a76/transportation/vehicles'; - get: (vehicleKey: string, companyId: string) => { - return api.get( - `/v1/a76/transportation/vehicles/${vehicleKey}?company_id=${companyId}` - ); + async list( + companyId: string | number, + params?: Record + ): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString(), + ...params + }); + return api.get(`${this.baseUrl}?${queryParams.toString()}`); } -}; + + async get(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.get(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } + + async create(data: Vehicle, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.post(`${this.baseUrl}?${queryParams.toString()}`, data); + } + + async update(id: string, data: Vehicle, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.put(`${this.baseUrl}/${id}/?${queryParams.toString()}`, data); + } + + async delete(id: string, companyId: string | number): Promise> { + const queryParams = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete(`${this.baseUrl}/${id}?${queryParams.toString()}`); + } +} + +export const vehiclesApi = new VehiclesApi(); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 372198ff..8bc9b75a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -20,7 +20,7 @@ } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; - import { itemsApi, type Item } from '$lib/api/dashboard/a76/items'; + import { itemsApi, type Item as InvoiceItem } from '$lib/api/dashboard/a76/items'; import { companyStore } from '$lib/stores/company.svelte'; import ItemSheetFa from './fa/item-sheet-fa.svelte'; import ItemSheetInv from './inv/item-sheet-inv.svelte'; @@ -43,7 +43,7 @@ } = $props(); // 1. Core State - let items = $state([]); + let items = $state([]); let displayedItems = $state([]); let imported = $state(0); let net_weight = $state(0); @@ -129,9 +129,9 @@ let showItemSheet = $state(false); let isEditMode = $state(false); let showDeleteDialog = $state(false); - let selectedItem = $state(null); - let originalItemData = $state | null>(null); - let editingItem = $state>({ + let selectedItem = $state(null); + let originalItemData = $state | null>(null); + let editingItem = $state>({ invoice_id: undefined, reference_number: '', order: '', @@ -400,7 +400,7 @@ return cleanLineData({ ...rest }); } - function cloneItemForPreset(item: Item) { + function cloneItemForPreset(item: InvoiceItem) { const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any; return { ...sanitizeLineForPreset(rest), @@ -502,7 +502,7 @@ isSavingPreset = true; try { // We group everything as items for the template - const lines = builderItems.map((item: Item, idx: number) => { + const lines = builderItems.map((item: InvoiceItem, idx: number) => { return { ...cleanLineData(item), line_number: item.line_number || idx + 1, // Ensure line_number is present @@ -552,7 +552,7 @@ } // Enrich item with descriptive data for display - async function enrichItemData(item: Partial) { + async function enrichItemData(item: Partial) { if (!item || !activeCompanyId) return; // Load class data @@ -697,7 +697,7 @@ } // Normalize numeric values from strings to numbers - function normalizeItemData(item: Partial): Partial { + function normalizeItemData(item: Partial): Partial { if (item) { const normalizedItem = { ...item }; diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/columns.ts b/frontend/src/lib/components/dashboard/transportation/trailers/columns.ts new file mode 100644 index 00000000..e00011c6 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/columns.ts @@ -0,0 +1,64 @@ +/** + * Definición de columnas para la tabla de Trailers + */ +import type { Trailer } from '$lib/api/dashboard/a76/trailers'; +import type { ColumnDef } from '@tanstack/table-core'; +import { renderComponent } from '$lib/components/ui/data-table'; +import DataTableActions from './data-table-actions.svelte'; + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: 'trailer_number', + header: 'Número de Trailer', + cell: ({ row }) => { + return row.original.trailer_number; + } + }, + { + accessorKey: 'plate_number', + header: 'Placas', + cell: ({ row }) => { + return row.original.plate_number || '-'; + } + }, + { + accessorKey: 'trailer_type_key', + header: 'Tipo de Trailer', + cell: ({ row }) => { + return row.original.trailer_type_key || '-'; + } + }, + { + accessorKey: 'container_key', + header: 'Contenedor', + cell: ({ row }) => { + return row.original.container_key || '-'; + } + }, + { + accessorKey: 'state', + header: 'Estado', + cell: ({ row }) => { + return row.original.state || '-'; + } + }, + { + accessorKey: 'country', + header: 'País', + cell: ({ row }) => { + return row.original.country || '-'; + } + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => { + return renderComponent(DataTableActions, { + item: row.original, + onSuccess + }); + } + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte new file mode 100644 index 00000000..6959aed2 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte @@ -0,0 +1,207 @@ + + + + + + {title} + + {isEdit + ? 'Modifica los datos del trailer' + : 'Completa los datos para crear un nuevo trailer'} + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + {#if error} +
+ {error} +
+ {/if} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte new file mode 100644 index 00000000..6dce1ab9 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/data-table-actions.svelte @@ -0,0 +1,111 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + + Editar + + + + {#if loading} + + {:else} + + {/if} + Eliminar + + + + + diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte new file mode 100644 index 00000000..e0cdf7c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/trailers/data-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ +
+
+ Total: {totalItems} +
+
+ + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte new file mode 100644 index 00000000..0cc296fd --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -0,0 +1,306 @@ + + + + + + {title} + + {isEdit + ? 'Modifica los datos del transportista' + : 'Completa los datos para crear un nuevo transportista'} + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + {#if error} +
+ {error} +
+ {/if} + +
+ +
+

Información General

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Códigos de Transporte

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Dirección

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

Configuración FTP

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte new file mode 100644 index 00000000..8715b474 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/data-table-actions.svelte @@ -0,0 +1,114 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + + Editar + + + + {#if loading} + + {:else} + + {/if} + Eliminar + + + + + diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte new file mode 100644 index 00000000..e0cdf7c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/data-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ +
+
+ Total: {totalItems} +
+
+ + +
+
diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts b/frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts new file mode 100644 index 00000000..32e69333 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/transporters/transporter-columns.ts @@ -0,0 +1,64 @@ +/** + * Definición de columnas para la tabla de Transportistas + */ +import type { Transporter } from '$lib/api/dashboard/a76/transporters'; +import type { ColumnDef } from '@tanstack/table-core'; +import { renderComponent } from '$lib/components/ui/data-table'; +import DataTableActions from './data-table-actions.svelte'; + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: 'transporter_key', + header: 'Clave', + cell: ({ row }) => { + return row.original.transporter_key; + } + }, + { + accessorKey: 'name', + header: 'Nombre', + cell: ({ row }) => { + return row.original.name || '-'; + } + }, + { + accessorKey: 'short_name', + header: 'Nombre Corto', + cell: ({ row }) => { + return row.original.short_name || '-'; + } + }, + { + accessorKey: 'rfc', + header: 'RFC', + cell: ({ row }) => { + return row.original.rfc || '-'; + } + }, + { + accessorKey: 'caat_code', + header: 'CAAT', + cell: ({ row }) => { + return row.original.caat_code || '-'; + } + }, + { + accessorKey: 'transport_code', + header: 'Código Transporte', + cell: ({ row }) => { + return row.original.transport_code || '-'; + } + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => { + return renderComponent(DataTableActions, { + item: row.original, + onSuccess + }); + } + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts b/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts new file mode 100644 index 00000000..2bacea83 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/columns.ts @@ -0,0 +1,64 @@ +/** + * Definición de columnas para la tabla de Vehículos + */ +import type { Vehicle } from '$lib/api/dashboard/a76/vehicles'; +import type { ColumnDef } from '@tanstack/table-core'; +import { renderComponent } from '$lib/components/ui/data-table'; +import DataTableActions from './data-table-actions.svelte'; + +export function createColumns(onSuccess?: () => void): ColumnDef[] { + return [ + { + accessorKey: 'vehicle_key', + header: 'Clave', + cell: ({ row }) => { + return row.original.vehicle_key; + } + }, + { + accessorKey: 'brand', + header: 'Marca', + cell: ({ row }) => { + return row.original.brand || '-'; + } + }, + { + accessorKey: 'year', + header: 'Año', + cell: ({ row }) => { + return row.original.year || '-'; + } + }, + { + accessorKey: 'plate_number', + header: 'Placas', + cell: ({ row }) => { + return row.original.plate_number || '-'; + } + }, + { + accessorKey: 'transporter_key', + header: 'Transportista', + cell: ({ row }) => { + return row.original.transporter_key || '-'; + } + }, + { + accessorKey: 'transport_type', + header: 'Tipo Transporte', + cell: ({ row }) => { + return row.original.transport_type || '-'; + } + }, + { + id: 'actions', + header: 'Acciones', + cell: ({ row }) => { + return renderComponent(DataTableActions, { + item: row.original, + onSuccess + }); + } + } + ]; +} diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte new file mode 100644 index 00000000..313dd45b --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte @@ -0,0 +1,317 @@ + + + + + + {title} + + {isEdit + ? 'Modifica los datos del vehículo' + : 'Completa los datos para crear un nuevo vehículo de transporte'} + + + +
{ + e.preventDefault(); + handleSubmit(); + }} + class="space-y-6" + > + {#if error} +
+ {error} +
+ {/if} + +
+ +
+

Identificación del Vehículo

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+

Datos de Transporte

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+

Seguro y Otros

+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+

Ubicación y Detalles

+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
+ + + + + +
+
+
diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte new file mode 100644 index 00000000..9ec4eb00 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table-actions.svelte @@ -0,0 +1,111 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + Acciones + + + + Editar + + + + {#if loading} + + {:else} + + {/if} + Eliminar + + + + + diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte new file mode 100644 index 00000000..e0cdf7c1 --- /dev/null +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/data-table.svelte @@ -0,0 +1,99 @@ + + +
+ + + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} + + {#each headerGroup.headers as header (header.id)} + + {#if !header.isPlaceholder} + + {/if} + + {/each} + + {/each} + + + {#each table.getRowModel().rows as row (row.id)} + + {#each row.getVisibleCells() as cell (cell.id)} + + + + {/each} + + {:else} + + + No hay resultados. + + + {/each} + + +
+ +
+
+ Total: {totalItems} +
+
+ + +
+
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 5e7fe6ae..5ab973b2 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -15,6 +15,7 @@ import { Settings2, Shield, Ship, + Truck, Users, } from 'lucide-svelte'; import * as m from "$lib/paraglide/messages.js"; @@ -192,6 +193,7 @@ export function getSidebarData(): SidebarData { title: m["sidebar.general_catalogs.identifiers"](), url: "/dashboard/general_catalogs/identifiers", }, + // ------------------------------------- { title: m["sidebar.general_catalogs.incoterms"](), url: "/dashboard/reference_data/incoterms", @@ -333,6 +335,25 @@ export function getSidebarData(): SidebarData { }, ], }, + { + title: "Transportes", + url: "#", + icon: Truck, + items: [ + { + title: "Transportistas", + url: "/dashboard/general_catalogs/transporters", + }, + { + title: "Trailers", + url: "/dashboard/general_catalogs/trailers", + }, + { + title: "Vehículos", + url: "/dashboard/general_catalogs/vehicles", + }, + ], + }, { title: m["sidebar.goods.title"](), url: "#", @@ -522,4 +543,4 @@ export function getSidebarData(): SidebarData { } // Exportar también como constante para compatibilidad (deprecado) -export const sidebarData: SidebarData = getSidebarData(); +export const sidebarData: SidebarData = getSidebarData(); \ No newline at end of file diff --git a/frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte new file mode 100644 index 00000000..596c3d13 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/trailers/+page.svelte @@ -0,0 +1,117 @@ + + +
+
+
+

Trailers

+

Gestión del catálogo de trailers de la compañía

+
+ +
+ +
+ + +
+ + {#if loading && data.length === 0} +
+ Cargando trailers... +
+ {:else} + + {/if} + + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte new file mode 100644 index 00000000..150e006b --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/transporters/+page.svelte @@ -0,0 +1,117 @@ + + +
+
+
+

Transportistas

+

Gestión del catálogo de líneas transportistas

+
+ +
+ +
+ + +
+ + {#if loading && data.length === 0} +
+ Cargando transportistas... +
+ {:else} + + {/if} + + +
diff --git a/frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte new file mode 100644 index 00000000..1bcc8916 --- /dev/null +++ b/frontend/src/routes/dashboard/general_catalogs/vehicles/+page.svelte @@ -0,0 +1,119 @@ + + +
+
+
+

Vehículos (Transporte)

+

+ Gestión del catálogo de camiones y vehículos de transporte +

+
+ +
+ +
+ + +
+ + {#if loading && data.length === 0} +
+ Cargando vehículos... +
+ {:else} + + {/if} + + +
diff --git a/frontend/src/svelte-shims.d.ts b/frontend/src/svelte-shims.d.ts new file mode 100644 index 00000000..185afbcb --- /dev/null +++ b/frontend/src/svelte-shims.d.ts @@ -0,0 +1,7 @@ +// Ambient type declarations for .svelte files +// This must be a script (no top-level import/export) to be globally ambient +declare module "*.svelte" { + import type { Component } from "svelte"; + const component: Component; + export default component; +}