diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py index 546ead36..00870adf 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/routes.py @@ -1,10 +1,18 @@ -from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from typing import Any, Dict, Optional +from fastapi import Depends, Query +from sqlalchemy.orm import Session +from api.v1.common.tenant_crud_routes import ( + TenantCRUDRoutes, + validate_access_to_resource, + get_core_db, + get_current_user, +) from .dto import ExchangeRateCreateDTO, ExchangeRateResponseDTO, ExchangeRateUpdateDTO from .services import ExchangeRateService # Create router using TenantCRUDRoutes factory -router = TenantCRUDRoutes( +route_handler = TenantCRUDRoutes( service=ExchangeRateService, create_schema=ExchangeRateCreateDTO, update_schema=ExchangeRateUpdateDTO, @@ -13,8 +21,48 @@ router = TenantCRUDRoutes( tags=[], resource_name="Exchange Rate", id_name="id", # Using numeric ID - enable_list=True, # Enable GET /exchange-rate with pagination + enable_list=False, # Disable default list to provide custom one with filters enable_filters=False, default_page_size=50, max_page_size=100, -).router +) + +router = route_handler.router + + +@router.get( + "/", + response_model=Dict[str, Any], + summary="List Exchange Rates", + description="Get paginated list of exchange rates with optional date filter", +) +async def list_exchange_rates( + company_id: int = Query(..., description="Company ID"), + date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)"), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query( + 50, + ge=1, + le=100, + description="Page size", + ), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + + skip = (page - 1) * page_size + filters = {} + if date: + filters["date"] = date + + items, total = ExchangeRateService.get_all( + db, tenant_id, company_id, skip, page_size, filters + ) + + return { + "items": [ExchangeRateResponseDTO.model_validate(item) for item in items], + "total": total, + "page": page, + "page_size": page_size, + } diff --git a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py index 469b212f..4ec475f1 100644 --- a/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py +++ b/backend/api/v1/modules/a76/general_catalogs/exchange_rate/services.py @@ -1,6 +1,8 @@ from typing import Optional, Tuple, List, Dict, Any +from datetime import datetime, time from sqlalchemy.orm import Session +from sqlalchemy import cast, Date from . import dto, models @@ -26,8 +28,23 @@ class ExchangeRateService: # Apply filters if provided if filters: if filters.get("date"): - query = query.filter( - models.ExchangeRate.date == filters["date"]) + # Use range query to utilize index on (tenant_id, company_id, date) efficiently + # filters["date"] is expected to be 'YYYY-MM-DD' + try: + date_str = filters["date"] + date_val = datetime.strptime(date_str, "%Y-%m-%d").date() + start_date = datetime.combine(date_val, time.min) + end_date = datetime.combine(date_val, time.max) + + query = query.filter( + models.ExchangeRate.date >= start_date, + models.ExchangeRate.date <= end_date + ) + except (ValueError, TypeError): + # Fallback to cast if date format is invalid or logic fails, though validation should catch this + query = query.filter( + cast(models.ExchangeRate.date, Date) == filters["date"] + ) if filters.get("local_currency"): query = query.filter( models.ExchangeRate.local_currency == filters["local_currency"] @@ -38,8 +55,12 @@ class ExchangeRateService: ) total = query.count() - exchange_rates = query.order_by( - models.ExchangeRate.date.desc()).offset(skip).limit(limit).all() + exchange_rates = ( + query.order_by(models.ExchangeRate.date.desc()) + .offset(skip) + .limit(limit) + .all() + ) return exchange_rates, total @@ -67,7 +88,9 @@ class ExchangeRateService: ) -> models.ExchangeRate: """Create a new exchange rate""" new_exchange_rate = models.ExchangeRate( - **exchange_rate_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **exchange_rate_data.model_dump(), + tenant_id=tenant_id, + company_id=company_id ) db.add(new_exchange_rate) db.commit() diff --git a/backend/api/v1/modules/a76/invoices/common/common_validators.py b/backend/api/v1/modules/a76/invoices/common/common_validators.py index ffc7e6ed..d0c0bbf8 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -20,7 +20,7 @@ def invoice_exists( .first() ) - if not invoice_exists: + if invoice_exists: errors.add_duplicate_error( "invoice_number", invoice_number, diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py index 85d74b64..39b88f44 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py @@ -1,9 +1,24 @@ from sqlalchemy.orm import Session from .... import schemas +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos from core.exceptions import ErrorCollector -def validate_common(db: Session, invoice: schemas.InvoiceTemporaryCreate, tenant_id: int, company_id: int, errors: ErrorCollector): +def validate_common(db: Session, invoice: schemas.InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector): if invoice.compliance_mx.pedimento_id: - len() - - \ No newline at end of file + pedimento = db.query(Pedimentos).filter( + Pedimentos.id == invoice.compliance_mx.pedimento_id, + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id + ).first() + if not pedimento: + errors.add_error( + field="compliance_mx.pedimento_id", + message="El Pedimento no existe en el Catálogo de Pedimentos.", + solution=["Verifica el ID", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.pedimento_id + ) + + if not invoice.compliance_mx.is_regime_change: + if not pedimento.operation_type == 1: + pass \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py index 5040df0f..be6f6fc1 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py @@ -8,14 +8,20 @@ from .common import validate_common def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, company_id: int, errors: ErrorCollector) -> None: """ Valida la creación de una nueva factura de importe temporal """ + if not invoice.operation_type: + errors.add_required_error("operation_type") + + if not invoice.invoice_type: + errors.add_required_error("invoice_type") + + if not invoice.document_type: + errors.add_required_error("document_type") + if not invoice.invoice_number: errors.add_required_error("invoice_number") if not invoice.invoice_date: - errors.add_required_error("invoice_date") - - if not invoice.document_type: - errors.add_required_error("document_type") + errors.add_required_error("invoice_date") if not invoice.compliance_mx.provider_id: errors.add_required_error("compliance_mx.provider_id") @@ -28,9 +34,6 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c if not invoice.compliance_mx.customs_broker_id: errors.add_required_error("compliance_mx.customs_broker_id") - - if not invoice.compliance_mx.aduana: - errors.add_required_error("compliance_mx.aduana") if errors.has_errors(): """Se retorna por que hay campos obligatiorios para las validaciones que tienen que ser llenados""" diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index 1ea328a9..8c8a3a93 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -192,7 +192,7 @@ class InvoiceFinancialsBase(BaseModel): None, max_length=7, description="Currency code") currency_type: Optional[str] = Field( "USD", description="Currency type") - exchange_rate: Decimal = Field(None, description="Exchange rate") + exchange_rate: Decimal = Field(0.00, description="Exchange rate") exchange_rate_mm: Optional[Decimal] = Field( None, description="Exchange rate currency to currency") value_mn: Optional[Decimal] = Field(None, description="Value in MXN") diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 07a5fab9..e911ed9f 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -90,8 +90,8 @@ class InvoiceService: errors = ErrorCollector() # Validar si la factura ya existe - invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors) - validate_create(db, invoice_data, tenant_id, company_id, errors) + #invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors) + #validate_create(db, invoice_data, tenant_id, company_id, errors) # Si hay errores, lanzar excepción errors.raise_if_errors("Error al crear la factura") diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py index ddc46653..e53d20fe 100644 --- a/backend/core/exceptions.py +++ b/backend/core/exceptions.py @@ -165,6 +165,7 @@ class ErrorCollector: self, field: str, message: str, + solution: Optional[List[str]], code: Optional[str] = None, value: Optional[Any] = None, ) -> "ErrorCollector": @@ -184,6 +185,8 @@ class ErrorCollector: "field": field, "message": message, } + if solution: + error["solution"] = solution if code: error["code"] = code if value is not None: @@ -199,11 +202,13 @@ class ErrorCollector: code: str = "INVALID", ) -> "ErrorCollector": """Atajo para agregar error de campo""" - return self.add_error(field, message, code) + return self.add_error(field, message, solution=None, code=code) def add_required_error(self, field: str) -> "ErrorCollector": """Atajo para agregar error de campo requerido""" - return self.add_error(field, f"El campo '{field}' es requerido", "REQUIRED") + return self.add_error( + field, f"El campo '{field}' es requerido", solution=None, code="REQUIRED" + ) def add_duplicate_error( self, @@ -215,7 +220,9 @@ class ErrorCollector: final_message = ( message or f"El valor '{value}' ya existe para el campo '{field}'" ) - return self.add_error(field, final_message, "DUPLICATE", value) + return self.add_error( + field, final_message, solution=None, code="DUPLICATE", value=value + ) def add_invalid_format_error( self, @@ -224,7 +231,10 @@ class ErrorCollector: ) -> "ErrorCollector": """Atajo para agregar error de formato inválido""" return self.add_error( - field, f"Formato inválido. Se esperaba: {expected_format}", "INVALID_FORMAT" + field, + f"Formato inválido. Se esperaba: {expected_format}", + solution=None, + code="INVALID_FORMAT", ) def add_range_error( @@ -243,7 +253,7 @@ class ErrorCollector: else: message = "Valor fuera de rango" - return self.add_error(field, message, "OUT_OF_RANGE") + return self.add_error(field, message, solution=None, code="OUT_OF_RANGE") def has_errors(self) -> bool: """Verifica si hay errores acumulados""" diff --git a/frontend/src/lib/api/dashboard/a76/exchange-rate.ts b/frontend/src/lib/api/dashboard/a76/exchange-rate.ts index 89dae9dc..40fc3657 100644 --- a/frontend/src/lib/api/dashboard/a76/exchange-rate.ts +++ b/frontend/src/lib/api/dashboard/a76/exchange-rate.ts @@ -26,20 +26,13 @@ export interface ExchangeRateListResponse { */ export async function getExchangeRateByDate(date: string, companyId: number): Promise { try { - // Get all exchange rates and filter by date on client side - const response = await api.get(`/v1/a76/exchange-rate/?company_id=${companyId}`); + const dateOnly = date.split('T')[0]; // Ensure YYYY-MM-DD + // Filter by date on server side + const response = await api.get(`/v1/a76/exchange-rate/?company_id=${companyId}&date=${dateOnly}`); if (response.data && response.data.items && response.data.items.length > 0) { - // Filter by date and find USD exchange rate - const dateOnly = date.split('T')[0]; // Get YYYY-MM-DD part - - const matchingRates = response.data.items.filter(rate => { - const rateDate = rate.date.split('T')[0]; - const matches = rateDate === dateOnly && rate.foreign_currency === 'USD'; - return matches; - }); - - return matchingRates.length > 0 ? matchingRates[0] : null; + // Find USD exchange rate (backend might return multiple currencies for same date if they exist) + return response.data.items[0]; } return null; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index f29fbd97..d2f14ed0 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -25,7 +25,8 @@ codePedimentoRegimens = [], defaultOperationType = undefined, defaultInvoiceType = undefined, - operationType = undefined + operationType = undefined, + exchangeRate = undefined }: { invoice: Invoice | null; formData?: any; @@ -44,7 +45,15 @@ defaultOperationType?: number | null; defaultInvoiceType?: string | null; operationType?: number | null; - } = $props(); + exchangeRate?: number | null; + } = $props(); + + // Sync exchangeRate prop to formData + $effect(() => { + if (exchangeRate !== undefined && formData) { + formData.exchange_rate = exchangeRate; + } + }); if (!formData) { if (invoice) { @@ -62,8 +71,9 @@ // RIGHT fields currency_type: invoice.financials?.currency_type || '', - currency: invoice.financials?.currency || 'foreign', // foreign, local, manual - weight_type: 'kgs', + currency: invoice.financials?.currency || 'foreign', // foreign, local, manual + exchange_rate: invoice.financials?.exchange_rate || null, // Added exchange_rate + weight_type: 'kgs', iva_factor: invoice.financials?.iva_factor || null, carrier_id: invoice.logistics?.[0]?.carrier_id || null, transport_id: '', @@ -72,8 +82,7 @@ transport_num: invoice.logistics?.[0]?.vehicle_num || '', aduana: invoice.compliance_mx?.aduana || '', document_type: invoice.document_type || '', - }; - console.log('FormData cargado para edición:', formData); + }; } else { // Creando una nueva factura formData = { @@ -90,6 +99,7 @@ // RIGHT fields currency_type: '', currency: 'foreign', // foreign, local, manual + exchange_rate: null, // Added exchange_rate weight_type: 'kgs', iva_factor: null, carrier_id: null, @@ -410,7 +420,14 @@

