feat: refactor invoice compliance fields to use foreign keys and enhance validation logic

This commit is contained in:
AlexeerCT
2026-01-06 18:01:26 -06:00
parent 12ec8c6dfb
commit 9fe07e78a6
14 changed files with 704 additions and 75 deletions

View File

@@ -0,0 +1,28 @@
from core.exceptions import ErrorCollector
from .. import models
from sqlalchemy.orm import Session
def invoice_exists(
db: Session,
invoice_number: str,
tenant_id: int,
company_id: int,
errors: ErrorCollector
) -> bool:
invoice_exists = (
db.query(models.InvoiceHeader.id)
.filter(
models.InvoiceHeader.invoice_number == invoice_number,
models.InvoiceHeader.tenant_id == tenant_id,
models.InvoiceHeader.company_id == company_id,
)
.first()
)
if not invoice_exists:
errors.add_duplicate_error(
"invoice_number",
invoice_number,
f"Ya existe una factura con el número '{invoice_number}'",
)

View File

@@ -0,0 +1,15 @@
""" """
def clean_dict(data_dict: dict) -> dict:
cleaned = {}
for key, value in data_dict.items():
if isinstance(value, str) and not value.strip():
cleaned[key] = None
elif value == 0 and (key.endswith('_id') or key == 'remesa'):
cleaned[key] = None
else:
cleaned[key] = value
return cleaned

View File

@@ -0,0 +1,9 @@
from sqlalchemy.orm import Session
from .... import schemas
from core.exceptions import ErrorCollector
def validate_common(db: Session, invoice: schemas.InvoiceTemporaryCreate, tenant_id: int, company_id: int, errors: ErrorCollector):
if invoice.compliance_mx.pedimento_id:
len()

View File

