Merge branch 'development' into feature/partidas-clarion-csv-validaciones-expo

This commit is contained in:
hreyes
2026-03-10 12:44:34 -06:00
21 changed files with 1494 additions and 913 deletions

View File

@@ -0,0 +1,37 @@
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics
from core.exceptions import ErrorCollector
from .. import schemas
def apply_calculations(
invoice: schemas.InvoiceHeaderUpdate,
):
if invoice.invoice_type == "CR":
invoice.compliance_mx.is_regime_change = True
else:
invoice.compliance_mx.is_regime_change = False
increments_me = (invoice.financials.freight or 0) + (invoice.financials.insurance or 0) + (invoice.financials.packaging or 0) + (invoice.financials.other_increments or 0)
if invoice.financials.currency == "foreign":
invoice.financials.total_increments_me = increments_me
invoice.financials.total_increments_mn = invoice.financials.total_increments_me * invoice.financials.exchange_rate
invoice.financials.currency_type = "USD"
elif invoice.financials.currency == "local":
invoice.financials.total_increments_mn = increments_me
invoice.financials.total_increments_me = invoice.financials.total_increments_mn / invoice.financials.exchange_rate
invoice.financials.currency_type = "MXN"
elif invoice.financials.currency == "manual":
invoice.financials.total_increments_me = (increments_me)/invoice.financials.exchange_rate
invoice.financials.total_increments_mn = invoice.financials.total_increments_me * invoice.financials.exchange_rate
invoice.compliance_mx.is_pedimento_pending = False
if not invoice.compliance_mx.pedimento_id:
invoice.compliance_mx.is_pedimento_pending = True

View File

