Merge pull request 'development' (#89) from development into main
Reviewed-on: ADUANASOFT/anexo76#89
This commit is contained in:
@@ -21,14 +21,12 @@ KEYCLOAK_FRONTEND_CLIENT_ID=anexo76-frontend
|
||||
# ----- Backend -----
|
||||
DEBUG=True
|
||||
ENVIRONMENT=development
|
||||
|
||||
CORE_DB_HOST=postgres-a76
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=anexo76_core
|
||||
CORE_DB_USER=postgres
|
||||
CORE_DB_PASSWORD=postgres
|
||||
|
||||
|
||||
# ----- Frontend -----
|
||||
NODE_ENV=development
|
||||
VITE_API_URL=http://localhost:8000/api
|
||||
@@ -36,3 +34,8 @@ INTERNAL_API_URL=http://backend:8000/api
|
||||
VITE_KEYCLOAK_REALM=master
|
||||
VITE_KEYCLOAK_URL=http://localhost:8080
|
||||
VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend
|
||||
|
||||
# ----- Sitar API -----
|
||||
SITAR_API_URL=http://api.sitar.aduanasoft.com
|
||||
SITAR_API_USER=your_sitar_user
|
||||
SITAR_API_PASSWORD=your_sitar_password
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout código
|
||||
|
||||
@@ -350,7 +350,6 @@ class ClientProviderService:
|
||||
)
|
||||
|
||||
if client_or_provider:
|
||||
from .models import ClientOrProviderEnum
|
||||
query = query.filter(
|
||||
or_(
|
||||
ClientProvider.client_or_provider == client_or_provider,
|
||||
|
||||
@@ -27,10 +27,25 @@ route_handler = TenantCRUDRoutes(
|
||||
max_page_size=100,
|
||||
)
|
||||
|
||||
router = route_handler.router
|
||||
crud_router = route_handler.router
|
||||
|
||||
# Create a custom router for specific endpoints that must be matched BEFORE generic CRUD routes
|
||||
# We use the same prefix so they are grouped together
|
||||
from fastapi import APIRouter
|
||||
custom_router = APIRouter(prefix="/exchange-rate", tags=[])
|
||||
|
||||
@custom_router.get("/test-ping")
|
||||
async def test_ping():
|
||||
return {"message": "pong"}
|
||||
|
||||
# Master router to export
|
||||
router = APIRouter()
|
||||
# Include custom routes FIRST to avoid shadowing by /{id}
|
||||
router.include_router(custom_router)
|
||||
# router.include_router(crud_router)
|
||||
|
||||
|
||||
@router.get(
|
||||
@custom_router.get(
|
||||
"/",
|
||||
response_model=Dict[str, Any],
|
||||
summary="List Exchange Rates",
|
||||
@@ -66,3 +81,37 @@ async def list_exchange_rates(
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
@custom_router.get(
|
||||
"/dof-search",
|
||||
response_model=Dict[str, Any],
|
||||
summary="Fetch Exchange Rate from DOF",
|
||||
description="Fetches the exchange rate from the Official Journal of the Federation (DOF) for a specific date.",
|
||||
)
|
||||
async def fetch_exchange_rate_dof(
|
||||
date: str = Query(..., description="Date in YYYY-MM-DD format"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||
):
|
||||
# This endpoint can be public or protected. Assuming protected for now.
|
||||
# No specific tenant validation needed since it's an external query,
|
||||
# but good to ensure user is authenticated.
|
||||
|
||||
try:
|
||||
print(f"DEBUG: Route called with date={date}")
|
||||
rate = ExchangeRateService.fetch_from_dof(date)
|
||||
|
||||
if rate is None:
|
||||
return {"success": False, "message": "No se encontró el tipo de cambio en el DOF para la fecha especificada o el servicio no está disponible.", "value": None}
|
||||
|
||||
return {"success": True, "value": rate}
|
||||
except Exception as e:
|
||||
print(f"DEBUG: Error in route: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"success": False, "message": f"Error interno: {str(e)}", "value": None}
|
||||
|
||||
# Include routers at the end to ensure all routes are registered
|
||||
# Include custom routes FIRST to avoid shadowing by /{id} of crud_router
|
||||
router.include_router(custom_router)
|
||||
router.include_router(crud_router)
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
from datetime import datetime, time
|
||||
import requests
|
||||
import re
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import cast, Date
|
||||
|
||||
from . import dto, models
|
||||
import urllib3
|
||||
from core.config import settings
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
|
||||
|
||||
class ExchangeRateService:
|
||||
@@ -135,3 +143,92 @@ class ExchangeRateService:
|
||||
db.delete(exchange_rate)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_sitar_api_token(base_url, username, password) -> Optional[str]:
|
||||
"""Helper to get authentication token from external API"""
|
||||
try:
|
||||
print(base_url)
|
||||
login_url = f"{base_url}/exchange-rate/auth/login"
|
||||
payload = {"username": username, "password": password}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
response = requests.post(login_url, json=payload, headers=headers, timeout=5)
|
||||
if response.status_code not in [200, 201]:
|
||||
print(f"External API Login Failed: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
return data.get("token") or data.get("access_token")
|
||||
except Exception as e:
|
||||
print(f"External API Login Error: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def fetch_from_dof(date_str: str) -> Optional[float]:
|
||||
"""
|
||||
Fetches the exchange rate from an external API (replacing direct DOF scraping).
|
||||
The API handles date logic (holidays, weekends) automatically.
|
||||
|
||||
Args:
|
||||
date_str (str): Date in 'YYYY-MM-DD' format.
|
||||
|
||||
Returns:
|
||||
Optional[float]: The exchange rate value if found, None otherwise.
|
||||
"""
|
||||
# API Credentials
|
||||
API_BASE_URL = settings.SITAR_API_URL
|
||||
API_USER = settings.SITAR_API_USER
|
||||
API_PASS = settings.SITAR_API_PASSWORD
|
||||
|
||||
if not API_USER or not API_PASS:
|
||||
print("ERROR: External API credentials not properly configured in settings")
|
||||
return None
|
||||
|
||||
try:
|
||||
print(f"DEBUG: Fetching External API for date: {date_str}")
|
||||
|
||||
# 1. Get Token
|
||||
token = ExchangeRateService._get_sitar_api_token(API_BASE_URL, API_USER, API_PASS)
|
||||
if not token:
|
||||
print("Failed to obtain external API token")
|
||||
return None
|
||||
|
||||
# 2. Fetch Exchange Rate
|
||||
# The API endpoint is /tipoCambio/{YYYY-MM-DD}
|
||||
tc_endpoint = f"{API_BASE_URL}/exchange-rate/tipoCambio/{date_str}"
|
||||
|
||||
# Auth header: The API expects just the token string in common usage, but we try standard first
|
||||
# based on user feedback/code: 'Authorization:' . $token
|
||||
headers = {
|
||||
"Authorization": token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.get(tc_endpoint, headers=headers, timeout=5)
|
||||
|
||||
# Retry logic as per PHP reference (if 401, maybe formatting issue, but requests handles headers well)
|
||||
if response.status_code == 401:
|
||||
# Try with Bearer prefix just in case, though PHP code suggested raw token
|
||||
print("DEBUG: 401 received, retrying with Bearer prefix...")
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
response = requests.get(tc_endpoint, headers=headers, timeout=5)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"External API TC Error: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
# Expected response: {"Id":..., "Fecha":"...", "TipoCambio":17.452, "Mov":"..."}
|
||||
|
||||
if "TipoCambio" in data:
|
||||
val = float(data["TipoCambio"])
|
||||
print(f"DEBUG: External API returned value: {val}")
|
||||
return val
|
||||
|
||||
print(f"DEBUG: 'TipoCambio' key not found in response: {data}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching from External API: {e}")
|
||||
return None
|
||||
|
||||
@@ -12,6 +12,76 @@ from api.v1.modules.public.reference_data.currency_types.models import CurrencyT
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from ....models import TransportType, Currency, WeightUnit
|
||||
from core.exceptions import ErrorCollector
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
def validate_required_fields_by_operation(
|
||||
invoice_data: Dict[str, Any],
|
||||
operation_type: str,
|
||||
errors: ErrorCollector
|
||||
) -> None:
|
||||
"""
|
||||
Valida campos obligatorios según tipo de operación.
|
||||
Usar ANTES de guardar en BD.
|
||||
"""
|
||||
|
||||
# PROVEEDOR (SIEMPRE OBLIGATORIO - mensaje dinámico)
|
||||
if not invoice_data.get('provider_id'):
|
||||
# Mensaje dinámico según el header seleccionado
|
||||
provider_labels = {
|
||||
'proveedor': 'Proveedor',
|
||||
'exportador': 'Exportador'
|
||||
}
|
||||
provider_header = invoice_data.get('provider_header') or 'proveedor'
|
||||
field_label = provider_labels.get(provider_header, 'Proveedor')
|
||||
|
||||
errors.add_error(
|
||||
field="provider_id",
|
||||
message=f"Debe seleccionar {field_label}",
|
||||
solution=["Seleccione un proveedor de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
# VENDIDO A / CONSIGNADO A (SIEMPRE OBLIGATORIO - mensaje dinámico)
|
||||
if not invoice_data.get('sold_to_id'):
|
||||
# Mensaje dinámico según el header seleccionado
|
||||
sold_to_labels = {
|
||||
'consignado_a': 'Consignado a',
|
||||
'vendido_a': 'Vendido a',
|
||||
'exportado_a': 'Exportado a',
|
||||
'importador': 'Importador'
|
||||
}
|
||||
sold_to_header = invoice_data.get('sold_to_header') or 'consignado_a'
|
||||
field_label = sold_to_labels.get(sold_to_header, 'Cliente')
|
||||
|
||||
errors.add_error(
|
||||
field="sold_to_id",
|
||||
message=f"Debe seleccionar {field_label}",
|
||||
solution=["Seleccione una opción de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
# ENVIADO A (SIEMPRE OBLIGATORIO - mensaje fijo)
|
||||
if not invoice_data.get('shipped_to_id'):
|
||||
errors.add_error(
|
||||
field="shipped_to_id",
|
||||
message="Debe seleccionar el Destinatario",
|
||||
solution=["Seleccione un destinatario de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
# AGENTE ADUANAL (OBLIGATORIO si hay pedimento)
|
||||
if invoice_data.get('pedimento_id') and not invoice_data.get('customs_broker_id'):
|
||||
errors.add_error(
|
||||
field="customs_broker_id",
|
||||
message="Debe seleccionar un Agente Aduanal",
|
||||
solution=["Seleccione un agente aduanal de la lista desplegable"],
|
||||
code="REQUIRED",
|
||||
value=None
|
||||
)
|
||||
|
||||
|
||||
def validate_common(
|
||||
@@ -245,78 +315,87 @@ def validate_common(
|
||||
value=invoice.document_type,
|
||||
)
|
||||
|
||||
provider_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.provider_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not provider_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.provider_id",
|
||||
message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Proveedor", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.provider_id,
|
||||
# Validar proveedor solo si se proporciona
|
||||
if invoice.compliance_mx.provider_id:
|
||||
provider_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.provider_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not provider_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.provider_id",
|
||||
message="El Proveedor no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Proveedor", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.provider_id,
|
||||
)
|
||||
|
||||
selled_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.sold_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not selled_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.sold_to_id",
|
||||
message="El Cliente no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Cliente", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.sold_to_id,
|
||||
# Validar vendido a solo si se proporciona
|
||||
if invoice.compliance_mx.sold_to_id:
|
||||
selled_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.sold_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not selled_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.sold_to_id",
|
||||
message="El Cliente no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Cliente", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.sold_to_id,
|
||||
)
|
||||
|
||||
shipped_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.shipped_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not shipped_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
# Validar destinatario solo si se proporciona
|
||||
if invoice.compliance_mx.shipped_to_id:
|
||||
shipped_to_exists = (
|
||||
db.query(ClientProvider)
|
||||
.filter(
|
||||
ClientProvider.id == invoice.compliance_mx.shipped_to_id,
|
||||
ClientProvider.tenant_id == tenant_id,
|
||||
ClientProvider.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not shipped_to_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.shipped_to_id",
|
||||
message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Destinatario", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.shipped_to_id,
|
||||
)
|
||||
|
||||
customs_broker_exists = (
|
||||
db.query(CustomsBroker)
|
||||
.filter(
|
||||
CustomsBroker.id == invoice.compliance_mx.customs_broker_id,
|
||||
CustomsBroker.tenant_id == tenant_id,
|
||||
CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not customs_broker_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.customs_broker_id",
|
||||
message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.customs_broker_id,
|
||||
# Validar agente aduanal solo si se proporciona
|
||||
if invoice.compliance_mx.customs_broker_id:
|
||||
customs_broker_exists = (
|
||||
db.query(CustomsBroker)
|
||||
.filter(
|
||||
CustomsBroker.id == invoice.compliance_mx.customs_broker_id,
|
||||
CustomsBroker.tenant_id == tenant_id,
|
||||
CustomsBroker.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not customs_broker_exists:
|
||||
errors.add_error(
|
||||
field="compliance_mx.customs_broker_id",
|
||||
message="El Agente Aduanal no existe en el Catálogo de Clientes y Proveedores.",
|
||||
solution=["Verifica el ID del Agente Aduanal", "Revisa el catálogo"],
|
||||
code="NOT_FOUND",
|
||||
value=invoice.compliance_mx.customs_broker_id,
|
||||
)
|
||||
|
||||
# Validar transportista solo si se proporciona
|
||||
if invoice.logistics.carrier_id:
|
||||
carrier_exists = (
|
||||
db.query(ClientProvider)
|
||||
|
||||
@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
|
||||
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
|
||||
from core.exceptions import ErrorCollector
|
||||
from ....schemas import InvoiceHeaderCreate
|
||||
from .common import validate_common
|
||||
from .common import validate_common, validate_required_fields_by_operation
|
||||
|
||||
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 """
|
||||
@@ -21,22 +21,31 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c
|
||||
errors.add_required_error("invoice_number")
|
||||
|
||||
if not invoice.invoice_date:
|
||||
errors.add_required_error("invoice_date")
|
||||
|
||||
if not invoice.compliance_mx.provider_id:
|
||||
errors.add_required_error("compliance_mx.provider_id")
|
||||
errors.add_required_error("invoice_date")
|
||||
|
||||
if not invoice.compliance_mx.sold_to_id:
|
||||
errors.add_required_error("compliance_mx.sold_to_id")
|
||||
|
||||
if not invoice.compliance_mx.shipped_to_id:
|
||||
errors.add_required_error("compliance_mx.shipped_to_id")
|
||||
|
||||
if not invoice.compliance_mx.customs_broker_id:
|
||||
errors.add_required_error("compliance_mx.customs_broker_id")
|
||||
|
||||
if errors.has_errors():
|
||||
"""Se retorna por que hay campos obligatiorios para las validaciones que tienen que ser llenados"""
|
||||
"""Se retorna porque hay campos obligatorios básicos que deben ser llenados"""
|
||||
return
|
||||
|
||||
# Validar campos obligatorios según tipo de operación
|
||||
invoice_data = {
|
||||
'provider_header': invoice.compliance_mx.provider_header if invoice.compliance_mx else None,
|
||||
'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else None,
|
||||
'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None,
|
||||
'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else None,
|
||||
'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else None,
|
||||
'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else None,
|
||||
'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else None,
|
||||
}
|
||||
|
||||
validate_required_fields_by_operation(
|
||||
invoice_data=invoice_data,
|
||||
operation_type=invoice.operation_type,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
if errors.has_errors():
|
||||
"""Se retorna porque hay campos obligatorios según el tipo de operación que deben ser llenados"""
|
||||
return
|
||||
|
||||
validate_common(db, invoice, tenant_id, company_id, errors)
|
||||
|
||||
@@ -5,6 +5,7 @@ from decimal import Decimal
|
||||
from core.exceptions import ErrorCollector
|
||||
from ....schemas import InvoiceHeaderUpdate
|
||||
from ....models import InvoiceHeader
|
||||
from .common import validate_required_fields_by_operation
|
||||
|
||||
|
||||
# Helper function para limpiar strings (equivalente a Clip())
|
||||
@@ -33,6 +34,22 @@ def validate_update(
|
||||
None (modifica invoice_data in-place y acumula errores en errors)
|
||||
"""
|
||||
|
||||
# Validar campos requeridos según el tipo de operación
|
||||
invoice_dict = {
|
||||
'provider_id': invoice_data.compliance_mx.provider_id if invoice_data.compliance_mx else None,
|
||||
'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else None,
|
||||
'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else None,
|
||||
'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else None,
|
||||
'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else None,
|
||||
'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else None,
|
||||
}
|
||||
|
||||
validate_required_fields_by_operation(
|
||||
invoice_data=invoice_dict,
|
||||
operation_type=invoice_data.operation_type or 'IMP',
|
||||
errors=errors
|
||||
)
|
||||
|
||||
# Primero ejecutar validaciones comunes
|
||||
# validate_common(invoice_data, errors)
|
||||
|
||||
|
||||
@@ -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")
|
||||
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[int] = 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"
|
||||
)
|
||||
|
||||
@@ -34,7 +34,6 @@ class LineQuantity(Base):
|
||||
serial_count: Mapped[Optional[int]] = mapped_column(Integer) # CANT_SERIES/CANT_SERIESDEF
|
||||
|
||||
# Weight
|
||||
weight_unit: Mapped[Optional[str]] = mapped_column(String(3)) # 'KG' o 'LB'
|
||||
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESONETO
|
||||
gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8)) # PESOBRUTO
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ class LineQuantityBase(BaseModel):
|
||||
serial_count: Optional[int] = Field(None, description="Serial count (CANT_SERIES/CANT_SERIESDEF)")
|
||||
|
||||
# Weight
|
||||
weight_unit: Optional[str] = Field(None, max_length=3, description="Weight unit ('KG' or 'LB')")
|
||||
net_weight: Optional[Decimal] = Field(None, description="Net weight (PESONETO)")
|
||||
gross_weight: Optional[Decimal] = Field(None, description="Gross weight (PESOBRUTO)")
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ class ItemService:
|
||||
# Validaciones adicionales específicas del negocio
|
||||
|
||||
# Validar apóstrofes en número de parte
|
||||
if line_data.part_number and "'" in str(line_data.part_number):
|
||||
if line_data.part_number_id and "'" in str(line_data.part_number_id):
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].part_number",
|
||||
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
|
||||
@@ -420,7 +420,7 @@ class ItemService:
|
||||
# (Aplican tanto para crear como actualizar)
|
||||
|
||||
# Validar apóstrofes en número de parte
|
||||
if line_data.part_number and "'" in str(line_data.part_number):
|
||||
if line_data.part_number_id and "'" in str(line_data.part_number_id):
|
||||
errors.add_error(
|
||||
field=f"lines[{idx}].part_number",
|
||||
message=f"Advertencia: El Número de Parte contiene apóstrofes y serán omitidos",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,18 +10,20 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# --- MODELOS ---
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceLogistics
|
||||
from api.v1.modules.a76.items.line_financials.models import LineFinancial
|
||||
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
|
||||
from api.v1.modules.a76.items.line_items.models import LineItem
|
||||
from api.v1.modules.a76.clients_and_providers.models import (
|
||||
ClientProvider, ClientProviderAddress, ClientProviderPrograms
|
||||
ClientProvider,
|
||||
ClientProviderAddress,
|
||||
ClientProviderPrograms,
|
||||
)
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.pedmientos.models import Pedimentos
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
|
||||
# --- TRANSPORTATION MODELS ---
|
||||
from api.v1.modules.a76.transportation.transporters.models import Transporter
|
||||
@@ -32,20 +34,27 @@ from api.v1.modules.a76.transportation.drivers.models import Driver
|
||||
# --- MODELO DE FRACCIONES ---
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
|
||||
# --- MODELO DE UNIDADES DE MEDIDA ---
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
|
||||
# --- SCHEMAS ---
|
||||
from .schemas import (
|
||||
ClienteSchema, PartidaSchema, TotalesSchema,
|
||||
FacturaSchema, FacturaImportacionCompleta
|
||||
ClienteSchema,
|
||||
PartidaSchema,
|
||||
TotalesSchema,
|
||||
FacturaSchema,
|
||||
FacturaImportacionCompleta,
|
||||
)
|
||||
|
||||
|
||||
class FacturaImportacionMexService:
|
||||
def __init__(self):
|
||||
self.template_dir = Path(__file__).parent.parent / "templates"
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(self.template_dir),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
self.template = self.jinja_env.get_template('factura_mex_ver.html')
|
||||
self.template = self.jinja_env.get_template("factura_mex_ver.html")
|
||||
|
||||
def _get_wkhtmltopdf_config(self):
|
||||
path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf"
|
||||
@@ -54,28 +63,49 @@ class FacturaImportacionMexService:
|
||||
return pdfkit.configuration(wkhtmltopdf=path)
|
||||
|
||||
def formatear_numero(self, valor, decimales: int = 2):
|
||||
if valor is None: return 0.0
|
||||
if valor is None:
|
||||
return 0.0
|
||||
try:
|
||||
return round(float(valor), decimales)
|
||||
except: return 0.0
|
||||
except:
|
||||
return 0.0
|
||||
|
||||
def _format_fraccion_fallback(self, fraccion_raw: str) -> str:
|
||||
if not fraccion_raw or len(fraccion_raw) < 8:
|
||||
return fraccion_raw
|
||||
return f"{fraccion_raw[:4]}.{fraccion_raw[4:6]}.{fraccion_raw[6:]}"
|
||||
|
||||
def _obtener_datos_cliente(self, db: Session, client_id: int, rol: str) -> ClienteSchema:
|
||||
def _obtener_datos_cliente(
|
||||
self, db: Session, client_id: int, rol: str
|
||||
) -> ClienteSchema:
|
||||
main = db.query(ClientProvider).filter(ClientProvider.id == client_id).first()
|
||||
if not main:
|
||||
return ClienteSchema(header=rol, nombre="Desconocido", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="MEX")
|
||||
|
||||
addr = db.query(ClientProviderAddress).filter(ClientProviderAddress.client_id == client_id).first()
|
||||
prog = db.query(ClientProviderPrograms).filter(ClientProviderPrograms.client_id == client_id).first()
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre="Desconocido",
|
||||
direccion="",
|
||||
tax_id="",
|
||||
codigo_postal="",
|
||||
ciudad="",
|
||||
estado="",
|
||||
pais="MEX",
|
||||
)
|
||||
|
||||
addr = (
|
||||
db.query(ClientProviderAddress)
|
||||
.filter(ClientProviderAddress.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
prog = (
|
||||
db.query(ClientProviderPrograms)
|
||||
.filter(ClientProviderPrograms.client_id == client_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return ClienteSchema(
|
||||
header=rol,
|
||||
nombre=(main.name or main.short_name) or "S/N",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
direccion=(addr.streets or "") if addr else "",
|
||||
num_exterior=(addr.exterior_number or "") if addr else "",
|
||||
num_interior=(addr.interior_number or "") if addr else "",
|
||||
colonia=(addr.neighborhood or "") if addr else "",
|
||||
@@ -83,47 +113,114 @@ class FacturaImportacionMexService:
|
||||
ciudad=(addr.city or "") if addr else "",
|
||||
estado=(addr.state or "") if addr else "",
|
||||
pais=(addr.country or "MEX") if addr else "MEX",
|
||||
tax_id=prog.tax_id if (prog and prog.tax_id) else (getattr(main, 'rfc', "") or ""),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=prog.prosec_authorization if (prog and prog.prosec and prog.prosec_authorization) else "",
|
||||
reg_emp=prog.val_certified_company_registry if (prog and hasattr(prog, 'val_certified_company_registry')) else (
|
||||
prog.certified_company_registry if (prog and prog.certified_company_registry) else ""
|
||||
tax_id=(
|
||||
prog.tax_id
|
||||
if (prog and prog.tax_id)
|
||||
else (getattr(main, "rfc", "") or "")
|
||||
),
|
||||
programa="IMMEX" if (prog and prog.program) else "",
|
||||
autorizacion=prog.program_number if prog else "",
|
||||
prosec=(
|
||||
prog.prosec_authorization
|
||||
if (prog and prog.prosec and prog.prosec_authorization)
|
||||
else ""
|
||||
),
|
||||
reg_emp=(
|
||||
prog.val_certified_company_registry
|
||||
if (prog and hasattr(prog, "val_certified_company_registry"))
|
||||
else (
|
||||
prog.certified_company_registry
|
||||
if (prog and prog.certified_company_registry)
|
||||
else ""
|
||||
)
|
||||
),
|
||||
cert=(
|
||||
prog.is_certified_company
|
||||
if (prog and prog.is_certified_company)
|
||||
else ""
|
||||
),
|
||||
cert=prog.is_certified_company if (prog and prog.is_certified_company) else ""
|
||||
)
|
||||
|
||||
def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> FacturaImportacionCompleta:
|
||||
def obtener_datos(
|
||||
self,
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
company_id: int,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> FacturaImportacionCompleta:
|
||||
try:
|
||||
if progress_callback: progress_callback(10, "Buscando factura...")
|
||||
header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first()
|
||||
if not header: raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
if progress_callback:
|
||||
progress_callback(10, "Buscando factura...")
|
||||
header = (
|
||||
db.query(InvoiceHeader)
|
||||
.filter(
|
||||
InvoiceHeader.id == invoice_id,
|
||||
InvoiceHeader.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not header:
|
||||
raise HTTPException(status_code=404, detail="Factura no encontrada")
|
||||
|
||||
compliance = header.compliance_mx
|
||||
compliance = header.compliance_mx
|
||||
logistics = header.logistics if header.logistics else None
|
||||
financials = header.financials if header.financials else None
|
||||
if progress_callback: progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = compliance.pedimento_id if (compliance and compliance.pedimento_id) else header.related_doc_id
|
||||
pedimento = db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first() if pedimento_id else None
|
||||
|
||||
if progress_callback: progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
if progress_callback:
|
||||
progress_callback(20, "Obteniendo datos de pedimento...")
|
||||
pedimento_id = (
|
||||
compliance.pedimento_id
|
||||
if (compliance and compliance.pedimento_id)
|
||||
else header.related_doc_id
|
||||
)
|
||||
pedimento = (
|
||||
db.query(Pedimentos).filter(Pedimentos.id == pedimento_id).first()
|
||||
if pedimento_id
|
||||
else None
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(30, "Obteniendo cliente y proveedor...")
|
||||
proveedor_id = compliance.provider_id if compliance else None
|
||||
cliente_proveedor = self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:") if proveedor_id else ClienteSchema(header="Proveedor", nombre="No Asignado", direccion="", tax_id="", codigo_postal="", ciudad="", estado="", pais="")
|
||||
cliente_proveedor = (
|
||||
self._obtener_datos_cliente(db, proveedor_id, "Proveedor / supplier:")
|
||||
if proveedor_id
|
||||
else ClienteSchema(
|
||||
header="Proveedor",
|
||||
nombre="No Asignado",
|
||||
direccion="",
|
||||
tax_id="",
|
||||
codigo_postal="",
|
||||
ciudad="",
|
||||
estado="",
|
||||
pais="",
|
||||
)
|
||||
)
|
||||
|
||||
nombre_agente = ""
|
||||
if compliance and compliance.customs_broker_id:
|
||||
broker = db.query(CustomsBroker).filter(CustomsBroker.id == compliance.customs_broker_id).first()
|
||||
if broker: nombre_agente = broker.name
|
||||
broker = (
|
||||
db.query(CustomsBroker)
|
||||
.filter(CustomsBroker.id == compliance.customs_broker_id)
|
||||
.first()
|
||||
)
|
||||
if broker:
|
||||
nombre_agente = broker.name
|
||||
|
||||
company = db.query(Company).filter(Company.id == header.company_id).first()
|
||||
# Datos Default (Company/Importer) - Used for fallback or Right Side (Enviado A)
|
||||
cliente_default = ClienteSchema(
|
||||
header="Importador / consignatario:",
|
||||
nombre=getattr(company, 'name', "Empresa Local"),
|
||||
nombre=getattr(company, "name", "Empresa Local"),
|
||||
direccion="DOMICILIO FISCAL",
|
||||
num_exterior="", colonia="", codigo_postal="", ciudad="", estado="", pais="MEX",
|
||||
tax_id=getattr(company, 'rfc', ""),
|
||||
programa=getattr(company, 'program', "IMMEX"), autorizacion=getattr(company, 'program_number', "")
|
||||
num_exterior="",
|
||||
colonia="",
|
||||
codigo_postal="",
|
||||
ciudad="",
|
||||
estado="",
|
||||
pais="MEX",
|
||||
tax_id=getattr(company, "rfc", ""),
|
||||
programa=getattr(company, "program", "IMMEX"),
|
||||
autorizacion=getattr(company, "program_number", ""),
|
||||
)
|
||||
|
||||
# Left Side Logic (Consignatario / Sold To)
|
||||
@@ -131,34 +228,51 @@ class FacturaImportacionMexService:
|
||||
if compliance and compliance.sold_to_id:
|
||||
raw_header = compliance.sold_to_header or "CONSIGNATARIO"
|
||||
clean_header = raw_header.replace("_", " ").capitalize() + ":"
|
||||
cliente_vendido = self._obtener_datos_cliente(db, compliance.sold_to_id, clean_header)
|
||||
|
||||
cliente_vendido = self._obtener_datos_cliente(
|
||||
db, compliance.sold_to_id, clean_header
|
||||
)
|
||||
|
||||
# Right Side Logic (Enviado A / Shipped To)
|
||||
cliente_enviado = cliente_default
|
||||
if compliance and compliance.shipped_to_id:
|
||||
# Clean header: "enviado_a" -> "Enviado a:"
|
||||
raw_header_shipped = compliance.shipped_to_header or "DESTINATARIO"
|
||||
clean_header_shipped = raw_header_shipped.replace("_", " ").capitalize() + ":"
|
||||
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(db, compliance.shipped_to_id, clean_header_shipped)
|
||||
clean_header_shipped = (
|
||||
raw_header_shipped.replace("_", " ").capitalize() + ":"
|
||||
)
|
||||
|
||||
remesa_valor = str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
acuse_valor = str(compliance.edocument) if (compliance and compliance.edocument) else "N/A"
|
||||
# Fetch client data
|
||||
cliente_enviado = self._obtener_datos_cliente(
|
||||
db, compliance.shipped_to_id, clean_header_shipped
|
||||
)
|
||||
|
||||
remesa_valor = (
|
||||
str(compliance.remesa) if (compliance and compliance.remesa) else ""
|
||||
)
|
||||
acuse_valor = (
|
||||
str(compliance.edocument)
|
||||
if (compliance and compliance.edocument)
|
||||
else "N/A"
|
||||
)
|
||||
|
||||
patente_val = ""
|
||||
if pedimento and pedimento.license:
|
||||
patente_val = pedimento.license
|
||||
elif 'broker' in locals() and broker and broker.license:
|
||||
elif "broker" in locals() and broker and broker.license:
|
||||
patente_val = broker.license
|
||||
|
||||
|
||||
# --- Transport Data Fetching ---
|
||||
transporte_txt = str(logistics.transport_type) if (logistics and logistics.transport_type) else ""
|
||||
transporte_txt = (
|
||||
str(logistics.transport_type)
|
||||
if (logistics and logistics.transport_type)
|
||||
else ""
|
||||
)
|
||||
num_transporte_val = (logistics.trailer_num or "") if logistics else ""
|
||||
|
||||
|
||||
# Init values
|
||||
placas_val = (logistics.license_plate or "") if logistics else "" # Placas Tracto
|
||||
placas_val = (
|
||||
(logistics.license_plate or "") if logistics else ""
|
||||
) # Placas Tracto
|
||||
placas_remolque_val = ""
|
||||
transportista_val = (logistics.carrier_id or "") if logistics else ""
|
||||
caat_val = ""
|
||||
@@ -168,46 +282,82 @@ class FacturaImportacionMexService:
|
||||
if logistics:
|
||||
# 1. Transporter (CAAT / SCAC)
|
||||
if logistics.carrier_id:
|
||||
transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first()
|
||||
transporter_obj = (
|
||||
db.query(Transporter)
|
||||
.filter(Transporter.transporter_key == logistics.carrier_id)
|
||||
.first()
|
||||
)
|
||||
if transporter_obj:
|
||||
caat_val = transporter_obj.caat_code or ""
|
||||
scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC
|
||||
scac_val = (
|
||||
transporter_obj.transport_code or ""
|
||||
) # Mapping transport_code to SCAC
|
||||
transportista_val = transporter_obj.name or logistics.carrier_id
|
||||
|
||||
# 2. Vehicle (Placas Tracto) - Try transport_id first
|
||||
if logistics.transport_id:
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first()
|
||||
veh_obj = (
|
||||
db.query(Vehicle)
|
||||
.filter(Vehicle.vehicle_key == logistics.transport_id)
|
||||
.first()
|
||||
)
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty
|
||||
veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.vehicle_num).first()
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
elif (
|
||||
logistics.vehicle_num
|
||||
): # Fallback to vehicle_num if populated and transport_id failed/empty
|
||||
veh_obj = (
|
||||
db.query(Vehicle)
|
||||
.filter(Vehicle.vehicle_key == logistics.vehicle_num)
|
||||
.first()
|
||||
)
|
||||
if veh_obj:
|
||||
placas_val = veh_obj.plate_number or placas_val
|
||||
|
||||
# 3. Trailer (Placas Remolque)
|
||||
if logistics.trailer_num:
|
||||
trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first()
|
||||
trl_obj = (
|
||||
db.query(Trailer)
|
||||
.filter(Trailer.trailer_number == logistics.trailer_num)
|
||||
.first()
|
||||
)
|
||||
if trl_obj:
|
||||
placas_remolque_val = trl_obj.plate_number or ""
|
||||
|
||||
# 4. Driver (License)
|
||||
if logistics.carrier_id and logistics.driver_name:
|
||||
# Attempt to find driver by name + carrier
|
||||
drv_obj = db.query(Driver).filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name
|
||||
).first()
|
||||
drv_obj = (
|
||||
db.query(Driver)
|
||||
.filter(
|
||||
Driver.transporter_key == logistics.carrier_id,
|
||||
Driver.driver_name == logistics.driver_name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if drv_obj:
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
licencia_cond_val = drv_obj.license_number or ""
|
||||
|
||||
factura_schema = FacturaSchema(
|
||||
numero=header.invoice_number or "S/N",
|
||||
fecha=str(header.invoice_date) if header.invoice_date else "",
|
||||
tipo_cambio=float(financials.exchange_rate) if (financials and financials.exchange_rate) else (float(pedimento.exchange_rate) if pedimento and pedimento.exchange_rate else 1.0),
|
||||
moneda=getattr(header, 'currency', "USD") or "USD",
|
||||
tipo_cambio=(
|
||||
float(financials.exchange_rate)
|
||||
if (financials and financials.exchange_rate)
|
||||
else (
|
||||
float(pedimento.exchange_rate)
|
||||
if pedimento and pedimento.exchange_rate
|
||||
else 1.0
|
||||
)
|
||||
),
|
||||
moneda=getattr(header, "currency", "USD") or "USD",
|
||||
incoterm=(logistics.incoterm or "") if logistics else "",
|
||||
observaciones=header.observation_es or header.observation_en or "",
|
||||
pedimento=f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}" if pedimento else "",
|
||||
pedimento=(
|
||||
f"{pedimento.year} {pedimento.customs_office[:2] if pedimento.customs_office else ''} {pedimento.license} {pedimento.pedimento_number}"
|
||||
if pedimento
|
||||
else ""
|
||||
),
|
||||
clave_pedimento=pedimento.pedimento_code if pedimento else "",
|
||||
regimen=header.document_type or "",
|
||||
patente=patente_val,
|
||||
@@ -220,45 +370,74 @@ class FacturaImportacionMexService:
|
||||
caat=caat_val,
|
||||
scac=scac_val,
|
||||
licencia_conductor=licencia_cond_val,
|
||||
aduana=compliance.aduana if (compliance and compliance.aduana) else (pedimento.customs_office[:2] if (pedimento and pedimento.customs_office) else ""),
|
||||
aduana=(
|
||||
compliance.aduana
|
||||
if (compliance and compliance.aduana)
|
||||
else (
|
||||
pedimento.customs_office[:2]
|
||||
if (pedimento and pedimento.customs_office)
|
||||
else ""
|
||||
)
|
||||
),
|
||||
precinto=(logistics.seal_number or "") if logistics else "",
|
||||
destino=(logistics.destination_goods or "") if logistics else "",
|
||||
remesa=remesa_valor, acuse_electronico=acuse_valor
|
||||
remesa=remesa_valor,
|
||||
acuse_electronico=acuse_valor,
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(50, "Procesando partidas...")
|
||||
lines = (
|
||||
db.query(LineItem)
|
||||
.join(Item, LineItem.item_id == Item.id)
|
||||
.filter(Item.invoice_id == header.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
if progress_callback: progress_callback(50, "Procesando partidas...")
|
||||
lines = db.query(LineItem).join(Item, LineItem.item_id == Item.id).filter(Item.invoice_id == header.id).all()
|
||||
partidas_list = []
|
||||
|
||||
|
||||
for line in lines:
|
||||
qty = db.query(LineQuantity).filter(LineQuantity.item_line_id == line.id).first()
|
||||
fin = db.query(LineFinancial).filter(LineFinancial.item_line_id == line.id).first()
|
||||
qty = (
|
||||
db.query(LineQuantity)
|
||||
.filter(LineQuantity.item_line_id == line.id)
|
||||
.first()
|
||||
)
|
||||
fin = (
|
||||
db.query(LineFinancial)
|
||||
.filter(LineFinancial.item_line_id == line.id)
|
||||
.first()
|
||||
)
|
||||
part_master = db.query(Part).filter(Part.id == line.part_number).first()
|
||||
|
||||
desc_final = "S/D"
|
||||
num_parte_final = str(line.part_number or "S/N")
|
||||
fraccion_raw = ""
|
||||
fraccion_raw = ""
|
||||
origen_final = "MEX"
|
||||
|
||||
if part_master:
|
||||
desc_final = part_master.description_spanish or part_master.description_english or "Sin Desc."
|
||||
desc_final = (
|
||||
part_master.description_spanish
|
||||
or part_master.description_english
|
||||
or "Sin Desc."
|
||||
)
|
||||
num_parte_final = part_master.part_number
|
||||
fraccion_raw = part_master.fraction if part_master.fraction else ""
|
||||
|
||||
|
||||
# Fetch Origin from Master Catalog (FaPart)
|
||||
if part_master.fa_data and part_master.fa_data.origin_country:
|
||||
origen_final = part_master.fa_data.origin_country
|
||||
|
||||
|
||||
fraccion_limpia = fraccion_raw.replace(".", "").strip()
|
||||
if fraccion_limpia:
|
||||
fraccion_limpia = fraccion_limpia[:8].zfill(8)
|
||||
|
||||
# Consultar tabla tariff_fractions
|
||||
fraccion_db = db.query(TariffFraction).filter(TariffFraction.code == fraccion_limpia).first()
|
||||
fraccion_db = (
|
||||
db.query(TariffFraction)
|
||||
.filter(TariffFraction.code == fraccion_limpia)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
preferencia_txt = "General"
|
||||
preferencia_txt = "General"
|
||||
advalorem_txt = "0%"
|
||||
fraccion_imprimir = fraccion_raw
|
||||
|
||||
@@ -268,20 +447,20 @@ class FacturaImportacionMexService:
|
||||
if adv_db and adv_db.strip() not in ["0", "0.0", "0.00", ""]:
|
||||
advalorem_txt = adv_db if "%" in adv_db else f"{adv_db}%"
|
||||
else:
|
||||
advalorem_txt = "0%"
|
||||
|
||||
advalorem_txt = "0%"
|
||||
|
||||
fraccion_imprimir = fraccion_db.fraction or fraccion_raw
|
||||
else:
|
||||
|
||||
|
||||
fraccion_imprimir = self._format_fraccion_fallback(fraccion_limpia)
|
||||
|
||||
# Logic to determine values - Prioritize Specific Currency Columns
|
||||
v_unitario = 0.0
|
||||
v_total = 0.0
|
||||
|
||||
|
||||
if fin:
|
||||
is_mxn = (factura_schema.moneda == 'MXN')
|
||||
|
||||
is_mxn = factura_schema.moneda == "MXN"
|
||||
|
||||
# 1. Try Specific Currency Columns First
|
||||
if is_mxn:
|
||||
v_unitario = float(fin.unit_cost_commercial_mxn or 0.0)
|
||||
@@ -292,50 +471,83 @@ class FacturaImportacionMexService:
|
||||
|
||||
# 2. Fallback to Generic independently if Specific is 0
|
||||
if not v_unitario:
|
||||
v_unitario = float(fin.commercial_unit_cost or 0.0)
|
||||
|
||||
v_unitario = float(fin.commercial_unit_cost or 0.0)
|
||||
|
||||
if not v_total:
|
||||
v_total = float(fin.total_commercial_value or 0.0)
|
||||
v_total = float(fin.total_commercial_value or 0.0)
|
||||
|
||||
# 3. Calculate from Quantity if still missing
|
||||
cantidad = float(qty.quantity) if (qty and qty.quantity) else 0.0
|
||||
|
||||
|
||||
if cantidad > 0:
|
||||
if v_unitario > 0 and v_total == 0:
|
||||
v_total = v_unitario * cantidad
|
||||
elif v_total > 0 and v_unitario == 0:
|
||||
v_unitario = v_total / cantidad
|
||||
|
||||
partidas_list.append(PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
origen=origen_final,
|
||||
advalorem=advalorem_txt,
|
||||
preferencia=preferencia_txt,
|
||||
cantidad_importacion=self.formatear_numero(qty.quantity if qty else 0),
|
||||
unidad_medida=qty.weight_unit if qty else "PZA",
|
||||
cantidad_bultos=int(qty.package_quantity) if qty and qty.package_quantity else 0,
|
||||
clave_bultos=(qty.package_key or "") if qty else "",
|
||||
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
||||
peso_bruto=self.formatear_numero(qty.gross_weight if qty else 0),
|
||||
valor_costo_unitario=self.formatear_numero(v_unitario),
|
||||
valor_total=self.formatear_numero(v_total)
|
||||
))
|
||||
# Obtener descripción de la unidad de medida desde la tabla a76.item_lines
|
||||
unidad_desc = ""
|
||||
if line.unit_of_measure:
|
||||
uom = (
|
||||
db.query(UnitOfMeasure)
|
||||
.filter(
|
||||
UnitOfMeasure.id == line.unit_of_measure,
|
||||
UnitOfMeasure.company_id == company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if uom:
|
||||
unidad_desc = uom.description or uom.code
|
||||
else:
|
||||
unidad_desc = ""
|
||||
|
||||
totales = self.calcular_totales(partidas_list, Decimal(factura_schema.tipo_cambio))
|
||||
partidas_list.append(
|
||||
PartidaSchema(
|
||||
numero_parte=num_parte_final,
|
||||
descripcion=desc_final,
|
||||
fraccion=fraccion_imprimir,
|
||||
origen=origen_final,
|
||||
advalorem=advalorem_txt,
|
||||
preferencia=preferencia_txt,
|
||||
cantidad_importacion=self.formatear_numero(
|
||||
qty.quantity if qty else 0
|
||||
),
|
||||
unidad_medida=unidad_desc,
|
||||
cantidad_bultos=(
|
||||
int(qty.package_quantity)
|
||||
if qty and qty.package_quantity
|
||||
else 0
|
||||
),
|
||||
clave_bultos=(qty.package_key or "") if qty else "",
|
||||
peso_neto=self.formatear_numero(qty.net_weight if qty else 0),
|
||||
peso_bruto=self.formatear_numero(
|
||||
qty.gross_weight if qty else 0
|
||||
),
|
||||
valor_costo_unitario=self.formatear_numero(v_unitario),
|
||||
valor_total=self.formatear_numero(v_total),
|
||||
)
|
||||
)
|
||||
|
||||
totales = self.calcular_totales(
|
||||
partidas_list, Decimal(factura_schema.tipo_cambio)
|
||||
)
|
||||
|
||||
return FacturaImportacionCompleta(
|
||||
cliente_proveedor=cliente_proveedor, cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado, factura=factura_schema,
|
||||
partidas=partidas_list, totales=totales
|
||||
cliente_proveedor=cliente_proveedor,
|
||||
cliente_vendido=cliente_vendido,
|
||||
cliente_enviado=cliente_enviado,
|
||||
factura=factura_schema,
|
||||
partidas=partidas_list,
|
||||
totales=totales,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Service A76: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
|
||||
|
||||
def calcular_totales(self, partidas: List[PartidaSchema], tipo_cambio: Decimal) -> TotalesSchema:
|
||||
def calcular_totales(
|
||||
self, partidas: List[PartidaSchema], tipo_cambio: Decimal
|
||||
) -> TotalesSchema:
|
||||
cant = sum(p.cantidad_importacion for p in partidas)
|
||||
valor = sum(p.valor_total for p in partidas)
|
||||
peso_n = sum(p.peso_neto for p in partidas)
|
||||
@@ -343,20 +555,34 @@ class FacturaImportacionMexService:
|
||||
bultos = sum(p.cantidad_bultos for p in partidas)
|
||||
claves = [p.clave_bultos for p in partidas if p.clave_bultos]
|
||||
clave_comun = max(set(claves), key=claves.count) if claves else ""
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"): clave_comun += "S"
|
||||
if bultos > 1 and clave_comun and not clave_comun.endswith("S"):
|
||||
clave_comun += "S"
|
||||
tc = float(tipo_cambio) if tipo_cambio else 1.0
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant), bultos_total=bultos, clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n), peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor), valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0)
|
||||
return TotalesSchema(
|
||||
cantidad_total=self.formatear_numero(cant),
|
||||
bultos_total=bultos,
|
||||
clave_bultos=clave_comun,
|
||||
peso_neto_total=self.formatear_numero(peso_n),
|
||||
peso_bruto_total=self.formatear_numero(peso_b),
|
||||
valor_total_total=self.formatear_numero(valor),
|
||||
valor_total_dolares=self.formatear_numero(valor / tc if tc > 0 else 0),
|
||||
)
|
||||
|
||||
def generar_factura_completa(self, db: Session, invoice_id: int, company_id: int, formato: str = "pdf", progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]:
|
||||
if progress_callback: progress_callback(5, "Iniciando servicio de reporte...")
|
||||
def generar_factura_completa(
|
||||
self,
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
company_id: int,
|
||||
formato: str = "pdf",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> Tuple[bytes, str, str]:
|
||||
if progress_callback:
|
||||
progress_callback(5, "Iniciando servicio de reporte...")
|
||||
datos = self.obtener_datos(db, invoice_id, company_id, progress_callback)
|
||||
|
||||
if progress_callback: progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(80, "Renderizando plantilla...")
|
||||
|
||||
# LOGO LOGIC
|
||||
logo_b64 = None
|
||||
try:
|
||||
@@ -365,7 +591,7 @@ class FacturaImportacionMexService:
|
||||
comp_logo = db.query(Company).filter(Company.id == company_id).first()
|
||||
if comp_logo and comp_logo.logo:
|
||||
p = Path(comp_logo.logo)
|
||||
|
||||
|
||||
# Logic robusta de búsqueda (igual que en routes.py)
|
||||
target_path = p
|
||||
if not target_path.exists():
|
||||
@@ -377,27 +603,49 @@ class FacturaImportacionMexService:
|
||||
|
||||
if target_path.exists():
|
||||
with open(target_path, "rb") as image_file:
|
||||
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
encoded_string = base64.b64encode(image_file.read()).decode(
|
||||
"utf-8"
|
||||
)
|
||||
# Detect MIME type loosely
|
||||
mime = "image/png"
|
||||
if target_path.suffix.lower() in ['.jpg', '.jpeg']: mime = "image/jpeg"
|
||||
if target_path.suffix.lower() in [".jpg", ".jpeg"]:
|
||||
mime = "image/jpeg"
|
||||
logo_b64 = f"data:{mime};base64,{encoded_string}"
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
|
||||
context = {
|
||||
'cliente_proveedor': datos.cliente_proveedor.model_dump(), 'cliente_vendido': datos.cliente_vendido.model_dump(),
|
||||
'cliente_enviado': datos.cliente_enviado.model_dump(), 'factura': datos.factura.model_dump(),
|
||||
'partidas': [p.model_dump() for p in datos.partidas], 'totales': datos.totales.model_dump(),
|
||||
'logo_b64': logo_b64
|
||||
"cliente_proveedor": datos.cliente_proveedor.model_dump(),
|
||||
"cliente_vendido": datos.cliente_vendido.model_dump(),
|
||||
"cliente_enviado": datos.cliente_enviado.model_dump(),
|
||||
"factura": datos.factura.model_dump(),
|
||||
"partidas": [p.model_dump() for p in datos.partidas],
|
||||
"totales": datos.totales.model_dump(),
|
||||
"logo_b64": logo_b64,
|
||||
}
|
||||
html_content = self.template.render(**context)
|
||||
nombre = f"Factura_{datos.factura.numero}.{formato}"
|
||||
if formato == "html": return html_content.encode('utf-8'), nombre, "text/html"
|
||||
|
||||
if progress_callback: progress_callback(90, "Generando PDF final...")
|
||||
options = {'page-size': 'Letter', 'margin-top': '0.5in', 'margin-right': '0.5in', 'margin-bottom': '0.5in', 'margin-left': '0.5in', 'encoding': "UTF-8", 'enable-local-file-access': None}
|
||||
pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config())
|
||||
|
||||
if progress_callback: progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
if formato == "html":
|
||||
return html_content.encode("utf-8"), nombre, "text/html"
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(90, "Generando PDF final...")
|
||||
options = {
|
||||
"page-size": "Letter",
|
||||
"margin-top": "0.5in",
|
||||
"margin-right": "0.5in",
|
||||
"margin-bottom": "0.5in",
|
||||
"margin-left": "0.5in",
|
||||
"encoding": "UTF-8",
|
||||
"enable-local-file-access": None,
|
||||
}
|
||||
pdf = pdfkit.from_string(
|
||||
html_content,
|
||||
False,
|
||||
options=options,
|
||||
configuration=self._get_wkhtmltopdf_config(),
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(100, "Completado")
|
||||
return pdf, nombre, "application/pdf"
|
||||
|
||||
@@ -42,8 +42,16 @@ class Settings(BaseSettings):
|
||||
# License
|
||||
LICENSE_CHECK_ENABLED: bool = True
|
||||
|
||||
# External APIs
|
||||
SITAR_API_URL: str = "api.sitar.aduanasoft.com:880"
|
||||
SITAR_API_USER: str = ""
|
||||
SITAR_API_PASSWORD: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env", case_sensitive=True, extra="ignore", env_file_encoding="utf-8"
|
||||
env_file=[".env", "../.env"],
|
||||
case_sensitive=True,
|
||||
extra="ignore",
|
||||
env_file_encoding="utf-8",
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -21,6 +21,7 @@ passlib[bcrypt]==1.7.4
|
||||
httpx==0.28.1
|
||||
requests==2.32.5
|
||||
|
||||
|
||||
# Utilities
|
||||
python-multipart==0.0.20
|
||||
python-dotenv==1.1.1
|
||||
|
||||
@@ -159,6 +159,9 @@ services:
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://anexo76-dev.aduanasoft.com,http://localhost:3000}
|
||||
- SITAR_API_URL=${SITAR_API_URL}
|
||||
- SITAR_API_USER=${SITAR_API_USER}
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
ports:
|
||||
- "3467:8000"
|
||||
depends_on:
|
||||
@@ -166,7 +169,8 @@ services:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro
|
||||
networks:
|
||||
- backend-net
|
||||
@@ -180,11 +184,13 @@ services:
|
||||
"-k",
|
||||
"uvicorn.workers.UvicornWorker",
|
||||
"-w",
|
||||
"${WEB_CONCURRENCY:-4}",
|
||||
"${WEB_CONCURRENCY:-1}",
|
||||
"-b",
|
||||
"0.0.0.0:8000",
|
||||
"--log-level",
|
||||
"info"
|
||||
"info",
|
||||
"--forwarded-allow-ips",
|
||||
"*"
|
||||
]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"]
|
||||
@@ -204,6 +210,28 @@ services:
|
||||
reservations:
|
||||
memory: 256M
|
||||
|
||||
# celery
|
||||
celery_worker:
|
||||
image: dev.aduanasoft.com/anexo76/backend:latest
|
||||
container_name: worker
|
||||
command: celery -A core.celery_app worker --loglevel=info
|
||||
environment:
|
||||
- VALKEY_URL=redis://valkey:6379/0
|
||||
depends_on:
|
||||
- backend
|
||||
- valkey
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7.2
|
||||
container_name: valkey
|
||||
restart: always
|
||||
ports:
|
||||
- "6579:6379"
|
||||
networks:
|
||||
- backend-net
|
||||
|
||||
# Frontend - SvelteKit
|
||||
frontend:
|
||||
image: dev.aduanasoft.com/anexo76/frontend:latest
|
||||
@@ -262,6 +290,8 @@ volumes:
|
||||
driver: local
|
||||
backend_cache:
|
||||
driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
backend-net:
|
||||
|
||||
@@ -175,6 +175,9 @@ services:
|
||||
- KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-anexo76-backend}
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-dev-secret}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173,http://localhost:3000}
|
||||
- SITAR_API_URL=${SITAR_API_URL}
|
||||
- SITAR_API_USER=${SITAR_API_USER}
|
||||
- SITAR_API_PASSWORD=${SITAR_API_PASSWORD}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
@@ -185,6 +188,7 @@ services:
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backend_cache:/app/__pycache__
|
||||
- backend_uploads:/app/uploads
|
||||
- ./scripts/backend-entrypoint.sh:/entrypoint.sh:ro
|
||||
networks:
|
||||
- backend-net
|
||||
@@ -265,7 +269,7 @@ services:
|
||||
# celery
|
||||
celery_worker:
|
||||
build: ./backend
|
||||
container_name: a76_worker
|
||||
container_name: worker
|
||||
command: celery -A core.celery_app worker --loglevel=info
|
||||
environment:
|
||||
- VALKEY_URL=redis://valkey:6379/0
|
||||
@@ -277,7 +281,7 @@ services:
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7.2
|
||||
container_name: a76_valkey
|
||||
container_name: valkey
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@@ -295,6 +299,8 @@ volumes:
|
||||
driver: local
|
||||
backend_cache:
|
||||
driver: local
|
||||
backend_uploads:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
backend-net:
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
},
|
||||
"clients_and_providers": "Clients and Providers",
|
||||
"customs_brokers": "Customs Brokers",
|
||||
"client_provider_type": {
|
||||
"client_indicator": "C",
|
||||
"provider_indicator": "P",
|
||||
"both_indicator": "B"
|
||||
},
|
||||
"nav_user": {
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
},
|
||||
"clients_and_providers": "Clientes y Proveedores",
|
||||
"customs_brokers": "Agentes Aduanales",
|
||||
"client_provider_type": {
|
||||
"client_indicator": "C",
|
||||
"provider_indicator": "P",
|
||||
"both_indicator": "A"
|
||||
},
|
||||
"nav_user": {
|
||||
"profile": "Perfil",
|
||||
"settings": "Configuración"
|
||||
|
||||
@@ -19,8 +19,7 @@ export interface Company {
|
||||
responsible_last_name: string | null;
|
||||
responsible_mother_last_name: string | null;
|
||||
responsible_rfc?: string | null;
|
||||
position?: string | null;
|
||||
logo?: string | null;
|
||||
position?: string | null;
|
||||
has_express_line?: boolean;
|
||||
is_service_company?: boolean;
|
||||
order_format_type?: string | null;
|
||||
@@ -107,7 +106,7 @@ export async function getCompanies(
|
||||
}
|
||||
|
||||
export async function getCompany(id: number): Promise<ApiResponse<Company>> {
|
||||
return await api.get(`/v1/a76/company/${id}/`);
|
||||
return await api.get(`/v1/a76/company/${id}`);
|
||||
}
|
||||
|
||||
export async function createCompany(data: CompanyCreate): Promise<ApiResponse<Company>> {
|
||||
@@ -115,7 +114,7 @@ export async function createCompany(data: CompanyCreate): Promise<ApiResponse<Co
|
||||
}
|
||||
|
||||
export async function updateCompany(id: number, data: CompanyUpdate): Promise<ApiResponse<Company>> {
|
||||
return await api.put(`/v1/a76/company/${id}/`, data);
|
||||
return await api.put(`/v1/a76/company/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteCompany(id: number): Promise<ApiResponse<void>> {
|
||||
|
||||
@@ -37,10 +37,12 @@ export interface ExchangeRateFilters {
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
import type { ApiResponse } from '$lib/api';
|
||||
|
||||
export async function getExchangeRates(
|
||||
companyId: number,
|
||||
filters?: ExchangeRateFilters
|
||||
): Promise<ExchangeRateListResponse> {
|
||||
): Promise<ApiResponse<ExchangeRateListResponse>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
|
||||
if (filters) {
|
||||
@@ -57,7 +59,7 @@ export async function getExchangeRates(
|
||||
export async function getExchangeRate(
|
||||
exchangeRateId: number,
|
||||
companyId: number
|
||||
): Promise<ExchangeRate> {
|
||||
): Promise<ApiResponse<ExchangeRate>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.get<ExchangeRate>(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
|
||||
}
|
||||
@@ -65,7 +67,7 @@ export async function getExchangeRate(
|
||||
export async function createExchangeRate(
|
||||
data: ExchangeRateCreate,
|
||||
companyId: number
|
||||
): Promise<ExchangeRate> {
|
||||
): Promise<ApiResponse<ExchangeRate>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.post<ExchangeRate>(`/v1/a76/exchange-rate/?${params.toString()}`, data);
|
||||
}
|
||||
@@ -74,7 +76,7 @@ export async function updateExchangeRate(
|
||||
exchangeRateId: number,
|
||||
data: ExchangeRateUpdate,
|
||||
companyId: number
|
||||
): Promise<ExchangeRate> {
|
||||
): Promise<ApiResponse<ExchangeRate>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.put<ExchangeRate>(
|
||||
`/v1/a76/exchange-rate/${exchangeRateId}/?${params.toString()}`,
|
||||
@@ -85,7 +87,17 @@ export async function updateExchangeRate(
|
||||
export async function deleteExchangeRate(
|
||||
exchangeRateId: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
): Promise<ApiResponse<void>> {
|
||||
const params = new URLSearchParams({ company_id: companyId.toString() });
|
||||
return api.delete(`/v1/a76/exchange-rate/${exchangeRateId}?${params.toString()}`);
|
||||
}
|
||||
|
||||
export interface DofResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
value?: number | null;
|
||||
}
|
||||
|
||||
export async function getDofExchangeRate(date: string): Promise<ApiResponse<DofResponse>> {
|
||||
return api.get<DofResponse>(`/v1/a76/exchange-rate/dof-search?date=${date}`);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ export const itemsApi = {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items?${params.toString()}`);
|
||||
return api.get<ItemListResponse>(`/v1/a76/items/invoice/${invoiceId}/items/?${params.toString()}`);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -264,7 +264,7 @@ export const itemsApi = {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.post<Item>(`/v1/a76/items?${params.toString()}`, data);
|
||||
return api.post<Item>(`/v1/a76/items/?${params.toString()}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -274,7 +274,7 @@ export const itemsApi = {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.put<Item>(`/v1/a76/items/${itemId}?${params.toString()}`, data);
|
||||
return api.put<Item>(`/v1/a76/items/${itemId}/?${params.toString()}`, data);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -284,6 +284,6 @@ export const itemsApi = {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.delete(`/v1/a76/items/${itemId}?${params.toString()}`);
|
||||
return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { toast } from "svelte-sonner";
|
||||
import {
|
||||
createExchangeRate,
|
||||
updateExchangeRate,
|
||||
type ExchangeRate
|
||||
type ExchangeRate,
|
||||
getDofExchangeRate
|
||||
} from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { Scale, BadgeDollarSign, Info, AlertCircle, ArrowRight, CloudDownload } from "lucide-svelte";
|
||||
import { fly, scale } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
onSuccess,
|
||||
overlayClass = "bg-black/80 backdrop-blur-sm",
|
||||
initialDate = ""
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: ExchangeRate | null;
|
||||
onSuccess?: () => void;
|
||||
overlayClass?: string;
|
||||
initialDate?: string;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? "Editar Tipo de Cambio" : "Nuevo Tipo de Cambio");
|
||||
const isMissingRateContext = $derived(!isEdit && initialDate);
|
||||
const title = $derived(
|
||||
isEdit ? "Editar Tipo de Cambio" :
|
||||
isMissingRateContext ? "Tipo de Cambio Requerido" : "Nuevo Tipo de Cambio"
|
||||
);
|
||||
|
||||
let formData = $state({
|
||||
date: '',
|
||||
@@ -31,7 +45,9 @@
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let scraping = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let showConfirmation = $state(false);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
@@ -46,26 +62,66 @@
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
date: initialDate || new Date().toISOString().split('T')[0],
|
||||
value: null,
|
||||
local_currency: 'MXN',
|
||||
foreign_currency: 'USD'
|
||||
};
|
||||
|
||||
// Si es modo contexto (falta dato) y tenemos fecha, intentar cargar automáticamente del DOF
|
||||
if (initialDate && !item) {
|
||||
// Opcional: Auto-consultar
|
||||
// fetchFromDof(initialDate);
|
||||
}
|
||||
}
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
async function fetchFromDof(date: string) {
|
||||
if (!date) return;
|
||||
scraping = true;
|
||||
error = null;
|
||||
try {
|
||||
const response = await getDofExchangeRate(date);
|
||||
// The API returns an ApiResponse object, so we need to access response.data
|
||||
// response.data contains { success: boolean, value: number, message: string }
|
||||
if (response.data?.success && response.data?.value) {
|
||||
formData.value = response.data.value;
|
||||
toast.success(`Tipo de cambio obtenido del DOF: ${response.data.value}`);
|
||||
} else {
|
||||
toast.error(response.data?.message || response.error || 'No se pudo obtener el dato del DOF');
|
||||
// No bloquear, permitir manual
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error('Error al consultar el servicio del DOF');
|
||||
} finally {
|
||||
scraping = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
error = null;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
if (!formData.date) throw new Error('La fecha es requerida');
|
||||
if (formData.value === null) throw new Error('El valor es requerido');
|
||||
|
||||
showConfirmation = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al validar';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
if (!formData.date) throw new Error('La fecha es requerida');
|
||||
if (formData.value === null) throw new Error('El valor es requerido');
|
||||
|
||||
const dataToSend = {
|
||||
date: formData.date,
|
||||
value: Number(formData.value),
|
||||
@@ -75,16 +131,18 @@
|
||||
|
||||
if (isEdit && item) {
|
||||
await updateExchangeRate(item.id, dataToSend, companyId);
|
||||
alert(`✅ Tipo de cambio actualizado correctamente`);
|
||||
toast.success('Tipo de cambio actualizado correctamente');
|
||||
} else {
|
||||
await createExchangeRate(dataToSend, companyId);
|
||||
alert(`✅ Tipo de cambio creado correctamente`);
|
||||
toast.success('Tipo de cambio creado correctamente');
|
||||
}
|
||||
|
||||
showConfirmation = false;
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar';
|
||||
showConfirmation = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -93,59 +151,137 @@
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9999] bg-black/80 backdrop-blur-sm" />
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9999] {overlayClass}" />
|
||||
|
||||
<Dialog.Content class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[500px] translate-x-[-50%] translate-y-[-50%] border bg-background p-6 shadow-lg sm:rounded-lg">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-4">
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
<Dialog.Content
|
||||
class="fixed left-[50%] top-[50%] z-[10000] w-full max-w-[480px] translate-x-[-50%] translate-y-[-50%] border-0 bg-transparent shadow-2xl p-0 sm:rounded-xl overflow-hidden"
|
||||
>
|
||||
<div class="bg-background flex flex-col h-full rounded-xl overflow-hidden border border-border">
|
||||
|
||||
{#if isMissingRateContext}
|
||||
<div class="bg-amber-50 dark:bg-amber-950/40 p-5 flex gap-4 border-b border-amber-100 dark:border-amber-900/50">
|
||||
<div class="bg-amber-100 dark:bg-amber-900/60 p-2.5 rounded-full h-fit shadow-sm shrink-0">
|
||||
<AlertCircle class="text-amber-600 dark:text-amber-400" size={24} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-semibold text-amber-900 dark:text-amber-100 text-lg leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
<p class="text-sm text-amber-800/80 dark:text-amber-200/80 leading-relaxed">
|
||||
Para continuar con el guardado, es necesario registrar el tipo de cambio oficial para esta fecha.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Dialog.Header class="p-6 pb-2">
|
||||
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||
<BadgeDollarSign class="text-primary" />
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="date" class="text-right">Fecha *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="date" type="date" bind:value={formData.date} disabled={loading} required />
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="p-6 pt-4 space-y-6">
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-destructive/10 border border-destructive/20 p-3 flex gap-3 text-sm text-destructive" transition:fly={{ y: -10 }}>
|
||||
<AlertCircle size={16} class="mt-0.5 shrink-0" />
|
||||
<span class="font-medium">{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-5">
|
||||
<div class="grid gap-2">
|
||||
<Label for="date" class="text-sm font-medium text-muted-foreground ml-1">Fecha Aplicable</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="date"
|
||||
type="date"
|
||||
bind:value={formData.date}
|
||||
disabled={loading}
|
||||
required
|
||||
class="pl-3 h-11 text-base bg-muted/30 focus:bg-background transition-colors"
|
||||
/>
|
||||
{#if isMissingRateContext}
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-amber-600 font-medium bg-amber-100 px-2 py-0.5 rounded-full">
|
||||
Requerida
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="value" class="text-sm font-medium text-muted-foreground ml-1">Tipo de Cambio (MXN/USD)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 text-xs text-primary hover:text-primary/80 px-2 -mr-2"
|
||||
disabled={scraping || loading || !formData.date}
|
||||
onclick={() => fetchFromDof(formData.date)}
|
||||
>
|
||||
{#if scraping}
|
||||
<span class="animate-spin mr-1.5">⟳</span> Consultando...
|
||||
{:else}
|
||||
<CloudDownload size={14} class="mr-1.5" /> Consultar DOF
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-semibold">$</div>
|
||||
<Input
|
||||
id="value"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
bind:value={formData.value}
|
||||
disabled={loading}
|
||||
required
|
||||
placeholder="0.0000"
|
||||
class="pl-7 h-11 text-lg font-mono tracking-wide focus:ring-2 ring-primary/20 transition-all group-hover:border-primary/50"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground text-right px-1">
|
||||
Ej. 24.1234
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="value" class="text-right">Valor *</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="value" type="number" step="0.000001" bind:value={formData.value} disabled={loading} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="local_currency" class="text-right">Local</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="local_currency" bind:value={formData.local_currency} maxlength={3} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="foreign_currency" class="text-right">Extranjera</Label>
|
||||
<div class="col-span-3">
|
||||
<Input id="foreign_currency" bind:value={formData.foreign_currency} maxlength={3} disabled={loading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => open = false} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
<Dialog.Footer class="grid grid-cols-4 gap-2 pt-2">
|
||||
<Button type="button" variant="outline" class="w-full text-xs" disabled>
|
||||
Cargar TC
|
||||
</Button>
|
||||
<Button type="button" variant="outline" class="w-full text-xs" disabled>
|
||||
Ayuda
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onclick={() => open = false} disabled={loading} class="w-full hover:bg-muted/50">
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} class="w-full shadow-sm font-medium">
|
||||
{#if loading}
|
||||
<span class="animate-spin mr-2">⟳</span>
|
||||
{:else}
|
||||
Ok
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</Dialog.Root>
|
||||
|
||||
<AlertDialog.Root bind:open={showConfirmation}>
|
||||
<AlertDialog.Content class="z-[10002]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>¿Estás seguro?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Se {isEdit ? 'actualizará' : 'creará'} el tipo de cambio con valor {formData.value} para el día {formData.date}.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={() => showConfirmation = false}>Cancelar</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmSubmit}>Confirmar</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
let checked = $state(false);
|
||||
|
||||
async function checkExchangeRate() {
|
||||
console.log('[ExchangeRateGuard] Checking...', companyStore.activeCompany);
|
||||
if (!companyStore.activeCompany?.id) {
|
||||
console.log('[ExchangeRateGuard] No active company');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use local date instead of UTC
|
||||
const today = new Date().toLocaleDateString('fr-CA'); // YYYY-MM-DD
|
||||
console.log('[ExchangeRateGuard] Date:', today);
|
||||
|
||||
try {
|
||||
const response = await getExchangeRates(companyStore.activeCompany.id, {
|
||||
date: today,
|
||||
page_size: 1
|
||||
});
|
||||
console.log('[ExchangeRateGuard] Response (stringified):', JSON.stringify(response, null, 2));
|
||||
|
||||
// api.get returns { data: ..., status: ... } and types now reflect that
|
||||
const items = response.data?.items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('[ExchangeRateGuard] No rate found, opening modal');
|
||||
open = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ExchangeRateGuard] Error checking exchange rate:', error);
|
||||
} finally {
|
||||
checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (companyStore.activeCompany?.id && !checked) {
|
||||
checkExchangeRate();
|
||||
}
|
||||
});
|
||||
|
||||
function handleSuccess() {
|
||||
|
||||
console.log('Exchange rate created successfully via guard');
|
||||
checked = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<CreateEditDialog
|
||||
bind:open
|
||||
onSuccess={handleSuccess}
|
||||
overlayClass="bg-black/20"
|
||||
/>
|
||||
@@ -5,9 +5,11 @@
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { invoicesApi, type Invoice, type CreateInvoiceData, type UpdateInvoiceData } from "$lib/api/dashboard/a76/invoices";
|
||||
import { getExchangeRates } from "$lib/api/dashboard/a76/general_catalogs/exchange-rate";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import { LoaderCircle } from 'lucide-svelte';
|
||||
import * as Tabs from "$lib/components/ui/tabs";
|
||||
import ExchangeRateDialog from "$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
@@ -72,6 +74,9 @@
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let showExchangeRateDialog = $state(false);
|
||||
let missingExchangeRateDate = $state("");
|
||||
|
||||
// Actualizar formData cuando item cambia
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
@@ -181,6 +186,15 @@
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Verificar tipo de cambio antes de guardar
|
||||
if (formData.invoice_date) {
|
||||
const rateExists = await checkExchangeRate(formData.invoice_date);
|
||||
if (!rateExists) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let response;
|
||||
if (isEditing && item) {
|
||||
const payload: UpdateInvoiceData = {
|
||||
@@ -287,11 +301,37 @@
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
// Convertir el error a string para buscar mensajes específicos (maneja objetos/arrays de DRF)
|
||||
const errorStr = typeof response.error === 'string'
|
||||
? response.error
|
||||
: JSON.stringify(response.error);
|
||||
|
||||
if (errorStr.includes('No existe un Tipo de Cambio registrado') || errorStr.includes('financials.exchange_rate')) {
|
||||
// Interceptar error de tipo de cambio
|
||||
console.log("Interceptor: Exchange rate missing error caught (Invoice).");
|
||||
error = null;
|
||||
|
||||
const dateMatch = errorStr.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
missingExchangeRateDate = dateMatch ? dateMatch[0] : (formData.invoice_date || "");
|
||||
|
||||
showExchangeRateDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
error = response.error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check exchange rate BEFORE calling API to avoid 400 error
|
||||
if (formData.invoice_date) {
|
||||
const rateExists = await checkExchangeRate(formData.invoice_date);
|
||||
if (!rateExists) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Éxito
|
||||
open = false;
|
||||
if (onSuccess) {
|
||||
@@ -305,6 +345,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function checkExchangeRate(date: string): Promise<boolean> {
|
||||
if (!date || !companyStore.activeCompany?.id) return true;
|
||||
|
||||
try {
|
||||
const response = await getExchangeRates(companyStore.activeCompany.id, {
|
||||
date: date,
|
||||
page_size: 1
|
||||
});
|
||||
|
||||
// api.get returns { data: ..., status: ... } and types now reflect that
|
||||
const items = response.data?.items || [];
|
||||
|
||||
// Verificar estrictamente que haya items
|
||||
if (items.length === 0) {
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error checking exchange rate:', error);
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
@@ -679,3 +746,10 @@
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<ExchangeRateDialog
|
||||
bind:open={showExchangeRateDialog}
|
||||
initialDate={missingExchangeRateDate}
|
||||
overlayClass="bg-black/20"
|
||||
onSuccess={() => {/* Optional: maybe refresh something or just let user continue */}}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
import type { CustomsBroker } from '$lib/api/dashboard/a76/customs-brokers';
|
||||
import type { ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
|
||||
|
||||
interface CodePedimentoRegimen {
|
||||
regimen_code: string;
|
||||
type_code: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let {
|
||||
invoice,
|
||||
formData = $bindable(),
|
||||
@@ -22,6 +28,7 @@
|
||||
customsSections = [],
|
||||
codePedimentoRegimens = [],
|
||||
operationType = undefined,
|
||||
defaultOperationType = undefined,
|
||||
exchangeRate = undefined
|
||||
}: {
|
||||
invoice: Invoice | null;
|
||||
@@ -37,8 +44,8 @@
|
||||
drivers?: any[];
|
||||
trailers?: any[];
|
||||
customsSections?: any[];
|
||||
codePedimentoRegimens?: any[];
|
||||
defaultOperationType?: string | null;
|
||||
codePedimentoRegimens?: CodePedimentoRegimen[];
|
||||
defaultOperationType?: string | number | null;
|
||||
defaultInvoiceType?: string | null;
|
||||
operationType?: number | null;
|
||||
exchangeRate?: number | null;
|
||||
@@ -176,25 +183,55 @@
|
||||
]
|
||||
);
|
||||
|
||||
// Combinar clientes y proveedores para shipped_to
|
||||
const allClientsProviders = [...clients, ...providers];
|
||||
|
||||
// Filtrar regímenes por tipo de operación (1='E' exp, 2='I' imp) y obtener valores únicos
|
||||
const filteredRegimens = $derived.by(() => {
|
||||
const typeCode = operationType === 1 ? 'E' : operationType === 2 ? 'I' : null;
|
||||
const filtered = codePedimentoRegimens.filter(r => r.type_code === typeCode);
|
||||
|
||||
// Obtener solo regímenes únicos por regimen_code
|
||||
// Combinar clientes y proveedores para shipped_to, evitando duplicados de tipo "both"
|
||||
const allClientsProviders = $derived.by(() => {
|
||||
const uniqueMap = new Map();
|
||||
filtered.forEach(r => {
|
||||
if (r.regimen_code && !uniqueMap.has(r.regimen_code)) {
|
||||
uniqueMap.set(r.regimen_code, r);
|
||||
|
||||
// Agregar todos los clientes
|
||||
clients.forEach(c => {
|
||||
uniqueMap.set(c.id, { ...c, type: c.client_or_provider });
|
||||
});
|
||||
|
||||
// Agregar proveedores solo si no existen (evita duplicados de "both")
|
||||
providers.forEach(p => {
|
||||
if (!uniqueMap.has(p.id)) {
|
||||
uniqueMap.set(p.id, { ...p, type: p.client_or_provider });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueMap.values());
|
||||
});
|
||||
|
||||
// Determinar tipo de código basado en operationType prop, defaultOperationType o invoice.operation_type
|
||||
const typeCode = $derived(
|
||||
operationType === 1 ? 'E'
|
||||
: operationType === 2 ? 'I'
|
||||
: defaultOperationType === 1 ? 'E'
|
||||
: defaultOperationType === 2 ? 'I'
|
||||
: defaultOperationType === 'exp' ? 'E'
|
||||
: defaultOperationType === 'imp' ? 'I'
|
||||
: invoice?.operation_type === 'exp' ? 'E'
|
||||
: invoice?.operation_type === 'imp' ? 'I'
|
||||
: null
|
||||
);
|
||||
|
||||
// Filtrar regímenes por tipo de operación (1='E' exp, 2='I' imp) y obtener valores únicos
|
||||
const filteredRegimens = $derived<CodePedimentoRegimen[]>(
|
||||
!codePedimentoRegimens || codePedimentoRegimens.length === 0 || !typeCode
|
||||
? []
|
||||
: Array.from(
|
||||
codePedimentoRegimens
|
||||
.filter(r => r.type_code === typeCode)
|
||||
.reduce((map, r) => {
|
||||
if (r.regimen_code && !map.has(r.regimen_code)) {
|
||||
map.set(r.regimen_code, r);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, CodePedimentoRegimen>())
|
||||
.values()
|
||||
)
|
||||
);
|
||||
|
||||
// Efecto: Limpiar régimen si no existe en los regímenes filtrados al cambiar operation_type
|
||||
$effect(() => {
|
||||
if (formData.document_type && filteredRegimens.length > 0) {
|
||||
@@ -263,9 +300,11 @@
|
||||
>
|
||||
<Select.Trigger id="provider_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
|
||||
<span class="truncate">
|
||||
{formData.provider_id
|
||||
? providers.find(p => p.id === formData.provider_id)?.name || 'Selecciona...'
|
||||
: 'Selecciona...'}
|
||||
{#if formData.provider_id}
|
||||
{providers.find(p => p.id === formData.provider_id)?.name || 'Selecciona...'}
|
||||
{:else}
|
||||
Selecciona...
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[300px]">
|
||||
@@ -309,9 +348,11 @@
|
||||
>
|
||||
<Select.Trigger id="sold_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
|
||||
<span class="truncate">
|
||||
{formData.sold_to_id
|
||||
? clients.find(c => c.id === formData.sold_to_id)?.name || 'Selecciona...'
|
||||
: 'Selecciona...'}
|
||||
{#if formData.sold_to_id}
|
||||
{clients.find(c => c.id === formData.sold_to_id)?.name || 'Selecciona...'}
|
||||
{:else}
|
||||
Selecciona...
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[300px]">
|
||||
@@ -355,15 +396,17 @@
|
||||
>
|
||||
<Select.Trigger id="shipped_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
|
||||
<span class="truncate">
|
||||
{formData.shipped_to_id
|
||||
? allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'
|
||||
: 'Selecciona...'}
|
||||
{#if formData.shipped_to_id}
|
||||
{allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'}
|
||||
{:else}
|
||||
Selecciona...
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[300px]">
|
||||
{#each allClientsProviders as cp}
|
||||
<Select.Item value={String(cp.id)}>
|
||||
{cp.name} ({cp.type === 'client' ? 'C' : 'P'})
|
||||
{cp.name}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
|
||||
@@ -46,6 +46,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Efecto para actualizar operation_type cuando cambia defaultOperationType
|
||||
$effect(() => {
|
||||
if (formData && defaultOperationType !== undefined && defaultOperationType !== null) {
|
||||
// Si operation_type está vacío, null, o undefined, actualizarlo con defaultOperationType
|
||||
if (!formData.operation_type) {
|
||||
formData.operation_type = defaultOperationType;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!formData) {
|
||||
let operationType: string | null = null;
|
||||
if (invoice?.operation_type) {
|
||||
@@ -69,6 +79,11 @@
|
||||
clave_pedimento: '',
|
||||
regimen_pedimento: '',
|
||||
};
|
||||
} else {
|
||||
// Si formData ya existe pero operation_type está vacío, usar defaultOperationType
|
||||
if (!formData.operation_type && defaultOperationType !== undefined && defaultOperationType !== null) {
|
||||
formData.operation_type = defaultOperationType;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<SaveInvo
|
||||
}
|
||||
|
||||
return { success: true, newInvoiceId: newInvoiceId ?? undefined };
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : 'Error al guardar los cambios';
|
||||
const validationErrors = (e as any)?.validationErrors;
|
||||
return { success: false, error, validationErrors };
|
||||
@@ -110,9 +110,7 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI
|
||||
const payload: any = {
|
||||
// Datos generales desde InvoiceTopFieldsFormData
|
||||
system: 'fixed_asset',
|
||||
operation_type: InvoiceTopFieldsFormData?.operation_type !== null && InvoiceTopFieldsFormData?.operation_type !== undefined
|
||||
? (InvoiceTopFieldsFormData.operation_type === 1 ? 'exp' : 'imp') as OperationType
|
||||
: undefined,
|
||||
operation_type: InvoiceTopFieldsFormData?.operation_type || undefined,
|
||||
invoice_type: InvoiceTopFieldsFormData?.invoice_type || undefined,
|
||||
document_type: generalFormData?.document_type || undefined,
|
||||
invoice_number: InvoiceTopFieldsFormData?.invoice_number || undefined,
|
||||
@@ -130,10 +128,11 @@ function buildInvoicePayload(formData: FormDataSet): CreateInvoiceData | UpdateI
|
||||
|
||||
// Solo agregar sub-recursos si tienen valores reales
|
||||
|
||||
// Compliance MX
|
||||
// Compliance MX - Siempre incluir si hay headers o valores
|
||||
const hasComplianceValue = InvoiceTopFieldsFormData?.pedimento_id || InvoiceTopFieldsFormData?.remesa || generalFormData?.aduana ||
|
||||
generalFormData?.provider_id || generalFormData?.sold_to_id ||
|
||||
generalFormData?.shipped_to_id || generalFormData?.customs_broker_id ||
|
||||
generalFormData?.provider_header || generalFormData?.sold_to_header || generalFormData?.shipped_to_header ||
|
||||
observationFormData?.movement_type || observationFormData?.enclosure ||
|
||||
othersFormData?.is_mixed || othersFormData?.contingency_mode ||
|
||||
othersFormData?.cove || othersFormData?.operation_num ||
|
||||
@@ -179,11 +178,11 @@ function buildComplianceMxData(InvoiceTopFieldsFormData: any, generalFormData: a
|
||||
// Fields from generalFormData
|
||||
aduana: generalFormData?.aduana || null,
|
||||
port_of_entry: continuationFormData?.puerto_entrada || null,
|
||||
provider_header: generalFormData?.provider_header || '',
|
||||
provider_header: generalFormData?.provider_header || null,
|
||||
provider_id: generalFormData?.provider_id || null,
|
||||
sold_to_header: generalFormData?.sold_to_header || '',
|
||||
sold_to_header: generalFormData?.sold_to_header || null,
|
||||
sold_to_id: generalFormData?.sold_to_id || null,
|
||||
shipped_to_header: generalFormData?.shipped_to_header || '',
|
||||
shipped_to_header: generalFormData?.shipped_to_header || null,
|
||||
shipped_to_id: generalFormData?.shipped_to_id || null,
|
||||
customs_broker_id: generalFormData?.customs_broker_id ? Number(generalFormData.customs_broker_id) : null,
|
||||
customs_broker_us_id: generalFormData?.customs_broker_us_id ? Number(generalFormData.customs_broker_us_id) : null,
|
||||
|
||||
@@ -240,36 +240,6 @@ export function createColumns(onSuccess?: () => void): ColumnDef<Pedimento>[] {
|
||||
return renderSnippet(dateSnippet, { date: formatDate(row.original.pedimento_dates?.payment_date) });
|
||||
}
|
||||
},
|
||||
/*{
|
||||
accessorKey: "pedimento_config_update_rectification.pediment_rectifed_18",
|
||||
header: "Pedimento 18",
|
||||
meta: { className: "hidden lg:table-cell" },
|
||||
cell: ({ row }) => {
|
||||
const ped18Snippet = createRawSnippet<[{ value?: string | null }]>((getValue) => {
|
||||
const { value } = getValue();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm">${value || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(ped18Snippet, { value: row.original.pedimento_config_update_rectification?.pediment_rectifed_18 });
|
||||
}
|
||||
},*/
|
||||
/*{
|
||||
accessorKey: "pedimento_config_update_rectification.r1",
|
||||
header: "Pedimento R1",
|
||||
meta: { className: "hidden lg:table-cell" },
|
||||
cell: ({ row }) => {
|
||||
const r1Snippet = createRawSnippet<[{ value?: string | null }]>((getValue) => {
|
||||
const { value } = getValue();
|
||||
return {
|
||||
render: () =>
|
||||
`<div class="text-sm">${value || '-'}</div>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(r1Snippet, { value: row.original.pedimento_config_update_rectification?.r1 });
|
||||
}
|
||||
},*/
|
||||
{
|
||||
accessorKey: "pedimento_validation.electronic_signature",
|
||||
header: "Acuse Electrónico",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import type { ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
|
||||
import type { CodePedimentoRegimen } from '$lib/api/dashboard/refrence_data/code_pedimento_regimens';
|
||||
import IdentificadoresTabForm from './identifiers-tab-form.svelte';
|
||||
import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte';
|
||||
import { Calendar, Clock } from 'lucide-svelte';
|
||||
import {
|
||||
loadServerDate,
|
||||
@@ -45,6 +46,10 @@
|
||||
// Estado para controlar la sección activa de la navegación
|
||||
let activeSection = $state('fechas');
|
||||
|
||||
// Exchange Rate Check State
|
||||
let showExchangeRateDialog = $state(false);
|
||||
let missingExchangeRateDate = $state("");
|
||||
|
||||
// Extraer regímenes únicos de codePedimentoRegimens
|
||||
const uniqueRegimens = $derived(
|
||||
Array.from(new Set(codePedimentoRegimens.map(r => r.regimen_code).filter((code): code is string => code !== null)))
|
||||
@@ -314,6 +319,32 @@
|
||||
{ value: 'adicional', label: 'Adicional' },
|
||||
{ value: 'decrementable', label: 'Decrementable' }
|
||||
];
|
||||
|
||||
export async function checkPaymentDateRate(date: string): Promise<boolean> {
|
||||
if (!date || !companyStore.activeCompany?.id) return true;
|
||||
|
||||
try {
|
||||
const rate = await getExchangeRateByDate(date, companyStore.activeCompany.id);
|
||||
if (!rate) {
|
||||
// Abrir modal preventivamente
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error checking payment date rate:', error);
|
||||
// Si hay error de red, asumimos que falta para forzar reintento/captura segura
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function openExchangeRateDialog(date: string) {
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
@@ -1159,3 +1190,10 @@
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<ExchangeRateDialog
|
||||
bind:open={showExchangeRateDialog}
|
||||
initialDate={missingExchangeRateDate}
|
||||
overlayClass="bg-black/20"
|
||||
onSuccess={() => {/* Optional: maybe refresh something or just let user continue */}}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
import ExchangeRateGuard from "$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte";
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
|
||||
@@ -61,3 +62,5 @@
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
<ExchangeRateGuard />
|
||||
|
||||
@@ -427,11 +427,6 @@
|
||||
|
||||
function handleCreateClick() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const operationType = params.get('operation_type');
|
||||
if (operationType) {
|
||||
const operationTypeNumber = operationType === 'exp' ? 1 : 2;
|
||||
params.set('operation_type', operationTypeNumber.toString());
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString
|
||||
|
||||
@@ -20,13 +20,10 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
|
||||
const operationTypeParam = url.searchParams.get('operation_type');
|
||||
const invoiceTypeParam = url.searchParams.get('invoice_type');
|
||||
|
||||
// Parsear operation_type de forma segura
|
||||
let parsedOperationType: number | null = null;
|
||||
if (operationTypeParam) {
|
||||
const parsed = parseInt(operationTypeParam, 10);
|
||||
if (!isNaN(parsed)) {
|
||||
parsedOperationType = parsed;
|
||||
}
|
||||
// Validar que operation_type sea 'exp' o 'imp'
|
||||
let parsedOperationType: string | null = null;
|
||||
if (operationTypeParam && (operationTypeParam === 'exp' || operationTypeParam === 'imp')) {
|
||||
parsedOperationType = operationTypeParam;
|
||||
}
|
||||
|
||||
// Cargar datos de referencia necesarios
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -32,6 +33,8 @@
|
||||
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';
|
||||
import { getExchangeRates } from '$lib/api/dashboard/a76/general_catalogs/exchange-rate';
|
||||
import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte';
|
||||
|
||||
// Cargar companyStore solo en el cliente - no usamos sidebar en esta página
|
||||
let companyStore: any = $state(undefined);
|
||||
@@ -101,6 +104,44 @@
|
||||
let continuationExists = $state(false);
|
||||
|
||||
let calculatedExchangeRate = $state<number | null>(data.invoice?.financials?.exchange_rate ?? null);
|
||||
|
||||
let showExchangeRateDialog = $state(false);
|
||||
let missingExchangeRateDate = $state("");
|
||||
|
||||
async function checkExchangeRate(date: string): Promise<boolean> {
|
||||
if (!date || !companyStore?.activeCompany?.id) return true;
|
||||
|
||||
// Si ya tenemos un tipo de cambio calculado y es válido, asumimos que existe
|
||||
if (calculatedExchangeRate && calculatedExchangeRate > 0) return true;
|
||||
|
||||
try {
|
||||
// Verificar explícitamente si existe
|
||||
const response = await getExchangeRates(companyStore.activeCompany.id, {
|
||||
date: date,
|
||||
page_size: 1
|
||||
});
|
||||
|
||||
const actualResponse = response as any;
|
||||
const items = actualResponse.data?.items || [];
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('No exchange rate found for', date);
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error checking exchange rate:', error);
|
||||
// Ante la duda, si es un error de red, quizás deberíamos dejar pasar o bloquear.
|
||||
// Si asumimos que falló la petición, mejor pedir al usuario que verifique.
|
||||
// O podríamos asumir que falta si es 404/Empty.
|
||||
// Por seguridad, abrimos modal.
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Efecto para actualizar el tipo de cambio cuando cambia la fecha de factura
|
||||
$effect(() => {
|
||||
@@ -130,6 +171,22 @@
|
||||
saving = true;
|
||||
|
||||
try {
|
||||
// Validar ID si es edición
|
||||
if (!data.isCreate && !invoiceId) {
|
||||
toast.error("Error interrrno: No se encuentra el ID de la factura para actualizar.");
|
||||
saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar tipo de cambio antes de guardar
|
||||
if (InvoiceTopFieldsFormData?.invoice_date) {
|
||||
const rateExists = await checkExchangeRate(InvoiceTopFieldsFormData.invoice_date);
|
||||
if (!rateExists) {
|
||||
saving = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await saveInvoice({
|
||||
invoiceId,
|
||||
isCreate: data.isCreate || false,
|
||||
@@ -165,11 +222,23 @@
|
||||
} else {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Error al guardar los cambios';
|
||||
|
||||
// Intercept exchange rate error
|
||||
if (errorMessage.includes('No existe un Tipo de Cambio registrado') || errorMessage.includes('financials.exchange_rate')) {
|
||||
console.log("Interceptor: Exchange rate missing error caught (Invoice Page).");
|
||||
|
||||
const dateMatch = errorMessage.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
const missingDate = dateMatch ? dateMatch[0] : (InvoiceTopFieldsFormData?.invoice_date || "");
|
||||
|
||||
missingExchangeRateDate = missingDate;
|
||||
showExchangeRateDialog = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Si hay errores de validación, mostrarlos en detalle
|
||||
if (e && typeof e === 'object' && 'validationErrors' in e && Array.isArray((e as any).validationErrors)) {
|
||||
const validationErrors = (e as any).validationErrors;
|
||||
const errorList = validationErrors.map((err: any) =>
|
||||
`• ${err.field}: ${err.message}${err.solution ? ' - ' + err.solution.join(', ') : ''}`
|
||||
`• ${err.message}`
|
||||
).join('\n');
|
||||
|
||||
toast.error(errorMessage, {
|
||||
@@ -259,7 +328,13 @@
|
||||
codePedimentoRegimens={data.codePedimentoRegimens || []}
|
||||
defaultOperationType={data.filters?.operation_type ?? undefined}
|
||||
defaultInvoiceType={data.filters?.invoice_type ?? undefined}
|
||||
operationType={InvoiceTopFieldsFormData?.operation_type}
|
||||
operationType={
|
||||
InvoiceTopFieldsFormData?.operation_type === 'exp' ? 1
|
||||
: InvoiceTopFieldsFormData?.operation_type === 'imp' ? 2
|
||||
: data.invoice?.operation_type === 'exp' ? 1
|
||||
: data.invoice?.operation_type === 'imp' ? 2
|
||||
: undefined
|
||||
}
|
||||
exchangeRate={calculatedExchangeRate}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
@@ -305,6 +380,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer fijo en la parte inferior -->
|
||||
<ExchangeRateDialog
|
||||
bind:open={showExchangeRateDialog}
|
||||
initialDate={missingExchangeRateDate}
|
||||
onSuccess={() => {
|
||||
// Actualizar el tipo de cambio mostrado
|
||||
if (InvoiceTopFieldsFormData.invoice_date && companyStore?.activeCompany?.id) {
|
||||
getExchangeRateByDate(InvoiceTopFieldsFormData.invoice_date, companyStore.activeCompany.id)
|
||||
.then(rate => {
|
||||
if (rate) calculatedExchangeRate = rate.value;
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5] group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]"
|
||||
>
|
||||
|
||||
@@ -64,12 +64,41 @@
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
import { getExchangeRateByDate } from '$lib/api/dashboard/a76/exchange-rate';
|
||||
import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let { data }: { data: ExtendedPageData } = $props();
|
||||
|
||||
let activeTab = $state('general');
|
||||
let generalTabInstance = $state<any>(null); // Todavía útil para otras cosas
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let success = $state(false);
|
||||
|
||||
// Dialog state lifted up
|
||||
let showExchangeRateDialog = $state(false);
|
||||
let missingExchangeRateDate = $state("");
|
||||
|
||||
async function checkPaymentDateRate(date: string): Promise<boolean> {
|
||||
if (!date || !companyStore.activeCompany?.id) return true;
|
||||
|
||||
try {
|
||||
const rate = await getExchangeRateByDate(date, companyStore.activeCompany.id);
|
||||
if (!rate) {
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error checking payment date rate:', error);
|
||||
// Si falla, forzamos diálogo para seguridad
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ID del pedimento
|
||||
let pedimentoId = $state<number | null>(data.pedimentoId ?? null);
|
||||
@@ -274,6 +303,16 @@
|
||||
success = false;
|
||||
|
||||
try {
|
||||
// Verificar tipo de cambio antes de guardar si hay instancia del tab general y hay fecha de pago
|
||||
if (generalTabInstance && generalFormData?.payment_date) {
|
||||
const rateExists = await generalTabInstance.checkPaymentDateRate(generalFormData.payment_date);
|
||||
if (!rateExists) {
|
||||
saving = false;
|
||||
// Asegurar que se muestre el tab general
|
||||
activeTab = 'general';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validar campos requeridos para creación
|
||||
if (data.isCreate && generalFormData) {
|
||||
@@ -718,14 +757,60 @@
|
||||
success = false;
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('401')) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = e instanceof Error ? e.message : 'Error al guardar los cambios';
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
if (e.message.includes('401')) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
// Extracción segura del error
|
||||
let errorStr = "";
|
||||
try {
|
||||
if (typeof e.message === 'object') {
|
||||
errorStr = JSON.stringify(e.message);
|
||||
} else if (e.message === '[object Object]') {
|
||||
// Si el mensaje es literalmente [object Object], intentamos ver si e tiene otras props
|
||||
errorStr = JSON.stringify(e);
|
||||
} else {
|
||||
errorStr = e.message || String(e);
|
||||
}
|
||||
} catch (jsonErr) {
|
||||
errorStr = String(e);
|
||||
}
|
||||
|
||||
if (errorStr.includes('No existe un Tipo de Cambio registrado') || errorStr.includes('financials.exchange_rate')) {
|
||||
// Interceptar error de tipo de cambio
|
||||
console.log("Interceptor: Exchange rate missing error caught (Pedimento).");
|
||||
error = null;
|
||||
|
||||
const dateMatch = errorStr.match(/(\d{4}-\d{2}-\d{2})/);
|
||||
const missingDate = dateMatch ? dateMatch[0] : (generalFormData?.payment_date || "");
|
||||
|
||||
missingExchangeRateDate = missingDate;
|
||||
showExchangeRateDialog = true;
|
||||
activeTab = 'general';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback normal: intentar mostrar algo legible
|
||||
if (errorStr.includes('{')) {
|
||||
// Si parece JSON, intentar formatearlo un poco o mostrar mensaje genérico
|
||||
try {
|
||||
const errObj = JSON.parse(errorStr);
|
||||
// Si es del formato {"field": ["msg"]}
|
||||
const values = Object.values(errObj).flat();
|
||||
error = values.join(', ');
|
||||
} catch {
|
||||
error = "Error al guardar (ver consola)";
|
||||
}
|
||||
} else {
|
||||
error = errorStr;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error = 'Error al guardar los cambios';
|
||||
}
|
||||
console.error('Error saving all:', e);
|
||||
} finally {
|
||||
saving = false;
|
||||
@@ -794,6 +879,7 @@
|
||||
<div class="pb-56">
|
||||
<Tabs.Content value="general">
|
||||
<GeneralTabForm
|
||||
bind:this={generalTabInstance}
|
||||
pedimento={data.pedimento}
|
||||
bind:formData={generalFormData}
|
||||
bind:identificadoresFormData={identificadoresFormData}
|
||||
|
||||
Reference in New Issue
Block a user