feat: implement exchange rate filtering and validation enhancements in invoice processing
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -20,7 +20,7 @@ def invoice_exists(
|
||||
.first()
|
||||
)
|
||||
|
||||
if not invoice_exists:
|
||||
if invoice_exists:
|
||||
errors.add_duplicate_error(
|
||||
"invoice_number",
|
||||
invoice_number,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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
|
||||
@@ -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"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -26,20 +26,13 @@ export interface ExchangeRateListResponse {
|
||||
*/
|
||||
export async function getExchangeRateByDate(date: string, companyId: number): Promise<ExchangeRate | null> {
|
||||
try {
|
||||
// Get all exchange rates and filter by date on client side
|
||||
const response = await api.get<ExchangeRateListResponse>(`/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<ExchangeRateListResponse>(`/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;
|
||||
|
||||
@@ -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 @@
|
||||
<div class="border rounded-md p-3 space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Tipo de Moneda - Pesos Netos y Brutos</h4>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Tipo de cambio: </h4>
|
||||
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Tipo de cambio:
|
||||
<span class="text-primary ml-1">
|
||||
{(exchangeRate !== undefined && exchangeRate !== null)
|
||||
? (exchangeRate === 0 ? 'N/A' : Number(exchangeRate).toFixed(4))
|
||||
: (formData.exchange_rate ? Number(formData.exchange_rate).toFixed(4) : 'N/A')}
|
||||
</span>
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Radio buttons para tipo de moneda -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<number | null>(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}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user