@@ -1,7 +1,24 @@
from typing import Optional
from core.exceptions import ErrorCollector
from .. import models
from sqlalchemy import func
from sqlalchemy.orm import Session
from .. import models
from .. import schemas
from ..models import InvoiceComplianceMx
from ..models import TransportType, Currency, WeightUnit
from api.v1.modules.a76.invoices.common.calculations import apply_calculations
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from core.exceptions import ErrorCollector
from typing import Dict, Any
def invoice_exists(
@@ -58,7 +75,6 @@ def invoice_exists_by_id(
return invoice
return None
def invoice_updated(
db: Session,
invoice_id: str,
@@ -86,3 +102,590 @@ def invoice_updated(
)
return True
return False
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(
db: Session,
invoice: schemas.InvoiceHeaderUpdate,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
):
if invoice.compliance_mx.pedimento_id:
pedimento = (
db.query(Pedimentos)
.filter(
Pedimentos.id == invoice.compliance_mx.pedimento_id,
Pedimentos.tenant_id == tenant_id,
Pedimentos.company_id == company_id,
)
.first()
)
if not pedimento:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento no existe en el Catálogo de Pedimentos.",
solution=["Verifica el ID", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.pedimento_id,
)
return # Stop here if pedimento not found
if not invoice.compliance_mx.is_regime_change:
operacion = "Importación" if pedimento.operation_type == "imp" else "Exportación"
# Validar que el pedimento sea de importación
if pedimento.operation_type != invoice.operation_type:
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento seleccionado no corresponde a una {operacion}.",
solution=[f"Selecciona un Pedimento de {operacion}"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
# Validar regímenes incompatibles
only_regimes = ["EXD", "ETE", "ETR"]
is_valid = True
if invoice.operation_type == "imp" and pedimento.regime in only_regimes:
is_valid = False
oposite_operacion = "Exportación"
elif invoice.operation_type == "exp" and pedimento.regime not in only_regimes:
is_valid = False
oposite_operacion = "Importación"
if not is_valid:
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento seleccionado no corresponde a una {oposite_operacion}, no a una {operacion}.",
solution=[f"Selecciona un Pedimento de {operacion}"],
code="INVALID_REGIME",
value=pedimento.regime,
)
# Validar que el tipo de documento coincida con el régimen del pedimento
# NOTA: Solo validamos si no hay errores previos y si document_type está presente
if not errors.has_errors() and invoice.document_type:
if invoice.document_type.upper().strip() != pedimento.regime:
errors.add_error(
field="document_type",
message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.",
solution=[
"Ajusta el Tipo de Documento o selecciona otro Pedimento"
],
code="REGIME_MISMATCH",
value=invoice.document_type,
)
else:
# Cambio de Régimen - Generalmente es de Importación Temporal a Definitiva (IMD)
# En el código legacy se comparaba pedimento.operation_type != 2.
# Si asumimos que 2 era Importación en el sistema anterior:
if pedimento.operation_type != "imp":
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado no corresponde a una Importación (requerido para Cambio de Régimen).",
solution=["Selecciona un Pedimento de Importación"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
if pedimento.regime != "IMD" and invoice.document_type == "IMD":
# Si el destino es IMD, validamos que el pedimento original sea de importación
# (aunque usualmente el pedimento que se asocia aquí es el nuevo, el de IMD)
pass
if invoice.document_type.upper().strip() != pedimento.regime:
errors.add_error(
field="document_type",
message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.",
solution=[
"Ajusta el Tipo de Documento o selecciona otro Pedimento"
],
code="REGIME_MISMATCH",
value=invoice.document_type,
)
if pedimento.pedimento_code not in ["A1", "A3"]:
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento seleccionado no es de tipo A1 o A3 requerido para Cambio de Régimen.",
solution=["Selecciona un Pedimento de tipo A1 o A3"],
code="INVALID_PEDEMENTO_CODE",
value=pedimento.pedimento_code,
)
if pedimento.pedimento_type == "consolidated":
if not pedimento.pedimento_dates:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado no tiene fechas registradas.",
solution=["Verifica las fechas del Pedimento en el catálogo"],
code="MISSING_PEDIMENTO_DATES",
value=invoice.compliance_mx.pedimento_id,
)
else:
# Convertir invoice_date a date si es datetime para poder comparar
invoice_date = (
invoice.invoice_date.date()
if hasattr(invoice.invoice_date, "date")
else invoice.invoice_date
)
entry_date = (
pedimento.pedimento_dates.entry_date.date()
if hasattr(pedimento.pedimento_dates.entry_date, "date")
else pedimento.pedimento_dates.entry_date
)
end_date = (
pedimento.pedimento_dates.end_date.date()
if hasattr(pedimento.pedimento_dates.end_date, "date")
else pedimento.pedimento_dates.end_date
)
if pedimento.pedimento_dates and (invoice_date < entry_date or invoice_date > end_date):
errors.add_error(
field="invoice_date",
message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.",
solution=[
f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {pedimento.pedimento_dates.entry_date} y la Fecha Final: {pedimento.pedimento_dates.end_date} ."
],
code="DATE_OUT_OF_RANGE",
value=invoice.invoice_date,
)
# Remesa check
if pedimento.pedimento_type == "consolidated":
if not invoice.compliance_mx.remesa:
errors.add_error(
field="compliance_mx.remesa",
message="El campo Remesa es obligatorio cuando se asocia un Pedimento consolidado.",
solution=["Proporciona un valor para Remesa"],
code="REQUIRED_FIELD",
value=invoice.compliance_mx.remesa,
)
elif invoice.compliance_mx.remesa == 0:
errors.add_error(
field="compliance_mx.remesa",
message="El campo Remesa no puede ser cero cuando se asocia un Pedimento.",
solution=["Proporciona un valor válido para Remesa"],
code="INVALID_VALUE",
value=invoice.compliance_mx.remesa,
)
duplicated_remesa = (
db.query(InvoiceComplianceMx)
.filter(
InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa,
InvoiceComplianceMx.tenant_id == tenant_id,
InvoiceComplianceMx.company_id == company_id,
)
.first()
)
if duplicated_remesa and hasattr(invoice, "id"):
if invoice.id != duplicated_remesa.invoice_id:
errors.add_error(
field="compliance_mx.remesa",
message="El valor de Remesa ya está asociado a otro Pedimento.",
solution=["Proporciona un valor único para Remesa"],
code="DUPLICATE_VALUE",
value=invoice.compliance_mx.remesa,
)
# Financials checks (if provided)
if invoice.financials:
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:
exchange_rate_exists = (
db.query(ExchangeRate)
.filter(
func.date(ExchangeRate.date) == invoice.invoice_date,
ExchangeRate.tenant_id == tenant_id,
ExchangeRate.company_id == company_id,
)
.first()
)
if not exchange_rate_exists:
errors.add_error(
field="financials.exchange_rate",
message=f"No existe un Tipo de Cambio registrado para la fecha {invoice.invoice_date}.",
solution=["Registra el Tipo de Cambio en el catálogo correspondiente"],
code="EXCHANGE_RATE_NOT_FOUND",
value=invoice.financials.exchange_rate,
)
else:
invoice.financials.exchange_rate = exchange_rate_exists.value
else:
# If financials missing, we might want to error if it's required for this operation
pass
if invoice.compliance_mx.is_regime_change:
if invoice.document_type in ["EXD", "ETE", "ETR"]:
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser de Exportación cuando se trata de un Cambio de Régimen.",
solution=[
"Selecciona un Tipo de Documento válido para Cambio de Régimen"
],
code="INVALID_DOCUMENT_TYPE",
value=invoice.document_type,
)
else:
if invoice.document_type == "IMD":
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser 'IMD' a menos que sea un Cambio de Régimen.",
solution=["Selecciona un Tipo de Documento válido"],
code="INVALID_DOCUMENT_TYPE",
value=invoice.document_type,
)
# 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,
)
# 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,
)
# 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,
)
if invoice.compliance_mx.shipped_by_id:
shipped_by_exists = (
db.query(ClientProvider)
.filter(
ClientProvider.id == invoice.compliance_mx.shipped_by_id,
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
.first()
)
if not shipped_by_exists:
errors.add_error(
field="compliance_mx.shipped_by_id",
message="El Remitente no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Remitente", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.shipped_by_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,
)
if invoice.logistics:
if invoice.logistics.carrier_id:
carrier_exists = (
db.query(Transporter)
.filter(
Transporter.id == invoice.logistics.carrier_id,
Transporter.tenant_id == tenant_id,
Transporter.company_id == company_id,
)
.first()
)
if not carrier_exists:
errors.add_error(
field="logistics.carrier_id",
message="El Transportista no existe en el Catálogo de Clientes y Proveedores.",
solution=["Verifica el ID del Transportista", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.logistics.carrier_id,
)
if invoice.logistics.transport_type not in [t.value for t in TransportType]:
errors.add_error(
field="logistics.transport_type",
message="El Tipo de Transporte proporcionado no es válido.",
solution=[
f"Selecciona un Tipo de Transporte válido: {[t.value for t in TransportType]}"
],
code="INVALID_TRANSPORT_TYPE",
value=invoice.logistics.transport_type,
)
else:
if (
invoice.logistics.transport_type == "none"
and invoice.logistics.transport_num
):
errors.add_error(
field="logistics.transport_num",
message="El Número de Transporte no debe proporcionarse cuando el Tipo de Transporte es 'none'.",
solution=[
"Elimina el Número de Transporte o selecciona un Tipo de Transporte válido"
],
code="INVALID_VALUE",
value=invoice.logistics.transport_num,
)
else:
if (
not invoice.logistics.transport_num
and invoice.logistics.transport_type != "none"
):
errors.add_error(
field="logistics.transport_num",
message="El Número de Transporte es obligatorio cuando se proporciona un Tipo de Transporte distinto de 'none'.",
solution=["Proporciona un Número de Transporte válido"],
code="REQUIRED_FIELD",
value=invoice.logistics.transport_num,
)
invoice.financials.currency = invoice.financials.currency or "foreign"
if invoice.financials.currency not in [c.value for c in Currency]:
errors.add_error(
field="financials.currency",
message="La Moneda proporcionada no es válida.",
solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"],
code="INVALID_CURRENCY",
value=invoice.financials.currency,
)
else:
# Only check for existing items during update operations (when invoice has an id)
if hasattr(invoice, "id"):
has_items = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
if has_items:
errors.add_error(
field="items",
message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.",
solution=[
"Verifica la moneda de los items asociados a la factura."
],
code="CURRENCY_CANNOT_BE_CHANGED",
value=invoice.financials.currency,
)
if invoice.financials.currency == "foreign":
invoice.financials.currency_type = "USD"
elif invoice.financials.currency == "local":
invoice.financials.currency_type = "MXN"
elif invoice.financials.currency == "manual":
if not invoice.financials.currency_type:
errors.add_error(
field="financials.currency_type",
message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.",
solution=["Proporciona un Tipo de Moneda válido"],
code="REQUIRED_FIELD",
value=invoice.financials.currency_type,
)
else:
currency_exists = (
db.query(CurrencyType)
.filter(CurrencyType.code == invoice.financials.currency_type)
.first()
)
if not currency_exists:
errors.add_error(
field="financials.currency_type",
message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.",
solution=[
"Verifica el código del Tipo de Moneda",
"Revisa el catálogo",
],
code="NOT_FOUND",
value=invoice.financials.currency_type,
)
if invoice.logistics.incoterm:
incoterm_exists = (
db.query(Incoterm)
.filter(
Incoterm.code == invoice.logistics.incoterm
)
.first()
)
if not incoterm_exists:
errors.add_error(
field="logistics.incoterm",
message="El Incoterm no existe en el Catálogo de Incoterms.",
solution=["Verifica el código del Incoterm", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.logistics.incoterm,
)
if invoice.logistics.weight_type not in [w.value for w in WeightUnit]:
errors.add_error(
field="logistics.weight_type",
message="La Unidad de Peso proporcionada no es válida.",
solution=[
f"Selecciona una Unidad de Peso válida: {[w.value for w in WeightUnit]}"
],
code="INVALID_WEIGHT_UNIT",
value=invoice.logistics.weight_type,
)
if invoice.compliance_mx.aduana:
custom_section_exists = (
db.query(CustomsSection)
.filter(
CustomsSection.customs_code == invoice.compliance_mx.aduana,
)
.first()
)
if not custom_section_exists:
errors.add_error(
field="compliance_mx.aduana",
message="La Aduana no existe en el Catálogo de Secciones Aduaneras.",
solution=["Verifica el código de la Aduana", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.aduana,
)
if invoice.compliance_mx.manifest_number:
manifest_exists = (
db.query(Manifest)
.filter(
Manifest.manifest_number == invoice.compliance_mx.manifest_number,
Manifest.tenant_id == tenant_id,
Manifest.company_id == company_id,
)
.first()
)
if not manifest_exists:
errors.add_error(
field="compliance_mx.manifest_number",
message="El Número de Manifiesto no existe en el sistema.",
solution=["Verifica el Número de Manifiesto", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.manifest_number,
)
apply_calculations(invoice)

View File

@@ -2,8 +2,8 @@ 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, validate_required_fields_by_operation
from ...schemas import InvoiceHeaderCreate
from ...common.common_validators 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 """
@@ -23,16 +23,13 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
if errors.has_errors():
"""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,
'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None,
'shipped_to_header': invoice.compliance_mx.shipped_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,

View File

@@ -0,0 +1,284 @@
from typing import Optional
from datetime import date
from decimal import Decimal
from sqlalchemy.orm import Session
from ...common.common_validators import validate_common, validate_required_fields_by_operation
from core.exceptions import ErrorCollector
from ...schemas import InvoiceHeaderUpdate
from ...models import InvoiceHeader
# Helper function para limpiar strings (equivalente a Clip())
def clean_str(value: Optional[str]) -> Optional[str]:
if value is None or value == "":
return None
return value.strip()
def validate_update(
db: Session,
invoice: InvoiceHeaderUpdate,
existing_invoice: InvoiceHeader,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> None:
"""
Valida y procesa la actualización parcial de una factura de importación temporal.
Lógica: Si un campo viene con valor, se limpia/valida.
Si no, se mantiene el valor existente de la factura.
Args:
invoice: Datos de la factura a validar/actualizar (modificado in-place)
existing_invoice: Factura existente en la base de datos
errors: Colector de errores
Returns:
None (modifica invoice in-place y acumula errores en errors)
"""
# Validar campos requeridos según el tipo de operación
invoice_dict = {
'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None),
'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None),
'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None),
'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None),
'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None),
'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None),
}
validate_required_fields_by_operation(
invoice_data=invoice_dict,
operation_type=invoice.operation_type or (existing_invoice.operation_type or 'imp'),
errors=errors
)
# Primero ejecutar validaciones comunes
validate_common(db, invoice, tenant_id, company_id, errors)
# Mapeo de columnas CSV a campos de la factura
# Siguiendo la lógica del código Clarion original
# Columna A: Pedimento (si no viene en CSV, usar el existente)
if invoice.compliance_mx:
if invoice.compliance_mx.pedimento_id:
invoice.compliance_mx.pedimento_id = invoice.compliance_mx.pedimento_id
else:
invoice.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
# Columna B: Remesa
if invoice.compliance_mx:
if invoice.compliance_mx.remesa:
invoice.compliance_mx.remesa = invoice.compliance_mx.remesa
else:
invoice.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None
# Columna C: Factura (OBLIGATORIO)
if invoice.invoice_number is not None:
invoice.invoice_number = clean_str(invoice.invoice_number)
if not invoice.invoice_number:
errors.add_required_error("invoice_number")
else:
invoice.invoice_number = existing_invoice.invoice_number
# Columna D: Fecha
if not invoice.invoice_date:
invoice.invoice_date = existing_invoice.invoice_date
# Columna E: Tipo Cambio
if invoice.financials:
if invoice.financials.exchange_rate is None:
if existing_invoice.financials:
invoice.financials.exchange_rate = existing_invoice.financials.exchange_rate
# Columna F: Régimen
if invoice.document_type:
invoice.document_type = clean_str(invoice.document_type).upper()
else:
invoice.document_type = existing_invoice.document_type
# Columna G: Clave Proveedor
if invoice.compliance_mx:
if invoice.compliance_mx.provider_id is None:
invoice.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None
# Columna H: Clave Vendido A
if invoice.compliance_mx:
if invoice.compliance_mx.sold_to_id is None:
invoice.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None
# Columna I: Clave Enviado A
if invoice.compliance_mx:
if invoice.compliance_mx.shipped_to_id is None:
invoice.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None
# Columna J: Clave A. Aduanal
if invoice.compliance_mx:
if invoice.compliance_mx.customs_broker_id is None:
invoice.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None
# Columna K: Clave Transportista
if invoice.logistics:
# Note: logistics in update schema seems to be a single object, but in model it's a list.
# This validator seems to expect a single object (InvoiceLogisticsUpdate).
# We'll stick to the existing logic but make it safe.
if hasattr(invoice.logistics, 'carrier_id') and invoice.logistics.carrier_id is None:
invoice.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None
# Columna L: Nombre Conductor
if invoice.logistics:
if hasattr(invoice.logistics, 'driver_name') and not invoice.logistics.driver_name:
invoice.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'driver_name'):
invoice.logistics.driver_name = clean_str(invoice.logistics.driver_name)
# Columna M: Tipo Transporte
if invoice.logistics:
if hasattr(invoice.logistics, 'transport_type') and not invoice.logistics.transport_type:
invoice.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'transport_type'):
invoice.logistics.transport_type = clean_str(invoice.logistics.transport_type)
# Columna N: Número de Transporte
if invoice.logistics:
if hasattr(invoice.logistics, 'transport_num') and not invoice.logistics.transport_num:
invoice.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'transport_num'):
invoice.logistics.transport_num = clean_str(invoice.logistics.transport_num)
# Columna O: Tipo de Moneda
if invoice.financials:
if not invoice.financials.currency:
invoice.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None
else:
invoice.financials.currency = clean_str(invoice.financials.currency).lower()
# Columna P: Clave Moneda
if invoice.financials:
if not invoice.financials.currency_type:
invoice.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None
else:
invoice.financials.currency_type = clean_str(invoice.financials.currency_type).upper()
# Columna Q: Flete
if invoice.financials:
if invoice.financials.freight is None:
invoice.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None
# Columna R: Val Seguros
if invoice.financials:
if invoice.financials.insurance_value is None:
invoice.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None
# Columna S: Seguros
if invoice.financials:
if invoice.financials.insurance is None:
invoice.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None
# Columna T: Embalaje
if invoice.financials:
if invoice.financials.packaging is None:
invoice.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None
# Columna U: Otros Incrementables
if invoice.financials:
if invoice.financials.other_increments is None:
invoice.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None
# Columna V: Incoterms
if invoice.logistics:
if hasattr(invoice.logistics, 'incoterm') and not invoice.logistics.incoterm:
invoice.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'incoterm'):
invoice.logistics.incoterm = clean_str(invoice.logistics.incoterm).upper()
# Columna W: Precinto
if invoice.logistics:
if hasattr(invoice.logistics, 'seal_number') and not invoice.logistics.seal_number:
invoice.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'seal_number'):
invoice.logistics.seal_number = clean_str(invoice.logistics.seal_number)
# Columna X: Fecha de Emisión
if not invoice.emission_date:
invoice.emission_date = existing_invoice.emission_date
# Columna Y: Tipo de Peso (Opcional)
if invoice.logistics:
if hasattr(invoice.logistics, 'weight_type') and not invoice.logistics.weight_type:
invoice.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'weight_type'):
invoice.logistics.weight_type = clean_str(invoice.logistics.weight_type).upper()
# Columna Z: Número de Manifiesto (Opcional)
if invoice.compliance_mx.manifest_number:
if not invoice.compliance_mx.manifest_number:
invoice.compliance_mx.manifest_number = existing_invoice.compliance_mx.manifest_number if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.manifest_number = clean_str(invoice.compliance_mx.manifest_number)
# Columna AA: E-Document (Opcional)
if invoice.compliance_mx:
if not invoice.compliance_mx.edocument:
invoice.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.edocument = clean_str(invoice.compliance_mx.edocument)
# Columna AB: Num. Operación (Opcional)
if invoice.compliance_mx:
if not invoice.compliance_mx.vucem_operation_num:
invoice.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.vucem_operation_num = clean_str(invoice.compliance_mx.vucem_operation_num)
# Columna AB: Aduana (OBLIGATORIO)
if invoice.compliance_mx:
if not invoice.compliance_mx.aduana:
invoice.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.aduana = clean_str(invoice.compliance_mx.aduana)
# Columna AC: Enviado Por (Obligatorio)
if invoice.compliance_mx:
if not invoice.compliance_mx.shipped_by_id:
invoice.compliance_mx.shipped_by_id = existing_invoice.compliance_mx.shipped_by_id if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.shipped_by_id = clean_str(invoice.compliance_mx.shipped_by_id)
# Columna AD: Aduana_Cruce (Obligatorio)
current_aduana = invoice.compliance_mx.aduana if invoice.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None)
if not current_aduana:
errors.add_required_error("aduana")
# Columna AC: Sección de Despacho / Puerto de Entrada (Opcional)
if invoice.compliance_mx:
if not invoice.compliance_mx.port_of_entry:
invoice.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.port_of_entry = clean_str(invoice.compliance_mx.port_of_entry)
# Columna AE: Observación en Español (Opcional)
if not invoice.observation_es:
invoice.observation_es = existing_invoice.observation_es
else:
invoice.observation_es = clean_str(invoice.observation_es)
# Columna AF: Observación en Inglés (Opcional)
if not invoice.observation_en:
invoice.observation_en = existing_invoice.observation_en
else:
invoice.observation_en = clean_str(invoice.observation_en)
# Columna AG: cfdi_uuid (Opcional)
if not invoice.cfdi_uuid:
invoice.cfdi_uuid = existing_invoice.cfdi_uuid if existing_invoice.compliance_mx else None
else:
invoice.cfdi_uuid = clean_str(invoice.cfdi_uuid)
# Columna AH: Localizacion (Opcional)
if invoice.compliance_mx.location:
if not invoice.compliance_mx.location:
invoice.compliance_mx.location = existing_invoice.compliance_mx.location if existing_invoice.compliance_mx.location else None
else:
invoice.compliance_mx.location = clean_str(invoice.compliance_mx.location)

