diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py
index afba527a..e0ae5137 100644
--- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py
+++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py
@@ -109,18 +109,30 @@ def validate_common(
code="NOT_FOUND",
value=invoice.compliance_mx.pedimento_id,
)
+ return # Stop here if pedimento not found
if not invoice.compliance_mx.is_regime_change:
- if not pedimento.operation_type == "imp":
+ # Validar que el tipo de operación del pedimento coincida con el de la factura
+ # Nota: Para UPDATES, operation_type puede ser None. En ese caso usamos el del pedimento (asumimos que es correcto)
+ # o podríamos buscarlo en existing_invoice si tuviéramos acceso aquí.
+ current_operation = invoice.operation_type or pedimento.operation_type
+
+ if pedimento.operation_type != current_operation:
+ operation_labels = {"imp": "Importación", "exp": "Exportación"}
+ expected_label = operation_labels.get(current_operation, current_operation)
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"],
+ message=f"El Pedimento seleccionado no corresponde a una {expected_label}.",
+ solution=[f"Selecciona un Pedimento de {expected_label}"],
code="INVALID_OPERATION_TYPE",
value=pedimento.operation_type,
)
else:
- if pedimento.regime in ["EXD", "ETE", "ETR"]:
+ # Validar regímenes incompatibles
+ export_only_regimes = ["EXD", "ETE", "ETR"]
+ import_only_regimes = ["IMD"] # Usualmente IMD es solo importación definitiva
+
+ if current_operation == "imp" and 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.",
@@ -128,7 +140,18 @@ def validate_common(
code="INVALID_REGIME",
value=pedimento.regime,
)
- else:
+ elif current_operation == "exp" and pedimento.regime in import_only_regimes:
+ errors.add_error(
+ field="compliance_mx.pedimento_id",
+ message="El Pedimento seleccionado corresponde a una Importación, no a una Exportación.",
+ solution=["Selecciona un Pedimento de Exportació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",
@@ -140,43 +163,42 @@ def validate_common(
value=invoice.document_type,
)
else:
- if pedimento.operation_type != 2:
+ # 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 Importacion Definitiva.",
- solution=["Selecciona un Pedimento de Importacion Definitiva"],
+ 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":
+ 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 {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number} no corresponde a una Importacion Definitiva.",
- solution=["Selecciona un Pedimento de Importacion Definitiva"],
- code="INVALID_REGIME",
- value=pedimento.regime,
+ 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,
)
- else:
- 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:
- 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
@@ -207,22 +229,24 @@ def validate_common(
value=invoice.invoice_date,
)
- if not invoice.compliance_mx.remesa:
- errors.add_error(
- field="compliance_mx.remesa",
- message="El campo Remesa es obligatorio cuando se asocia un Pedimento.",
- 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,
- )
+ # 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)
@@ -242,57 +266,32 @@ def validate_common(
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 not invoice.compliance_mx.is_pedimento_pending:
- errors.add_error(
- field="compliance_mx.pedimento_id",
- message="El campo Pedimento es obligatorio cuando no se indica que el Pedimento está pendiente.",
- solution=[
- "Proporciona un ID de Pedimento",
- "Marca el campo Pedimento Pendiente",
- ],
- code="REQUIRED_FIELD",
- value=invoice.compliance_mx.pedimento_id,
- )
-
- if invoice.compliance_mx.remesa and not invoice.compliance_mx.pedimento_id:
- errors.add_error(
- field="compliance_mx.pedimento_id",
- message="El campo Pedimento es obligatorio cuando se proporciona Remesa.",
- solution=["Proporciona un ID de Pedimento"],
- code="REQUIRED_FIELD",
- value=invoice.compliance_mx.pedimento_id,
- )
-
- if len(invoice.invoice_number) > 100:
- errors.add_error(
- field="invoice_number",
- message="El número de factura excede la longitud máxima de 100 caracteres.",
- solution=["Acorta el número de factura a 100 caracteres o menos"],
- code="MAX_LENGTH_EXCEEDED",
- value=invoice.invoice_number,
- )
-
- 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
+ # 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"]:
@@ -395,62 +394,47 @@ def validate_common(
value=invoice.compliance_mx.customs_broker_id,
)
- # Validar transportista solo si se proporciona
- if invoice.logistics.carrier_id:
- carrier_exists = (
- db.query(ClientProvider)
- .filter(
- ClientProvider.id == invoice.logistics.carrier_id,
- ClientProvider.tenant_id == tenant_id,
- ClientProvider.company_id == company_id,
- )
- .first()
- )
- if not carrier_exists:
+ 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.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'.",
+ field="logistics.transport_type",
+ message="El Tipo de Transporte proporcionado no es válido.",
solution=[
- "Elimina el Número de Transporte o selecciona un Tipo de Transporte válido"
+ f"Selecciona un Tipo de Transporte válido: {[t.value for t in TransportType]}"
],
- code="INVALID_VALUE",
- value=invoice.logistics.transport_num,
+ code="INVALID_TRANSPORT_TYPE",
+ value=invoice.logistics.transport_type,
)
else:
if (
- not invoice.logistics.transport_num
- and invoice.logistics.transport_type != "none"
+ invoice.logistics.transport_type == "none"
+ and invoice.logistics.transport_num
):
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",
+ 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"
diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py
index 56800ff5..b52049e7 100644
--- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py
+++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py
@@ -54,34 +54,38 @@ def validate_create(db: Session, invoice: InvoiceHeaderCreate, tenant_id: int, c
"""Se retorna por que fallaron las validaciones generales"""
return
- if not invoice.compliance_mx.pedimento_id:
- invoice.compliance_mx.remesa = None
-
- if not invoice.financials.exchange_rate:
- invoice.financials.exchange_rate = db.query(ExchangeRate.value).filter(ExchangeRate.date == invoice.invoice_date).scalar()
+ 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 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
-
- if not invoice.financials.currency:
- invoice.financials.currency = "foreign"
-
- if invoice.financials.currency == "local":
- invoice.financials.currency_type = "MXN"
- elif invoice.financials.currency_type == "foreign":
- invoice.financials.currency = "USD"
- elif invoice.financials.currency_type == "manual":
- invoice.financials.currency_type = invoice.financials.currency_type.upper()
-
- invoice.logistics.incoterm = (invoice.logistics.incoterm or "").upper()
-
- if not invoice.logistics.weight_type:
- invoice.logistics.weight_type = "kgs"
+ 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()
diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py
index 8e6f35d5..402054a1 100644
--- a/backend/api/v1/modules/a76/invoices/models.py
+++ b/backend/api/v1/modules/a76/invoices/models.py
@@ -488,6 +488,9 @@ class InvoiceFinancials(Base, TenantScopedMixin, TimestampMixin):
other_increments: Mapped[Optional[float]] = mapped_column(
Numeric(19, 8), default=0, server_default="0"
) # OTROSINCREMENTA / Otros incrementables
+ other_deductibles: Mapped[Optional[float]] = mapped_column(
+ Numeric(19, 8), default=0, server_default="0"
+ ) # OTROSDEDUCIBLES / Otros deducibles
total_increments_mn: Mapped[Optional[float]] = mapped_column(
Numeric(23, 8), default=0, server_default="0"
) # TOTALINCREMMN / Total incrementables MN
diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py
index 77312a77..1c3995df 100644
--- a/backend/api/v1/modules/a76/invoices/schemas.py
+++ b/backend/api/v1/modules/a76/invoices/schemas.py
@@ -1,7 +1,7 @@
from typing import Literal, Optional, List
from datetime import datetime, date
from decimal import Decimal
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from .models import (
DestinationOriginCove,
OperationType,
@@ -119,7 +119,7 @@ class InvoiceComplianceMxBase(BaseModel):
sold_to_id: Optional[int] = Field(None, description="Sold to ID")
shipped_to_header: Optional[str] = Field(None, max_length=20, description="Shipped to header")
shipped_to_id: Optional[int] = Field(None, description="Shipped to ID")
- shipped_by_header: Optional[int] = Field(
+ shipped_by_header: Optional[str] = Field(
None, max_length=20, description="Shipped by header"
)
shipped_by_id: Optional[int] = Field(None, description="Shipped by ID")
@@ -248,6 +248,7 @@ class InvoiceFinancialsBase(BaseModel):
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")
total_increments_mn: Optional[Decimal] = Field(
None, description="Total increments in MXN"
)
@@ -318,6 +319,13 @@ class InvoiceLogisticsBase(BaseModel):
weight_type: WeightUnit = Field(
default="kgs", max_length=3, description="Weight type"
)
+
+ @field_validator("weight_type", mode="before")
+ @classmethod
+ def normalize_weight_type(cls, v):
+ if isinstance(v, str):
+ return v.lower()
+ return v
container_types: Optional[str] = Field(
None, max_length=500, description="Container types"
)
diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py
index 8ef3964e..273049af 100644
--- a/backend/api/v1/modules/a76/invoices/services.py
+++ b/backend/api/v1/modules/a76/invoices/services.py
@@ -301,6 +301,23 @@ class InvoiceService:
new_financials = models.InvoiceFinancials(**financials_dict)
db.add(new_financials)
+ # Update logistics if provided
+ if invoice_data.logistics is not None:
+ if invoice.logistics:
+ for key, value in invoice_data.logistics.model_dump(
+ exclude_unset=True
+ ).items():
+ if value == "":
+ value = None
+ setattr(invoice.logistics, key, value)
+ else:
+ logistics_dict = invoice_data.logistics.model_dump()
+ logistics_dict["invoice_id"] = invoice.id
+ logistics_dict["tenant_id"] = tenant_id
+ logistics_dict["company_id"] = company_id
+ new_logistics = models.InvoiceLogistics(**logistics_dict)
+ db.add(new_logistics)
+
db.commit()
db.refresh(invoice)
return invoice
diff --git a/frontend/src/lib/api/dashboard/a76/invoices.ts b/frontend/src/lib/api/dashboard/a76/invoices.ts
index b2a855c0..6b00d38b 100644
--- a/frontend/src/lib/api/dashboard/a76/invoices.ts
+++ b/frontend/src/lib/api/dashboard/a76/invoices.ts
@@ -161,6 +161,12 @@ export interface InvoiceLogistics {
is_ctm_process?: string | null;
is_subdivision?: boolean | null;
acts_as_cd?: boolean | null;
+ equipment_reviewed?: boolean | null;
+ pedimento_arrived?: boolean | null;
+ green_light_mx?: boolean | null;
+ green_light_us?: boolean | null;
+ red_light_mx?: boolean | null;
+ red_light_us?: boolean | null;
}
export interface InvoiceSalesDetails {
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/ManifestSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/ManifestSelectorModal.svelte
new file mode 100644
index 00000000..daeba6db
--- /dev/null
+++ b/frontend/src/lib/components/dashboard/invoices/edit/ManifestSelectorModal.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+ {#if manifests.length === 0}
+ Número de Manifiesto
+ Descripción
+
+
+ {:else}
+ {#each manifests as manifest}
+
+ {#if loading}
+ Buscando manifiestos...
+ {:else}
+ No se encontraron resultados
+ {/if}
+
+ handleSelect(manifest)}
+ >
+
+ {/each}
+ {/if}
+
+
+ {manifest.manifest_number}
+
+
+ {manifest.description || '-'}
+
+
- {#if InvoiceTopFieldsFormData?.operation_type === 'exp'} - Factura para salida de mercancías del territorio nacional. - {:else if InvoiceTopFieldsFormData?.operation_type === 'imp'} - Factura para entrada de mercancías al territorio nacional. - {:else} - Por favor, seleccione el tipo de operación para continuar. - {/if} -
-