Merge pull request 'development' (#89) from development into main

Reviewed-on: ADUANASOFT/anexo76#89
This commit is contained in:
2026-01-24 00:40:14 +00:00
36 changed files with 1958 additions and 618 deletions

View File

@@ -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

View File

@@ -7,7 +7,7 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: self-hosted
steps:
- name: Checkout código

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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)")

View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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",

View File

@@ -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"

View File

@@ -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>> {

View File

@@ -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}`);
}

View File

@@ -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()}`);
}
};

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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 */}}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 */}}
/>

View File

@@ -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 />

View File

@@ -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

View File

@@ -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

View File

@@ -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))]"
>

View File

@@ -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}