View File

@@ -1,533 +0,0 @@
from sqlalchemy import func
from sqlalchemy.orm import Session
from .... import schemas
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from ....models import InvoiceComplianceMx
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
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(
db: Session,
invoice: schemas.InvoiceHeaderUpdate,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
):
if invoice.compliance_mx.pedimento_id:
pedimento = (
db.query(Pedimentos)
.filter(
Pedimentos.id == invoice.compliance_mx.pedimento_id,
Pedimentos.tenant_id == tenant_id,
Pedimentos.company_id == company_id,
)
.first()
)
if not pedimento:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento no existe en el Catálogo de Pedimentos.",
solution=["Verifica el ID", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.pedimento_id,
)
return # Stop here if pedimento not found
if not invoice.compliance_mx.is_regime_change:
# Validar que el pedimento sea de importación (hardcoded restriction)
if pedimento.operation_type != "imp":
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado no corresponde a una Importación.",
solution=["Selecciona un Pedimento de Importación"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
# Validar regímenes incompatibles
export_only_regimes = ["EXD", "ETE", "ETR"]
if pedimento.regime in export_only_regimes:
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado corresponde a una Exportación, no a una Importación.",
solution=["Selecciona un Pedimento de Importación"],
code="INVALID_REGIME",
value=pedimento.regime,
)
# Validar que el tipo de documento coincida con el régimen del pedimento
# NOTA: Solo validamos si no hay errores previos y si document_type está presente
if not errors.has_errors() and invoice.document_type:
if invoice.document_type.upper().strip() != pedimento.regime:
errors.add_error(
field="document_type",
message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.",
solution=[
"Ajusta el Tipo de Documento o selecciona otro Pedimento"
],
code="REGIME_MISMATCH",
value=invoice.document_type,
)
else:
# Cambio de Régimen - Generalmente es de Importación Temporal a Definitiva (IMD)
# En el código legacy se comparaba pedimento.operation_type != 2.
# Si asumimos que 2 era Importación en el sistema anterior:
if pedimento.operation_type != "imp":
errors.add_error(
field="compliance_mx.pedimento_id",
message="El Pedimento seleccionado no corresponde a una Importación (requerido para Cambio de Régimen).",
solution=["Selecciona un Pedimento de Importación"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
if pedimento.regime != "IMD" and invoice.document_type == "IMD":
# Si el destino es IMD, validamos que el pedimento original sea de importación
# (aunque usualmente el pedimento que se asocia aquí es el nuevo, el de IMD)
pass
if invoice.document_type.upper().strip() != pedimento.regime:
errors.add_error(
field="document_type",
message=f"El Tipo de Documento '{invoice.document_type}' no coincide con el Régimen '{pedimento.regime}' del Pedimento seleccionado.",
solution=[
"Ajusta el Tipo de Documento o selecciona otro Pedimento"
],
code="REGIME_MISMATCH",
value=invoice.document_type,
)
if pedimento.pedimento_code not in ["A1", "A3"]:
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento seleccionado no es de tipo A1 o A3 requerido para Cambio de Régimen.",
solution=["Selecciona un Pedimento de tipo A1 o A3"],
code="INVALID_PEDEMENTO_CODE",
value=pedimento.pedimento_code,
)
if pedimento.pedimento_type == "consolidated":
# Convertir invoice_date a date si es datetime para poder comparar
invoice_date = (
invoice.invoice_date.date()
if hasattr(invoice.invoice_date, "date")
else invoice.invoice_date
)
entry_date = (
pedimento.pedimento_dates.entry_date.date()
if hasattr(pedimento.pedimento_dates.entry_date, "date")
else pedimento.pedimento_dates.entry_date
)
end_date = (
pedimento.pedimento_dates.end_date.date()
if hasattr(pedimento.pedimento_dates.end_date, "date")
else pedimento.pedimento_dates.end_date
)
if invoice_date < entry_date or invoice_date > end_date:
errors.add_error(
field="invoice_date",
message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.",
solution=[
f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {pedimento.pedimento_dates.entry_date} y la Fecha Final: {pedimento.pedimento_dates.end_date} ."
],
code="DATE_OUT_OF_RANGE",
value=invoice.invoice_date,
)
# Remesa check
if pedimento.pedimento_type == "consolidated":
if not invoice.compliance_mx.remesa:
errors.add_error(
field="compliance_mx.remesa",
message="El campo Remesa es obligatorio cuando se asocia un Pedimento consolidado.",
solution=["Proporciona un valor para Remesa"],
code="REQUIRED_FIELD",
value=invoice.compliance_mx.remesa,
)
elif invoice.compliance_mx.remesa == 0:
errors.add_error(
field="compliance_mx.remesa",
message="El campo Remesa no puede ser cero cuando se asocia un Pedimento.",
solution=["Proporciona un valor válido para Remesa"],
code="INVALID_VALUE",
value=invoice.compliance_mx.remesa,
)
duplicated_remesa = (
db.query(InvoiceComplianceMx)
.filter(
InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa,
InvoiceComplianceMx.tenant_id == tenant_id,
InvoiceComplianceMx.company_id == company_id,
)
.first()
)
if duplicated_remesa and hasattr(invoice, "id"):
if invoice.id != duplicated_remesa.invoice_id:
errors.add_error(
field="compliance_mx.remesa",
message="El valor de Remesa ya está asociado a otro Pedimento.",
solution=["Proporciona un valor único para Remesa"],
code="DUPLICATE_VALUE",
value=invoice.compliance_mx.remesa,
)
# Financials checks (if provided)
if invoice.financials:
if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0:
exchange_rate_exists = (
db.query(ExchangeRate)
.filter(
func.date(ExchangeRate.date) == invoice.invoice_date,
ExchangeRate.tenant_id == tenant_id,
ExchangeRate.company_id == company_id,
)
.first()
)
if not exchange_rate_exists:
errors.add_error(
field="financials.exchange_rate",
message=f"No existe un Tipo de Cambio registrado para la fecha {invoice.invoice_date}.",
solution=["Registra el Tipo de Cambio en el catálogo correspondiente"],
code="EXCHANGE_RATE_NOT_FOUND",
value=invoice.financials.exchange_rate,
)
else:
invoice.financials.exchange_rate = exchange_rate_exists.value
else:
# If financials missing, we might want to error if it's required for this operation
pass
if invoice.compliance_mx.is_regime_change:
if invoice.document_type in ["EXD", "ETE", "ETR"]:
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser de Exportación cuando se trata de un Cambio de Régimen.",
solution=[
"Selecciona un Tipo de Documento válido para Cambio de Régimen"
],
code="INVALID_DOCUMENT_TYPE",
value=invoice.document_type,
)
else:
if invoice.document_type == "IMD":
errors.add_error(
field="document_type",
message="El Tipo de Documento no puede ser 'IMD' a menos que sea un Cambio de Régimen.",
solution=["Selecciona un Tipo de Documento válido"],
code="INVALID_DOCUMENT_TYPE",
value=invoice.document_type,
)
# 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,
)
# 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,
)
# 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,
)
# 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,
)
if invoice.logistics:
if invoice.logistics.transport_num and not invoice.logistics.transport_num:
# logic ...
pass
if invoice.logistics.transport_type not in [t.value for t in TransportType]:
errors.add_error(
field="logistics.transport_type",
message="El Tipo de Transporte proporcionado no es válido.",
solution=[
f"Selecciona un Tipo de Transporte válido: {[t.value for t in TransportType]}"
],
code="INVALID_TRANSPORT_TYPE",
value=invoice.logistics.transport_type,
)
else:
if (
invoice.logistics.transport_type == "none"
and invoice.logistics.transport_num
):
errors.add_error(
field="logistics.transport_num",
message="El Número de Transporte no debe proporcionarse cuando el Tipo de Transporte es 'none'.",
solution=[
"Elimina el Número de Transporte o selecciona un Tipo de Transporte válido"
],
code="INVALID_VALUE",
value=invoice.logistics.transport_num,
)
else:
if (
not invoice.logistics.transport_num
and invoice.logistics.transport_type != "none"
):
errors.add_error(
field="logistics.transport_num",
message="El Número de Transporte es obligatorio cuando se proporciona un Tipo de Transporte distinto de 'none'.",
solution=["Proporciona un Número de Transporte válido"],
code="REQUIRED_FIELD",
value=invoice.logistics.transport_num,
)
invoice.financials.currency = invoice.financials.currency or "foreign"
if invoice.financials.currency not in [c.value for c in Currency]:
errors.add_error(
field="financials.currency",
message="La Moneda proporcionada no es válida.",
solution=[f"Selecciona una Moneda válida: {[c.value for c in Currency]}"],
code="INVALID_CURRENCY",
value=invoice.financials.currency,
)
else:
# Only check for existing items during update operations (when invoice has an id)
if hasattr(invoice, "id"):
has_items = (
db.query(LineItem)
.filter(
LineItem.invoice_id == invoice.id,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
if has_items:
errors.add_error(
field="items",
message=f"La opcion tipo de moneda {invoice.financials.currency} no puede ser modificada ya que la factura tiene items asociados.",
solution=[
"Verifica la moneda de los items asociados a la factura."
],
code="CURRENCY_CANNOT_BE_CHANGED",
value=invoice.financials.currency,
)
if invoice.financials.currency == "foreign":
invoice.financials.currency_type = "USD"
elif invoice.financials.currency == "local":
invoice.financials.currency_type = "MXN"
elif invoice.financials.currency == "manual":
if not invoice.financials.currency_type:
errors.add_error(
field="financials.currency_type",
message="El Tipo de Moneda es obligatorio cuando la Moneda es 'manual'.",
solution=["Proporciona un Tipo de Moneda válido"],
code="REQUIRED_FIELD",
value=invoice.financials.currency_type,
)
else:
currency_exists = (
db.query(CurrencyType)
.filter(CurrencyType.code == invoice.financials.currency_type)
.first()
)
if not currency_exists:
errors.add_error(
field="financials.currency_type",
message="El Tipo de Moneda no existe en el Catálogo de Tipos de Moneda.",
solution=[
"Verifica el código del Tipo de Moneda",
"Revisa el catálogo",
],
code="NOT_FOUND",
value=invoice.financials.currency_type,
)
if invoice.logistics.incoterm:
incoterm_exists = (
db.query(Incoterm)
.filter(
Incoterm.code == invoice.logistics.incoterm,
Incoterm.tenant_id == tenant_id,
Incoterm.company_id == company_id,
)
.first()
)
if not incoterm_exists:
errors.add_error(
field="logistics.incoterm",
message="El Incoterm no existe en el Catálogo de Incoterms.",
solution=["Verifica el código del Incoterm", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.logistics.incoterm,
)
if invoice.logistics.weight_type not in [w.value for w in WeightUnit]:
errors.add_error(
field="logistics.weight_type",
message="La Unidad de Peso proporcionada no es válida.",
solution=[
f"Selecciona una Unidad de Peso válida: {[w.value for w in WeightUnit]}"
],
code="INVALID_WEIGHT_UNIT",
value=invoice.logistics.weight_type,
)
if invoice.compliance_mx.aduana:
custom_section_exists = (
db.query(CustomsSection)
.filter(
CustomsSection.customs_code == invoice.compliance_mx.aduana,
)
.first()
)
if not custom_section_exists:
errors.add_error(
field="compliance_mx.aduana",
message="La Aduana no existe en el Catálogo de Secciones Aduaneras.",
solution=["Verifica el código de la Aduana", "Revisa el catálogo"],
code="NOT_FOUND",
value=invoice.compliance_mx.aduana,
)

View File

@@ -1,255 +0,0 @@
from typing import Optional
from datetime import date
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())
def clean_str(value: Optional[str]) -> Optional[str]:
if value is None or value == "":
return None
return value.strip()
def validate_update(
invoice_data: InvoiceHeaderUpdate,
existing_invoice: InvoiceHeader,
errors: ErrorCollector,
) -> None:
"""
Valida y procesa la actualización parcial de una factura de importación temporal.
Lógica: Si un campo viene con valor, se limpia/valida.
Si no, se mantiene el valor existente de la factura.
Args:
invoice_data: Datos de la factura a validar/actualizar (modificado in-place)
existing_invoice: Factura existente en la base de datos
errors: Colector de errores
Returns:
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 (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None),
'sold_to_id': invoice_data.compliance_mx.sold_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None),
'sold_to_header': invoice_data.compliance_mx.sold_to_header if invoice_data.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None),
'shipped_to_id': invoice_data.compliance_mx.shipped_to_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None),
'customs_broker_id': invoice_data.compliance_mx.customs_broker_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None),
'pedimento_id': invoice_data.compliance_mx.pedimento_id if invoice_data.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None),
}
validate_required_fields_by_operation(
invoice_data=invoice_dict,
operation_type=invoice_data.operation_type or (existing_invoice.operation_type or 'imp'),
errors=errors
)
# Primero ejecutar validaciones comunes
# validate_common(invoice_data, errors)
# Mapeo de columnas CSV a campos de la factura
# Siguiendo la lógica del código Clarion original
# Columna A: Pedimento (si no viene en CSV, usar el existente)
if invoice_data.compliance_mx:
if invoice_data.compliance_mx.pedimento_id:
invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id
else:
invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
# Columna B: Remesa
if invoice_data.compliance_mx:
if invoice_data.compliance_mx.remesa:
invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa
else:
invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None
# Columna C: Factura (OBLIGATORIO)
if invoice_data.invoice_number is not None:
invoice_data.invoice_number = clean_str(invoice_data.invoice_number)
if not invoice_data.invoice_number:
errors.add_required_error("invoice_number")
else:
invoice_data.invoice_number = existing_invoice.invoice_number
# Columna D: Fecha
if not invoice_data.invoice_date:
invoice_data.invoice_date = existing_invoice.invoice_date
# Columna E: Tipo Cambio
if invoice_data.financials:
if invoice_data.financials.exchange_rate is None:
if existing_invoice.financials:
invoice_data.financials.exchange_rate = existing_invoice.financials.exchange_rate
# Columna F: Régimen
if invoice_data.document_type:
invoice_data.document_type = clean_str(invoice_data.document_type).upper()
else:
invoice_data.document_type = existing_invoice.document_type
# Columna G: Clave Proveedor
if invoice_data.compliance_mx:
if invoice_data.compliance_mx.provider_id is None:
invoice_data.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None
# Columna H: Clave Vendido A
if invoice_data.compliance_mx:
if invoice_data.compliance_mx.sold_to_id is None:
invoice_data.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None
# Columna I: Clave Enviado A
if invoice_data.compliance_mx:
if invoice_data.compliance_mx.shipped_to_id is None:
invoice_data.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None
# Columna J: Clave A. Aduanal
if invoice_data.compliance_mx:
if invoice_data.compliance_mx.customs_broker_id is None:
invoice_data.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None
# Columna K: Clave Transportista
if invoice_data.logistics:
# Note: logistics in update schema seems to be a single object, but in model it's a list.
# This validator seems to expect a single object (InvoiceLogisticsUpdate).
# We'll stick to the existing logic but make it safe.
if hasattr(invoice_data.logistics, 'carrier_id') and invoice_data.logistics.carrier_id is None:
invoice_data.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None
# Columna L: Nombre Conductor
if invoice_data.logistics:
if hasattr(invoice_data.logistics, 'driver_name') and not invoice_data.logistics.driver_name:
invoice_data.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None
elif hasattr(invoice_data.logistics, 'driver_name'):
invoice_data.logistics.driver_name = clean_str(invoice_data.logistics.driver_name)
# Columna M: Tipo Transporte
if invoice_data.logistics:
if hasattr(invoice_data.logistics, 'transport_type') and not invoice_data.logistics.transport_type:
invoice_data.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None
elif hasattr(invoice_data.logistics, 'transport_type'):
invoice_data.logistics.transport_type = clean_str(invoice_data.logistics.transport_type)
# Columna N: Número de Transporte
if invoice_data.logistics:
if hasattr(invoice_data.logistics, 'transport_num') and not invoice_data.logistics.transport_num:
invoice_data.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None
elif hasattr(invoice_data.logistics, 'transport_num'):
invoice_data.logistics.transport_num = clean_str(invoice_data.logistics.transport_num)
# Columna O: Tipo de Moneda
if invoice_data.financials:
if not invoice_data.financials.currency:
invoice_data.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None
else:
invoice_data.financials.currency = clean_str(invoice_data.financials.currency).lower()
# Columna P: Clave Moneda
if invoice_data.financials:
if not invoice_data.financials.currency_type:
invoice_data.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None
else:
invoice_data.financials.currency_type = clean_str(invoice_data.financials.currency_type).upper()
# Columna Q: Flete
if invoice_data.financials:
if invoice_data.financials.freight is None:
invoice_data.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None
# Columna R: Val Seguros
if invoice_data.financials:
if invoice_data.financials.insurance_value is None:
invoice_data.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None
# Columna S: Seguros
if invoice_data.financials:
if invoice_data.financials.insurance is None:
invoice_data.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None
# Columna T: Embalaje
if invoice_data.financials:
if invoice_data.financials.packaging is None:
invoice_data.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None
# Columna U: Otros Incrementables
if invoice_data.financials:
if invoice_data.financials.other_increments is None:
invoice_data.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None
# Columna V: Incoterms
if invoice_data.logistics:
if hasattr(invoice_data.logistics, 'incoterm') and not invoice_data.logistics.incoterm:
invoice_data.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None
elif hasattr(invoice_data.logistics, 'incoterm'):
invoice_data.logistics.incoterm = clean_str(invoice_data.logistics.incoterm).upper()
# Columna W: Precinto
if invoice_data.logistics:
if hasattr(invoice_data.logistics, 'seal_number') and not invoice_data.logistics.seal_number:
invoice_data.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None
elif hasattr(invoice_data.logistics, 'seal_number'):
invoice_data.logistics.seal_number = clean_str(invoice_data.logistics.seal_number)
# Columna X: Fecha de Emisión
if not invoice_data.emission_date:
invoice_data.emission_date = existing_invoice.emission_date
# Columna Y: Tipo de Peso (Opcional)
if invoice_data.logistics:
if hasattr(invoice_data.logistics, 'weight_type') and not invoice_data.logistics.weight_type:
invoice_data.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None
elif hasattr(invoice_data.logistics, 'weight_type'):
invoice_data.logistics.weight_type = clean_str(invoice_data.logistics.weight_type).upper()
# Columna Z: E-Document (Opcional)
if invoice_data.compliance_mx:
if not invoice_data.compliance_mx.edocument:
invoice_data.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None
else:
invoice_data.compliance_mx.edocument = clean_str(invoice_data.compliance_mx.edocument)
# Columna AA: Num. Operación (Opcional)
if invoice_data.compliance_mx:
if not invoice_data.compliance_mx.vucem_operation_num:
invoice_data.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None
else:
invoice_data.compliance_mx.vucem_operation_num = clean_str(invoice_data.compliance_mx.vucem_operation_num)
# Columna AB: Aduana (OBLIGATORIO)
if invoice_data.compliance_mx:
if not invoice_data.compliance_mx.aduana:
invoice_data.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None
else:
invoice_data.compliance_mx.aduana = clean_str(invoice_data.compliance_mx.aduana)
# Validar que aduana sea obligatorio (excepto para MEX)
if existing_invoice.invoice_type != "MEX":
current_aduana = invoice_data.compliance_mx.aduana if invoice_data.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None)
if not current_aduana:
errors.add_required_error("aduana")
# Columna AC: Sección de Despacho / Puerto de Entrada (Opcional)
if invoice_data.compliance_mx:
if not invoice_data.compliance_mx.port_of_entry:
invoice_data.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None
else:
invoice_data.compliance_mx.port_of_entry = clean_str(invoice_data.compliance_mx.port_of_entry)
# Columna AD: Observación en Español (Opcional)
if not invoice_data.observation_es:
invoice_data.observation_es = existing_invoice.observation_es
else:
invoice_data.observation_es = clean_str(invoice_data.observation_es)
# Columna AD: Observación en Inglés (Opcional)
if not invoice_data.observation_en:
invoice_data.observation_en = existing_invoice.observation_en
else:
invoice_data.observation_en = clean_str(invoice_data.observation_en)

View File

@@ -0,0 +1,95 @@
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.common_validators 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 """
if not invoice.operation_type:
errors.add_required_error("operation_type")
if not invoice.invoice_type:
errors.add_required_error("invoice_type")
if not invoice.document_type and invoice.invoice_type != "MEX":
errors.add_required_error("document_type")
if not invoice.invoice_number:
errors.add_required_error("invoice_number")
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
# 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_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else None,
'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else None,
'shipped_to_header': invoice.compliance_mx.shipped_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)
if errors.has_errors():
"""Se retorna por que fallaron las validaciones generales"""
return
if invoice.compliance_mx:
if not invoice.compliance_mx.pedimento_id:
invoice.compliance_mx.remesa = None
if invoice.financials:
if not invoice.financials.exchange_rate:
invoice.financials.exchange_rate = db.query(ExchangeRate.value).filter(ExchangeRate.date == invoice.invoice_date).scalar()
invoice.document_type = (invoice.document_type or "").upper()
if invoice.logistics:
if not invoice.logistics.transport_type:
invoice.logistics.transport_type = "none"
if invoice.logistics.transport_type == "none" and invoice.logistics.transport_num:
invoice.logistics.transport_num = None
invoice.logistics.incoterm = (invoice.logistics.incoterm or "").upper()
if not invoice.logistics.weight_type:
invoice.logistics.weight_type = "kgs"
if invoice.financials:
if not invoice.financials.currency:
invoice.financials.currency = "foreign"
if invoice.financials.currency == "local":
invoice.financials.currency_type = "MXN"
elif invoice.financials.currency == "foreign":
invoice.financials.currency_type = "USD"
elif invoice.financials.currency == "manual":
invoice.financials.currency_type = (invoice.financials.currency_type or "").upper()

View File

@@ -0,0 +1,259 @@
from typing import Optional
from datetime import date
from decimal import Decimal
from sqlalchemy.orm import Session
from ...common.common_validators import validate_common, validate_required_fields_by_operation
from core.exceptions import ErrorCollector
from ...schemas import InvoiceHeaderUpdate
from ...models import InvoiceHeader
# Helper function para limpiar strings (equivalente a Clip())
def clean_str(value: Optional[str]) -> Optional[str]:
if value is None or value == "":
return None
return value.strip()
def validate_update(
db: Session,
invoice: InvoiceHeaderUpdate,
existing_invoice: InvoiceHeader,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
) -> None:
"""
Valida y procesa la actualización parcial de una factura de importación temporal.
Lógica: Si un campo viene con valor, se limpia/valida.
Si no, se mantiene el valor existente de la factura.
Args:
invoice: Datos de la factura a validar/actualizar (modificado in-place)
existing_invoice: Factura existente en la base de datos
errors: Colector de errores
Returns:
None (modifica invoice in-place y acumula errores en errors)
"""
# Validar campos requeridos según el tipo de operación
invoice_dict = {
'provider_id': invoice.compliance_mx.provider_id if invoice.compliance_mx else (existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None),
'sold_to_id': invoice.compliance_mx.sold_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None),
'sold_to_header': invoice.compliance_mx.sold_to_header if invoice.compliance_mx else (existing_invoice.compliance_mx.sold_to_header if existing_invoice.compliance_mx else None),
'shipped_to_id': invoice.compliance_mx.shipped_to_id if invoice.compliance_mx else (existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None),
'customs_broker_id': invoice.compliance_mx.customs_broker_id if invoice.compliance_mx else (existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None),
'pedimento_id': invoice.compliance_mx.pedimento_id if invoice.compliance_mx else (existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None),
}
validate_required_fields_by_operation(
invoice_data=invoice_dict,
operation_type=invoice.operation_type or (existing_invoice.operation_type or 'imp'),
errors=errors
)
# Primero ejecutar validaciones comunes
validate_common(db, invoice, tenant_id, company_id, errors)
# Mapeo de columnas CSV a campos de la factura
# Siguiendo la lógica del código Clarion original
# Columna A: Pedimento (si no viene en CSV, usar el existente)
if invoice.compliance_mx:
if invoice.compliance_mx.pedimento_id:
invoice.compliance_mx.pedimento_id = invoice.compliance_mx.pedimento_id
else:
invoice.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None
# Columna B: Remesa
if invoice.compliance_mx:
if invoice.compliance_mx.remesa:
invoice.compliance_mx.remesa = invoice.compliance_mx.remesa
else:
invoice.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None
# Columna C: Factura (OBLIGATORIO)
if invoice.invoice_number is not None:
invoice.invoice_number = clean_str(invoice.invoice_number)
if not invoice.invoice_number:
errors.add_required_error("invoice_number")
else:
invoice.invoice_number = existing_invoice.invoice_number
# Columna D: Fecha
if not invoice.invoice_date:
invoice.invoice_date = existing_invoice.invoice_date
# Columna E: Tipo Cambio
if invoice.financials:
if invoice.financials.exchange_rate is None:
if existing_invoice.financials:
invoice.financials.exchange_rate = existing_invoice.financials.exchange_rate
# Columna F: Régimen
if invoice.document_type:
invoice.document_type = clean_str(invoice.document_type).upper()
else:
invoice.document_type = existing_invoice.document_type
# Columna G: Clave Proveedor
if invoice.compliance_mx:
if invoice.compliance_mx.provider_id is None:
invoice.compliance_mx.provider_id = existing_invoice.compliance_mx.provider_id if existing_invoice.compliance_mx else None
# Columna H: Clave Vendido A
if invoice.compliance_mx:
if invoice.compliance_mx.sold_to_id is None:
invoice.compliance_mx.sold_to_id = existing_invoice.compliance_mx.sold_to_id if existing_invoice.compliance_mx else None
# Columna I: Clave Enviado A
if invoice.compliance_mx:
if invoice.compliance_mx.shipped_to_id is None:
invoice.compliance_mx.shipped_to_id = existing_invoice.compliance_mx.shipped_to_id if existing_invoice.compliance_mx else None
# Columna J: Clave A. Aduanal
if invoice.compliance_mx:
if invoice.compliance_mx.customs_broker_id is None:
invoice.compliance_mx.customs_broker_id = existing_invoice.compliance_mx.customs_broker_id if existing_invoice.compliance_mx else None
# Columna K: Clave Transportista
if invoice.logistics:
# Note: logistics in update schema seems to be a single object, but in model it's a list.
# This validator seems to expect a single object (InvoiceLogisticsUpdate).
# We'll stick to the existing logic but make it safe.
if hasattr(invoice.logistics, 'carrier_id') and invoice.logistics.carrier_id is None:
invoice.logistics.carrier_id = existing_invoice.logistics.carrier_id if existing_invoice.logistics else None
# Columna L: Nombre Conductor
if invoice.logistics:
if hasattr(invoice.logistics, 'driver_name') and not invoice.logistics.driver_name:
invoice.logistics.driver_name = existing_invoice.logistics.driver_name if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'driver_name'):
invoice.logistics.driver_name = clean_str(invoice.logistics.driver_name)
# Columna M: Tipo Transporte
if invoice.logistics:
if hasattr(invoice.logistics, 'transport_type') and not invoice.logistics.transport_type:
invoice.logistics.transport_type = existing_invoice.logistics.transport_type if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'transport_type'):
invoice.logistics.transport_type = clean_str(invoice.logistics.transport_type)
# Columna N: Número de Transporte
if invoice.logistics:
if hasattr(invoice.logistics, 'transport_num') and not invoice.logistics.transport_num:
invoice.logistics.transport_num = existing_invoice.logistics.transport_num if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'transport_num'):
invoice.logistics.transport_num = clean_str(invoice.logistics.transport_num)
# Columna O: Tipo de Moneda
if invoice.financials:
if not invoice.financials.currency:
invoice.financials.currency = existing_invoice.financials.currency if existing_invoice.financials else None
else:
invoice.financials.currency = clean_str(invoice.financials.currency).lower()
# Columna P: Clave Moneda
if invoice.financials:
if not invoice.financials.currency_type:
invoice.financials.currency_type = existing_invoice.financials.currency_type if existing_invoice.financials else None
else:
invoice.financials.currency_type = clean_str(invoice.financials.currency_type).upper()
# Columna Q: Flete
if invoice.financials:
if invoice.financials.freight is None:
invoice.financials.freight = existing_invoice.financials.freight if existing_invoice.financials else None
# Columna R: Val Seguros
if invoice.financials:
if invoice.financials.insurance_value is None:
invoice.financials.insurance_value = existing_invoice.financials.insurance_value if existing_invoice.financials else None
# Columna S: Seguros
if invoice.financials:
if invoice.financials.insurance is None:
invoice.financials.insurance = existing_invoice.financials.insurance if existing_invoice.financials else None
# Columna T: Embalaje
if invoice.financials:
if invoice.financials.packaging is None:
invoice.financials.packaging = existing_invoice.financials.packaging if existing_invoice.financials else None
# Columna U: Otros Incrementables
if invoice.financials:
if invoice.financials.other_increments is None:
invoice.financials.other_increments = existing_invoice.financials.other_increments if existing_invoice.financials else None
# Columna V: Incoterms
if invoice.logistics:
if hasattr(invoice.logistics, 'incoterm') and not invoice.logistics.incoterm:
invoice.logistics.incoterm = existing_invoice.logistics.incoterm if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'incoterm'):
invoice.logistics.incoterm = clean_str(invoice.logistics.incoterm).upper()
# Columna W: Precinto
if invoice.logistics:
if hasattr(invoice.logistics, 'seal_number') and not invoice.logistics.seal_number:
invoice.logistics.seal_number = existing_invoice.logistics.seal_number if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'seal_number'):
invoice.logistics.seal_number = clean_str(invoice.logistics.seal_number)
# Columna X: Fecha de Emisión
if not invoice.emission_date:
invoice.emission_date = existing_invoice.emission_date
# Columna Y: Tipo de Peso (Opcional)
if invoice.logistics:
if hasattr(invoice.logistics, 'weight_type') and not invoice.logistics.weight_type:
invoice.logistics.weight_type = existing_invoice.logistics.weight_type if existing_invoice.logistics else None
elif hasattr(invoice.logistics, 'weight_type'):
invoice.logistics.weight_type = clean_str(invoice.logistics.weight_type).upper()
# Columna Z: E-Document (Opcional)
if invoice.compliance_mx:
if not invoice.compliance_mx.edocument:
invoice.compliance_mx.edocument = existing_invoice.compliance_mx.edocument if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.edocument = clean_str(invoice.compliance_mx.edocument)
# Columna AA: Num. Operación (Opcional)
if invoice.compliance_mx:
if not invoice.compliance_mx.vucem_operation_num:
invoice.compliance_mx.vucem_operation_num = existing_invoice.compliance_mx.vucem_operation_num if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.vucem_operation_num = clean_str(invoice.compliance_mx.vucem_operation_num)
# Columna AB: Aduana (OBLIGATORIO)
if invoice.compliance_mx:
if not invoice.compliance_mx.aduana:
invoice.compliance_mx.aduana = existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.aduana = clean_str(invoice.compliance_mx.aduana)
# Validar que aduana sea obligatorio (excepto para MEX)
if existing_invoice.invoice_type != "MEX":
current_aduana = invoice.compliance_mx.aduana if invoice.compliance_mx else (existing_invoice.compliance_mx.aduana if existing_invoice.compliance_mx else None)
if not current_aduana:
errors.add_required_error("aduana")
# Columna AC: Sección de Despacho / Puerto de Entrada (Opcional)
if invoice.compliance_mx:
if not invoice.compliance_mx.port_of_entry:
invoice.compliance_mx.port_of_entry = existing_invoice.compliance_mx.port_of_entry if existing_invoice.compliance_mx else None
else:
invoice.compliance_mx.port_of_entry = clean_str(invoice.compliance_mx.port_of_entry)
# Columna AD: Observación en Español (Opcional)
if not invoice.observation_es:
invoice.observation_es = existing_invoice.observation_es
else:
invoice.observation_es = clean_str(invoice.observation_es)
# Columna AD: Observación en Inglés (Opcional)
if not invoice.observation_en:
invoice.observation_en = existing_invoice.observation_en
else:
invoice.observation_en = clean_str(invoice.observation_en)