@@ -0,0 +1,82 @@
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
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.invoice_number:
errors.add_required_error("invoice_number")
if not invoice.invoice_date:
errors.add_required_error("invoice_date")
if not invoice.document_type:
errors.add_required_error("document_type")
if not invoice.compliance_mx.provider_id:
errors.add_required_error("compliance_mx.provider_id")
if not invoice.compliance_mx.sold_to_id:
errors.add_required_error("compliance_mx.sold_to_id")
if not invoice.compliance_mx.shipped_to_id:
errors.add_required_error("compliance_mx.shipped_to_id")
if not invoice.compliance_mx.customs_broker_id:
errors.add_required_error("compliance_mx.customs_broker_id")
if not invoice.compliance_mx.aduana:
errors.add_required_error("compliance_mx.aduana")
if errors.has_errors():
"""Se retorna por que hay campos obligatiorios para las validaciones que tienen que 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 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()
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"

View File

@@ -0,0 +1,2 @@
def validate_update():
pass

View File

@@ -136,9 +136,9 @@ class InvoiceComplianceMx(Base, TenantScopedMixin, TimestampMixin):
invoice_id: Mapped[int] = mapped_column(ForeignKey("a76.invoice_header.id"), primary_key=True)
# Core Customs Data
pedimento: Mapped[Optional[str]] = mapped_column(String(19)) # PEDIMENTO/PEDIMENTOIMPO/EXPO
pedimento_code: Mapped[Optional[str]] = mapped_column(String(5)) # PEDIMENTOR1
pedimento_k1: Mapped[Optional[str]] = mapped_column(String(15)) # PEDIMENTOK1
pedimento_id: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.pedimentos.id")) # PEDIMENTO/PEDIMENTOIMPO/EXPO
pedimento_r1: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.pedimentos.id")) # PEDIMENTOR1
pedimento_k1: Mapped[Optional[str]] = mapped_column(ForeignKey("a76.pedimentos.id")) # PEDIMENTOK1
remesa: Mapped[Optional[int]] = mapped_column(Integer) # REMESA
aduana: Mapped[Optional[str]] = mapped_column(ForeignKey("public.customs_sections.customs_code")) # ADUANA_CRUCE
port_of_entry: Mapped[Optional[str]] = mapped_column(String(6)) # PUERTOENTRADA / Puerto de entrada

View File

@@ -84,15 +84,14 @@ class InvoiceHeaderBase(BaseModel):
class InvoiceComplianceMxBase(BaseModel):
"""Base fields for Compliance MX"""
pedimento: Optional[str] = Field(
None, max_length=19, description="Pedimento number")
pedimento_code: Optional[str] = Field(
None, max_length=5, description="Pedimento code (R1)")
pedimento_k1: Optional[str] = Field(
None, max_length=15, description="Pedimento K1")
pedimento_id: Optional[int] = Field(
None, description="Pedimento id")
pedimento_r1: Optional[int] = Field(
None, description="Pedimento id (R1)")
pedimento_k1: Optional[int] = Field(
None, description="Pedimento id (K1)")
remesa: Optional[int] = Field(None, description="Remesa")
aduana: Optional[str] = Field(
None, max_length=5, description="Customs office")
aduana: Optional[str] = Field(None, max_length=5, description="Customs office")
port_of_entry: Optional[str] = Field(
None, max_length=6, description="Port of entry")
destination: Optional[str] = Field(

View File

@@ -1,15 +1,22 @@
import traceback
from typing import Optional, List, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_
from core.exceptions import ErrorCollector, DuplicateResourceException
from .common.mappers import clean_dict
from .imports.temporary.validators.create import validate_create
from .imports.temporary.validators.update import validate_update
from .common.common_validators import invoice_exists
from . import models, schemas
class InvoiceService:
"""Service for Invoice Header operations"""
@staticmethod
def get_by_id(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Optional[models.InvoiceHeader]:
def get_by_id(
db: Session, invoice_id: int, tenant_id: int, company_id: int
) -> Optional[models.InvoiceHeader]:
"""Get an invoice by ID with tenant/company validation"""
return (
db.query(models.InvoiceHeader)
@@ -39,25 +46,32 @@ class InvoiceService:
# Apply filters if provided
if filters:
if filters.get("status"):
query = query.filter(
models.InvoiceHeader.status == filters["status"])
query = query.filter(models.InvoiceHeader.status == filters["status"])
if filters.get("operation_type"):
query = query.filter(
models.InvoiceHeader.operation_type == filters["operation_type"])
models.InvoiceHeader.operation_type == filters["operation_type"]
)
if filters.get("invoice_type"):
query = query.filter(
models.InvoiceHeader.invoice_type == filters["invoice_type"])
models.InvoiceHeader.invoice_type == filters["invoice_type"]
)
if filters.get("invoice_number"):
query = query.filter(models.InvoiceHeader.invoice_number.ilike(
f"%{filters['invoice_number']}%"))
query = query.filter(
models.InvoiceHeader.invoice_number.ilike(
f"%{filters['invoice_number']}%"
)
)
if filters.get("pedimento"):
query = query.join(models.InvoiceComplianceMx).filter(
models.InvoiceComplianceMx.pedimento.ilike(
f"%{filters['pedimento']}%")
f"%{filters['pedimento']}%"
)
)
if not filters.get("invoice_type") and filters.get("operation_type") == "exp":
query = query.filter(
models.InvoiceHeader.operation_type != "REPAR")
if (
not filters.get("invoice_type")
and filters.get("operation_type") == "exp"
):
query = query.filter(models.InvoiceHeader.operation_type != "REPAR")
total = query.count()
items = query.offset(skip).limit(limit).all()
@@ -68,30 +82,19 @@ class InvoiceService:
db: Session,
invoice_data: schemas.InvoiceHeaderCreate,
tenant_id: int,
company_id: int
company_id: int,
) -> models.InvoiceHeader:
"""Create a new invoice with all related data"""
def clean_dict(data_dict: dict) -> dict:
cleaned = {}
for key, value in data_dict.items():
if key == 'customs_agent':
key = 'customs_broker_id'
elif key == 'provider':
key = 'provider_id'
if isinstance(value, str) and not value.strip():
cleaned[key] = None
elif value == 0 and (key.endswith('_id') or key == 'remesa'):
cleaned[key] = None
else:
cleaned[key] = value
return cleaned
# Validaciones con ErrorCollector
errors = ErrorCollector()
# 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)
# Si hay errores, lanzar excepción
errors.raise_if_errors("Error al crear la factura")
try:
# Extract nested data
@@ -103,27 +106,32 @@ class InvoiceService:
# Create main invoice header
raw_invoice_dict = invoice_data.model_dump(
exclude={"compliance_mx", "financials",
"logistics", "details", "collections"}
exclude={
"compliance_mx",
"financials",
"logistics",
"details",
"collections",
}
)
invoice_dict = clean_dict(raw_invoice_dict)
invoice_dict["tenant_id"] = tenant_id
invoice_dict["company_id"] = company_id
new_invoice = models.InvoiceHeader(**invoice_dict)
db.add(new_invoice)
db.flush() # Flush to get the invoice ID
# Create compliance_mx if provided
if compliance_data:
raw_comp_dict = compliance_data.model_dump()
# Pasamos los datos por la lavadora para arreglar pedimento, aduana, etc.
compliance_dict = clean_dict(raw_comp_dict)
compliance_dict["invoice_id"] = new_invoice.id
compliance_dict["tenant_id"] = tenant_id
compliance_dict["company_id"] = company_id
new_compliance = models.InvoiceComplianceMx(**compliance_dict)
db.add(new_compliance)
@@ -131,11 +139,11 @@ class InvoiceService:
if financials_data:
raw_fin_dict = financials_data.model_dump()
financials_dict = clean_dict(raw_fin_dict)
financials_dict["invoice_id"] = new_invoice.id
financials_dict["tenant_id"] = tenant_id
financials_dict["company_id"] = company_id
new_financials = models.InvoiceFinancials(**financials_dict)
db.add(new_financials)
@@ -143,7 +151,7 @@ class InvoiceService:
for logistics_item in logistics_data:
raw_log_dict = logistics_item.model_dump()
logistics_dict = clean_dict(raw_log_dict)
logistics_dict["invoice_id"] = new_invoice.id
logistics_dict["tenant_id"] = tenant_id
logistics_dict["company_id"] = company_id
@@ -154,7 +162,7 @@ class InvoiceService:
for detail_item in details_data:
raw_det_dict = detail_item.model_dump()
detail_dict = clean_dict(raw_det_dict)
detail_dict["invoice_id"] = new_invoice.id
detail_dict["tenant_id"] = tenant_id
detail_dict["company_id"] = company_id
@@ -165,7 +173,7 @@ class InvoiceService:
for collection_item in collections_data:
raw_col_dict = collection_item.model_dump()
collection_dict = clean_dict(raw_col_dict)
collection_dict["invoice_id"] = new_invoice.id
collection_dict["tenant_id"] = tenant_id
collection_dict["company_id"] = company_id
@@ -180,7 +188,7 @@ class InvoiceService:
db.rollback()
print("\n\n🔥 ERROR AL GUARDAR FACTURA 🔥")
print(f"Error: {str(e)}")
traceback.print_exc() # Esto imprime el error real en la consola
traceback.print_exc() # Esto imprime el error real en la consola
print("--------------------------------\n")
raise e
@@ -190,20 +198,24 @@ class InvoiceService:
invoice_id: int,
tenant_id: int,
invoice_data: schemas.InvoiceHeaderUpdate,
company_id: int
company_id: int,
) -> Optional[models.InvoiceHeader]:
# ... (El resto de tu código update se queda igual) ...
# (Te recomiendo implementar clean_dict aquí también si tienes problemas al editar)
invoice = InvoiceService.get_by_id(
db, invoice_id, tenant_id, company_id)
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if not invoice:
return None
# Update main invoice header fields
update_dict = invoice_data.model_dump(
exclude={"compliance_mx", "financials",
"logistics", "details", "collections"},
exclude_unset=True
exclude={
"compliance_mx",
"financials",
"logistics",
"details",
"collections",
},
exclude_unset=True,
)
for key, value in update_dict.items():
setattr(invoice, key, value)
@@ -211,15 +223,21 @@ class InvoiceService:
# Update compliance_mx if provided
if invoice_data.compliance_mx is not None:
if invoice.compliance_mx:
for key, value in invoice_data.compliance_mx.model_dump(exclude_unset=True).items():
for key, value in invoice_data.compliance_mx.model_dump(
exclude_unset=True
).items():
# Parche rápido para update
if value == "": value = None
if value == "":
value = None
setattr(invoice.compliance_mx, key, value)
else:
compliance_dict = invoice_data.compliance_mx.model_dump()
# Aplicar limpieza manual si es necesario
if 'customs_agent' in compliance_dict: compliance_dict['customs_broker_id'] = compliance_dict.pop('customs_agent')
if "customs_agent" in compliance_dict:
compliance_dict["customs_broker_id"] = compliance_dict.pop(
"customs_agent"
)
compliance_dict["invoice_id"] = invoice.id
compliance_dict["tenant_id"] = tenant_id
compliance_dict["company_id"] = company_id
@@ -229,8 +247,11 @@ class InvoiceService:
# Update financials if provided
if invoice_data.financials is not None:
if invoice.financials:
for key, value in invoice_data.financials.model_dump(exclude_unset=True).items():
if value == "": value = None
for key, value in invoice_data.financials.model_dump(
exclude_unset=True
).items():
if value == "":
value = None
setattr(invoice.financials, key, value)
else:
financials_dict = invoice_data.financials.model_dump()
@@ -247,10 +268,9 @@ class InvoiceService:
@staticmethod
def delete(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> bool:
"""Delete an invoice and all related data (cascade delete)"""
invoice = InvoiceService.get_by_id(
db, invoice_id, tenant_id, company_id)
invoice = InvoiceService.get_by_id(db, invoice_id, tenant_id, company_id)
if invoice:
db.delete(invoice)
db.commit()
return True
return False
return False

View File

@@ -106,7 +106,7 @@ class SubassemblyEntry(Base, TenantScopedMixin, TimestampMixin):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
remission_line: Mapped[int] = mapped_column(Integer) # LINEAREMISION
exit_invoice: Mapped[Optional[str]] = mapped_column(
String(15)) # FACTURASALIDA
String(15)) # FACTURASALIDA
exit_line: Mapped[Optional[int]] = mapped_column(Integer) # LINEASALIDA
# ============================================================================

View File

@@ -55,7 +55,7 @@ seed = [
("LTT", "LITAS", "LITUANIA"),
("LYD", "DINAR", "LIBIA"),
("MAD", "DIRHAM", "MARRUECOS"),
("MXP", "PESO", "MEXICO"),
("MXN", "PESO", "MEXICO"),
("MYR", "RINGGIT", "MALASIA"),
("NGN", "NAIRA", "NIGERIA (FED)"),
("NIC", "CORDOBA", "NICARAGUA"),