feat: Enhance invoice validation and calculations

- Added new validations for pedimento regimes and compliance checks in common_validators.py.
- Implemented missing date checks for consolidated pedimentos.
- Introduced validation for shipped_by_id and manifest_number in invoice compliance.
- Refactored create_validators.py by removing the file as it was no longer needed.
- Updated update validators for exports and imports to use invoice_data instead of invoice.
- Modified schemas.py to set default values for financial fields to avoid None values.
- Created calculations.py to handle invoice calculations, including total increments and regime changes.
- Improved layout responsiveness in various Svelte components for better user experience.
This commit is contained in:
2026-03-10 11:29:38 -05:00
parent a578135183
commit c2d691a4ad
13 changed files with 268 additions and 124 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

@@ -6,11 +6,14 @@ 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
@@ -208,13 +211,20 @@ def validate_common(
value=pedimento.operation_type,
)
else:
# Validar regímenes incompatibles
only_regimes = ["EXD", "ETE", "ETR"]
# 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 pedimento.regime in only_regimes:
if not is_valid:
errors.add_error(
field="compliance_mx.pedimento_id",
message=f"El Pedimento seleccionado corresponde a una Exportación, no a una {operacion}.",
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,
@@ -272,24 +282,33 @@ def validate_common(
)
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 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 invoice_date < entry_date or invoice_date > 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}.",
@@ -444,6 +463,25 @@ def validate_common(
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:
@@ -463,12 +501,27 @@ def validate_common(
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.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(
@@ -614,4 +667,25 @@ def validate_common(
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

@@ -49,7 +49,7 @@ def validate_update(
}
validate_required_fields_by_operation(
invoice=invoice_dict,
invoice_data=invoice_dict,
operation_type=invoice.operation_type or (existing_invoice.operation_type or 'imp'),
errors=errors
)
@@ -211,14 +211,21 @@ def validate_update(
elif hasattr(invoice.logistics, 'weight_type'):
invoice.logistics.weight_type = clean_str(invoice.logistics.weight_type).upper()
# Columna Z: E-Document (Opcional)
# 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 AA: Num. Operación (Opcional)
# 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
@@ -232,11 +239,17 @@ def validate_update(
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: 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:
@@ -245,15 +258,27 @@ def validate_update(
else:
invoice.compliance_mx.port_of_entry = clean_str(invoice.compliance_mx.port_of_entry)
# Columna AD: Observación en Español (Opcional)
# 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 AD: Observación en Inglés (Opcional)
# 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

@@ -49,7 +49,7 @@ def validate_update(
}
validate_required_fields_by_operation(
invoice=invoice_dict,
invoice_data=invoice_dict,
operation_type=invoice.operation_type or (existing_invoice.operation_type or 'imp'),
errors=errors
)

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

@@ -277,9 +277,9 @@ class InvoiceService:
)
if invoice_data.operation_type == "exp":
validate_update_export(invoice_data, invoice, errors)
validate_update_export(db, invoice_data, invoice, tenant_id, company_id, errors)
else:
validate_update_import(invoice_data, invoice, errors)
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")