View File

@@ -210,75 +210,75 @@ class InvoiceFinancialsBase(BaseModel):
currency_type: Optional[str] = Field("USD", description="Currency type")
exchange_rate: Optional[Decimal] = Field(0.00, description="Exchange rate")
exchange_rate_mm: Optional[Decimal] = Field(
None, description="Exchange rate currency to currency"
0.00, description="Exchange rate currency to currency"
)
value_mn: Optional[Decimal] = Field(None, description="Value in MXN")
value_me: Optional[Decimal] = Field(None, description="Value in foreign currency")
value_mc: Optional[Decimal] = Field(None, description="Value in third currency")
value_mn: Optional[Decimal] = Field(0.00, description="Value in MXN")
value_me: Optional[Decimal] = Field(0.00, description="Value in foreign currency")
value_mc: Optional[Decimal] = Field(0.00, description="Value in third currency")
customs_value_mn: Optional[Decimal] = Field(
None, description="Customs value in MXN"
0.00, description="Customs value in MXN"
)
customs_value_me: Optional[Decimal] = Field(
None, description="Customs value in foreign currency"
0.00, description="Customs value in foreign currency"
)
raw_material_value_mn: Optional[Decimal] = Field(
None, description="Raw material value in MXN"
0.00, description="Raw material value in MXN"
)
raw_material_value_me: Optional[Decimal] = Field(
None, description="Raw material value in foreign currency"
0.00, description="Raw material value in foreign currency"
)
aggregate_value_mn: Optional[Decimal] = Field(
None, description="Aggregate value in MXN"
0.00, description="Aggregate value in MXN"
)
aggregate_value_me: Optional[Decimal] = Field(
None, description="Aggregate value in foreign currency"
0.00, description="Aggregate value in foreign currency"
)
aggregate_value_mc: Optional[Decimal] = Field(
None, description="Aggregate value in third currency"
0.00, description="Aggregate value in third currency"
)
mexican_value_mn: Optional[Decimal] = Field(
None, description="Mexican merchandise value in MXN"
0.00, description="Mexican merchandise value in MXN"
)
mexican_value_me: Optional[Decimal] = Field(
None, description="Mexican merchandise value in foreign currency"
0.00, description="Mexican merchandise value in foreign currency"
)
mexican_value_mc: Optional[Decimal] = Field(
None, description="Mexican merchandise value in third currency"
0.00, description="Mexican merchandise value in third currency"
)
national_packaging_mn: Optional[Decimal] = Field(
None, description="National packaging in MXN"
0.00, description="National packaging in MXN"
)
national_packaging_me: Optional[Decimal] = Field(
None, description="National packaging in foreign currency"
0.00, description="National packaging in foreign currency"
)
national_packaging_mc: Optional[Decimal] = Field(
None, description="National packaging in third currency"
0.00, description="National packaging in third currency"
)
freight: Optional[Decimal] = Field(None, description="Freight cost")
insurance: Optional[Decimal] = Field(None, description="Insurance cost")
insurance_value: Optional[Decimal] = Field(None, description="Insurance value")
packaging: Optional[Decimal] = Field(None, description="Packaging")
other_increments: Optional[Decimal] = Field(None, description="Other increments")
other_deductibles: Optional[Decimal] = Field(None, description="Other deductibles")
freight: Optional[Decimal] = Field(0.00, description="Freight cost")
insurance: Optional[Decimal] = Field(0.00, description="Insurance cost")
insurance_value: Optional[Decimal] = Field(0.00, description="Insurance value")
packaging: Optional[Decimal] = Field(0.00, description="Packaging")
other_increments: Optional[Decimal] = Field(0.00, description="Other increments")
other_deductibles: Optional[Decimal] = Field(0.00, description="Other deductibles")
total_increments_mn: Optional[Decimal] = Field(
None, description="Total increments in MXN"
0.00, description="Total increments in MXN"
)
total_increments_me: Optional[Decimal] = Field(
None, description="Total increments in foreign currency"
0.00, description="Total increments in foreign currency"
)
iva_mn: Optional[Decimal] = Field(None, description="IVA in MXN")
iva_me: Optional[Decimal] = Field(None, description="IVA in foreign currency")
iva_mc: Optional[Decimal] = Field(None, description="IVA in third currency")
iva_factor: Optional[Decimal] = Field(None, description="IVA factor")
iva_mn: Optional[Decimal] = Field(0.00, description="IVA in MXN")
iva_me: Optional[Decimal] = Field(0.00, description="IVA in foreign currency")
iva_mc: Optional[Decimal] = Field(0.00, description="IVA in third currency")
iva_factor: Optional[Decimal] = Field(0.00, description="IVA factor")
tax_value_me: Optional[Decimal] = Field(
None, description="Tax value in foreign currency"
0.00, description="Tax value in foreign currency"
)
seal_value_2500: Optional[bool] = Field(None, description="Seal value 2500")
total_quantity: Optional[Decimal] = Field(None, description="Total quantity")
gross_weight: Optional[Decimal] = Field(None, description="Gross weight")
net_weight: Optional[Decimal] = Field(None, description="Net weight")
bundle_count: Optional[int] = Field(None, description="Bundle count")
weight_factor: Optional[Decimal] = Field(None, description="Weight factor")
total_quantity: Optional[Decimal] = Field(0.00, description="Total quantity")
gross_weight: Optional[Decimal] = Field(0.00, description="Gross weight")
net_weight: Optional[Decimal] = Field(0.00, description="Net weight")
bundle_count: Optional[int] = Field(0, description="Bundle count")
weight_factor: Optional[Decimal] = Field(0.00, description="Weight factor")
class InvoiceLogisticsBase(BaseModel):
@@ -485,7 +485,7 @@ class InvoiceHeaderUpdate(InvoiceHeaderBase):
operation_type: Optional[OperationType] = None
compliance_mx: Optional[InvoiceComplianceMxUpdate] = None
financials: Optional[InvoiceFinancialsUpdate] = None
financials: Optional[InvoiceFinancialsUpdate]
logistics: Optional[InvoiceLogisticsUpdate] = None
details: Optional[List[InvoiceSalesDetailsUpdate]] = None
collections: Optional[List[InvoiceCollectionsUpdate]] = None