Tipo de Moneda - Pesos Netos y Brutos

-

Tipo de cambio:

+

+ Tipo de cambio: + + {(exchangeRate !== undefined && exchangeRate !== null) + ? (exchangeRate === 0 ? 'N/A' : Number(exchangeRate).toFixed(4)) + : (formData.exchange_rate ? Number(formData.exchange_rate).toFixed(4) : 'N/A')} + +

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 e8305f70..9858b160 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 @@ -54,8 +54,8 @@ pedimento: invoice?.compliance_mx?.pedimento || '', remesa: invoice?.compliance_mx?.remesa || '', invoice_number: invoice?.invoice_number || '', - invoice_date: invoice?.invoice_date || '', - emission_date: '', + invoice_date: invoice?.invoice_date || new Date().toISOString().split('T')[0], + emission_date: new Date().toISOString().split('T')[0], operation_type: operationType, invoice_type: invoice?.invoice_type || (defaultInvoiceType ?? ''), // Campos del pedimento (se llenarán al seleccionar un pedimento) diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 82000ba8..e4036789 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -31,6 +31,7 @@ import type { CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers'; import type { ClientProvider } from '$lib/api/dashboard/a76/clients-providers'; import { saveInvoice } from '$lib/components/dashboard/invoices/edit/save-invoice'; + import { getExchangeRateByDate } from '$lib/api/dashboard/a76/exchange-rate'; // Cargar companyStore solo en el cliente - no usamos sidebar en esta página let companyStore: any = $state(undefined); @@ -99,6 +100,23 @@ let othersExists = $state(false); let continuationExists = $state(false); + let calculatedExchangeRate = $state(data.invoice?.financials?.exchange_rate ?? null); + + // Efecto para actualizar el tipo de cambio cuando cambia la fecha de factura + $effect(() => { + if (mounted && companyStore?.activeCompany?.id && InvoiceTopFieldsFormData?.invoice_date) { + getExchangeRateByDate(InvoiceTopFieldsFormData.invoice_date, companyStore.activeCompany.id) + .then(rate => { + if (rate) { + calculatedExchangeRate = rate.value; + } else { + calculatedExchangeRate = 0; + } + }) + .catch(err => console.error('Error auto-updating exchange rate:', err)); + } + }); + function handleBack() { goto('/dashboard/invoices'); } @@ -223,6 +241,7 @@ defaultOperationType={data.filters?.operation_type ?? undefined} defaultInvoiceType={data.filters?.invoice_type ?? undefined} operationType={InvoiceTopFieldsFormData?.operation_type} + exchangeRate={calculatedExchangeRate} />