View File

@@ -5,8 +5,10 @@ from sqlalchemy import func
from core.exceptions import ErrorCollector, DuplicateResourceException
from core.context import get_user_context
from .common.mappers import clean_dict
from .imports.temporary.validators.create import validate_create
from .imports.temporary.validators.update import validate_update
from .imports.validators.create import validate_create as validate_create_import
from .imports.validators.update import validate_update as validate_update_import
from .exports.validators.create import validate_create as validate_create_export
from .exports.validators.update import validate_update as validate_update_export
from .common.common_validators import invoice_exists
from . import models, schemas
@@ -124,7 +126,7 @@ class InvoiceService:
# Validar si la factura ya existe
invoice_exists(db, invoice_data.invoice_number, tenant_id, company_id, errors)
validate_create(db, invoice_data, tenant_id, company_id, errors)
validate_create_import(db, invoice_data, tenant_id, company_id, errors)
# Si hay errores, lanzar excepción ANTES de intentar crear
errors.raise_if_errors("Error al crear la factura")
@@ -273,7 +275,11 @@ class InvoiceService:
invoice_data.invoice_number,
f"Ya existe otra factura con el número '{invoice_data.invoice_number}'",
)
validate_update(invoice_data, invoice, errors)
if invoice_data.operation_type == "exp":
validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors)
else:
validate_update_import(db, invoice_data, invoice, tenant_id, company_id, errors)
# Si hay errores, lanzar excepción ANTES de actualizar
errors.raise_if_errors("Error al actualizar la factura")

View File

@@ -109,6 +109,10 @@
formData.import_tariff_type = snap.import_tariff_type ?? '';
formData.export_tariff_code = snap.export_tariff_code ?? '';
formData.export_tariff_type = snap.export_tariff_type ?? '';
// Reiniciar banderas de error al cargar datos
showErrors = false;
validationErrors = {};
} else {
// Reset form when initialData is null (new class)
formData.class_code = '';
@@ -128,12 +132,17 @@
formData.import_tariff_type = '';
formData.export_tariff_code = '';
formData.export_tariff_type = '';
// Reiniciar banderas de error al limpiar formulario
showErrors = false;
validationErrors = {};
}
});
// Estado de validación
let validationErrors = $state<Record<string, string>>({});
let showErrors = $state(false);
let isSubmitting = $state(false);
// Estado de diálogos
let showMaterialDialog = $state(false);
@@ -262,6 +271,37 @@
searchMaterial = '';
}
// Fix 1: Buscar descripción de Tipo de Activo Fijo al perder el foco
async function handleMaterialBlur() {
validateField('material_key');
const code = formData.material_key?.trim().toUpperCase();
if (!code) {
formData.material_description = '';
return;
}
// Primero buscar en la caché local (si ya se cargó el catálogo)
if (materialTypes.length > 0) {
const found = materialTypes.find((m) => m.key.toUpperCase() === code);
if (found) {
formData.material_key = found.key;
formData.material_description = found.description;
return;
}
}
// Si no está en caché, consultar el API
try {
const response = await materialTypesApi.get(code);
if (response.data) {
formData.material_key = response.data.key;
formData.material_description = response.data.description;
} else {
formData.material_description = '(Código no encontrado)';
}
} catch {
formData.material_description = '(Código no encontrado)';
}
}
async function openUnitOfMeasureSearch() {
showUnitDialog = true;
searchUnit = '';
@@ -276,6 +316,39 @@
searchUnit = '';
}
// Fix 1: Buscar descripción de U.M. Comercial al perder el foco
async function handleUnitBlur() {
validateField('unit_of_measure');
const code = formData.unit_of_measure?.trim().toUpperCase();
if (!code) {
formData.unit_of_measure_description = '';
return;
}
// Primero buscar en la caché local (si ya se cargó el catálogo)
await loadUnitsOfMeasure();
const found = unitsOfMeasureData.find((u) => u.code.toUpperCase() === code);
if (found) {
formData.unit_of_measure = found.code;
formData.unit_of_measure_description = found.description;
formData.unit_measure_key = found.claveMexicana;
} else {
formData.unit_of_measure_description = '(Código no encontrado)';
}
}
// Fix 2: Máscara para Fracción Americana (formato 0000.00.00.00 = 13 chars)
function formatUSFraction(event: Event) {
const input = event.target as HTMLInputElement;
// Extraer solo dígitos
const digits = input.value.replace(/\D/g, '').slice(0, 10);
// Construir la máscara insertando puntos en las posiciones correctas
let masked = digits;
if (digits.length > 4) masked = digits.slice(0, 4) + '.' + digits.slice(4);
if (digits.length > 6) masked = digits.slice(0, 4) + '.' + digits.slice(4, 6) + '.' + digits.slice(6);
if (digits.length > 8) masked = digits.slice(0, 4) + '.' + digits.slice(4, 6) + '.' + digits.slice(6, 8) + '.' + digits.slice(8);
formData.us_fraction = masked;
}
async function openUSFractionSearch() {
showUSFractionDialog = true;
searchUSFraction = '';
@@ -451,7 +524,8 @@
// Validar campo individual (para validación en blur)
function validateField(fieldName: string) {
if (!showErrors) return; // Solo validar si ya se intentó guardar
// Bloquear validación repetida si: a) no se ha intentado guardar o b) está guardando
if (!showErrors || isSubmitting) return;
const errors = { ...validationErrors };
@@ -496,22 +570,28 @@
validationErrors = errors;
}
function handleSave() {
// Activar visualización de errores
showErrors = true;
async function handleSave() {
// Validar formulario
if (!validateForm()) {
showErrors = true;
toast.error('Por favor, complete todos los campos obligatorios');
return;
}
// Activar bandera the submitting para que no se validen inputs al azar en el blur
isSubmitting = true;
// Tomamos una copia muerta de los datos actuales
const dataToSave = $state.snapshot(formData);
// Ejecutamos el onSave pasándole la copia
if (onSave) {
onSave(dataToSave);
try {
// Ejecutamos el onSave pasándole la copia. Await en caso de que devuelva promesa.
if (onSave) {
await onSave(dataToSave);
}
} finally {
// Liberar el estado de subida solo después de que acabe todo el flujo
isSubmitting = false;
}
}
@@ -522,9 +602,17 @@
}
// Escuchar el evento de guardado del padre
import { onDestroy } from 'svelte';
if (typeof document !== 'undefined') {
document.addEventListener('save-form', handleSave);
}
onDestroy(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('save-form', handleSave);
}
});
</script>
<div class="space-y-3">
@@ -569,7 +657,7 @@
? 'border-red-500 focus-visible:ring-red-500'
: ''}"
maxlength={10}
onblur={() => validateField('material_key')}
onblur={handleMaterialBlur}
/>
<Button type="button" variant="outline" size="icon" onclick={openMaterialSearch}>
<Folder class="h-4 w-4" />
@@ -629,7 +717,7 @@
? 'border-red-500 focus-visible:ring-red-500'
: ''}"
maxlength={5}
onblur={() => validateField('unit_of_measure')}
onblur={handleUnitBlur}
/>
<Button type="button" variant="outline" size="icon" onclick={openUnitOfMeasureSearch}>
<Folder class="h-4 w-4" />
@@ -676,9 +764,10 @@
<Input
id="us_fraction"
bind:value={formData.us_fraction}
placeholder="Fracción americana"
class="flex-1"
maxlength={16}
placeholder="0000.00.00.00"
class="flex-1 font-mono tracking-wider"
maxlength={13}
oninput={formatUSFraction}
/>
<Button type="button" variant="outline" size="icon" onclick={openUSFractionSearch}>
<Folder class="h-4 w-4" />

View File

@@ -98,7 +98,7 @@
</script>
<!-- Layout de 2 columnas compacto -->
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Columna Izquierda -->
<div class="space-y-3 rounded-md border p-3">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Información General</h4>
@@ -148,7 +148,7 @@
</div>
<!-- DESTINO/ORIGEN Y ES MIXTO -->
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="destino_origen" class="text-xs">Destino/Origen:</Label>
<Input
@@ -165,7 +165,7 @@
<RadioGroup
value={String(formData.is_mixed)}
onValueChange={(v: string | undefined) => (formData.is_mixed = v === 'true')}
class="flex gap-4"
class="flex flex-wrap gap-4"
>
<div class="flex items-center gap-2">
<RadioGroupItem value="true" id="mixed_si" />
@@ -205,7 +205,7 @@
<div class="space-y-3 pt-1">
<div class="space-y-1.5">
<Label class="text-xs">Razón de exportación:</Label>
<RadioGroup bind:value={formData.reason_export} class="flex gap-4">
<RadioGroup bind:value={formData.reason_export} class="flex flex-wrap gap-4">
<div class="flex items-center gap-2">
<RadioGroupItem value="1" id="reason_vendido" />
<Label for="reason_vendido" class="text-xs text-muted-foreground">Vendido</Label>
@@ -245,7 +245,7 @@
{/if}
<!-- CHECKBOXES -->
<div class="grid grid-cols-2 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
{#if operationType !== 1 && invoiceType !== 'CR'}
<div class="flex items-center gap-2">
<Checkbox id="fue_revisado" bind:checked={formData.fue_revisado_equipo} />

View File

@@ -302,12 +302,12 @@
</script>
<!-- Layout de 2 columnas compacto -->
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Columna Izquierda: Clientes - Proveedores - Agente Aduanal -->
<div class="space-y-3 rounded-md border p-3">
{#if invoiceType !== 'MEX'}
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Datos del pedimento</h4>
<div class="grid grid-cols-4 gap-3 text-xs">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<span class="text-muted-foreground">Fecha del:</span>
<p class="font-medium">{formData.fecha_pedimento_del || '-'}</p>
@@ -330,7 +330,8 @@
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Clientes - Proveedores - Agente Aduanal
</h4>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<div class="grid grid-cols-[1fr_4fr_4fr] items-center gap-x-2 gap-y-2 min-w-0">
<!-- Row 1: Proveedor / Exportador -->
<Select.Root
type="single"
value={formData.provider_header || providerHeaderOptions[0]?.value || ''}
@@ -338,7 +339,7 @@
formData.provider_header = v ?? '';
}}
>
<Select.Trigger id="provider_header" class="h-7 max-w-[250px] min-w-[125px] text-xs">
<Select.Trigger id="provider_header" class="h-7 w-full text-xs">
<span class="truncate">
{providerHeaderOptions.find(
(o) => o.value === (formData.provider_header || providerHeaderOptions[0]?.value)
@@ -360,7 +361,7 @@
formData.provider_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="provider_id" class="h-7 max-w-[250px] min-w-[120px] text-xs">
<Select.Trigger id="provider_id" class="h-7 w-full text-xs">
<span class="truncate">
{#if formData.provider_id}
{providers.find((p) => p.id === formData.provider_id)?.name || 'Selecciona...'}
@@ -377,10 +378,9 @@
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
</div>
<span class="text-red-500 font-bold">*</span>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<!-- Row 2: Consignado a / Vendido a / Importador -->
<Select.Root
type="single"
value={formData.sold_to_header || soldToHeaderOptions[0]?.value || ''}
@@ -388,7 +388,7 @@
formData.sold_to_header = v ?? '';
}}
>
<Select.Trigger id="sold_to_header" class="h-7 max-w-[250px] min-w-[125px] text-xs">
<Select.Trigger id="sold_to_header" class="h-7 w-full text-xs">
<span class="truncate">
{soldToHeaderOptions.find(
(o) => o.value === (formData.sold_to_header || soldToHeaderOptions[0]?.value)
@@ -410,7 +410,7 @@
formData.sold_to_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="sold_to_id" class="h-7 max-w-[250px] min-w-[120px] text-xs">
<Select.Trigger id="sold_to_id" class="h-7 w-full text-xs">
<span class="truncate">
{#if formData.sold_to_id}
{clients.find((c) => c.id === formData.sold_to_id)?.name || 'Selecciona...'}
@@ -427,10 +427,9 @@
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
</div>
<span class="text-red-500 font-bold">*</span>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<!-- Row 3: Enviado a -->
<Select.Root
type="single"
value={formData.shipped_to_header || shippedToHeaderOptions[0]?.value || ''}
@@ -438,7 +437,7 @@
formData.shipped_to_header = v ?? '';
}}
>
<Select.Trigger id="shipped_to_header" class="h-7 text-xs min-w-[125px] max-w-[250px]">
<Select.Trigger id="shipped_to_header" class="h-7 w-full text-xs">
<span class="truncate">
{shippedToHeaderOptions.find(
(o) => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value)
@@ -460,7 +459,7 @@
formData.shipped_to_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="shipped_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<Select.Trigger id="shipped_to_id" class="h-7 w-full text-xs">
<span class="truncate">
{#if formData.shipped_to_id}
{allClientsProviders.find((cp) => cp.id === formData.shipped_to_id)?.name ||
@@ -478,7 +477,7 @@
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
<span class="text-red-500 font-bold">*</span>
</div>
<div class="space-y-1.5">
@@ -542,7 +541,7 @@
<div class="space-y-3">
<!-- Tipo de Moneda - Pesos Netos y Brutos -->
<div class="border rounded-md p-3 space-y-2">
<div class="flex justify-between">
<div class="flex flex-wrap justify-between gap-1">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de Moneda - Pesos Netos y Brutos
</h4>
@@ -562,7 +561,7 @@
<!-- Radio buttons para tipo de moneda -->
<div class="space-y-1.5">
<RadioGroup.Root bind:value={formData.currency} class="flex gap-4">
<RadioGroup.Root bind:value={formData.currency} class="flex flex-wrap gap-x-4 gap-y-2">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
<Label for="currency-foreign" class="text-xs font-normal cursor-pointer"
@@ -608,7 +607,7 @@
</Select.Root>
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div class="space-y-1.5">
<Label for="weight_type" class="text-xs">Tipo Peso:</Label>
<Select.Root
@@ -675,7 +674,7 @@
<div class="border rounded-md p-3 space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Transportista</h4>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
{#if invoiceType !== 'MEX'}
<div class="space-y-1.5">
<Label for="carrier_id" class="text-xs">Transportista:</Label>
@@ -773,7 +772,7 @@
</Select.Root>
</div>
<div class="grid grid-cols-4 gap-2">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div class="space-y-1.5">
<Label for="transport_type" class="text-xs">Tipo Transporte:</Label>
<Select.Root
@@ -799,7 +798,7 @@
</Select.Root>
</div>
<div class="col-span-3 space-y-1.5">
<div class="col-span-1 sm:col-span-3 space-y-1.5">
<Label for="transport_num" class="text-xs">Placas:</Label>
<Select.Root
type="single"

View File

@@ -124,8 +124,8 @@
</script>
<!-- Datos Principales en una fila compacta (reusable across tabs) -->
<div class="grid grid-cols-12 items-end gap-3 pb-3">
<div class="col-span-1 space-y-1">
<div class="flex flex-wrap items-end gap-3 pb-3">
<div class="min-w-[100px] flex-1 space-y-1">
<Label for="operation_type" class="text-xs"
>Tipo de Operación<span class="text-red-500">*</span></Label
>
@@ -147,7 +147,7 @@
</Select.Content>
</Select.Root>
</div>
<div class="col-span-1 space-y-1">
<div class="min-w-[100px] flex-1 space-y-1">
<Label for="operation_type" class="text-xs"
>Tipo de factura <span class="text-red-500">*</span></Label
>
@@ -174,21 +174,31 @@
</div>
{#if invoiceType !== 'MEX'}
<div class="col-span-1 flex flex-col items-center space-y-1 pb-1">
<div class="flex flex-col items-center space-y-1 pb-1">
<Label for="is_pedimento_pending" class="text-xs">Pedimento Pendiente?</Label>
<Switch
id="is_pedimento_pending"
checked={formData.is_pedimento_pending}
onCheckedChange={(checked) => {
formData.is_pedimento_pending = checked;
if (checked) {
formData.pedimento_id = null;
formData.pedimento = '';
formData.remesa = '';
formData.fecha_pedimento_del = '';
formData.fecha_pedimento_al = '';
formData.clave_pedimento = '';
formData.regimen_pedimento = '';
}
}}
/>
</div>
<div class="col-span-2 space-y-1">
<div class="min-w-[75px] flex-[2] space-y-1">
<Label for="pedimento" class="text-xs">Pedimento</Label>
<Select.Root
<Select.Root
type="single"
value={formData.pedimento_id ? String(formData.pedimento_id) : ''}
disabled={formData.is_pedimento_pending}
onValueChange={(v) => {
formData.pedimento_id = v ? parseInt(v) : null;
if (v) {
@@ -214,13 +224,13 @@
</Select.Root>
</div>
<div class="col-span-1 space-y-1">
<div class="min-w-[80px] flex-1 space-y-1">
<Label for="remesa" class="text-xs">Remesa</Label>
<Input id="remesa" bind:value={formData.remesa} class="h-8 text-sm" />
</div>
{/if}
<div class="col-span-2 space-y-1">
<div class="min-w-[140px] flex-[2] space-y-1">
<Label for="invoice_number" class="text-xs"
>Núm. Factura <span class="text-red-500">*</span></Label
>
@@ -232,7 +242,7 @@
/>
</div>
<div class="col-span-2 space-y-1">
<div class="min-w-[140px] flex-[2] space-y-1">
<Label for="invoice_date" class="text-xs">
{formData.operation_type === 'exp' || invoiceType === 'CR'
? 'Fecha'
@@ -244,17 +254,17 @@
<Input id="invoice_date" type="date" bind:value={formData.invoice_date} class="h-8 text-sm" />
</div>
<div class="col-span-2 space-y-1">
<div class="min-w-[140px] flex-[2] space-y-1">
<Label for="emission_date" class="text-xs">Fecha Emisión</Label>
<Input id="emission_date" type="date" bind:value={formData.emission_date} class="h-8 text-sm" />
</div>
{#if invoiceType === 'MEX'}
<div class="col-span-1 space-y-1">
<div class="min-w-[90px] flex-1 space-y-1">
<Label for="iva_factor" class="text-xs">Factor IVA</Label>
<Input id="iva_factor" bind:value={formData.iva_factor} class="h-8 text-sm" />
</div>
<div class="col-span-2 space-y-1">
<div class="min-w-[140px] flex-[2] space-y-1">
<Label for="alternate_invoice" class="text-xs">Factura Alterna</Label>
<Input id="alternate_invoice" bind:value={formData.alternate_invoice} class="h-8 text-sm" />
</div>

View File

@@ -1016,7 +1016,7 @@
</Button>
<Button size="sm" onclick={handleAdd} type="button">
<Plus class="mr-1 h-4 w-4" />
Agregar Item
Agregar Partidas
</Button>
</div>
</div>

View File

@@ -96,7 +96,7 @@
}
</script>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Left Column: Observations and Catalog Info -->
<div class="space-y-4">
<!-- Observations Section -->
@@ -249,7 +249,7 @@
{#if operationType === 1 || invoiceType === 'CR'}
<!-- Precintos & Tipo Mov Group (No Title) -->
<div class="grid grid-cols-2 gap-4 rounded-md border bg-muted/20 p-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 rounded-md border bg-muted/20 p-3">
<div class="space-y-1">
<Label for="num_seals_exp" class="text-xs font-semibold">Número de Precinto:</Label>
<Select.Root
@@ -290,7 +290,7 @@
<h4 class="mb-1 border-b pb-2 text-xs font-bold text-muted-foreground uppercase">
Factura Alterna & Flags
</h4>
<div class="grid grid-cols-2 gap-x-6 gap-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
<div class="space-y-1.5">
<Label for="alternate_invoice_exp" class="text-xs font-semibold">Factura Alterna:</Label
>
@@ -520,8 +520,8 @@
<!-- Extra Bottom Row for Import Mode specific fields (Original Layout preserved for Import) -->
{#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR'}
<div class="col-span-2 space-y-3 rounded-md border p-3">
<div class="grid grid-cols-4 gap-3">
<div class="col-span-1 md:col-span-2 space-y-3 rounded-md border p-3">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="space-y-1">
<Label for="num_seals" class="text-xs">Num Precintos:</Label>
<Select.Root

View File

@@ -107,7 +107,7 @@
</script>
{#if invoiceType !== 'MEX'}
<div class="grid grid-cols-3 grid-rows-1 gap-3">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="space-y-3 rounded-md border p-3">
<!-- Modo de Transporte -->
<div class="space-y-2">
@@ -152,7 +152,7 @@
<RadioGroup.Root
value={String(formData.is_mixed)}
onValueChange={(v: string | undefined) => (formData.is_mixed = v === 'true')}
class="flex gap-4"
class="flex flex-wrap gap-4"
>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="true" id="mixed-si" class="h-4 w-4" />
@@ -248,7 +248,7 @@
</div>
</div>
<div class="col-span-2 space-y-3 rounded-md border p-3">
<div class="col-span-1 md:col-span-2 space-y-3 rounded-md border p-3">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- ID Relación Docs -->
<div class="space-y-2">

View File

@@ -1002,7 +1002,7 @@
}}
/>
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
class="fixed right-0 bottom-0 left-0 z-[5] md:ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur md:group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
<div class="mx-auto max-w-[1400px] space-y-4 px-4 py-4">
<!-- Tabs Navigation -->

View File

@@ -532,10 +532,6 @@
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
<Button size="sm" onclick={handleCreateClick}>
<Plus class="mr-1 h-4 w-4" />
Insertar
</Button>
<Button variant="outline" size="sm" onclick={handleEditSelected} disabled={!hasSelection}>
<Edit size={16} class="mr-1" />
Editar

View File

@@ -975,10 +975,6 @@
}
if (!response.data?.id) throw new Error('No se recibió el ID del pedimento creado');
newPedimentoId = response.data.id;
// Redirigir a la página de edición
await goto(`/dashboard/pedimentos/edit/${newPedimentoId}`);
return;
} else {
// Actualizar pedimento existente con todos sus sub-recursos
const response = await pedimentosApi.update(
@@ -988,15 +984,11 @@
);
if (response.error) throw new Error(response.error);
// Recargar los datos del pedimento desde el servidor
// Forzar recarga de datos del router (útil si hay navegación con SvelteKit)
try {
await invalidateAll();
// Forzar recarga de datos esperando un tick
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (invalidateError) {
console.error('❌ Error en invalidateAll:', invalidateError);
// No lanzar el error, solo loguearlo
// El pedimento ya se guardó exitosamente en el backend
}
}
@@ -1005,6 +997,9 @@
? 'Pedimento creado exitosamente'
: 'Todos los cambios se guardaron correctamente'
);
// Redirigir siempre de vuelta al listado principal una vez guardado correctamente
await goto('/dashboard/pedimentos');
} catch (e) {
if (e instanceof Error) {
if (e.message.includes('401')) {