Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/partePais_BOM

This commit is contained in:
2026-03-13 10:59:03 -05:00
125 changed files with 18840 additions and 4414 deletions

View File

@@ -139,6 +139,7 @@ class TenantCRUDRoutes(
async def list_resources(
request: Request,
company_id: int = Query(..., description="Company ID"),
all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
self.default_page_size,
@@ -149,19 +150,32 @@ class TenantCRUDRoutes(
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
self.list_permissions,
self.require_all,
)
from core.security import get_tenant_from_token
if all_companies:
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
# In all_companies mode, we don't filter by company_id,
# but we still need the tenant_id from the session/token.
target_company_id = None
else:
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
self.list_permissions,
self.require_all,
)
target_company_id = company_id
skip = (page - 1) * page_size
# Extraer todos los parámetros de búsqueda dinámicamente
# Excluimos los parámetros estándar de paginación y control
standard_params = {"company_id", "page", "page_size"}
standard_params = {"company_id", "all_companies", "page", "page_size"}
filters = {
k: v
for k, v in request.query_params.items()
@@ -169,7 +183,7 @@ class TenantCRUDRoutes(
}
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, filters
db, tenant_id, target_company_id, skip, page_size, filters
)
@@ -192,6 +206,7 @@ class TenantCRUDRoutes(
)
async def list_resources(
company_id: int = Query(..., description="Company ID"),
all_companies: bool = Query(False, description="Whether to search in all companies of the tenant"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(
self.default_page_size,
@@ -202,18 +217,29 @@ class TenantCRUDRoutes(
db: Session = Depends(self.db_dependency),
current_user: Dict[str, Any] = Depends(self.auth_dependency),
):
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
self.list_permissions,
self.require_all,
)
from core.security import get_tenant_from_token
if all_companies:
tenant_id = get_tenant_from_token(current_user)
if not tenant_id:
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(status_code=400, detail="Tenant ID not found in token")
target_company_id = None
else:
tenant_id = validate_access_to_resource(
db,
company_id,
current_user,
self.list_permissions,
self.require_all,
)
target_company_id = company_id
skip = (page - 1) * page_size
items, total = self.service.get_all(
db, tenant_id, company_id, skip, page_size, None
db, tenant_id, target_company_id, skip, page_size, None
)
return {

View File

@@ -32,7 +32,7 @@ class ClassService:
def get_all(
db: Session,
tenant_id: int,
company_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
@@ -40,9 +40,10 @@ class ClassService:
"""
Get all classes for a tenant with pagination and filters
"""
query = db.query(Class).filter(
Class.tenant_id == tenant_id, Class.company_id == company_id
)
query = db.query(Class).filter(Class.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Class.company_id == company_id)
if filters:
if filters.get("class_code"):
@@ -77,7 +78,7 @@ class ClassService:
def get_all_with_fa_data(
db: Session,
tenant_id: int,
company_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 1000,
filters: Optional[Dict[str, Any]] = None,
@@ -98,9 +99,11 @@ class ClassService:
QClasses.tenant_id == tenant_id
))
.filter(Class.tenant_id == tenant_id)
.filter(Class.company_id == company_id)
)
if company_id is not None:
query = query.filter(Class.company_id == company_id)
# Apply filters if provided
if filters:
if filters.get("class_code"):

View File

@@ -34,16 +34,16 @@ class ClientProviderService:
def get_all(
db: Session,
tenant_id: int,
company_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[ClientProvider], int]:
"""Get all clients/providers for a tenant/company with pagination"""
query = db.query(ClientProvider).filter(
ClientProvider.tenant_id == tenant_id,
ClientProvider.company_id == company_id,
)
"""Get all clients/providers for a tenant with pagination"""
query = db.query(ClientProvider).filter(ClientProvider.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(ClientProvider.company_id == company_id)
# Apply filters if provided
if filters:

View File

@@ -59,8 +59,20 @@ def _canonicals_from_columns(cols: Optional[List[Dict]]) -> List[str]:
def _build_registry() -> Dict[str, List[str]]:
registry: Dict[str, List[str]] = {}
# a76/imports (facturas): imp_temp_header, imp_temp_details, imp_def_*, exp_def_*
for tid in ("imp_temp_header", "imp_temp_details", "imp_def_header", "imp_def_details", "exp_def_header", "exp_def_details"):
# a76/imports (facturas): imp_temp_header, imp_temp_details, imp_def_*, exp_def_*, cmex_*, series
for tid in (
"imp_temp_header",
"imp_temp_details",
"imp_temp_series",
"imp_def_header",
"imp_def_details",
"imp_def_series",
"exp_def_header",
"exp_def_details",
"cmex_header",
"cmex_details",
"cmex_series",
):
cols = resolve_imports_template(tid)
registry[tid] = _canonicals_from_columns(cols)
@@ -137,8 +149,13 @@ TEMPLATE_FILENAMES: Dict[str, str] = {
"transporters": "EstructuraCatTransportistas.csv",
"imp_temp_header": "EstructuraEncFacImpoTemp.csv",
"imp_temp_details": "EstructuraParFacImpoTempAF.csv",
"imp_temp_series": "EstructuraSeriesFacImpoTemp.csv",
"imp_def_header": "EstructuraEncFacImpoDef.csv",
"imp_def_details": "EstructuraParFacImpoDefAF.csv",
"imp_def_series": "EstructuraSeriesFacImpoDef.csv",
"cmex_header": "EstructuraEncFacComprasMex.csv",
"cmex_details": "EstructuraParFacComprasMex.csv",
"cmex_series": "EstructuraSeriesFacComprasMex.csv",
"exp_def_header": "EstructuraEncFacExpoCamReg.csv",
"exp_def_details": "EstructuraParExpoCamReg.csv",
}

View File

@@ -13,4 +13,5 @@ router = TenantCRUDRoutes(
tags=["a76.general_catalogs.ports"],
resource_name="Port",
enable_list=True,
max_page_size=1000,
).router

View File

@@ -11,14 +11,15 @@ class PortService:
def get_all(
db: Session,
tenant_id: int,
company_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> Tuple[List[Port], int]:
query = db.query(Port).filter(
Port.tenant_id == tenant_id
)
query = db.query(Port).filter(Port.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Port.company_id == company_id)
if filters:
# Add filters here if needed

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

@@ -131,7 +131,7 @@ class InvoiceComplianceMxBase(BaseModel):
None, max_length=20, description="Shipped by header"
)
shipped_by_id: Optional[int] = Field(None, description="Shipped by ID")
customs_broker_id: int = Field(None, description="Customs broker ID")
customs_broker_id: Optional[int] = Field(None, description="Customs broker ID (null for MEX)")
customs_broker_us_id: Optional[int] = Field(
None, description="US customs broker ID"
)
@@ -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,10 @@ 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)
if invoice_data.operation_type == "exp":
validate_create_export(db, invoice_data, tenant_id, company_id, errors)
else:
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 +278,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

@@ -0,0 +1,220 @@
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics
from ...models import LineItem
from ...series.models import Serie
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.classes.models import Class
def apply_calculations(
db: Session, line: LineItem, tenant_id: int, company_id: int, line_number: int
):
#TODO: SSisGen Logic
# if ssisgen.calcularcostounitarioenbaseavalortotalscaf = 1:
# unit_cost_capture = line.financial.total_value / line.financial.total_value <-- habria que revisar por que esta asi, por que para mi no tiene sentido, pero es lo que esta en clarion
calculate_values(db, line, tenant_id, company_id)
# ==========================================
# LLEVASERIE / LlevaCodFDA defaults
# ==========================================
# EqiPex:LlevaCodFDA = 'N'
line.has_fda_code = False
# ==========================================
# PAGO IMPUESTO default: 'N' (False)
# ==========================================
if line.tax_payment is None:
# TODO: Leer de SisExp:PagoImpuesto (preferencias del sistema)
line.tax_payment = False
# ==========================================
# FORMA DE PAGO default: '5'
# ==========================================
if not line.payment_method:
# TODO: Leer de SisExp:FormaPago (preferencias del sistema)
line.payment_method = "5"
# ==========================================
# SUBPARTIDAS: EsSubPartida / ContieneSubP / IncuyeSubPartidas
# ==========================================
fa_data: FaLineItem = getattr(line, "fa_data", None)
if fa_data is not None:
if fa_data.is_subitem is None:
fa_data.is_subitem = False
if not fa_data.is_subitem:
# Es partida principal — subitem_number se fuerza a 0
fa_data.subitem_number = 0
# ContieneSubP: verificar si ya existen subitems en DB que referencian esta línea
# (útil en updates; en create siempre será False porque la línea aún no existe)
existing_subitems = (
db.query(FaLineItem)
.join(LineItem, FaLineItem.id == LineItem.id)
.filter(
LineItem.invoice_id == line.invoice_id,
LineItem.line_number == line_number,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
FaLineItem.is_subitem == True,
FaLineItem.subitem_number == line_number,
)
.count()
)
fa_data.contains_subitems = existing_subitems > 0
invoice_date = db.query(InvoiceHeader.invoice_date).filter(InvoiceHeader.id == line.invoice_id, InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id).scalar()
line.depreciation_date = invoice_date
if (not line.description.description_spanish and not line.description.description_english) and (line.part_info.description_spanish and line.part_info.description_english):
line.description.description_spanish = line.part_info.description_spanish
line.description.description_english = line.part_info.description_english
else:
if not line.description.description_spanish:
class_desc = (
db.query(Class.description_es, Class.description_en)
.filter(Class.id == line.class_id, Class.tenant_id == tenant_id, Class.company_id == company_id)
.first()
)
if class_desc:
line.description.description_spanish, line.description.description_english = class_desc
def calculate_values(
db: Session,
line: LineItem,
tenant_id: int,
company_id: int,
) -> None:
"""
Calcula valores financieros y copia campos de la línea de importación referenciada.
Traduce el CALCULOS ROUTINE de Clarion:
- Busca la factura de importación por fa_data.search_invoice (TEM → DEF como fallback)
- Copia clase, unidad de medida, fracción (si fa_data.download), país, tipo fracción,
bultos y descripción inglés desde la línea de importación encontrada
- Calcula valores en moneda (USD/MXN/MC) según la moneda de la factura
"""
fa_data: FaLineItem = getattr(line, "fa_data", None)
# ==========================================
# BUSCAR FACTURA DE IMPORTACIÓN (TEM → DEF)
# EqiFim:FacturaImpo = fa_data.search_invoice / EqiPim:LineaImpo = fa_data.search_line
# ==========================================
import_line = None
import_invoice_number = fa_data.search_invoice if fa_data else None
import_line_number = fa_data.search_line if fa_data else None
if import_invoice_number and import_line_number:
# 1. Intentar TEM (Importación Temporal)
tem_invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.invoice_number == import_invoice_number,
InvoiceHeader.invoice_type == "TEM",
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if tem_invoice:
import_line = (
db.query(LineItem)
.filter(
LineItem.invoice_id == tem_invoice.id,
LineItem.line_number == import_line_number,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
# 2. Si no hay TEM, intentar DEF (Importación Definitiva)
if not import_line:
def_invoice = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.invoice_number == import_invoice_number,
InvoiceHeader.invoice_type == "DEF",
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if def_invoice:
import_line = (
db.query(LineItem)
.filter(
LineItem.invoice_id == def_invoice.id,
LineItem.line_number == import_line_number,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
# ==========================================
# COPIAR CAMPOS DESDE LÍNEA DE IMPORTACIÓN
# EqiPex:Clase, UnidadMedida, FraccionExpo (condicional), PaisOrigen,
# TipoFraccion, CantBultos, ClaveBultos, DescripcionE
# ==========================================
if import_line:
line.class_id = import_line.class_id
line.unit_of_measure = import_line.unit_of_measure
# Fracción: copiar solo si fa_data.download == True (≡ ColumnaV != '')
if fa_data and fa_data.download and import_line.customs:
line.customs.fraction = import_line.customs.fraction
if import_line.customs:
line.customs.origin_country = import_line.customs.origin_country
line.customs.fraction_type = import_line.customs.fraction_type
if import_line.quantity:
line.quantity.package_quantity = import_line.quantity.package_quantity
line.quantity.package_id = import_line.quantity.package_id
if import_line.description:
line.description.description_english = import_line.description.description_english
# ==========================================
# CÁLCULOS DE VALORES EN MONEDA
# foreign=ME, local=MN, manual=MC
# ==========================================
result = (
db.query(InvoiceFinancials.currency, InvoiceFinancials.exchange_rate)
.filter(
InvoiceFinancials.invoice_id == line.invoice_id,
InvoiceFinancials.tenant_id == tenant_id,
InvoiceFinancials.company_id == company_id,
)
.first()
)
if not result:
return
currency, exchange_rate = result
if currency == "foreign": # ME
line.financial.unit_cost_usd = line.financial.unit_cost_capture
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.unit_cost_mxn = line.financial.unit_cost_capture * exchange_rate
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
elif currency == "local": # MN
line.financial.unit_cost_mxn = line.financial.unit_cost_capture
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_usd * line.quantity.quantity
elif currency == "manual": # MC
line.financial.unit_cost_usd = line.financial.unit_cost_capture / exchange_rate
line.financial.value_usd = line.financial.unit_cost_usd * line.quantity.quantity
line.financial.unit_cost_mxn = line.financial.unit_cost_usd * exchange_rate
line.financial.value_mxn = line.financial.unit_cost_mxn * line.quantity.quantity
line.financial.value_mc = line.financial.unit_cost_capture * line.quantity.quantity

View File

@@ -0,0 +1,417 @@
from sqlalchemy import exists
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id
from core.exceptions import ErrorCollector
from sqlalchemy import func
from ...common.fractions import search_fraction_preference
from ...common.common_validators import item_exists
from ...models import LineItem
from ...line_customs.models import FractionType, LineCustom
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.items.exports.validators.calculations import apply_calculations
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.general_catalogs.company.models import Company
def validate_common(
db: Session,
line: LineItemCreate,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
line_number: int,
):
invoice: InvoiceHeader = invoice_exists_by_id(
db, line.invoice_id, tenant_id, company_id, errors
)
line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id)
fecha_factura = invoice.invoice_date if invoice else None
fraction = None
class_ = db.query(Class).filter(Class.id == line.class_id).first()
if not class_:
errors.add_error(
field=f"line[{line_number}].class_id",
message="La clase especificada no existe.",
solution=["Darla de alta en el catalogo de clases."],
code="CLASS_NOT_FOUND",
)
else:
if not line.unit_of_measure and not class_.unit_of_measure:
errors.add_error(
field=f"line[{line_number}].unit_of_measure",
message="La unidad de medida es obligatoria para la clase especificada.",
solution=["Proporciona una unidad de medida valida."],
code="UNIT_OF_MEASURE_REQUIRED",
)
if not line.customs.fraction:
if not line_item:
if not class_.fraction:
errors.add_error(
field=f"line[{line_number}].customs.fraction",
message="La fracción arancelaria es obligatoria para la clase especificada.",
solution=["Proporciona una fracción arancelaria valida."],
code="FRACTION_REQUIRED",
)
else:
fraction = class_.fraction
else:
if not line.customs.fraction:
if not class_.fraction:
errors.add_error(
field=f"line[{line_number}].customs.fraction",
message="La fracción arancelaria es obligatoria para la clase especificada.",
solution=["Proporciona una fracción arancelaria valida."],
code="FRACTION_REQUIRED",
)
else:
fraction = class_.fraction
else:
if line_item:
fraction = line.customs.fraction
if not line.description.description_spanish and not class_.description_es:
errors.add_error(
field=f"line[{line_number}].description.description_spanish",
message="La descripción en español es obligatoria para la clase especificada.",
solution=["Proporciona una descripción en español valida."],
code="DESCRIPTION_SPANISH_REQUIRED",
)
if not line.description.description_english and not class_.description_en:
errors.add_error(
field=f"line[{line_number}].description.description_english",
message="La descripción en inglés es obligatoria para la clase especificada.",
solution=["Proporciona una descripción en inglés valida."],
code="DESCRIPTION_ENGLISH_REQUIRED",
)
if line.quantity.quantity and line.quantity.quantity <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser mayor a cero.",
solution=["Proporciona una cantidad valida."],
code="QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
if line.unit_of_measure:
um = (
db.query(func.count(UnitOfMeasure.id))
.filter(
UnitOfMeasure.id == line.unit_of_measure,
UnitOfMeasure.tenant_id == tenant_id,
UnitOfMeasure.company_id == company_id,
)
.scalar()
)
if um == 0:
errors.add_error(
field=f"line[{line_number}].unit_of_measure",
message="La unidad de medida especificada no existe.",
solution=["Proporciona una unidad de medida valida."],
code="UNIT_OF_MEASURE_NOT_FOUND",
)
if line.quantity.package_id:
package = (
db.query(func.count(Package.id))
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.scalar()
)
if package == 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_id",
message="El paquete especificado no existe.",
solution=["Proporciona un paquete valido."],
code="PACKAGE_NOT_FOUND",
)
if not line.quantity.package_quantity:
errors.add_error(
field=f"line[{line_number}].quantity.package_quantity",
message="La cantidad de paquetes es obligatoria cuando se proporciona el paquete.",
solution=["Proporciona una cantidad de paquetes valida."],
code="PACKAGE_QUANTITY_REQUIRED",
)
if line.quantity.package_quantity <= 0:
errors.add_error(
field=f"line[{line_number}].quantity.package_quantity",
message="La cantidad de paquetes debe ser mayor a cero.",
solution=["Proporciona una cantidad de paquetes valida."],
code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO",
)
else:
if line.quantity.package_quantity and (line.quantity.package_quantity > 0 and not line.quantity.package_id):
errors.add_error(
field=f"line[{line_number}].quantity.package_id",
message="El paquete es obligatorio cuando se proporciona la cantidad de paquetes.",
solution=["Proporciona un paquete valido."],
code="PACKAGE_ID_REQUIRED",
)
# ==========================================
# FA DATA: PROCEDENCIA Y REFERENCIA DE IMPORTACIÓN (Col. C / D / E / H)
# ==========================================
fa_data: FaLineItem = getattr(line, "fa_data", None)
if fa_data and fa_data.movement_type_import:
# Col. C: debe ser 'TEM' o 'DEF'
if fa_data.movement_type_import.upper() not in ("TEM", "DEF"):
errors.add_error(
field=f"line[{line_number}].fa_data.movement_type_import",
message=f"La Procedencia '{fa_data.movement_type_import}' no es válida. Debe ser 'TEM' o 'DEF'.",
solution=["Capturar una procedencia válida como TEM o DEF."],
code="MOVEMENT_TYPE_IMPORT_INVALID",
)
elif fa_data.search_invoice:
tipo_label = "Temporales" if fa_data.movement_type_import.upper() == "TEM" else "Definitivas"
tipo_label_sg = "Temporal" if fa_data.movement_type_import.upper() == "TEM" else "Definitiva"
# Col. D: validar que la factura de importación exista
import_invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.invoice_number == fa_data.search_invoice,
InvoiceHeader.invoice_type == fa_data.movement_type_import.upper(),
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not import_invoice:
errors.add_error(
field=f"line[{line_number}].fa_data.search_invoice",
message=f"La Factura '{fa_data.search_invoice}' de Importación {tipo_label_sg} no existe.",
solution=[f"Capturar un Número de Factura que exista en el Catálogo de Importaciones {tipo_label}."],
code="IMPORT_INVOICE_NOT_FOUND",
)
elif fa_data.search_line:
# Col. D + E: validar que la línea de importación exista dentro de esa factura
import_line: LineItem = (
db.query(LineItem)
.filter(
LineItem.invoice_id == import_invoice.id,
LineItem.line_number == fa_data.search_line,
LineItem.tenant_id == tenant_id,
LineItem.company_id == company_id,
)
.first()
)
if not import_line:
errors.add_error(
field=f"line[{line_number}].fa_data.search_line",
message=f"La Factura '{fa_data.search_invoice}' con línea '{fa_data.search_line}' de Importación {tipo_label_sg} no existe.",
solution=["Capturar un Número de Factura con diferente línea que esté en el Catálogo de Importaciones correspondiente."],
code="IMPORT_LINE_NOT_FOUND",
)
elif (
# Col. H: valida unidad de medida sólo cuando hay descarga
fa_data.download is True
and line.unit_of_measure
and import_line.unit_of_measure
and line.unit_of_measure != import_line.unit_of_measure
):
errors.add_error(
field=f"line[{line_number}].unit_of_measure",
message=(
f"La Unidad de Medida '{line.unit_of_measure}' es diferente de "
f"'{import_line.unit_of_measure}', que es la U.M. de la Factura "
f"'{fa_data.search_invoice}' con línea '{fa_data.search_line}' "
f"de Importación {tipo_label_sg}."
),
solution=["Capturar la Unidad de Medida correcta para el descargo de esta línea de Importación."],
code="UNIT_OF_MEASURE_MISMATCH",
)
country = None
fraction_type = None
sector = None
if fraction:
fraction = line.customs.fraction if line.customs.fraction else fraction
country = line.customs.origin_country
if line_item and line_item.customs:
country = (
line_item.customs.origin_country
if line_item.customs.origin_country
else country
)
fraction_type = line.customs.fraction_type.upper()
if line_item and line_item.customs:
fraction_type = (
line_item.customs.fraction_type
if line_item.customs.fraction_type
else fraction_type
)
sector = line.customs.sector
if line_item and line_item.customs:
sector = line_item.customs.sector if line_item.customs.sector else sector
country_m3 = db.query(Country.m3_key).filter(Country.m3_key == country).scalar()
if not country_m3:
country_m3 = (
db.query(Country.m3_key).filter(Country.ame_key == country).scalar()
)
country = country_m3
if not country:
errors.add_error(
field=f"line[{line_number}].customs.origin_country",
message="El país de origen especificado no existe.",
solution=["Proporciona un país de origen valido."],
code="ORIGIN_COUNTRY_NOT_FOUND",
)
else:
if fraction_type.strip().upper() not in vars(FractionType).values():
valid_types = [
v
for k, v in vars(FractionType).items()
if not k.startswith("_") and isinstance(v, str)
]
errors.add_error(
field=f"line[{line_number}].customs.fraction_type",
message="El tipo de fracción especificado no es válido.",
solution=[
f"Proporciona un tipo de fracción válido. Valores permitidos: {', '.join(valid_types)}"
],
code="FRACTION_TYPE_INVALID",
value=fraction_type,
)
else:
if fraction_type.strip().upper() == FractionType.PROSEC and not sector:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector es obligatorio cuando el tipo de fracción es 'PROSEC'.",
solution=["Proporciona un sector valido."],
code="SECTOR_REQUIRED_FOR_PROSEC",
)
elif fraction_type.strip().upper() != FractionType.PROSEC and sector:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector solo es aplicable cuando el tipo de fracción es 'PROSEC'.",
solution=[
"Elimina el sector o cambia el tipo de fracción a 'PROSEC'."
],
code="SECTOR_ONLY_FOR_PROSEC",
)
elif fraction_type.strip().upper() == FractionType.PROSEC and sector:
sector_db: Sector = (
db.query(Sector).filter(Sector.key == sector).scalar()
)
if sector_db:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector especificado no existe.",
solution=["Proporciona un sector valido."],
code="SECTOR_NOT_FOUND",
)
else:
if not sector_db.authorized:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message="El sector especificado no está autorizado.",
solution=["Proporciona un sector autorizado."],
code="SECTOR_NOT_AUTHORIZED",
)
company_db = db.query(Company).filter(Company.id == company_id).first()
if not company_db.prosec:
errors.add_error(
field=f"line[{line_number}].customs.sector",
message=" La empresa no cuenta con autorización PROSEC.",
solution=[
"Accese a los datos de la empresa y selecione la opción Pertenece al Programa de Promoción Sectorial y capture el número de permiso PROSEC."
],
code="COMPANY_NOT_AUTHORIZED_FOR_PROSEC",
)
if fraction:
search_fraction_preference(
db=db,
country=country,
fraccion=fraction,
fraction_type=fraction_type,
sector=sector,
invoice_date=fecha_factura,
errors=errors,
)
if line.customs.american_fraction:
american_fraction_exists = db.query(
exists().where(
LineCustom.american_fraction == line.customs.american_fraction
)
).scalar()
if not american_fraction_exists:
errors.add_error(
field=f"line[{line_number}].customs.american_fraction",
message="La fracción americana especificada no existe.",
solution=["Proporciona una fracción americana valida."],
code="AMERICAN_FRACTION_NOT_FOUND",
)
if line.order:
if len(line.order) > 20:
errors.add_error(
field=f"item.order",
message="El campo orden no debe exceder los 20 caracteres.",
solution=["Proporciona un valor valido para el campo orden."],
code="ORDER_EXCEEDS_MAX_LENGTH",
)
unit_of_measure = line.unit_of_measure or (
class_.unit_of_measure if class_ else None
)
#TODO: SSisGen Logic Restringer cantidades decimales para piezas, revisar si es necesario agregar validación similar para otras unidades de medida
#TODO: SSisGen Logic Seguridad
if unit_of_measure == "PZA" and line.quantity.quantity % 1 != 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
message="La cantidad debe ser un número entero cuando la unidad de medida es PZA.",
solution=["Proporciona una cantidad entera."],
code="QUANTITY_MUST_BE_INTEGER_FOR_PIECES",
)
if line.valuation_method:
valuation_method_exists = db.query(
exists().where(ValuationMethod.key == line.valuation_method)
).scalar()
if not valuation_method_exists:
errors.add_error(
field=f"line[{line_number}].valuation_method",
message="El método de valoración especificado no existe.",
solution=["Proporciona un método de valoración valido."],
code="VALUATION_METHOD_NOT_FOUND",
)
if line.part_number_id:
part_exists = db.query(exists().where(Part.id == line.part_number_id)).scalar()
if not part_exists:
errors.add_error(
field=f"line[{line_number}].part_number_id",
message="El número de parte especificado no existe.",
solution=["Proporciona un número de parte valido."],
code="PART_NUMBER_NOT_FOUND",
)
apply_calculations(db, line, tenant_id, company_id, line_number)

View File

@@ -0,0 +1,394 @@
from decimal import Decimal
from sqlalchemy import func, exists
from sqlalchemy.orm import Session
from ...common.common_validators import count_items
from core.exceptions import ErrorCollector
from ...models import LineItem
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from ...models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from .common import validate_common
def validate_create(
db: Session,
line: LineItem,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
line_number: int,
):
"""
Validates and calculates fields for a new line item before DB creation.
Works with Pydantic schemas, modifying them in-place.
Args:
line: LineItemCreate schema with nested data (financial, quantity, customs, etc.)
invoice_id: ID of the invoice this line belongs to
fa_data: FaLineItemCreateDTO or None (None for INV system)
"""
# Access fa_data safely
fa_data: FaLineItem = getattr(line, "fa_data", None)
# Required field validations
if not line.class_id:
errors.add_required_error(field=f"line[{line_number}].class_id")
if not line.quantity.quantity or line.quantity.quantity <= 0:
errors.add_required_error(field=f"line[{line_number}].quantity.quantity")
# TODO: Añadir validacion SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf <-- de la tabla de preferencias de el sistema
# if SSisGen:CalcularCostoUnitarioEnBaseAValorTotalScaf == False:
if fa_data and not fa_data.is_subitem:
if (
not line.financial.unit_cost_capture
or line.financial.unit_cost_capture <= 0
):
errors.add_required_error(
field=f"line[{line_number}].financial.unit_cost_capture"
)
if not line.quantity.net_weight or line.quantity.net_weight <= 0:
errors.add_required_error(field=f"line[{line_number}].quantity.net_weight")
if not line.customs.origin_country:
errors.add_required_error(field=f"line[{line_number}].customs.origin_country")
if not line.customs.fraction_type:
errors.add_required_error(field=f"line[{line_number}].customs.fraction_type")
# FA-specific validations
if fa_data:
# Col. C: Procedencia de la Importación (TipoMovImpo) — obligatorio
if not fa_data.movement_type_import:
errors.add_required_error(field=f"line[{line_number}].fa_data.movement_type_import")
# Col. F: ¿Descarga la línea? (DescargaPartida) — obligatorio
if fa_data.download is None:
errors.add_required_error(field=f"line[{line_number}].fa_data.download")
# Col. D / E: Factura y Línea de Impo — obligatorios sólo si hay descarga
if fa_data.download is True:
if not fa_data.search_invoice:
errors.add_required_error(field=f"line[{line_number}].fa_data.search_invoice")
if not fa_data.search_line:
errors.add_required_error(field=f"line[{line_number}].fa_data.search_line")
if (
fa_data.is_subitem and fa_data.contains_subitems
) and not fa_data.subitem_number:
errors.add_required_error(
field=f"line[{line_number}].fa_data.subitem_number"
)
# Validar que si es un subitem, existe un item principal correspondiente
if (
fa_data.is_subitem
and fa_data.subitem_number
and fa_data.subitem_number != 0
):
principal_item_exists = db.query(
exists().where(
(LineItem.id == FaLineItem.id)
& (LineItem.id == LineItem.id)
& (LineItem.invoice_id == line.invoice_id)
& (LineItem.line_number == line_number)
& (FaLineItem.is_subitem == False)
& (FaLineItem.contains_subitems == True)
& (LineItem.tenant_id == tenant_id)
& (LineItem.company_id == company_id)
)
).scalar()
if not principal_item_exists:
errors.add_error(
field=f"line[{line_number}]",
message=f"No existe un item principal registrado para esta linea {line_number} con subitem {fa_data.subitem_number}",
solution=[
"Registrar el item principal correspondiente a esta linea antes de registrar subitems."
],
code="SUBITEM_WITHOUT_PRINCIPAL_ITEM",
)
if fa_data.is_subitem and (
fa_data.subitem_number == 0 or not fa_data.subitem_number
):
errors.add_error(
field=f"line[{line_number}]",
message=f"El número de subitem no puede ser 0 si la línea es un subitem.",
solution=["Asignar un número de subitem mayor a 0 para esta línea."],
code="SUBITEM_NUMBER_INVALID",
)
validate_common(db, line, tenant_id, company_id, errors, line_number)
if not errors.has_errors():
# Obtener la factura para acceder a tipo de cambio, moneda y peso
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
)
.first()
)
if not invoice or not invoice.financials or not invoice.logistics:
errors.add_error(
field=f"line[{line_number}]",
message="No se pudo obtener información de la factura",
solution=[
"Verificar que la factura existe y tiene datos financieros y logísticos"
],
code="INVOICE_DATA_MISSING",
)
return
# Obtener la clase para valores por defecto
class_info: Class = (
db.query(Class)
.filter(
Class.id == line.class_id,
Class.tenant_id == tenant_id,
Class.company_id == company_id,
)
.first()
)
# ==========================================
# ASIGNAR TIPO DE CAMBIO
# ==========================================
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
# ==========================================
# ASIGNAR UNIDAD DE MEDIDA
# ==========================================
# Si no se proporcionó unidad de medida, usar la de la clase
if not line.unit_of_measure and class_info:
line.unit_of_measure = class_info.unit_of_measure
# ==========================================
# ASIGNAR TIPOS DE MONEDA Y CALCULAR COSTOS
# ==========================================
currency_type = invoice.financials.currency_type
unit_cost_capture = line.financial.unit_cost_capture or Decimal("0")
# Calcular costos según tipo de moneda
if currency_type == "USD" or currency_type == "ME": # Moneda Extranjera (ME)
line.financial.unit_cost_capture = unit_cost_capture
line.financial.unit_cost_usd = unit_cost_capture
line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
elif currency_type == "MXN" or currency_type == "MN": # Moneda Nacional (MN)
line.financial.unit_cost_capture = unit_cost_capture
line.financial.unit_cost_usd = (
unit_cost_capture / exchange_rate if exchange_rate else Decimal("0")
)
line.financial.unit_cost_mxn = unit_cost_capture
# Si es otro tipo de moneda, dejamos el costo como está
# Calcular valores totales basados en cantidad y costo unitario
quantity = line.quantity.quantity or Decimal("0")
# Valor Comercial
if line.financial.unit_cost_usd is not None:
line.financial.value_usd = line.financial.unit_cost_usd * quantity
if line.financial.unit_cost_mxn is not None:
line.financial.value_mxn = line.financial.unit_cost_mxn * quantity
# Valor Aduanas (asumiendo que es igual al Valor Comercial por defecto)
line.financial.customs_value_usd = line.financial.value_usd
line.financial.customs_value_mxn = line.financial.value_mxn
# Valor MP Temp (Materia Prima Temporal)
line.financial.value_temp_material_usd = line.financial.value_usd
line.financial.value_temp_material_mxn = line.financial.value_mxn
# ==========================================
# VALIDAR Y CONVERTIR PESOS NETOS
# ==========================================
invoice_weight_type = invoice.logistics.weight_type # 'kgs' o 'lbs'
quantity = line.quantity.quantity or Decimal("0")
net_weight_input = line.quantity.net_weight or Decimal("0")
# Determinar si la unidad de medida es de peso
unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS
unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS
# Calcular peso neto en kilogramos (estándar interno)
if unit_is_kgs:
if invoice_weight_type == "KGS":
line.quantity.net_weight = quantity
else: # invoice en libras
line.quantity.net_weight = quantity * Decimal("2.204624")
elif unit_is_lbs:
if invoice_weight_type == "KGS":
line.quantity.net_weight = quantity / Decimal("2.204624")
else: # invoice en libras
line.quantity.net_weight = quantity
else:
# Otra unidad de medida - usar peso capturado y convertir si es necesario
if invoice_weight_type == "KGS":
# El peso capturado está en kilos
line.quantity.net_weight = net_weight_input
else:
# El peso capturado está en libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
# ==========================================
# CALCULAR PESO BRUTO
# ==========================================
gross_weight_input = line.quantity.gross_weight
package_quantity = line.quantity.package_quantity or 0
package_weight_unit = Decimal("0")
# Obtener peso unitario del bulto si existe
if line.quantity.package_id:
package: Package = (
db.query(Package)
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package and package.weight_unit:
package_weight_unit = package.weight_unit
# Si no se proporcionó peso bruto, calcularlo
if not gross_weight_input or gross_weight_input == 0:
if invoice_weight_type == "KGS":
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
else: # libras
line.quantity.gross_weight = line.quantity.net_weight + (
(package_weight_unit * Decimal("2.204624")) * package_quantity
)
else:
# Convertir peso bruto capturado según tipo de factura
if invoice_weight_type == "KGS":
line.quantity.gross_weight = gross_weight_input
else: # libras
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
# ==========================================
# VALIDAR PESO BRUTO < PESO NETO
# ==========================================
if line.quantity.gross_weight < line.quantity.net_weight:
line.quantity.gross_weight = line.quantity.net_weight + (
package_weight_unit * package_quantity
)
# ==========================================
# ASIGNAR DESCRIPCIÓN DE BULTOS
# ==========================================
if package_quantity and package_quantity > 0 and line.quantity.package_id:
package: Package = (
db.query(Package)
.filter(
Package.id == line.quantity.package_id,
Package.tenant_id == tenant_id,
Package.company_id == company_id,
)
.first()
)
if package:
line.description.package_description = package.description_es
else:
line.quantity.package_quantity = 0
line.quantity.package_id = None
line.description.package_description = None
# ==========================================
# ASIGNAR FRACCIÓN AMERICANA POR DEFECTO
# ==========================================
if not line.customs.american_fraction and class_info and class_info.us_fraction:
line.customs.american_fraction = class_info.us_fraction
# Buscar el advalorem de la fracción americana
if line.customs.american_fraction:
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if us_fraction:
# Si el tipo es 'ME' (Moneda Extranjera), usar costo fijo
# De lo contrario, usar ad valorem
if us_fraction.type_code == "foreign":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
# ==========================================
# ASIGNAR DESCRIPCIONES POR DEFECTO
# ==========================================
if not line.description.description_spanish and class_info:
line.description.description_spanish = class_info.description_es
if not line.description.description_english and class_info:
line.description.description_english = class_info.description_en
# ==========================================
# NORMALIZAR CAMPOS DE TEXTO
# ==========================================
# Convertir a mayúsculas campos que lo requieran
if line.description.brand:
line.description.brand = line.description.brand.upper().strip()
if line.description.model:
line.description.model = line.description.model.upper().strip()
# ==========================================
# VALIDAR Y ASIGNAR PAGO DE IMPUESTO
# ==========================================
# Col. L: Se Pagó Impuesto — opcional, defaults a preferencia del sistema
if line.tax_payment is not None:
# Ya viene como bool desde Pydantic; valor válido por definición de tipo
pass
else:
# TODO: Asignar desde SisExp:PagoImpuesto (preferencias del sistema)
pass
# ==========================================
# VALIDAR Y ASIGNAR FORMA DE PAGO
# ==========================================
# Col. M: Forma de Pago — opcional, debe existir en catálogo si se proporciona
if line.payment_method:
payment_method_exists = (
db.query(PaymentMethod)
.filter(PaymentMethod.key == line.payment_method)
.first()
)
if not payment_method_exists:
errors.add_error(
field=f"line[{line_number}].payment_method",
message=f"La Forma de Pago '{line.payment_method}' no es válida.",
solution=[
"Capturar una Forma de Pago dentro del Catálogo General de Formas de Pago."
],
code="PAYMENT_METHOD_INVALID",
)
else:
# TODO: Asignar desde SisExp:FormaPago (preferencias del sistema)
pass
# ==========================================
# ASIGNAR MÉTODO DE VALORACIÓN POR DEFECTO
# ==========================================
# TODO: Si no se especificó método de valoración, tomar de preferencias del sistema (SisImp:MetValor)

View File

@@ -0,0 +1,251 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from core.exceptions import ErrorCollector
from ...models import LineItem
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
from .common import validate_common
def validate_update(
db: Session,
line: LineItem,
existing_line: LineItem,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
line_number: int,
):
"""
Validar y procesar actualización parcial de línea de importación temporal.
Si un campo no se proporciona, se mantiene el valor existente.
"""
validate_common(db, line, tenant_id, company_id, errors, line_number)
if not errors.has_errors():
# Obtener la factura para acceder a tipo de cambio, moneda y peso
invoice: InvoiceHeader = (
db.query(InvoiceHeader)
.filter(
InvoiceHeader.id == line.invoice_id,
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id
)
.first()
)
if not invoice or not invoice.financials or not invoice.logistics:
errors.add_error(
field=f"line[{line_number}]",
message="No se pudo obtener información de la factura",
solution=[
"Verificar que la factura existe y tiene datos financieros y logísticos"
],
code="INVOICE_DATA_MISSING",
)
return
# ==========================================
# ACTUALIZACIÓN PARCIAL DE CAMPOS
# Si no se proporciona, mantener valor existente
# ==========================================
# Tipo de cambio de la factura
exchange_rate = invoice.financials.exchange_rate or Decimal("1.0")
# Unidad de medida
if not line.unit_of_measure:
line.unit_of_measure = existing_line.unit_of_measure
# Costo unitario
if line.financial.unit_cost_capture is None:
line.financial.unit_cost_capture = existing_line.financial.unit_cost_capture
# Recalcular valores monetarios si el costo o la cantidad cambian
currency_type = invoice.financials.currency_type
unit_cost_capture = line.financial.unit_cost_capture or Decimal("0")
if currency_type in ["USD", "ME"]:
line.financial.unit_cost_usd = unit_cost_capture
line.financial.unit_cost_mxn = unit_cost_capture * exchange_rate
elif currency_type in ["MXN", "MN"]:
line.financial.unit_cost_usd = (unit_cost_capture / exchange_rate) if exchange_rate else Decimal("0")
line.financial.unit_cost_mxn = unit_cost_capture
quantity = line.quantity.quantity if line.quantity.quantity is not None else existing_line.quantity.quantity
if line.financial.unit_cost_usd is not None:
line.financial.value_usd = line.financial.unit_cost_usd * quantity
if line.financial.unit_cost_mxn is not None:
line.financial.value_mxn = line.financial.unit_cost_mxn * quantity
line.financial.customs_value_usd = line.financial.value_usd
line.financial.customs_value_mxn = line.financial.value_mxn
line.financial.value_temp_material_usd = line.financial.value_usd
line.financial.value_temp_material_mxn = line.financial.value_mxn
# Convertir peso neto si se proporcionó
invoice_weight_type = invoice.logistics.weight_type
if line.quantity.net_weight is not None:
# Se proporcionó nuevo peso neto, convertir según tipo
net_weight_input = line.quantity.net_weight
if invoice_weight_type == "KGS":
line.quantity.net_weight = net_weight_input
else: # libras, convertir a kilos
line.quantity.net_weight = net_weight_input / Decimal("2.204624")
else:
# Mantener peso existente
line.quantity.net_weight = existing_line.quantity.net_weight
print(f"After weight conversion: net_weight={line.quantity.net_weight}, gross_weight={line.quantity.gross_weight}, weight_type={invoice_weight_type}")
# Convertir peso bruto si se proporcionó
if line.quantity.gross_weight is not None:
gross_weight_input = line.quantity.gross_weight
if invoice_weight_type == "KGS":
line.quantity.gross_weight = gross_weight_input
else: # libras, convertir a kilos
line.quantity.gross_weight = gross_weight_input / Decimal("2.204624")
else:
# Mantener peso existente
line.quantity.gross_weight = existing_line.quantity.gross_weight
# Cantidad de bultos
if line.quantity.package_quantity is None:
line.quantity.package_quantity = existing_line.quantity.package_quantity
# Clave de bultos
if not line.quantity.package_id:
line.quantity.package_id = existing_line.quantity.package_id
# País de origen
if not line.customs.origin_country:
line.customs.origin_country = existing_line.customs.origin_country
# Fracción arancelaria
if not line.customs.fraction:
line.customs.fraction = existing_line.customs.fraction
# Tipo de fracción
if not line.customs.fraction_type:
line.customs.fraction_type = existing_line.customs.fraction_type
# Sector
if not line.customs.sector:
line.customs.sector = existing_line.customs.sector
# Fracción americana y su advalorem
if line.customs.american_fraction:
# Se proporcionó nueva fracción americana, buscar su advalorem
us_fraction: USTariffFraction = (
db.query(USTariffFraction)
.filter(
USTariffFraction.code == line.customs.american_fraction,
USTariffFraction.tenant_id == tenant_id,
USTariffFraction.company_id == company_id,
)
.first()
)
if us_fraction:
if us_fraction.type_code == "ME":
line.customs.advalorem_american = us_fraction.fixed_cost
else:
line.customs.advalorem_american = us_fraction.ad_valorem
else:
# Mantener fracción americana existente
line.customs.american_fraction = existing_line.customs.american_fraction
line.customs.advalorem_american = existing_line.customs.advalorem_american
# Orden de compra
if not line.order:
line.order = existing_line.order
# Descripciones
if not line.description.description_spanish:
line.description.description_spanish = (
existing_line.description.description_spanish
)
if not line.description.description_english:
line.description.description_english = (
existing_line.description.description_english
)
if not line.description.extra_description:
line.description.extra_description = (
existing_line.description.extra_description
)
# Marca y modelo
if line.description.brand:
line.description.brand = line.description.brand.upper().strip()
else:
line.description.brand = existing_line.description.brand
if line.description.model:
line.description.model = line.description.model.upper().strip()
else:
line.description.model = existing_line.description.model
# ==========================================
# DATOS FA (Activo Fijo) — ACTUALIZACIÓN PARCIAL
# ==========================================
fa_data: FaLineItem = getattr(line, "fa_data", None)
existing_fa_data: FaLineItem = getattr(existing_line, "fa_data", None)
if fa_data is not None and existing_fa_data is not None:
# Col. C: Procedencia de la Importación (TipoMovImpo)
if not fa_data.movement_type_import:
fa_data.movement_type_import = existing_fa_data.movement_type_import
# Col. F: ¿Descarga la línea? (Descarga)
if fa_data.download is None:
fa_data.download = existing_fa_data.download
# Col. D: Factura de Importación — obligatoria sólo si hay descarga
if not fa_data.search_invoice:
fa_data.search_invoice = existing_fa_data.search_invoice
# Col. E: Línea de Importación — obligatoria sólo si hay descarga
if fa_data.search_line is None:
fa_data.search_line = existing_fa_data.search_line
# Subpartidas (EsSubPartida / SubPartida)
if fa_data.is_subitem is None:
fa_data.is_subitem = existing_fa_data.is_subitem
if fa_data.subitem_number is None:
fa_data.subitem_number = existing_fa_data.subitem_number
# Número de parte
if not line.part_number_id:
line.part_number_id = existing_line.part_number_id
# Pago de impuesto
if line.tax_payment is None:
line.tax_payment = existing_line.tax_payment
# Forma de pago
if not line.payment_method:
line.payment_method = existing_line.payment_method
# Método de valoración
if not line.valuation_method:
if existing_line.valuation_method:
line.valuation_method = existing_line.valuation_method
# else: TODO: Tomar de SisImp:MetValor (preferencias del sistema)
# Número de entrada
if not line.description.entry_number:
line.description.entry_number = existing_line.description.entry_number
# Lote
if not line.description.lot:
line.description.lot = existing_line.description.lot

View File

@@ -2,18 +2,8 @@ from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.models import InvoiceFinancials, InvoiceHeader, InvoiceLogistics
from core.exceptions import ErrorCollector
from ....models import LineItem
from ....line_financials.models import LineFinancial
from ....line_financials.schemas import LineFinancialCreate
from ....line_quantities.models import LineQuantity
from ....line_quantities.schemas import LineQuantityCreate
from ....line_customs.models import LineCustom
from ....line_customs.schemas import LineCustomCreate
from ....line_descriptions.models import LineDescription
from ....line_descriptions.schemas import LineDescriptionCreate
from ....line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from ....models import LineItem
from ...models import LineItem
from ...models import LineItem
from api.v1.modules.a76.classes.models import Class

View File

@@ -1,13 +1,14 @@
from sqlalchemy import exists
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists_by_id
from api.v1.modules.a76.items.imports.validators.calculations import apply_calculations
from core.exceptions import ErrorCollector
from sqlalchemy import func
from ....common.fractions import search_fraction_preference
from ....common.common_validators import item_exists
from ....models import LineItem
from ....line_customs.models import FractionType, LineCustom
from ...common.fractions import search_fraction_preference
from ...common.common_validators import item_exists
from ...models import LineItem
from ...line_customs.models import FractionType, LineCustom
from api.v1.modules.a76.items.schemas import LineItemCreate
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
@@ -304,6 +305,10 @@ def validate_common(
unit_of_measure = line.unit_of_measure or (
class_.unit_of_measure if class_ else None
)
#TODO: SSisGen Logic Restringer cantidades decimales para piezas, revisar si es necesario agregar validación similar para otras unidades de medida
#TODO: SSisGen Logic Seguridad
if unit_of_measure == "PZA" and line.quantity.quantity % 1 != 0:
errors.add_error(
field=f"line[{line_number}].quantity.quantity",
@@ -333,3 +338,5 @@ def validate_common(
solution=["Proporciona un número de parte valido."],
code="PART_NUMBER_NOT_FOUND",
)
apply_calculations(db, line, tenant_id, company_id, line_number)

View File

@@ -1,21 +1,12 @@
from decimal import Decimal
from sqlalchemy import func, exists
from sqlalchemy.orm import Session
from ....common.common_validators import count_items
from ...common.common_validators import count_items
from core.exceptions import ErrorCollector
from ....models import LineItem
from ....line_financials.models import LineFinancial
from ....line_financials.schemas import LineFinancialCreate
from ....line_quantities.models import LineQuantity
from ....line_quantities.schemas import LineQuantityCreate
from ....line_customs.models import LineCustom
from ....line_customs.schemas import LineCustomCreate
from ....line_descriptions.models import LineDescription
from ....line_descriptions.schemas import LineDescriptionCreate
from ....line_references.models import LineReference
from ...models import LineItem
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from ....models import LineItem
from ...models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.packages.models import Package
@@ -27,7 +18,7 @@ from .common import validate_common
def validate_create(
db: Session,
line: LineItem, # LineItemCreate schema (Pydantic)
line: LineItem,
tenant_id: int,
company_id: int,
errors: ErrorCollector,
@@ -44,7 +35,7 @@ def validate_create(
"""
# Access fa_data safely
fa_data = getattr(line, "fa_data", None)
fa_data: FaLineItem = getattr(line, "fa_data", None)
# Required field validations
if not line.class_id:

View File

@@ -1,9 +1,8 @@
from decimal import Decimal
from sqlalchemy.orm import Session
from api.v1.modules.a76.invoices.common.common_validators import invoice_exists
from core.exceptions import ErrorCollector
from ....models import LineItem
from ...models import LineItem
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,

View File

@@ -42,6 +42,11 @@ from api.v1.modules.a24.fa.fa_item_lines.dto import (
FaLineItemUpdateDTO,
FaLineItemResponseDTO,
)
from .series.schemas import (
SerieCreate,
SerieUpdate,
SerieResponse,
)
# ============================================================================
@@ -230,6 +235,9 @@ class LineItemCreate(LineItemBase):
fa_data: Optional[FaLineItemCreateDTO] = Field(
None, description="Fixed Asset data for this line"
)
series: Optional[list[SerieCreate]] = Field(
None, description="Series data for this line (multiple per line)"
)
class LineItemUpdate(LineItemBase):
@@ -256,6 +264,9 @@ class LineItemUpdate(LineItemBase):
fa_data: Optional[FaLineItemUpdateDTO] = Field(
None, description="Fixed Asset data for this line"
)
series: Optional[list[SerieUpdate]] = Field(
None, description="Series data for this line (replace all)"
)
class LineItemResponse(LineItemBase):
@@ -283,6 +294,7 @@ class LineItemResponse(LineItemBase):
description: Optional[LineDescriptionResponse] = None
reference: Optional[LineReferenceResponse] = None
fa_data: Optional[FaLineItemResponseDTO] = None
series: Optional[list[SerieResponse]] = None
# Fields populated from relationships
class_code: Optional[str] = None

View File

@@ -0,0 +1,27 @@
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
class SerieBase(BaseModel):
row: Optional[int] = Field(None, description="Serie row number (RENGLON)")
serial_numbers: Optional[str] = Field(None, max_length=50, description="Serial number (SERIEEXPO)")
model: Optional[str] = Field(None, max_length=50, description="Model (MODELOEXPO)")
sub_model: Optional[str] = Field(None, max_length=50, description="Sub model (SUBMODELOEXPO)")
brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)")
expo_brad: Optional[str] = Field(None, max_length=50, description="Expo brand (MARCAEXPO)")
number_id: Optional[str] = Field(None, max_length=25, description="Number ID (NUMIDEXPO)")
class SerieCreate(SerieBase):
pass
class SerieUpdate(SerieBase):
pass
class SerieResponse(SerieBase):
id: int
line_item_id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -24,8 +24,10 @@ from api.v1.modules.a76.invoices.common.common_validators import (
invoice_updated,
)
from core.exceptions import ErrorCollector
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 .schemas import LineItemCreate, LineItemUpdate
from .line_financials.models import LineFinancial
@@ -35,6 +37,7 @@ from .line_descriptions.models import LineDescription
from .line_references.models import LineReference
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
from .models import LineItem
from .series.models import Serie
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.parts.models import Part
@@ -170,12 +173,45 @@ class ItemService:
)
db.add(FaLineItem(**fa_dict))
# Serie data (list: multiple series per line)
if hasattr(line_data, "series") and line_data.series:
series_list = (
line_data.series
if isinstance(line_data.series, list)
else [line_data.series]
)
for s in series_list:
serie_dict = (
s.model_dump(exclude_unset=True)
if hasattr(s, "model_dump")
else (dict(s) if isinstance(s, dict) else {})
)
if not serie_dict:
continue
serie_dict["line_item_id"] = line.id
serie_dict["tenant_id"] = tenant_id
serie_dict["company_id"] = company_id
if serie_dict.get("row") is None:
serie_dict["row"] = 1
db.add(Serie(**serie_dict))
@staticmethod
def _attach_series(db: Session, item: LineItem) -> None:
"""Query and attach all Serie rows for this item as a list."""
series = (
db.query(Serie)
.filter(Serie.line_item_id == item.id)
.order_by(Serie.row, Serie.id)
.all()
)
item.series = list(series)
@staticmethod
def get_by_id(
db: Session, item_id: int, tenant_id: int, company_id: int
) -> Optional[LineItem]:
"""Get an item by ID with tenant/company validation"""
return (
result = (
db.query(LineItem)
.options(
joinedload(LineItem.financial),
@@ -194,6 +230,9 @@ class ItemService:
)
.first()
)
if result:
ItemService._attach_series(db, result)
return result
@staticmethod
def get_all(
@@ -244,6 +283,8 @@ class ItemService:
total = query.count()
items = query.offset(skip).limit(limit).all()
for item in items:
ItemService._attach_series(db, item)
return items, total
@staticmethod
@@ -275,6 +316,8 @@ class ItemService:
total = query.count()
items = query.offset(skip).limit(limit).all()
for item in items:
ItemService._attach_series(db, item)
return items, total
@staticmethod
@@ -292,14 +335,16 @@ class ItemService:
# Validar que la factura exista y no esté actualizada (si viene invoice_id)
if not item_data.invoice_id:
errors.add_required_error(field="invoice_id")
errors.raise_if_errors("Error al crear el item")
errors.raise_if_errors("Error al crear el item - invoice_id es requerido")
if not invoice_exists_by_id(
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al crear el item")
)
if not invoice:
errors.raise_if_errors("Error al encontra la factura para el item")
if not invoice_updated(db, item_data.invoice_id, tenant_id, company_id, errors):
errors.raise_if_errors("Error al crear el item")
errors.raise_if_errors("Error al crear el item - la factura ya fue actualizada, no se pueden agregar items")
# Lock invoice and calculate line number
if not ItemService._lock_invoice(
@@ -329,14 +374,27 @@ class ItemService:
item_data.component_part_number_id = resolved_id
# Validar el item
validate_create(
db,
item_data, # Schema Pydantic completo
tenant_id,
company_id,
errors,
line_number,
)
if invoice.operation_type == "exp":
if invoice.invoice_type == "CR" and invoice.document_type == "AFIJO":
errors.add_error("invoice_id", "No se pueden agregar items a una factura de tipo CR con documento AFIJO", code="INVALID_INVOICE_TYPE")
validate_create_export(
db,
item_data, # Schema Pydantic completo
tenant_id,
company_id,
errors,
line_number,
)
else:
validate_create_import(
db,
item_data, # Schema Pydantic completo
tenant_id,
company_id,
errors,
line_number,
)
# Validaciones adicionales específicas del negocio
if item_data.fa_data and item_data.fa_data.is_subitem is None:
@@ -359,6 +417,7 @@ class ItemService:
"description",
"reference",
"fa_data",
"series",
}
)
@@ -383,6 +442,7 @@ class ItemService:
db.commit()
db.refresh(db_item)
ItemService._attach_series(db, db_item)
return db_item
except IntegrityError as e:
@@ -415,6 +475,12 @@ class ItemService:
# Validaciones con ErrorCollector
errors = ErrorCollector()
invoice = invoice_exists_by_id(
db, item_data.invoice_id, tenant_id, company_id, errors
)
if not invoice:
errors.raise_if_errors("Error al encontra la factura para el item")
# Lock invoice
invoice_id_to_lock = (
item_data.invoice_id if item_data.invoice_id else db_item.invoice_id
@@ -422,7 +488,7 @@ class ItemService:
if not ItemService._lock_invoice(
db, invoice_id_to_lock, tenant_id, company_id, errors
):
errors.raise_if_errors("Error al actualizar el item")
errors.raise_if_errors("Error al actualizar el item")
# Resolve part ID if a string is provided in part_number (alias for part_number_id)
if hasattr(item_data, 'part_number_id') and item_data.part_number_id and not isinstance(item_data.part_number_id, int):
@@ -442,17 +508,28 @@ class ItemService:
if resolved_id:
item_data.component_part_number_id = resolved_id
# Validar el item que se va a actualizar
validate_update(
db,
item_data, # Schema de update
db_item, # LineItem existente en DB
tenant_id,
company_id,
errors,
db_item.line_number,
)
if invoice.operation_type == "exp":
# Validar el item que se va a actualizar
validate_update_export(
db,
item_data, # Schema de update
db_item, # LineItem existente en DB
tenant_id,
company_id,
errors,
db_item.line_number,
)
else:
# Validar el item que se va a actualizar
validate_update_import(
db,
item_data, # Schema de update
db_item, # LineItem existente en DB
tenant_id,
company_id,
errors,
db_item.line_number,
)
# Validar tipo de partida
if hasattr(item_data, "item_type") and item_data.item_type:
@@ -489,6 +566,7 @@ class ItemService:
"description",
"reference",
"fa_data",
"series",
},
exclude_unset=True,
)
@@ -512,6 +590,7 @@ class ItemService:
LineReference.item_line_id == db_item.id
).delete()
db.query(FaLineItem).filter(FaLineItem.id == db_item.id).delete()
db.query(Serie).filter(Serie.line_item_id == db_item.id).delete()
db.flush()
# Create new nested data
@@ -524,6 +603,7 @@ class ItemService:
db.commit()
db.refresh(db_item)
ItemService._attach_series(db, db_item)
return db_item
except HTTPException:

View File

@@ -5,6 +5,8 @@ Placeholders hasta tener el XLS definitivo; ajustar canónicos y aliases según
from typing import Dict, List, Any
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"boms": [
{"canonical": "NUMPARTE_PADRE", "aliases": ["PARTE PADRE", "PART NUMBER", "PARENT PART", "NUM PARTE PADRE"]},
@@ -33,10 +35,10 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -5,6 +5,8 @@ Por ahora misma estructura que encabezado/partidas de exportación; luego se aju
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str
# Cambio de régimen: cam_reg_header, cam_reg_details
# Regularización: regulariz_header, regulariz_details
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
@@ -129,10 +131,10 @@ def row_from_template(row: Dict[str, Any], template_id: str, normalize_header_fn
"""Fila CSV -> dict con nombres canónicos de la plantilla."""
lookup = build_normalized_lookup(template_id, normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -6,6 +6,8 @@ import csv
import io
from typing import Dict, List, Any, Optional, Tuple
from ..common.cell_value import cell_to_str
# Valores que indican que la primera fila es cabecera (primera columna normalizada)
FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE")
@@ -92,16 +94,17 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
elif key_norm.startswith("CLAVE CLASE"):
# CSV leído con delimitador incorrecto: primera columna es "CLAVE CLASE,..." -> usar primer valor como CLASE
if "CLASE" not in out and value:
first_val = (value.split(",")[0] if "," in str(value) else value).strip()
val_str = cell_to_str(value)
first_val = (val_str.split(",")[0] if "," in val_str else val_str).strip()
if first_val:
out["CLASE"] = first_val
return out

View File

@@ -12,6 +12,8 @@ AH=COL_EXTRA (desfase).
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"client_providers": [
# Col A - Procedencia (E=Extranjero, N=Nacional)
@@ -109,10 +111,10 @@ def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos."""
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -0,0 +1,19 @@
"""
Convierte valor de celda CSV a str. Evita 'list' object has no attribute 'strip'
cuando columnas duplicadas o el lector devuelve listas.
"""
from typing import Any
def cell_to_str(value: Any) -> str:
"""
Convierte valor de celda a str.
Si es lista (p. ej. CSV con columnas duplicadas), usa el primer elemento.
"""
if value is None:
return ""
if isinstance(value, list):
if not value:
return ""
return str(value[0]) if value[0] is not None else ""
return str(value)

View File

@@ -11,6 +11,16 @@ LICENSE_MAX = 4
RFC_MAX = 30
CURP_MAX = 19
# Formato RFC/CURP (paridad con flujo manual dashboard/customs_brokers)
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
# CURP: 18 caracteres (inicial, vocal, 2 letras, fecha, sexo, estado, 3 consonantes, homoclave)
CURP_PATTERN = re.compile(
r"^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM]"
r"(AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)"
r"[B-DF-HJ-NP-TV-Z]{3}[0-9A-Z]\d$",
re.IGNORECASE,
)
def check_required_broker_key(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Col B: Clave de Agente Aduanal obligatoria, máx 5 caracteres."""
@@ -174,6 +184,38 @@ def check_curp_max(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any
return None
def check_rfc_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Col E: RFC opcional; si tiene valor, debe cumplir formato RFC (ej. XAXX010101XXX)."""
val = (row.get("RFC") or "").strip()
if not val:
return None
if not RFC_PATTERN.match(val):
clave = (row.get("CLAVE") or "").strip()
return {
"line": line_num,
"col": "RFC",
"msg": "Error: (Col. E) El formato del RFC es inválido.",
"solution": "Capturar en la columna E un RFC con formato válido (ej. XAXX010101XXX).",
}
return None
def check_curp_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Col N: CURP/PERSONAL_ID opcional; si tiene valor, debe cumplir formato CURP de 18 caracteres."""
val = (row.get("PERSONAL_ID") or "").strip()
if not val:
return None
if not CURP_PATTERN.match(val):
clave = (row.get("CLAVE") or "").strip()
return {
"line": line_num,
"col": "PERSONAL_ID",
"msg": "Error: (Col. N) El formato de la CURP es inválido.",
"solution": "Capturar en la columna N un CURP con formato válido de 18 caracteres.",
}
return None
def check_pais_catalogo(
row: Dict[str, Any],
line_num: int,

View File

@@ -8,6 +8,8 @@ Solo se leen columnas definidas aquí; el resto se ignora.
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"customs_brokers": [
# Col A - TIPO (MEX/Mexicano, AME/Americano)
@@ -91,10 +93,10 @@ def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos."""
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -12,6 +12,8 @@ from ..common.common_validators import (
check_patente_obligatoria_si_mex,
check_rfc_max,
check_curp_max,
check_rfc_format,
check_curp_format,
check_pais_catalogo,
)
@@ -40,9 +42,15 @@ def validaciones_agente_aduanal(
if err:
return err
err = check_rfc_max(row, line_num)
if err:
return err
err = check_rfc_format(row, line_num)
if err:
return err
err = check_curp_max(row, line_num)
if err:
return err
err = check_curp_format(row, line_num)
if err:
return err
err = check_pais_catalogo(row, line_num, valid_country_m3)

View File

@@ -4,6 +4,8 @@ Configuracion de plantilla CSV para Conductores (EstructuraCatConductor.xls).
from typing import Dict, List, Any
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"drivers": [
{"canonical": "TRANSPORTISTA", "aliases": ["TRANSPORTISTA CLAVE", "CLAVE TRANSPORTISTA", "TRANSPORTER"]},
@@ -48,10 +50,10 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -5,6 +5,8 @@ Solo se leen columnas definidas aquí; el resto se ignora.
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"exchange_rates": [
{"canonical": "FECHA", "aliases": ["FECHA APLICABLE", "DATE", "FECHA TIPO CAMBIO"]},
@@ -33,10 +35,10 @@ def row_from_template(row: Dict[str, Any], normalize_header_fn, template_id: str
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos."""
lookup = build_normalized_lookup(normalize_header_fn, template_id)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -5,6 +5,7 @@ validación fecha (longitud, día acorde al mes, mes ≤ 12; sin límite de año
"""
from typing import Dict, Any, Optional
from ...common.cell_value import cell_to_str
from ..common.common_validators import (
check_required_value_positive,
check_optional_max_length,
@@ -25,12 +26,14 @@ def validate_row_desfase(raw_row: Dict[str, Any], line_num: int) -> Optional[Dic
Si la fila tiene 3 o más columnas y la 3ª tiene valor, error de desfase (Clarion ColumnaC <> '').
"""
values_ordered = list(raw_row.values()) if raw_row else []
if len(values_ordered) >= 3 and (values_ordered[2] or "").strip():
return {
"line": line_num,
"col": "",
"msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}",
}
if len(values_ordered) >= 3:
cell = cell_to_str(values_ordered[2])
if cell.strip():
return {
"line": line_num,
"col": "",
"msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}",
}
return None

View File

@@ -33,7 +33,7 @@ def _get_redis():
@router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details"],
model_target: Literal["invoice_header", "invoice_details", "invoice_series"],
file: UploadFile = File(...),
footer_config: Optional[str] = Form(None),
template_id: Optional[str] = Form(None),
@@ -56,13 +56,18 @@ async def upload_import_file(
contents = await file.read()
file_key, meta_key, _ = common_storage.storage_keys(JOB_TYPE, job_id)
default_template = (
"exp_def_header" if model_target == "invoice_header"
else "exp_def_series" if model_target == "invoice_series"
else "exp_def_partidas"
)
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"footer_config": footer_config,
"operation_type": operation_type or "exp",
"template_id": template_id or ("exp_def_header" if model_target == "invoice_header" else "exp_def_details"),
"template_id": template_id or default_template,
}
try:

View File

@@ -9,7 +9,7 @@ class ImportJobResponse(BaseModel):
class CommitRequest(BaseModel):
model_target: Literal["invoice_header", "invoice_details"]
model_target: Literal["invoice_header", "invoice_details", "invoice_series"]
class ImportJobStatus(BaseModel):

View File

@@ -1,7 +1,7 @@
"""
Tareas Celery para importación CSV de Exportación (encabezado y partidas).
Flujo: scan_file (sin validaciones) → insert_valid_rows (sin inserción en BD).
Usa layouts_csv.common (storage, normalize, meta, responses, csv_reader).
Para invoice_header: scan_file delega en facturas._do_scan_file (validaciones FK y reporte de errores);
insert_valid_rows delega en facturas._do_insert_valid_rows (inserción en BD). Storage "exp".
"""
import logging
import os
@@ -37,58 +37,74 @@ def _norm_row(row: Dict[str, Any], template_id: str) -> Dict[str, Any]:
@celery_app.task(bind=True, name="api.v1.modules.a76.layouts_csv.exportacion.tasks.scan_file")
def scan_file(self, job_id: str, model_target: str, config: str = None):
"""
Scan CSV sin validaciones: leer, normalizar con plantilla, devolver total_rows y 0 errores.
Para invoice_header: delega en facturas._do_scan_file con storage "exp" (validaciones FK, encabezados_expo).
Para invoice_details: delega en facturas._do_scan_file con template exp_def_partidas (validaciones partidas expo).
"""
logger.info("Exportación import: starting scan for job %s target %s", job_id, model_target)
if model_target == "invoice_header":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_scan_file
return _do_scan_file(job_id, "invoice_header", config, job_type_override="exp")
if model_target == "invoice_details":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_scan_file
return _do_scan_file(job_id, "invoice_details", config, job_type_override="exp")
if model_target == "invoice_series":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_scan_file
return _do_scan_file(job_id, "invoice_series", config, job_type_override="exp")
# Fallback (e.g. unknown model_target)
file_path = _ensure_file(job_id)
if not file_path:
return {"status": "failed", "error": "Archivo no encontrado (expirado o no subido). Sube de nuevo."}
if os.path.getsize(file_path) == 0:
return {"status": "failed", "error": "El archivo está vacío."}
_ensure_meta(job_id, file_path)
try:
common_meta.require_tenant_context(file_path)
except ValueError as e:
return {"status": "failed", "error": str(e)}
meta = common_meta.load_meta(file_path) or {}
template_id = meta.get("template_id") or (
"exp_def_header" if model_target == "invoice_header" else "exp_def_details"
)
total_rows = 0
processed_rows = 0
template_id = meta.get("template_id") or "exp_def_details"
try:
total_rows = common_csv_reader.count_csv_rows(file_path, has_header=True)
except Exception as e:
return {"status": "failed", "error": str(e)}
def on_progress(current: int, total: int) -> None:
self.update_state(state="PROGRESS", meta={"current": current, "total": total})
processed_rows = 0
try:
for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=None):
if i % 500 == 0:
on_progress(i, total_rows)
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows})
_norm_row(row, template_id)
processed_rows += 1
except Exception as e:
logger.error("Exportación import scan failed: %s", e)
return {"status": "failed", "error": str(e)}
return common_responses.scan_result(job_id, processed_rows, 0, [])
@celery_app.task(bind=True, name="api.v1.modules.a76.layouts_csv.exportacion.tasks.insert_valid_rows")
def insert_valid_rows(self, job_id: str, model_target: str):
"""
Commit sin inserción en BD: leer CSV, omitir líneas de error (vacío por ahora), cleanup, devolver finished con inserted=0.
Commit: para invoice_header delega en facturas (inserción real en BD con storage "exp").
Para invoice_details delega en facturas (inserción partidas expo cuando esté implementada; mientras tanto mismo flujo).
"""
logger.info("Exportación import: starting commit for job %s target %s", job_id, model_target)
if model_target == "invoice_header":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_insert_valid_rows
return _do_insert_valid_rows(job_id, "invoice_header", job_type_override="exp")
if model_target == "invoice_details":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_insert_valid_rows
return _do_insert_valid_rows(job_id, "invoice_details", job_type_override="exp")
if model_target == "invoice_series":
from api.v1.modules.a76.layouts_csv.facturas.tasks import _do_insert_valid_rows
return _do_insert_valid_rows(job_id, "invoice_series", job_type_override="exp")
# Fallback: stub sin inserción
file_path = _ensure_file(job_id)
if not file_path:
alt_path = common_storage.file_path_for_job(JOB_TYPE, job_id)
@@ -109,7 +125,7 @@ def insert_valid_rows(self, job_id: str, model_target: str):
error_lines = common_storage.get_error_lines(JOB_TYPE, job_id, error_path)
template_id = meta.get("template_id") or (
"exp_def_header" if model_target == "invoice_header" else "exp_def_details"
"exp_def_header" if model_target == "invoice_header" else "exp_def_partidas"
)
try:

View File

@@ -5,6 +5,8 @@ Misma estructura que facturas exp_def_header / exp_def_details; módulo autocont
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str
# Columnas para encabezado y partidas de exportación (EstructuraEncFacExpoCamReg / EstructuraParExpoCamReg)
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"exp_def_header": [
@@ -55,6 +57,48 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
{"canonical": "FRACCION"},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA"]},
],
# Partidas exportación definitiva (Clarion EstructuraParExpoCamReg: AV)
"exp_def_partidas": [
{"canonical": "NUMERO FACTURA EXPO", "aliases": ["NUMERO FACTURA EXPO.", "NUM FACTURA EXPO", "FACTURA EXPO"]},
{"canonical": "LINEA EXPO", "aliases": ["LINEA EXPO.", "RENGLON EXPO"]},
{"canonical": "TIPO DE IMPO", "aliases": ["TIPO DE IMPO.", "TIPO IMPO", "PROCEDENCIA"]},
{"canonical": "FACTURA IMPO", "aliases": ["FACTURA IMPO.", "FACTURA IMPORTACION"]},
{"canonical": "LINEA IMPO", "aliases": ["LINEA IMPO.", "LINEA IMPORTACION"]},
{"canonical": "GENERA DESCARGA", "aliases": ["GENERA DESCARGA?", "DESCARGA"]},
{"canonical": "CANTIDAD EXPORTADA/DESCARGAR", "aliases": ["CANTIDAD EXPORTADA", "CANTIDAD EXPORTADA/DESCARGAR", "CANTIDAD"]},
{"canonical": "UNIDAD DE MEDIDA", "aliases": ["U.M.", "UNIDAD MEDIDA"]},
{"canonical": "COSTO UNITARIO", "aliases": ["COSTOUNITARIO"]},
{"canonical": "PESO NETO", "aliases": ["PESONETO"]},
{"canonical": "PESO BRUTO", "aliases": ["PESOBRUTO"]},
{"canonical": "SE PAGO IMPUESTO", "aliases": ["SE PAGO IMPUESTO? (SI o NO)", "SEPAGOIMPUESTO"]},
{"canonical": "FORMA DE PAGO", "aliases": ["FORMADEPAGO", "FORMA PAGO"]},
{"canonical": "DESCRIPCION EXTRA", "aliases": ["DESCRIPCION EXTRA", "DESCRIPCIONEXTRA"]},
{"canonical": "INFORMACION ADICIONAL", "aliases": ["INFORMACION ADICIONAL", "INFORMACIONADICIONAL"]},
{"canonical": "AGREGAR(A)/SUSTITUIR(S)", "aliases": ["AGREGAR(A)/SUSTITUIR(S)", "AGREGAR/SUSTITUIR", "SUSTITUIR"]},
{"canonical": "LOTE"},
{"canonical": "NUMERO ENTRADA", "aliases": ["NUMERO ENTRADA", "NUM ENTRADA"]},
{"canonical": "ES PARTIDA/SUBPARTIDA", "aliases": ["ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA"]},
{"canonical": "LINEA PRINCIPAL", "aliases": ["LINEAPRINCIPAL", "PARTIDA PRINCIPAL"]},
{"canonical": "FRACCION AMERICANA", "aliases": ["FRACCION AMERICANA", "FRACCIONAMERICANA"]},
{"canonical": "FRACCION ARANCELARIA", "aliases": ["FRACCION ARANCELARIA", "FRACCIONARANCELARIA"]},
{"canonical": "CANTIDAD BULTOS", "aliases": ["CANTIDADBULTOS"]},
{"canonical": "CLAVE BULTOS", "aliases": ["CLAVEBULTOS"]},
{"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN", "PAIS"]},
{"canonical": "NUM. PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE", "NUM PARTE"]},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA", "ORDEN DE VENTA"]},
],
# Series de exportación definitiva (misma estructura que imp_def_series; Clarion SERIES EXPO)
"exp_def_series": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "FACTURA EXPO"]},
{"canonical": "LINEA FACTURA", "aliases": ["LINEA", "PARTIDA"]},
{"canonical": "LINEA SERIE", "aliases": ["RENGLON", "LINEA SER"]},
{"canonical": "SERIE", "aliases": ["NUMERO SERIE"]},
{"canonical": "MODELO"},
{"canonical": "NUM PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE", "NUM PART"]},
{"canonical": "SUB MODELO", "aliases": ["SUBMODELO", "SUB MODE"]},
{"canonical": "NUMERO ID", "aliases": ["NUMEROID"]},
{"canonical": "COL_EXTRA"},
],
}
@@ -80,10 +124,10 @@ def row_from_template(row: Dict[str, Any], template_id: str, normalize_header_fn
"""Fila CSV -> dict con nombres canónicos de la plantilla."""
lookup = build_normalized_lookup(template_id, normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -35,7 +35,7 @@ def _get_redis():
@router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details"],
model_target: Literal["invoice_header", "invoice_details", "invoice_series"],
file: UploadFile = File(...),
footer_config: Optional[str] = Form(None), # JSON string with settings
template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas

View File

@@ -7,7 +7,7 @@ class ImportJobResponse(BaseModel):
message: str
class CommitRequest(BaseModel):
model_target: Literal["invoice_header", "invoice_details"]
model_target: Literal["invoice_header", "invoice_details", "invoice_series"]
class ImportJobStatus(BaseModel):
status: str

File diff suppressed because it is too large Load Diff

View File

@@ -6,69 +6,256 @@ Solo se escribe en BD lo que los modelos de facturas aceptan (respetando models)
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str
# Cada plantilla define sus columnas canónicas y alias (otros nombres que aceptamos en el CSV).
# canonical = nombre estándar con el que trabajamos internamente; debe coincidir con lo que
# espera la lógica de validación e insert (tasks.py).
# aliases = cabeceras alternativas que la plantilla .xls puede traer (ej. "Num Factura" → NUM FACTURA).
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
# --- Encabezado factura: Impo Temp (EstructuraEncFacImpoTemp.xls) ---
# --- Encabezado factura: Impo Temp (EstructuraEncFacImpoTemp.xls) - Clarion A-AD ---
"imp_temp_header": [
{"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]},
{"canonical": "REMESA"},
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]},
{"canonical": "FECHA FACTURA", "aliases": ["FECHA"]},
{"canonical": "FECHA EMISION"},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]},
{"canonical": "CLAVE PROVEEDOR"},
{"canonical": "CLAVE VENDIDO A"},
{"canonical": "CLAVE ENVIADO A"},
{"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]},
{"canonical": "ADUANA DE CRUCE"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "TIPO MONEDA"},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "TIPO PESO"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "REMESA"},
{"canonical": "AGENTE ADUANAL"},
{"canonical": "CLAVE TRANSPORTISTA"},
{"canonical": "NOMBRE CONDUCTOR"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "NUMERO TRANSPORTE"},
{"canonical": "TIPO MONEDA"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "FLETES"},
{"canonical": "VALOR SEGUROS"},
{"canonical": "SEGUROS"},
{"canonical": "EMBALAJES"},
{"canonical": "OTROS INCREMENTABLES"},
{"canonical": "NUM PROYECTO", "aliases": ["NUMPROYECTO"]},
{"canonical": "ORDEN COMPRA", "aliases": ["ORDENCOMPRA"]},
{"canonical": "FACTURA ALTERNA"},
{"canonical": "FACTURA EXPO REF", "aliases": ["FACTURAEXPOREF"]},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "PRECINTO"},
{"canonical": "FECHA EMISION"},
{"canonical": "TIPO PESO"},
{"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]},
{"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]},
{"canonical": "ADUANA DE CRUCE"},
{"canonical": "OBSERVACIONES E"},
{"canonical": "OBSERVACIONES I"},
{"canonical": "E DOCUMENT"},
{"canonical": "NUM OPERACION"},
{"canonical": "FACTURA ALTERNA"},
{"canonical": "NUM PROYECTO", "aliases": ["NUMPROYECTO"]},
{"canonical": "ORDEN COMPRA", "aliases": ["ORDENCOMPRA"]},
{"canonical": "FACTURA EXPO REF", "aliases": ["FACTURAEXPOREF"]},
],
# --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - 30 columnas Clarion ---
"imp_def_header": [
{"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]},
{"canonical": "REMESA"},
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]},
{"canonical": "FECHA FACTURA", "aliases": ["FECHA"]},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]},
{"canonical": "CLAVE PROVEEDOR"},
{"canonical": "CLAVE VENDIDO A", "aliases": ["CLAVE VENDIDO A:"]},
{"canonical": "CLAVE ENVIADO A"},
{"canonical": "AGENTE ADUANAL"},
{"canonical": "CLAVE TRANSPORTISTA"},
{"canonical": "NOMBRE CONDUCTOR"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "NUMERO TRANSPORTE"},
{"canonical": "TIPO MONEDA"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "FLETES"},
{"canonical": "VALOR SEGUROS"},
{"canonical": "SEGUROS"},
{"canonical": "EMBALAJES"},
{"canonical": "OTROS INCREMENTABLES"},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "PRECINTO"},
{"canonical": "FECHA EMISION"},
{"canonical": "TIPO PESO"},
{"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]},
{"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]},
{"canonical": "ADUANA DE CRUCE"},
{"canonical": "OBSERVACIONES E"},
{"canonical": "OBSERVACIONES I"},
],
# --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - misma estructura ---
"imp_def_header": None, # se resuelve igual que imp_temp_header
# --- Encabezado factura: Expo (EstructuraEncFacExpoCamReg.xls) - misma estructura ---
"exp_def_header": None,
# --- Partidas factura: Impo Temp (EstructuraParFacImpoTempAF.xls) ---
# --- Encabezado factura: Expo (EstructuraEncFacExpoCamReg.xls) - Clarion A-AE ---
# PEDIMENTO, REMESA, NUMERO FACTURA, FECHA FACTURA, TIPO DE CAMBIO, REGIMEN, CLAVE PROVEEDOR,
# CLAVE VENDIDO A:, CLAVE ENVIADO A, AGENTE ADUANAL, ... MANIFIESTO, E-DOCUMENT, NUM. OPERACION,
# ENVIADO POR, ADUANA DE CRUCE, OBSERVACIONES E, OBSERVACIONES I, FACTURA ALTERNA
"exp_def_header": [
{"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]},
{"canonical": "REMESA"},
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]},
{"canonical": "FECHA FACTURA", "aliases": ["FECHA"]},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]},
{"canonical": "CLAVE PROVEEDOR"},
{"canonical": "CLAVE VENDIDO A", "aliases": ["CLAVE VENDIDO A:"]},
{"canonical": "CLAVE ENVIADO A"},
{"canonical": "AGENTE ADUANAL"},
{"canonical": "CLAVE TRANSPORTISTA"},
{"canonical": "NOMBRE CONDUCTOR"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "NUMERO TRANSPORTE"},
{"canonical": "TIPO MONEDA"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "FLETES"},
{"canonical": "VALOR SEGUROS"},
{"canonical": "SEGUROS"},
{"canonical": "EMBALAJES"},
{"canonical": "OTROS INCREMENTABLES"},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "PRECINTO"},
{"canonical": "TIPO PESO"},
{"canonical": "MANIFIESTO"},
{"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]},
{"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]},
{"canonical": "ENVIADO POR"},
{"canonical": "ADUANA DE CRUCE"},
{"canonical": "OBSERVACIONES E"},
{"canonical": "OBSERVACIONES I"},
{"canonical": "FACTURA ALTERNA"},
],
# --- Encabezado factura: Compras Mexicanas (Clarion VALIDA_TODA_FAC_COM_MEX / VALIDA_PARCIAL) ---
# Estructura CSV: A,B=CAPTURAR CMEX; C=NUMERO FACTURA; D=FECHA FACTURA; E=TIPO DE CAMBIO; F=CAPTURAR CMEX;
# G=CLAVE PROVEEDOR; H=CLAVE VENDIDO A; I=CLAVE ENVIADO A; J=CAPTURAR CMEX; K=CLAVE TRANSPORTISTA; ...; Z=OBSERVACIONES E
"cmex_header": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA"]},
{"canonical": "FECHA FACTURA", "aliases": ["FECHA"]},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "CLAVE PROVEEDOR"},
{"canonical": "CLAVE VENDIDO A", "aliases": ["CLAVE VENDIDO A:"]},
{"canonical": "CLAVE ENVIADO A"},
{"canonical": "CLAVE TRANSPORTISTA"},
{"canonical": "NOMBRE CONDUCTOR"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "NUMERO TRANSPORTE"},
{"canonical": "TIPO MONEDA"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "FLETES"},
{"canonical": "VALOR SEGUROS"},
{"canonical": "SEGUROS"},
{"canonical": "EMBALAJES"},
{"canonical": "OTROS INCREMENTABLES"},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "PRECINTO"},
{"canonical": "FECHA EMISION"},
{"canonical": "TIPO PESO"},
{"canonical": "OBSERVACIONES E"},
],
# --- Partidas factura: Impo Temp (EstructuraParFacImpoTemp - paridad Clarion A-AG) ---
"imp_temp_details": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA"]},
{"canonical": "LINEA", "aliases": ["RENGLON", "PARTIDA"]},
{"canonical": "NUMPARTE", "aliases": ["NUMERO PARTE"]},
{"canonical": "PRECIO UNITARIO", "aliases": ["PRECIOUNITARIO"]},
{"canonical": "VALOR COMERCIAL", "aliases": ["VALORCOMERCIAL"]},
{"canonical": "CANTIDAD"},
{"canonical": "CLASE"},
{"canonical": "CANTIDAD IMPORTADA", "aliases": ["CANTIDAD"]},
{"canonical": "UNIDAD DE MEDIDA", "aliases": ["UNIDAD MEDIDA"]},
{"canonical": "COSTO UNITARIO", "aliases": ["COSTOUNITARIO", "PRECIO UNITARIO", "PRECIOUNITARIO"]},
{"canonical": "PESO NETO", "aliases": ["PESONETO"]},
{"canonical": "PESO BRUTO", "aliases": ["PESOBRUTO"]},
{"canonical": "CANTIDAD BULTOS", "aliases": ["CANTIDADBULTOS"]},
{"canonical": "DESCRIPCION"},
{"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN"]},
{"canonical": "FRACCION"},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA"]},
{"canonical": "CLAVE BULTOS", "aliases": ["CLAVEBULTOS"]},
{"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN", "PAIS"]},
{"canonical": "FRACCION ARANCELARIA", "aliases": ["FRACCION", "FRACCIONARANCELARIA"]},
{"canonical": "PREFERENCIA ARANCELARIA", "aliases": ["PREFERENCIA", "PREFERENCIAARANCELARIA"]},
{"canonical": "SECTOR"},
{"canonical": "FRACCION AMERICANA", "aliases": ["FRACCIONAMERICANA"]},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA", "ORDEN COMPRA"]},
{"canonical": "DESCRIPCION ESPAÑOL", "aliases": ["DESCRIPCIONE", "DESCRIPCION"]},
{"canonical": "DESCRIPCION INGLES", "aliases": ["DESCRIPCION INGLES", "DESCRIPCIONI"]},
{"canonical": "MARCA"},
{"canonical": "MODELO"},
{"canonical": "ES PARTIDA O SUBPARTIDA", "aliases": ["ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA"]},
{"canonical": "LINEA PRINCIPAL", "aliases": ["LINEAPRINCIPAL", "PARTIDA PRINCIPAL"]},
{"canonical": "NUM. PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE", "NUM PARTE"]},
{"canonical": "SE PAGO IMPUESTO", "aliases": ["SE PAGO IMPUESTO", "SEPAGOIMPUESTO"]},
{"canonical": "FORMA DE PAGO", "aliases": ["FORMADEPAGO", "FORMA PAGO"]},
{"canonical": "METODO DE VALORACION", "aliases": ["METODODEVALORACION", "METODO VALORACION"]},
{"canonical": "DESCRIPCION EXTRA", "aliases": ["DESCRIPCIONEXTRA"]},
{"canonical": "INFORMACION ADICIONAL", "aliases": ["INFORMACIONADICIONAL"]},
{"canonical": "AGREGAR/SUSTITUIR", "aliases": ["AGREGAR SUSTITUIR", "SUSTITUIR"]},
{"canonical": "TOTAL"},
{"canonical": "NUMERO ENTRADA", "aliases": ["NUMEROENTRADA", "NUM ENTRADA"]},
{"canonical": "LOTE"},
{"canonical": "ID TYPE", "aliases": ["IDTYPE"]},
],
# --- Partidas: Impo Def y Expo - misma estructura ---
"imp_def_details": None,
"exp_def_details": None,
# --- Partidas exportación definitiva (Clarion EstructuraParExpoCamReg AV) ---
"exp_def_partidas": [
{"canonical": "NUMERO FACTURA EXPO", "aliases": ["NUMERO FACTURA EXPO.", "NUM FACTURA EXPO", "FACTURA EXPO"]},
{"canonical": "LINEA EXPO", "aliases": ["LINEA EXPO.", "RENGLON EXPO"]},
{"canonical": "TIPO DE IMPO", "aliases": ["TIPO DE IMPO.", "TIPO IMPO", "PROCEDENCIA"]},
{"canonical": "FACTURA IMPO", "aliases": ["FACTURA IMPO.", "FACTURA IMPORTACION"]},
{"canonical": "LINEA IMPO", "aliases": ["LINEA IMPO.", "LINEA IMPORTACION"]},
{"canonical": "GENERA DESCARGA", "aliases": ["GENERA DESCARGA?", "DESCARGA"]},
{"canonical": "CANTIDAD EXPORTADA/DESCARGAR", "aliases": ["CANTIDAD EXPORTADA", "CANTIDAD EXPORTADA/DESCARGAR", "CANTIDAD"]},
{"canonical": "UNIDAD DE MEDIDA", "aliases": ["U.M.", "UNIDAD MEDIDA"]},
{"canonical": "COSTO UNITARIO", "aliases": ["COSTOUNITARIO"]},
{"canonical": "PESO NETO", "aliases": ["PESONETO"]},
{"canonical": "PESO BRUTO", "aliases": ["PESOBRUTO"]},
{"canonical": "SE PAGO IMPUESTO", "aliases": ["SE PAGO IMPUESTO? (SI o NO)", "SEPAGOIMPUESTO"]},
{"canonical": "FORMA DE PAGO", "aliases": ["FORMADEPAGO", "FORMA PAGO"]},
{"canonical": "DESCRIPCION EXTRA", "aliases": ["DESCRIPCION EXTRA", "DESCRIPCIONEXTRA"]},
{"canonical": "INFORMACION ADICIONAL", "aliases": ["INFORMACION ADICIONAL", "INFORMACIONADICIONAL"]},
{"canonical": "AGREGAR(A)/SUSTITUIR(S)", "aliases": ["AGREGAR(A)/SUSTITUIR(S)", "AGREGAR/SUSTITUIR", "SUSTITUIR"]},
{"canonical": "LOTE"},
{"canonical": "NUMERO ENTRADA", "aliases": ["NUMERO ENTRADA", "NUM ENTRADA"]},
{"canonical": "ES PARTIDA/SUBPARTIDA", "aliases": ["ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA"]},
{"canonical": "LINEA PRINCIPAL", "aliases": ["LINEAPRINCIPAL", "PARTIDA PRINCIPAL"]},
{"canonical": "FRACCION AMERICANA", "aliases": ["FRACCION AMERICANA", "FRACCIONAMERICANA"]},
{"canonical": "FRACCION ARANCELARIA", "aliases": ["FRACCION ARANCELARIA", "FRACCIONARANCELARIA"]},
{"canonical": "CANTIDAD BULTOS", "aliases": ["CANTIDADBULTOS"]},
{"canonical": "CLAVE BULTOS", "aliases": ["CLAVEBULTOS"]},
{"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN", "PAIS"]},
{"canonical": "NUM. PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE", "NUM PARTE"]},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA", "ORDEN DE VENTA"]},
],
# --- Series de Importación Temporal (EstructuraSeriesFacImpoTemp.xls) ---
# Clarion: NUMERO FACTURA, LINEA FACTURA, LINEA SERIE, SERIE, MODELO, NUM PARTE, SUB MODELO, NUMERO ID
"imp_temp_series": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA"]},
{"canonical": "LINEA FACTURA", "aliases": ["LINEA", "PARTIDA"]},
{"canonical": "LINEA SERIE", "aliases": ["RENGLON"]},
{"canonical": "SERIE", "aliases": ["NUMERO SERIE"]},
{"canonical": "MODELO"},
{"canonical": "NUM PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE"]},
{"canonical": "SUB MODELO", "aliases": ["SUBMODELO"]},
{"canonical": "NUMERO ID", "aliases": ["NUMEROID"]},
{"canonical": "COL_EXTRA"}, # Optional; if has value → desfase warning (Clarion)
],
# --- Series de Importación Definitiva (misma estructura que TEM; cabeceras imagen: NUMERO/LINEA FAC, LINEA SER, etc.) ---
"imp_def_series": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "NUMERO / LINEA FAC"]},
{"canonical": "LINEA FACTURA", "aliases": ["LINEA", "PARTIDA"]},
{"canonical": "LINEA SERIE", "aliases": ["RENGLON", "LINEA SER"]},
{"canonical": "SERIE", "aliases": ["NUMERO SERIE"]},
{"canonical": "MODELO"},
{"canonical": "NUM PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE", "NUM PART"]},
{"canonical": "SUB MODELO", "aliases": ["SUBMODELO", "SUB MODE"]},
{"canonical": "NUMERO ID", "aliases": ["NUMEROID"]},
{"canonical": "COL_EXTRA"},
],
# --- Series de Exportación Definitiva (Clarion VALIDA_TODA_SERIES_EXPO / VALIDA_PARCIAL_SERIES_EXPO) ---
# Misma estructura: NUMERO FACTURA, LINEA FACTURA, LINEA SERIE, SERIE, MODELO, NUM PARTE, SUB MODELO, NUMERO ID
"exp_def_series": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "FACTURA EXPO"]},
{"canonical": "LINEA FACTURA", "aliases": ["LINEA", "PARTIDA"]},
{"canonical": "LINEA SERIE", "aliases": ["RENGLON", "LINEA SER"]},
{"canonical": "SERIE", "aliases": ["NUMERO SERIE"]},
{"canonical": "MODELO"},
{"canonical": "NUM PARTE", "aliases": ["NUMPARTE", "NUMERO PARTE", "NUM PART"]},
{"canonical": "SUB MODELO", "aliases": ["SUBMODELO", "SUB MODE"]},
{"canonical": "NUMERO ID", "aliases": ["NUMEROID"]},
{"canonical": "COL_EXTRA"},
],
}
@@ -76,10 +263,18 @@ def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]
cols = TEMPLATE_COLUMNS.get(template_id)
if cols is not None:
return cols
if template_id in ("imp_def_header", "exp_def_header"):
if template_id == "imp_def_header":
return TEMPLATE_COLUMNS.get("imp_temp_header")
if template_id in ("imp_def_details", "exp_def_details"):
return TEMPLATE_COLUMNS.get("imp_temp_details")
if template_id == "exp_def_partidas":
return TEMPLATE_COLUMNS.get("exp_def_partidas")
if template_id == "cmex_details":
return TEMPLATE_COLUMNS.get("imp_temp_details")
if template_id == "cmex_series":
return TEMPLATE_COLUMNS.get("imp_def_series")
if template_id == "exp_def_series":
return TEMPLATE_COLUMNS.get("exp_def_series")
return None
@@ -108,11 +303,10 @@ def row_from_template(row: Dict[str, Any], template_id: str, normalize_header_fn
"""
lookup = build_normalized_lookup(template_id, normalize_header_fn)
if not lookup:
# Sin template definido: comportamiento legacy (normalizar todo)
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -0,0 +1,40 @@
# Validators for invoice CSV imports (header, details, series).
from .encabezados_impo_temp import (
validate_row_encabezados_impo_temp,
row_to_transport_type_clarion,
parse_pedimento_col_a,
_pedimento_key_from_parsed,
)
from .encabezados_impo_def import (
validate_row_encabezados_impo_def,
parse_pedimento_col_a_impo_def,
)
from .encabezados_cmex import validate_row_encabezados_cmex
from .encabezados_expo import validate_row_encabezados_expo
from .partidas_expo import validate_row_partidas_expo
from .partidas_impo_def import validate_row_partidas_impo_def
from .series_impo_def import (
validate_row_series_impo_def,
row_to_series_normalized_def,
)
from .series_expo import (
validate_row_series_expo,
row_to_series_normalized_expo,
)
__all__ = [
"validate_row_encabezados_impo_temp",
"validate_row_encabezados_impo_def",
"validate_row_encabezados_cmex",
"validate_row_encabezados_expo",
"validate_row_partidas_expo",
"validate_row_partidas_impo_def",
"validate_row_series_impo_def",
"row_to_series_normalized_def",
"validate_row_series_expo",
"row_to_series_normalized_expo",
"row_to_transport_type_clarion",
"parse_pedimento_col_a",
"parse_pedimento_col_a_impo_def",
"_pedimento_key_from_parsed",
]

View File

@@ -0,0 +1,280 @@
"""
Validaciones CSV para Encabezados de Facturas de Compras Mexicanas.
Paridad Clarion: VALIDA_TODA_FAC_COM_MEX, VALIDA_PARCIAL_FAC_COM_MEX, VALIDACIONES_FAC_COM_MEX.
Sin pedimento, remesa, agente aduanal ni aduana de cruce.
Estructura CSV: NUMERO FACTURA (C), FECHA FACTURA (D), TIPO DE CAMBIO (E), CLAVE PROVEEDOR (G), ...
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Dict, List, Optional, Set
from .encabezados_impo_temp import (
MAX_LEN_FACTURA,
TIPO_PESO_VALIDOS,
TIPOS_MONEDA_VALIDOS,
_clip,
_err,
_get,
_parse_decimal,
_parse_int,
_validaciones_factura_longitud,
_validaciones_moneda,
_validaciones_tipo_cambio,
_validaciones_tipo_peso,
)
# Clarion Col M: Compras Mexicanas incluye "FERRO BARCAZA" (con espacio) y "PLATAFORMA"
TIPO_TRANSPORTE_VALIDOS_CMEX = frozenset({
"NINGUNO", "TRANSPORTE", "CAJA", "PLACAS", "CAMION", "BUQUE",
"FERROBARCAZA", "FERRO BARCAZA", "CONTENEDOR", "PLATAFORMA", "AVION",
})
def _validaciones_transporte_cmex(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Tipo transporte (Col M) y número (Col N). Acepta FERRO BARCAZA y FERROBARCAZA."""
m_raw = _get(row, "TIPO TRANSPORTE")
m = m_raw.upper().replace(" ", "") if m_raw else ""
m_with_space = m_raw.upper() if m_raw else ""
n = _get(row, "NUMERO TRANSPORTE")
if m_raw and m_with_space not in TIPO_TRANSPORTE_VALIDOS_CMEX and m not in TIPO_TRANSPORTE_VALIDOS_CMEX:
return _err(
line_num,
"TIPO TRANSPORTE",
"Error: (Celda M{}) El Tipo de Transporte: {} no es válido. "
"Válidos: NINGUNO, TRANSPORTE, CAJA, PLACAS, CAMION, BUQUE, FERRO BARCAZA, CONTENEDOR, PLATAFORMA, AVION.".format(
line_num, m_raw
),
)
if not m_raw and n:
return _err(
line_num,
"NUMERO TRANSPORTE",
"Error: (Celda N{}) El Tipo de Transporte está vacío y está capturado un número de transporte.".format(line_num),
)
if m_raw and (m == "NINGUNO" or m_with_space == "NINGUNO") and n:
return _err(
line_num,
"NUMERO TRANSPORTE",
"Error: (Celda N{}) El Tipo de Transporte es NINGUNO y está capturado un número de transporte.".format(line_num),
)
if m_raw and m != "NINGUNO" and m_with_space != "NINGUNO" and not n:
return _err(
line_num,
"NUMERO TRANSPORTE",
"Error: (Celda N{}) El Tipo de Transporte es {} y no está capturado el número de transporte.".format(line_num, m_raw),
)
return None
def _validaciones_obligatorios_toda_cmex(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
) -> Optional[Dict[str, Any]]:
"""Obligatorios VALIDA_TODA_FAC_COM_MEX: C siempre; si no es actualizar, también D, G, H, I."""
obligatorios: List[str] = []
if not _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA"):
obligatorios.append("(Col.C) Factura")
if not actualizar:
if not _get(row, "FECHA FACTURA", "FECHA"):
obligatorios.append("(Col.D) Fecha de la Factura")
if not _get(row, "CLAVE PROVEEDOR"):
obligatorios.append("(Col.G) Clave del Proveedor")
if not _get(row, "CLAVE VENDIDO A"):
obligatorios.append("(Col.H) Clave del Vendido A")
if not _get(row, "CLAVE ENVIADO A"):
obligatorios.append("(Col.I) Clave del Enviado A")
if obligatorios:
return _err(
line_num,
"ARCHIVO CSV",
"Existen campos vacíos que son obligatorios: {}. Revisar la línea del archivo y capturar los campos con la información correcta.".format(
", ".join(obligatorios)
),
)
return None
def _validaciones_catalogos_cmex(
row: Dict[str, Any],
line_num: int,
valid_provider_ids: Set[int],
valid_sold_to_ids: Set[int],
valid_shipped_to_ids: Set[int],
valid_provider_short_names: Set[str],
valid_sold_to_short_names: Set[str],
valid_shipped_to_short_names: Set[str],
valid_transporter_keys: Set[str],
valid_incoterms: Set[str],
) -> Optional[Dict[str, Any]]:
"""Catálogos para Compras Mexicanas: Proveedor, Vendido A, Enviado A, Transportista, Incoterm (sin Agente Aduanal ni Aduana)."""
def check_id_or_rfc(
val: Any,
col: str,
catalog_name: str,
valid_ids: Set[int],
valid_short_names: Set[str],
) -> Optional[Dict[str, Any]]:
if val is None or str(val).strip() == "":
return None
v = _parse_int(val)
if v is not None:
if valid_ids and v not in valid_ids:
return _err(line_num, col, "Error: La clave en {} no existe en el Catálogo de {}.".format(col, catalog_name))
return None
sn_norm = str(val).strip().upper()
if valid_short_names and sn_norm not in valid_short_names:
return _err(line_num, col, "Error: La clave/corta en {} no existe en el Catálogo de {}.".format(col, catalog_name))
if not valid_short_names:
return _err(line_num, col, "Error: (Celda) {} debe ser un número entero o clave corta (short name) válida.".format(col))
return None
err = check_id_or_rfc(
row.get("CLAVE PROVEEDOR"), "CLAVE PROVEEDOR", "Clientes/Proveedores",
valid_provider_ids, valid_provider_short_names,
)
if err:
return err
err = check_id_or_rfc(
row.get("CLAVE VENDIDO A"), "CLAVE VENDIDO A", "Clientes/Proveedores",
valid_sold_to_ids, valid_sold_to_short_names,
)
if err:
return err
err = check_id_or_rfc(
row.get("CLAVE ENVIADO A"), "CLAVE ENVIADO A", "Clientes/Proveedores",
valid_shipped_to_ids, valid_shipped_to_short_names,
)
if err:
return err
k = _get(row, "CLAVE TRANSPORTISTA")
if k and valid_transporter_keys and k.upper() not in valid_transporter_keys:
return _err(
line_num,
"CLAVE TRANSPORTISTA",
"Error: (Celda K{}) La Clave del Transportista: {} no existe en el Catálogo de Transportistas.".format(line_num, k),
)
v = _get(row, "CLAVE INCOTERM")
if v and valid_incoterms and v.upper() not in valid_incoterms:
return _err(
line_num,
"CLAVE INCOTERM",
"Error: (Celda V{}) La Clave de INCOTERM: {} no existe en el Catálogo de INCOTERMS.".format(line_num, v),
)
return None
def validate_row_encabezados_cmex(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
invoice_exists_by_number: Dict[str, bool],
invoice_updated_by_number: Dict[str, bool],
valid_provider_ids: Set[int],
valid_sold_to_ids: Set[int],
valid_shipped_to_ids: Set[int],
valid_transporter_keys: Set[str],
valid_incoterms: Set[str],
valid_currency_codes: Set[str],
exchange_rate_by_date: Optional[Dict[str, Any]] = None,
invoice_has_partidas_by_number: Optional[Dict[str, bool]] = None,
existing_tipo_moneda_by_number: Optional[Dict[str, str]] = None,
valid_provider_short_names: Optional[Set[str]] = None,
valid_sold_to_short_names: Optional[Set[str]] = None,
valid_shipped_to_short_names: Optional[Set[str]] = None,
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Valida una fila de CSV de Encabezados de Compras Mexicanas.
Clarion: VALIDA_TODA_FAC_COM_MEX (factura nueva o no actualizar) vs VALIDA_PARCIAL_FAC_COM_MEX (actualizar existente).
Siempre ejecuta VALIDACIONES_FAC_COM_MEX (longitud C, tipo cambio, catálogos G/H/I/K, transporte M/N, moneda O/P, incoterm V, tipo peso Y).
"""
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.",
)
if invoice_updated_by_number.get(factura.strip(), False):
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Celda C{}) El Número de Factura: {} ya existe y está Actualizada, no se puede hacer cambios.".format(
line_num, factura
),
)
if actualizar and factura.strip() not in invoice_exists_by_number:
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) Factura de importación {} no existe (modo Actualizar).".format(factura),
)
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
if not use_partial:
err = _validaciones_obligatorios_toda_cmex(row, line_num, actualizar)
if err:
return err
err = _validaciones_factura_longitud(row, line_num)
if err:
return err
err = _validaciones_transporte_cmex(row, line_num)
if err:
return err
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
err = _validaciones_moneda(
row,
line_num,
valid_currency_codes or set(),
has_partidas if use_partial else None,
existing_moneda if use_partial else None,
)
if err:
return err
err = _validaciones_tipo_peso(row, line_num)
if err:
return err
err = _validaciones_catalogos_cmex(
row,
line_num,
valid_provider_ids or set(),
valid_sold_to_ids or set(),
valid_shipped_to_ids or set(),
valid_provider_short_names or set(),
valid_sold_to_short_names or set(),
valid_shipped_to_short_names or set(),
valid_transporter_keys or set(),
valid_incoterms or set(),
)
if err:
return err
invoice_date_parsed = None
if parse_date_fn:
date_str = _get(row, "FECHA FACTURA", "FECHA")
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
err = _validaciones_tipo_cambio(
row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
)
if err:
return err
return None

View File

@@ -0,0 +1,464 @@
"""
Validaciones CSV para Encabezados de Facturas de Exportación (Expo Def) y Cambio de Régimen.
Paridad Clarion: VALIDA_TODA_FAC_EXPO, VALIDA_PARCIAL_FAC_EXPO, VALIDACIONES_FAC_EXPO.
Estructura CSV: PEDIMENTO (A), REMESA (B), NUMERO FACTURA (C), ... MANIFIESTO (Y), E-DOCUMENT (Z),
NUM. OPERACION (AA), ENVIADO POR (AB), ADUANA DE CRUCE (AC), OBSERVACIONES E/I, FACTURA ALTERNA.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional, Set
from .encabezados_impo_temp import (
_clip,
_err,
_get,
_parse_int,
_pedimento_key_from_parsed,
_validaciones_catalogos,
_validaciones_factura_longitud,
_validaciones_moneda,
_validaciones_tipo_cambio,
_validaciones_tipo_peso,
_validaciones_transporte,
)
from .encabezados_impo_def import (
parse_pedimento_col_a_impo_def,
)
# Expo: pedimento mismo formato ##-####-####### (15 chars)
MAX_LEN_PEDIMENTO_EXPO = 15
MAX_LEN_FACTURA = 15
# Regímenes: Exportación (sin cambio de régimen) vs Cambio de Régimen (IMD)
REGIMENES_EXPO = frozenset({"EXD", "ETE", "ETR"})
REGIMEN_IMD = "IMD"
CLAVES_PEDIMENTO_CAMBIO_REGIMEN = frozenset({"F5", "A3"})
def _validaciones_obligatorios_toda_expo(
row: Dict[str, Any],
line_num: int,
tiene_pedimento: bool,
) -> Optional[Dict[str, Any]]:
"""Obligatorios VALIDA_TODA para Expo: C, D, F, G, H, I, J; AC (Aduana de Cruce) si hay pedimento."""
obligatorios: List[str] = []
if not _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA"):
obligatorios.append("(Col.C) Número de Factura")
if not _get(row, "FECHA FACTURA", "FECHA"):
obligatorios.append("(Col.D) Fecha de la Factura")
if not _get(row, "REGIMEN", "CLAVEDOCUMENTO"):
obligatorios.append("(Col.F) Régimen")
if not _get(row, "CLAVE PROVEEDOR"):
obligatorios.append("(Col.G) Clave del Proveedor")
if not _get(row, "CLAVE VENDIDO A"):
obligatorios.append("(Col.H) Clave del Vendido A")
if not _get(row, "CLAVE ENVIADO A"):
obligatorios.append("(Col.I) Clave del Enviado A")
if not _get(row, "AGENTE ADUANAL"):
obligatorios.append("(Col.J) Clave del Agente Aduanal")
if tiene_pedimento and not _get(row, "ADUANA DE CRUCE"):
obligatorios.append("(Col.AC) Aduana de Cruce")
if obligatorios:
return _err(
line_num,
"ARCHIVO CSV",
f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}. Revisar para Exportación.",
)
return None
def _validaciones_regimen_expo(
row: Dict[str, Any],
line_num: int,
cambio_regimen: bool,
) -> Optional[Dict[str, Any]]:
"""Col F: si cambio_regimen → IMD; si no → EXD, ETR, ETE."""
f = _get(row, "REGIMEN", "CLAVEDOCUMENTO")
if not f:
return None
f_upper = f.upper()
if cambio_regimen:
if f_upper != REGIMEN_IMD:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {f} no es válido para este tipo de movimiento. "
"Los válidos para Cambio de Régimen son: IMD.",
)
else:
if f_upper not in REGIMENES_EXPO:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {f} no es válido para este tipo de movimiento. "
"Los válidos para Exportación son: EXD, ETE y ETR.",
)
return None
def _validaciones_pedimento_remesa_expo(
row: Dict[str, Any],
line_num: int,
cambio_regimen: bool,
pedimento_data_by_key: Dict[str, List[Dict[str, Any]]],
remesa_por_pedimento_bd: Dict[str, Set[int]],
remesa_por_pedimento_csv: Dict[str, Dict[int, str]],
autonumerar_remesas: bool,
invoice_date_parsed: Optional[datetime],
recalcular_fecha_pedimentos: bool = False,
) -> Optional[Dict[str, Any]]:
"""Pedimento Col A: formato ##-####-#######. Si cambio_regimen: tipo I, régimen IMD, ClavePed F5/A3. Si no: tipo E, régimen EXD/ETE/ETR. Remesa igual que imp_def."""
col_a = _get(row, "PEDIMENTO")
col_b_raw = row.get("REMESA")
col_b = _clip(col_b_raw)
col_f = _get(row, "REGIMEN", "CLAVEDOCUMENTO").upper()
if not col_a:
if col_b:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) Está asignado el número de Remesa y no se tiene un pedimento en (Celda A{line_num}).",
)
return None
if len(col_a) > MAX_LEN_PEDIMENTO_EXPO:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Pedimento: {col_a} supera la longitud de caracteres. Use formato ##-####-#######.",
)
parsed = parse_pedimento_col_a_impo_def(col_a)
if not parsed:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Formato del Pedimento: {col_a} es incorrecto. Use ##-####-#######.",
)
customs_office, license_val, pedimento_number = parsed
key = _pedimento_key_from_parsed(customs_office, license_val, pedimento_number)
ped_info_list = pedimento_data_by_key.get(key)
if not ped_info_list:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} no existe en el Catálogo de Pedimentos. "
+ (
"Darlo de alta como pedimento de Importación Definitiva (Cambio de Régimen)."
if cambio_regimen
else "Darlo de alta como pedimento de Exportación."
),
)
ped_info = ped_info_list[0]
op_type = (ped_info.get("operation_type") or "").strip().upper()
regimen_ped = (ped_info.get("regime") or "").strip().upper()
pedimento_code = (ped_info.get("pedimento_code") or "").strip().upper()
if cambio_regimen:
if op_type != "IMP" and op_type != "I":
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) Este Número de Pedimento: {col_a} no está marcado como Importación Definitiva.",
)
if regimen_ped != REGIMEN_IMD:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido para Cambio de Régimen. Válidos: IMD.",
)
if col_f and col_f != REGIMEN_IMD:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} debe ser IMD para Cambio de Régimen.",
)
if pedimento_code and pedimento_code not in CLAVES_PEDIMENTO_CAMBIO_REGIMEN:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene la clave {pedimento_code}, no definida para Cambio de Régimen/Regularización. Use F5 o A3.",
)
else:
if op_type != "EXP" and op_type != "E":
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) Este Número de Pedimento: {col_a} no está marcado como Exportación.",
)
if regimen_ped not in REGIMENES_EXPO:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido para Exportación. Válidos: EXD, ETE, ETR.",
)
if col_f and col_f != regimen_ped:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} capturado es diferente al del Pedimento: {regimen_ped}.",
)
# Rango de fechas si pedimento consolidado (omitir si recalcular_fecha_pedimentos = True, paridad Clarion)
if not recalcular_fecha_pedimentos:
pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower()
if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"):
entry = ped_info["entry_date"]
end = ped_info["end_date"]
if hasattr(entry, "date"):
entry = entry.date()
if hasattr(end, "date"):
end = end.date()
inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed
if inv_d < entry or inv_d > end:
return _err(
line_num,
"FECHA FACTURA",
f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.",
)
if not autonumerar_remesas and not col_b:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa está vacío y se tiene un Pedimento en la Celda A{line_num}.",
)
remesa_int = _parse_int(col_b_raw)
if col_b and remesa_int is not None and remesa_int == 0:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa no puede ser 0.",
)
factura_actual = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if remesa_int is not None and key in remesa_por_pedimento_csv:
other = remesa_por_pedimento_csv[key].get(remesa_int)
if other and other != factura_actual:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa ya está asignado a la factura {other} en este archivo CSV.",
)
return None
def _validaciones_manifiesto(
row: Dict[str, Any],
line_num: int,
valid_manifiesto_codes: Set[str],
) -> Optional[Dict[str, Any]]:
"""Col Y MANIFIESTO: si viene informado, debe existir en catálogo."""
y = _get(row, "MANIFIESTO")
if not y:
return None
if valid_manifiesto_codes and y.strip() not in valid_manifiesto_codes:
return _err(
line_num,
"MANIFIESTO",
f"Error: (Celda Y{line_num}) El Número de Manifiesto: {y} no está dado de alta en el Catálogo de Manifiestos.",
)
return None
def _validaciones_enviado_por(
row: Dict[str, Any],
line_num: int,
valid_enviado_por_ids: Set[int],
valid_enviado_por_short_names: Set[str],
) -> Optional[Dict[str, Any]]:
"""Col AB ENVIADO POR: Cliente/Proveedor o equivalente."""
ab = row.get("ENVIADO POR")
if ab is None or str(ab).strip() == "":
return None
v = _parse_int(ab)
if v is not None:
if valid_enviado_por_ids and v not in valid_enviado_por_ids:
return _err(
line_num,
"ENVIADO POR",
f"Error: (Celda AB{line_num}) La Clave del Enviado Por: {ab} no existe en el Catálogo de Clientes/Proveedores o Equivalentes.",
)
return None
sn_norm = str(ab).strip().upper()
if valid_enviado_por_short_names and sn_norm not in valid_enviado_por_short_names:
return _err(
line_num,
"ENVIADO POR",
f"Error: (Celda AB{line_num}) La Clave del Enviado Por: {ab} no existe en el Catálogo de Clientes/Proveedores o Equivalentes.",
)
return None
def _normalize_tipo_transporte_ferro(row: Dict[str, Any]) -> Dict[str, Any]:
"""Clarion acepta 'FERRO BARCAZA'. Normaliza a FERROBARCAZA para reutilizar validación TEM."""
out = dict(row)
m = out.get("TIPO TRANSPORTE")
if m is not None and str(m).strip().upper().replace(" ", "") == "FERROBARCAZA":
out["TIPO TRANSPORTE"] = "FERROBARCAZA"
return out
def validate_row_encabezados_expo(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
cambio_regimen: bool,
tipo_factura: str,
invoice_exists_by_number: Dict[str, bool],
invoice_updated_by_number: Dict[str, bool],
remesa_por_pedimento_bd: Dict[str, Set[int]],
remesa_por_pedimento_csv: Dict[str, Dict[int, str]],
valid_provider_ids: Set[int],
valid_sold_to_ids: Set[int],
valid_shipped_to_ids: Set[int],
valid_broker_ids: Set[int],
valid_transporter_keys: Set[str],
valid_incoterms: Set[str],
valid_aduana_codes: Set[str],
valid_currency_codes: Set[str],
invoice_in_report_by_number: Optional[Dict[str, bool]] = None,
pedimento_data_by_key: Optional[Dict[str, List[Dict[str, Any]]]] = None,
valid_provider_short_names: Optional[Set[str]] = None,
valid_sold_to_short_names: Optional[Set[str]] = None,
valid_shipped_to_short_names: Optional[Set[str]] = None,
valid_broker_claves: Optional[Set[str]] = None,
valid_manifiesto_codes: Optional[Set[str]] = None,
valid_enviado_por_ids: Optional[Set[int]] = None,
valid_enviado_por_short_names: Optional[Set[str]] = None,
exchange_rate_by_date: Optional[Dict[str, Any]] = None,
invoice_has_partidas_by_number: Optional[Dict[str, bool]] = None,
existing_tipo_moneda_by_number: Optional[Dict[str, str]] = None,
autonumerar_remesas: bool = False,
recalcular_fecha_pedimentos: bool = False,
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Valida una fila de CSV de Encabezados de Exportación (Expo Def) o Cambio de Régimen.
Clarion: VALIDA_TODA_FAC_EXPO vs VALIDA_PARCIAL_FAC_EXPO.
"""
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.",
)
if invoice_updated_by_number.get(factura.strip(), False):
return _err(
line_num,
"NUMERO FACTURA",
f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios.",
)
if actualizar and factura.strip() not in invoice_exists_by_number:
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) Factura de Exportación no existe (modo Actualizar).",
)
if actualizar and (invoice_in_report_by_number or {}).get(factura.strip(), False):
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) Factura de Exportación Rep. La factura está en reporte y no se puede actualizar.",
)
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
tiene_pedimento = bool(_get(row, "PEDIMENTO"))
if not use_partial:
err = _validaciones_obligatorios_toda_expo(row, line_num, tiene_pedimento)
if err:
return err
invoice_date_parsed = None
if parse_date_fn:
date_str = _get(row, "FECHA FACTURA", "FECHA")
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
err = _validaciones_pedimento_remesa_expo(
row,
line_num,
cambio_regimen,
pedimento_data_by_key or {},
remesa_por_pedimento_bd,
remesa_por_pedimento_csv,
autonumerar_remesas,
invoice_date_parsed,
recalcular_fecha_pedimentos,
)
if err:
return err
err = _validaciones_factura_longitud(row, line_num)
if err:
return err
err = _validaciones_regimen_expo(row, line_num, cambio_regimen)
if err:
return err
row_transport = _normalize_tipo_transporte_ferro(row)
err = _validaciones_transporte(row_transport, line_num)
if err:
return err
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
err = _validaciones_moneda(
row,
line_num,
valid_currency_codes or set(),
has_partidas if use_partial else None,
existing_moneda if use_partial else None,
)
if err:
return err
err = _validaciones_tipo_peso(row, line_num)
if err:
return err
err = _validaciones_catalogos(
row,
line_num,
valid_provider_ids or set(),
valid_sold_to_ids or set(),
valid_shipped_to_ids or set(),
valid_provider_short_names or set(),
valid_sold_to_short_names or set(),
valid_shipped_to_short_names or set(),
valid_broker_ids or set(),
valid_broker_claves or set(),
valid_transporter_keys or set(),
valid_incoterms or set(),
valid_aduana_codes or set(),
)
if err:
return err
err = _validaciones_manifiesto(row, line_num, valid_manifiesto_codes or set())
if err:
return err
err = _validaciones_enviado_por(
row,
line_num,
valid_enviado_por_ids or set(),
valid_enviado_por_short_names or set(),
)
if err:
return err
err = _validaciones_tipo_cambio(
row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
)
if err:
return err
return None

View File

@@ -0,0 +1,363 @@
"""
Validaciones CSV para Encabezados de Facturas de Importación Definitiva.
Paridad Clarion: VALIDA_TODA_FACIMPO_DEF, VALIDA_PARCIAL_FACIMPO_DEF, VALIDACIONES_FACIMPO_DEF.
Reutiliza de encabezados_impo_temp: claves, short names y validaciones de catálogos, transporte,
moneda, tipo peso y tipo cambio (misma lógica que TEM).
"""
from datetime import datetime
from typing import Any, Dict, List, Optional, Set, Tuple
# Reutilizar del TEM: helpers y validaciones de catálogos (claves/short names), transporte, moneda, tipo peso, tipo cambio
from .encabezados_impo_temp import (
_clip,
_err,
_get,
_parse_int,
_pedimento_key_from_parsed,
_validaciones_catalogos,
_validaciones_factura_longitud,
_validaciones_moneda,
_validaciones_tipo_cambio,
_validaciones_tipo_peso,
_validaciones_transporte,
)
# Impo Def: régimen único y pedimento formato ##-####-####### (15 chars)
REGIMEN_IMD = "IMD"
MAX_LEN_PEDIMENTO_DEF = 15
MAX_LEN_FACTURA = 15 # mismo que TEM
def parse_pedimento_col_a_impo_def(pedimento_str: str) -> Optional[Tuple[str, str, str]]:
"""
Parsea Col A (PEDIMENTO) formato ##-####-####### (15 caracteres).
Guiones en posiciones 3 y 8 (1-based): índices 2 y 7. Retorna (aduana_2, patente_4, numero_7) o None.
Ejemplo válido: 01-1234-2312412
"""
if not pedimento_str or not isinstance(pedimento_str, str):
return None
s = (pedimento_str or "").strip()
if len(s) != MAX_LEN_PEDIMENTO_DEF:
return None
if s[2:3] != "-" or s[7:8] != "-":
return None
part0, part1, part2 = s[0:2], s[3:7], s[8:15]
if not part0.isdigit() or not part1.isdigit() or not part2.isdigit():
return None
return (part0, part1, part2)
def _validaciones_obligatorios_toda_def(
row: Dict[str, Any],
line_num: int,
tiene_pedimento: bool,
) -> Optional[Dict[str, Any]]:
"""Obligatorios VALIDA_TODA para Impo Def: C, D, F, G, H, I, J. Aduana de Cruce (AB) no obligatoria."""
obligatorios: List[str] = []
if not _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA"):
obligatorios.append("(Col.C) Factura")
if not _get(row, "FECHA FACTURA", "FECHA"):
obligatorios.append("(Col.D) Fecha de la Factura")
if not _get(row, "REGIMEN", "CLAVEDOCUMENTO"):
obligatorios.append("(Col.F) Clave Régimen")
if not _get(row, "CLAVE PROVEEDOR"):
obligatorios.append("(Col.G) Clave del Proveedor")
if not _get(row, "CLAVE VENDIDO A"):
obligatorios.append("(Col.H) Clave del Vendido A")
if not _get(row, "CLAVE ENVIADO A"):
obligatorios.append("(Col.I) Clave del Enviado A")
if not _get(row, "AGENTE ADUANAL"):
obligatorios.append("(Col.J) Clave del Agente Aduanal")
if obligatorios:
return _err(
line_num,
"ARCHIVO CSV",
f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}. Revisar para Importación Definitiva.",
)
return None
def _validaciones_regimen_imd(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Col F: solo IMD válido para Impo Def."""
f = _get(row, "REGIMEN", "CLAVEDOCUMENTO")
if f and f.upper() != REGIMEN_IMD:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {f} no es válido para este tipo de movimiento. Los válidos son: IMD.",
)
return None
def _validaciones_pedimento_remesa_def(
row: Dict[str, Any],
line_num: int,
pedimento_data_by_key: Dict[str, List[Dict[str, Any]]],
remesa_por_pedimento_bd: Dict[str, Set[int]],
remesa_por_pedimento_csv: Dict[str, Dict[int, str]],
autonumerar_remesas: bool,
control_remesa: bool,
remesa_inicio: Optional[int],
remesa_fin: Optional[int],
invoice_date_parsed: Optional[datetime],
) -> Optional[Dict[str, Any]]:
"""Pedimento Col A: max 15 chars, formato ##-####-#######; catálogo con regime IMD. Remesa igual que TEM."""
col_a = _get(row, "PEDIMENTO")
col_b_raw = row.get("REMESA")
col_b = _clip(col_b_raw)
col_f = _get(row, "REGIMEN", "CLAVEDOCUMENTO").upper()
if not col_a:
if col_b:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) Está asignado el número de Remesa y no se tiene un pedimento en (Celda A{line_num}).",
)
return None
if len(col_a) > MAX_LEN_PEDIMENTO_DEF:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Pedimento: {col_a} supera la longitud de caracteres. Use formato ##-####-#######.",
)
parsed = parse_pedimento_col_a_impo_def(col_a)
if not parsed:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Formato del Pedimento: {col_a} es incorrecto. Use ##-####-#######.",
)
customs_office, license_val, pedimento_number = parsed
key = _pedimento_key_from_parsed(customs_office, license_val, pedimento_number)
ped_info_list = pedimento_data_by_key.get(key)
if not ped_info_list:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} no existe en el Catálogo de Pedimentos. "
"Darlo de alta como pedimento de Importación Definitiva.",
)
ped_info = ped_info_list[0]
regimen_ped = (ped_info.get("regime") or "").strip().upper()
if regimen_ped != REGIMEN_IMD:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido para este tipo de movimiento. Válidos: IMD.",
)
if col_f and col_f != REGIMEN_IMD:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no coincide con el del Pedimento. Debe ser IMD.",
)
if col_f and col_f != regimen_ped:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} capturado es diferente al del Pedimento: {regimen_ped}. Debe ser IMD.",
)
pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower()
if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"):
entry = ped_info["entry_date"]
end = ped_info["end_date"]
if hasattr(entry, "date"):
entry = entry.date()
if hasattr(end, "date"):
end = end.date()
inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed
if inv_d < entry or inv_d > end:
return _err(
line_num,
"FECHA FACTURA",
f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.",
)
if not autonumerar_remesas and not col_b:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa está vacío y se tiene un Pedimento en la Celda A{line_num}.",
)
remesa_int = _parse_int(col_b_raw)
if col_b and remesa_int is not None and remesa_int == 0:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa no puede ser 0.",
)
if control_remesa and remesa_int is not None and remesa_inicio is not None and remesa_fin is not None:
if remesa_int < remesa_inicio or remesa_int > remesa_fin:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa: {col_b} está fuera del rango configurado ({remesa_inicio}-{remesa_fin}).",
)
factura_actual = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if remesa_int is not None and key in remesa_por_pedimento_csv:
other = remesa_por_pedimento_csv[key].get(remesa_int)
if other and other != factura_actual:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa ya está asignado a la factura {other} en este archivo CSV.",
)
return None
def _normalize_tipo_transporte_ferro(row: Dict[str, Any]) -> Dict[str, Any]:
"""Clarion DEF acepta 'FERRO BARCAZA' (con espacio). Normaliza a FERROBARCAZA para reutilizar validación TEM."""
out = dict(row)
m = out.get("TIPO TRANSPORTE")
if m is not None and str(m).strip().upper().replace(" ", "") == "FERROBARCAZA":
out["TIPO TRANSPORTE"] = "FERROBARCAZA"
return out
def validate_row_encabezados_impo_def(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
invoice_exists_by_number: Dict[str, bool],
invoice_updated_by_number: Dict[str, bool],
pedimento_data_by_key: Dict[str, List[Dict[str, Any]]],
remesa_por_pedimento_bd: Dict[str, Set[int]],
remesa_por_pedimento_csv: Dict[str, Dict[int, str]],
valid_provider_ids: Set[int],
valid_sold_to_ids: Set[int],
valid_shipped_to_ids: Set[int],
valid_broker_ids: Set[int],
valid_transporter_keys: Set[str],
valid_incoterms: Set[str],
valid_aduana_codes: Set[str],
valid_currency_codes: Set[str],
valid_provider_short_names: Optional[Set[str]] = None,
valid_sold_to_short_names: Optional[Set[str]] = None,
valid_shipped_to_short_names: Optional[Set[str]] = None,
valid_broker_claves: Optional[Set[str]] = None,
exchange_rate_by_date: Optional[Dict[str, Any]] = None,
invoice_has_partidas_by_number: Optional[Dict[str, bool]] = None,
existing_tipo_moneda_by_number: Optional[Dict[str, str]] = None,
autonumerar_remesas: bool = False,
control_remesa: bool = False,
remesa_inicio: Optional[int] = None,
remesa_fin: Optional[int] = None,
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Valida una fila de CSV de Encabezados de Importación Definitiva.
Clarion: VALIDA_TODA_FACIMPO_DEF vs VALIDA_PARCIAL_FACIMPO_DEF.
Reutiliza de encabezados_impo_temp las validaciones de catálogos (claves y short names),
transporte, moneda, tipo peso y tipo cambio.
"""
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
return _err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.",
)
if invoice_updated_by_number.get(factura.strip(), False):
return _err(
line_num,
"NUMERO FACTURA",
f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas.",
)
if actualizar and factura.strip() not in invoice_exists_by_number:
return _err(
line_num,
"NUMERO FACTURA",
f"Error: (Col.C) Factura de importación Definitiva no existe (modo Actualizar).",
)
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
tiene_pedimento = bool(_get(row, "PEDIMENTO"))
if not use_partial:
err = _validaciones_obligatorios_toda_def(row, line_num, tiene_pedimento)
if err:
return err
invoice_date_parsed = None
if parse_date_fn:
date_str = _get(row, "FECHA FACTURA", "FECHA")
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
err = _validaciones_pedimento_remesa_def(
row,
line_num,
pedimento_data_by_key,
remesa_por_pedimento_bd,
remesa_por_pedimento_csv,
autonumerar_remesas,
control_remesa,
remesa_inicio,
remesa_fin,
invoice_date_parsed,
)
if err:
return err
err = _validaciones_factura_longitud(row, line_num)
if err:
return err
err = _validaciones_regimen_imd(row, line_num)
if err:
return err
# Normalizar FERRO BARCAZA -> FERROBARCAZA para reutilizar validación TEM
row_transport = _normalize_tipo_transporte_ferro(row)
err = _validaciones_transporte(row_transport, line_num)
if err:
return err
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
err = _validaciones_moneda(
row,
line_num,
valid_currency_codes or set(),
has_partidas if use_partial else None,
existing_moneda if use_partial else None,
)
if err:
return err
err = _validaciones_tipo_peso(row, line_num)
if err:
return err
# Mismas claves y short names que TEM (Proveedor, Vendido A, Enviado A, Agente, Transportista, Incoterm, Aduana)
err = _validaciones_catalogos(
row,
line_num,
valid_provider_ids or set(),
valid_sold_to_ids or set(),
valid_shipped_to_ids or set(),
valid_provider_short_names or set(),
valid_sold_to_short_names or set(),
valid_shipped_to_short_names or set(),
valid_broker_ids or set(),
valid_broker_claves or set(),
valid_transporter_keys or set(),
valid_incoterms or set(),
valid_aduana_codes or set(),
)
if err:
return err
err = _validaciones_tipo_cambio(
row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
)
if err:
return err
return None

View File

@@ -0,0 +1,651 @@
"""
Validaciones CSV para Encabezados de Facturas de Importación Temporal.
Paridad Clarion: VALIDA_TODA_FACIMPO_TEM, VALIDA_PARCIAL_FACIMPO_TEM, VALIDACIONES_FACIMPO_TEM.
Mapeo a BD en commit: LLENA_FACIMPO_TEM (tasks.insert_valid_rows).
Estructura CSV: PEDIMENTO (A), REMESA (B), NUMERO FACTURA (C), ... ADUANA DE CRUCE (AB), OBSERVACIONES E/I, FACTURA ALTERNA.
"""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Optional, Set, Tuple
# Longitudes máximas Clarion
MAX_LEN_PEDIMENTO = 18 # CC-LLLL-NNNNNNN o CCC-LLLL-NNNNNNN (sin año; ej. 01-1234-2312412 o 640-1234-2312412)
MAX_LEN_FACTURA = 15
# Formato PEDIMENTO: CC-LLLL-NNNNNNN (sin año: aduana 2-3, patente 4, número 7)
REGIMENES_VALIDOS = frozenset({"ITE", "ITR"})
TIPOS_MONEDA_VALIDOS = frozenset({"ME", "MN", "MC"})
TIPO_PESO_VALIDOS = frozenset({"KILOS", "LIBRAS"})
# Clarion Col M → valor normalizado (minúscula para TransportType enum)
TIPO_TRANSPORTE_CLARION_TO_NORM = {
"NINGUNO": "none",
"TRANSPORTE": "transport",
"CAJA": "box",
"PLACAS": "licence plates",
"CAMION": "truck",
"BUQUE": "vessel",
"FERROBARCAZA": "rail_barge",
"CONTENEDOR": "container",
"PLATAFORMA": "flatbed",
"GONDOLA": "gondola",
"AVION": "airplane",
}
TIPO_TRANSPORTE_VALIDOS = frozenset(TIPO_TRANSPORTE_CLARION_TO_NORM.keys())
def _clip(val: Any) -> str:
if val is None:
return ""
return str(val).strip()
def _get(row: Dict[str, Any], *keys: str) -> str:
for k in keys:
v = row.get(k)
if v is not None and str(v).strip():
return _clip(v)
return ""
def _parse_decimal(val: Any) -> Optional[Decimal]:
if val is None:
return None
s = _clip(val)
if not s:
return None
s = s.replace(",", "")
try:
return Decimal(s)
except (InvalidOperation, ValueError):
return None
def _parse_int(val: Any) -> Optional[int]:
if val is None:
return None
s = _clip(val)
if not s:
return None
try:
return int(s)
except ValueError:
return None
def parse_pedimento_col_a(pedimento_str: str) -> Optional[Tuple[str, str, str]]:
"""
Parsea Col A (PEDIMENTO) formato CC-LLLL-NNNNNNN o CCC-LLLL-NNNNNNN (18 caracteres, sin año).
Retorna (customs_office_2o3, license_4, pedimento_number_7) o None si formato inválido.
"""
if not pedimento_str or not isinstance(pedimento_str, str):
return None
s = (pedimento_str or "").strip()
parts = s.split("-")
if len(parts) != 3:
return None
customs_office, license_val, pedimento_number = parts[0], parts[1], parts[2]
if len(customs_office) not in (2, 3) or not customs_office.isdigit():
return None
if len(license_val) != 4 or not license_val.isdigit():
return None
if len(pedimento_number) != 7 or not pedimento_number.isdigit():
return None
return (customs_office, license_val, pedimento_number)
def _pedimento_key_from_parsed(customs_office: str, license_val: str, pedimento_number: str) -> str:
"""Clave para lookup: CC-LLLL-NNNNNNN (solo primeros 2 dígitos de aduana)."""
co = (customs_office or "").strip()[:2]
return f"{co}-{license_val}-{pedimento_number}"
def _err(line_num: int, col: str, msg: str) -> Dict[str, Any]:
return {"line": line_num, "col": col, "msg": msg}
# --- Obligatorios VALIDA_TODA (cuando no es actualizar) ---
def _validaciones_obligatorios_toda(
row: Dict[str, Any],
line_num: int,
tiene_pedimento: bool,
) -> Optional[Dict[str, Any]]:
"""Obligatorios: C, D, F, G, H, I, J; AB obligatoria si A tiene valor."""
obligatorios: List[str] = []
if not _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA"):
obligatorios.append("(Col.C) Número de Factura")
if not _get(row, "FECHA FACTURA", "FECHA"):
obligatorios.append("(Col.D) Fecha de la Factura")
if not _get(row, "REGIMEN", "CLAVEDOCUMENTO"):
obligatorios.append("(Col.F) Clave Régimen")
if not _get(row, "CLAVE PROVEEDOR"):
obligatorios.append("(Col.G) Clave del Proveedor")
if not _get(row, "CLAVE VENDIDO A"):
obligatorios.append("(Col.H) Clave del Vendido A")
if not _get(row, "CLAVE ENVIADO A"):
obligatorios.append("(Col.I) Clave del Enviado A")
if not _get(row, "AGENTE ADUANAL"):
obligatorios.append("(Col.J) Clave del Agente Aduanal")
if tiene_pedimento and not _get(row, "ADUANA DE CRUCE"):
obligatorios.append("(Col.AB) Aduana de Cruce")
if obligatorios:
return _err(
line_num,
"ARCHIVO CSV",
f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}.",
)
return None
# --- Pedimento y Remesa (Col A, B) ---
def _validaciones_pedimento_remesa(
row: Dict[str, Any],
line_num: int,
pedimento_data_by_key: Dict[str, List[Dict[str, Any]]],
remesa_por_pedimento_bd: Dict[str, Set[int]],
remesa_por_pedimento_csv: Dict[str, Dict[int, str]],
autonumerar_remesas: bool,
control_remesa: bool,
remesa_inicio: Optional[int],
remesa_fin: Optional[int],
date_format: Optional[str],
invoice_date_parsed: Optional[datetime],
) -> Optional[Dict[str, Any]]:
"""Col A: formato, longitud, catálogo, tipo I, régimen ITE/ITR, match Col F, fechas. Col B: oblig si A, no 0, rango, unicidad."""
col_a = _get(row, "PEDIMENTO")
col_b_raw = row.get("REMESA")
col_b = _clip(col_b_raw)
col_f = _get(row, "REGIMEN", "CLAVEDOCUMENTO").upper()
if not col_a:
if col_b:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) Está asignado el número de Remesa y no se tiene un pedimento en (Celda A{line_num}).",
)
return None
if len(col_a) > MAX_LEN_PEDIMENTO:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Pedimento: {col_a} supera la longitud de caracteres.",
)
parsed = parse_pedimento_col_a(col_a)
if not parsed:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Formato del Pedimento: {col_a} es incorrecto. Use CC-LLLL-NNNNNNN (ej. 01-1234-2312412, 18 caracteres sin año).",
)
customs_office, license_val, pedimento_number = parsed
key = _pedimento_key_from_parsed(customs_office, license_val, pedimento_number)
ped_info_list = pedimento_data_by_key.get(key)
if not ped_info_list:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} no existe en el Catálogo de Pedimentos. "
f"Verifique que esté dado de alta (formato CC-LLLL-NNNNNNN: aduana 2-3, patente 4, número 7, sin año) para esta empresa.",
)
ped_info = ped_info_list[0]
if (ped_info.get("operation_type") or "").upper() != "IMP":
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) Este Número de Pedimento: {col_a} no está marcado como Importación Temporal.",
)
regimen_ped = (ped_info.get("regime") or "").strip().upper()
if regimen_ped not in REGIMENES_VALIDOS:
return _err(
line_num,
"PEDIMENTO",
f"Error: (Celda A{line_num}) El Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido (ITE o ITR).",
)
if col_f and col_f not in REGIMENES_VALIDOS:
pass
elif col_f and col_f != regimen_ped:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no coincide con el del Pedimento: {regimen_ped}.",
)
pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower()
if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"):
entry = ped_info["entry_date"]
end = ped_info["end_date"]
if hasattr(entry, "date"):
entry = entry.date()
if hasattr(end, "date"):
end = end.date()
inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed
if inv_d < entry or inv_d > end:
return _err(
line_num,
"FECHA FACTURA",
f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.",
)
if not autonumerar_remesas and not col_b:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa está vacío y se tiene un Pedimento en la Celda A{line_num}.",
)
remesa_int = _parse_int(col_b_raw)
if col_b and remesa_int is not None and remesa_int == 0:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa no puede ser 0.",
)
if control_remesa and remesa_int is not None and remesa_inicio is not None and remesa_fin is not None:
if remesa_int < remesa_inicio or remesa_int > remesa_fin:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa: {col_b} está fuera del rango configurado ({remesa_inicio}-{remesa_fin}).",
)
factura_actual = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if remesa_int is not None and key in remesa_por_pedimento_csv:
other = remesa_por_pedimento_csv[key].get(remesa_int)
if other and other != factura_actual:
return _err(
line_num,
"REMESA",
f"Error: (Celda B{line_num}) El Número de Remesa ya está asignado a la factura {other} en este archivo CSV.",
)
return None
# --- Factura longitud (Col C) ---
def _validaciones_factura_longitud(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
c = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if c and len(c) > MAX_LEN_FACTURA:
return _err(line_num, "NUMERO FACTURA", f"Error: (Celda C{line_num}) El Número de Factura supera la longitud de caracteres.")
return None
# --- Régimen (Col F) ---
def _validaciones_regimen(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
f = _get(row, "REGIMEN", "CLAVEDOCUMENTO")
if f and f.upper() not in REGIMENES_VALIDOS:
return _err(
line_num,
"REGIMEN",
f"Error: (Celda F{line_num}) El Régimen Aduanero: {f} no es válido. Use ITE o ITR.",
)
return None
# --- Tipo transporte y número (Col M, N) ---
def _validaciones_transporte(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
m = _get(row, "TIPO TRANSPORTE").upper()
n = _get(row, "NUMERO TRANSPORTE")
if m and m not in TIPO_TRANSPORTE_VALIDOS:
return _err(
line_num,
"TIPO TRANSPORTE",
f"Error: (Celda M{line_num}) El Tipo de Transporte: {m} no es válido. Válidos: NINGUNO, TRANSPORTE, CAJA, PLACAS, CAMION, BUQUE, FERROBARCAZA, CONTENEDOR, PLATAFORMA, GONDOLA, AVION.",
)
if not m and n:
return _err(
line_num,
"NUMERO TRANSPORTE",
f"Error: (Celda N{line_num}) El Tipo de Transporte está vacío y está capturado un número de transporte.",
)
if m == "NINGUNO" and n:
return _err(
line_num,
"NUMERO TRANSPORTE",
f"Error: (Celda N{line_num}) El Tipo de Transporte es NINGUNO y está capturado un número de transporte.",
)
if m and m != "NINGUNO" and not n:
return _err(
line_num,
"NUMERO TRANSPORTE",
f"Error: (Celda N{line_num}) El Tipo de Transporte es {m} y no está capturado el número de transporte.",
)
return None
# --- Tipo moneda y clave moneda (Col O, P) ---
def _validaciones_moneda(
row: Dict[str, Any],
line_num: int,
valid_currency_codes: Set[str],
invoice_has_partidas: Optional[bool],
existing_tipo_moneda: Optional[str],
) -> Optional[Dict[str, Any]]:
o = _get(row, "TIPO MONEDA").upper()
p = _get(row, "CLAVE MONEDA").upper()
if o and o not in TIPOS_MONEDA_VALIDOS:
return _err(
line_num,
"TIPO MONEDA",
f"Error: (Celda O{line_num}) La opción de Tipo Moneda: {row.get('TIPO MONEDA')} no es válida. Use ME, MN o MC.",
)
if o == "MC":
if not p:
return _err(
line_num,
"CLAVE MONEDA",
f"Error: (Celda P{line_num}) La Clave de la Moneda es obligatoria cuando Tipo de Moneda es MC.",
)
if p and valid_currency_codes and p not in valid_currency_codes:
return _err(
line_num,
"CLAVE MONEDA",
f"Error: (Celda P{line_num}) La Clave de la Moneda: {p} no existe en el Catálogo.",
)
if invoice_has_partidas and existing_tipo_moneda and o and o != existing_tipo_moneda.upper():
return _err(
line_num,
"TIPO MONEDA",
f"Error: (Celda O{line_num}) No se puede cambiar el Tipo de Moneda porque la factura ya tiene partidas. Use {existing_tipo_moneda}.",
)
return None
# --- Tipo peso (Col Y) ---
def _validaciones_tipo_peso(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
y = _get(row, "TIPO PESO").upper()
if y and y not in TIPO_PESO_VALIDOS:
return _err(
line_num,
"TIPO PESO",
f"Error: (Celda Y{line_num}) La opción de Tipo de Peso: {row.get('TIPO PESO')} no es válida. Use KILOS o LIBRAS.",
)
return None
# --- Catálogos: Proveedor, Vendido A, Enviado A, Agente, Transportista, Incoterm, Aduana ---
def _validaciones_catalogos(
row: Dict[str, Any],
line_num: int,
valid_provider_ids: Set[int],
valid_sold_to_ids: Set[int],
valid_shipped_to_ids: Set[int],
valid_provider_short_names: Set[str],
valid_sold_to_short_names: Set[str],
valid_shipped_to_short_names: Set[str],
valid_broker_ids: Set[int],
valid_broker_claves: Set[str],
valid_transporter_keys: Set[str],
valid_incoterms: Set[str],
valid_aduana_codes: Set[str],
) -> Optional[Dict[str, Any]]:
def check_id(val: Any, col: str, catalog_name: str, valid_set: Set[int]) -> Optional[Dict[str, Any]]:
if val is None or str(val).strip() == "":
return None
v = _parse_int(val)
if v is None:
return _err(line_num, col, f"Error: (Celda) {col} debe ser un número entero.")
if valid_set and v not in valid_set:
return _err(line_num, col, f"Error: La clave en {col} no existe en el Catálogo de {catalog_name}.")
return None
def check_id_or_rfc(
val: Any,
col: str,
catalog_name: str,
valid_ids: Set[int],
valid_short_names: Set[str],
) -> Optional[Dict[str, Any]]:
if val is None or str(val).strip() == "":
return None
v = _parse_int(val)
if v is not None:
if valid_ids and v not in valid_ids:
return _err(line_num, col, f"Error: La clave en {col} no existe en el Catálogo de {catalog_name}.")
return None
sn_norm = str(val).strip().upper()
if valid_short_names and sn_norm not in valid_short_names:
return _err(line_num, col, f"Error: La clave/corta en {col} no existe en el Catálogo de {catalog_name}.")
if not valid_short_names:
return _err(line_num, col, f"Error: (Celda) {col} debe ser un número entero o clave corta (short name) válida.")
return None
def check_id_or_clave(
val: Any,
col: str,
catalog_name: str,
valid_ids: Set[int],
valid_claves: Set[str],
) -> Optional[Dict[str, Any]]:
"""Para AGENTE ADUANAL: acepta id (entero) o broker_key (clave)."""
if val is None or str(val).strip() == "":
return None
v = _parse_int(val)
if v is not None:
if valid_ids and v not in valid_ids:
return _err(line_num, col, f"Error: La clave en {col} no existe en el Catálogo de {catalog_name}.")
return None
clave = str(val).strip()
if valid_claves and clave not in valid_claves:
return _err(line_num, col, f"Error: La clave en {col} no existe en el Catálogo de {catalog_name}.")
if not valid_claves:
return _err(line_num, col, f"Error: (Celda) {col} debe ser un número entero o clave de agente aduanal válida.")
return None
err = check_id_or_rfc(
row.get("CLAVE PROVEEDOR"), "CLAVE PROVEEDOR", "Clientes/Proveedores",
valid_provider_ids, valid_provider_short_names,
)
if err:
return err
err = check_id_or_rfc(
row.get("CLAVE VENDIDO A"), "CLAVE VENDIDO A", "Clientes/Proveedores",
valid_sold_to_ids, valid_sold_to_short_names,
)
if err:
return err
err = check_id_or_rfc(
row.get("CLAVE ENVIADO A"), "CLAVE ENVIADO A", "Clientes/Proveedores",
valid_shipped_to_ids, valid_shipped_to_short_names,
)
if err:
return err
err = check_id_or_clave(
row.get("AGENTE ADUANAL"), "AGENTE ADUANAL", "Agentes Aduanales",
valid_broker_ids, valid_broker_claves,
)
if err:
return err
k = _get(row, "CLAVE TRANSPORTISTA").upper()
if k and valid_transporter_keys and k not in valid_transporter_keys:
return _err(line_num, "CLAVE TRANSPORTISTA", f"Error: (Celda K{line_num}) La Clave del Transportista: {k} no existe en el Catálogo.")
v = _get(row, "CLAVE INCOTERM").upper()
if v and valid_incoterms and v not in valid_incoterms:
return _err(line_num, "CLAVE INCOTERM", f"Error: (Celda V{line_num}) La Clave de INCOTERM: {v} no existe en el Catálogo.")
ab = _get(row, "ADUANA DE CRUCE")
if ab and valid_aduana_codes and ab not in valid_aduana_codes:
return _err(line_num, "ADUANA DE CRUCE", f"Error: (Celda AB{line_num}) La Aduana de Cruce: {ab} no existe en el Catálogo.")
return None
def _validaciones_tipo_cambio(
row: Dict[str, Any],
line_num: int,
date_parsed: Optional[datetime],
exchange_rate_by_date: Dict[str, Any],
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""Si hay fecha y no hay tipo cambio, debe existir en catálogo. Si hay tipo cambio, puede advertir si difiere del catálogo."""
d = _get(row, "FECHA FACTURA", "FECHA")
e = row.get("TIPO DE CAMBIO")
if not d or not date_parsed:
return None
date_key = date_parsed.isoformat()[:10] if hasattr(date_parsed, "isoformat") else str(date_parsed)[:10]
catalog_tc = exchange_rate_by_date.get(date_key) if exchange_rate_by_date else None
if not _clip(e):
if catalog_tc is None:
return _err(
line_num,
"TIPO DE CAMBIO",
f"Error: (Celda E{line_num}) El Tipo de Cambio para la Fecha {d} no se encontró en el Catálogo.",
)
return None
val_e = _parse_decimal(e)
if val_e is not None and catalog_tc is not None:
cat_val = catalog_tc if isinstance(catalog_tc, (Decimal, int, float)) else getattr(catalog_tc, "valor", None) or getattr(catalog_tc, "value", None)
if cat_val is not None and abs(float(val_e) - float(cat_val)) > 0.0001 and warnings is not None:
warnings.append({
"line": line_num,
"col": "TIPO DE CAMBIO",
"msg": f"Advertencia: (Celda E{line_num}) El Tipo de Cambio capturado ({e}) difiere del Catálogo para la fecha {d}.",
"warning": True,
})
return None
def validate_row_encabezados_impo_temp(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
invoice_exists_by_number: Dict[str, bool],
invoice_updated_by_number: Dict[str, bool],
pedimento_data_by_key: Dict[str, List[Dict[str, Any]]],
remesa_por_pedimento_bd: Dict[str, Set[int]],
remesa_por_pedimento_csv: Dict[str, Dict[int, str]],
valid_provider_ids: Set[int],
valid_sold_to_ids: Set[int],
valid_shipped_to_ids: Set[int],
valid_broker_ids: Set[int],
valid_transporter_keys: Set[str],
valid_incoterms: Set[str],
valid_aduana_codes: Set[str],
valid_currency_codes: Set[str],
valid_provider_short_names: Optional[Set[str]] = None,
valid_sold_to_short_names: Optional[Set[str]] = None,
valid_shipped_to_short_names: Optional[Set[str]] = None,
valid_broker_claves: Optional[Set[str]] = None,
exchange_rate_by_date: Optional[Dict[str, Any]] = None,
invoice_has_partidas_by_number: Optional[Dict[str, bool]] = None,
existing_tipo_moneda_by_number: Optional[Dict[str, str]] = None,
autonumerar_remesas: bool = False,
control_remesa: bool = False,
remesa_inicio: Optional[int] = None,
remesa_fin: Optional[int] = None,
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Encabezados de Importación Temporal.
Clarion: VALIDA_TODA_FACIMPO_TEM vs VALIDA_PARCIAL_FACIMPO_TEM según actualizar y si la factura existe.
"""
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
return _err(line_num, "NUMERO FACTURA", "Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.")
if invoice_updated_by_number.get(factura.strip(), False):
return _err(
line_num,
"NUMERO FACTURA",
f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios.",
)
if actualizar and factura.strip() not in invoice_exists_by_number:
return _err(
line_num,
"NUMERO FACTURA",
f"Error: (Col.C) Factura de importación {factura} no existe (modo Actualizar).",
)
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
tiene_pedimento = bool(_get(row, "PEDIMENTO"))
if not use_partial:
err = _validaciones_obligatorios_toda(row, line_num, tiene_pedimento)
if err:
return err
invoice_date_parsed = None
if parse_date_fn:
date_str = _get(row, "FECHA FACTURA", "FECHA")
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
err = _validaciones_pedimento_remesa(
row,
line_num,
pedimento_data_by_key,
remesa_por_pedimento_bd,
remesa_por_pedimento_csv,
autonumerar_remesas,
control_remesa,
remesa_inicio,
remesa_fin,
date_format,
invoice_date_parsed,
)
if err:
return err
err = _validaciones_factura_longitud(row, line_num)
if err:
return err
err = _validaciones_regimen(row, line_num)
if err:
return err
err = _validaciones_transporte(row, line_num)
if err:
return err
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
err = _validaciones_moneda(
row,
line_num,
valid_currency_codes or set(),
has_partidas if use_partial else None,
existing_moneda if use_partial else None,
)
if err:
return err
err = _validaciones_tipo_peso(row, line_num)
if err:
return err
err = _validaciones_catalogos(
row,
line_num,
valid_provider_ids or set(),
valid_sold_to_ids or set(),
valid_shipped_to_ids or set(),
valid_provider_short_names or set(),
valid_sold_to_short_names or set(),
valid_shipped_to_short_names or set(),
valid_broker_ids or set(),
valid_broker_claves or set(),
valid_transporter_keys or set(),
valid_incoterms or set(),
valid_aduana_codes or set(),
)
if err:
return err
err = _validaciones_tipo_cambio(
row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
)
if err:
return err
return None
def row_to_transport_type_clarion(val: Optional[str]) -> Optional[str]:
"""Mapea valor Clarion Col M a valor enum TransportType (minúscula)."""
if not val:
return "none"
u = _clip(val).upper()
return TIPO_TRANSPORTE_CLARION_TO_NORM.get(u, "none")

View File

@@ -0,0 +1,558 @@
"""
Validaciones CSV para Partidas de Exportación Definitiva (y Cambio de Régimen).
Paridad Clarion: VALIDA_TODA_PAR_EXPO, VALIDA_PARCIAL_PAR_EXPO, VALIDACIONES_PAR_EXPO.
Estructura: NUMERO FACTURA EXPO, LINEA EXPO, TIPO DE IMPO, FACTURA IMPO, LINEA IMPO, GENERA DESCARGA, ...
Variante RFC EGM0303257J1: columnas ES PARTIDA/SUBPARTIDA y LINEA PRINCIPAL en T y U.
"""
from decimal import Decimal, InvalidOperation
from typing import Dict, Any, Optional, Set, Tuple, List
from .partidas_impo_temp import _clip, _get
# Longitudes Clarion partidas expo (Factura EXPO alineado con invoice_number String(100) en InvoiceHeader)
MAX_LEN_FACTURA_EXPO = 100
MAX_LEN_LINEA_EXPO = 5
MAX_LEN_TIPO_IMPO = 3
MAX_LEN_ORDEN_COMPRA = 20
MAX_LEN_NUM_PARTE = 30
TIPO_IMPO_VALIDOS = frozenset({"TEM", "DEF"})
GENERA_DESCARGA_VALIDOS = frozenset({"SI", "NO"})
SE_PAGO_IMPUESTO_VALIDOS = frozenset({"SI", "NO"})
def _err(
line_num: int,
col: str,
msg: str,
identifier: str = "ARCHIVO CSV",
) -> Dict[str, Any]:
return {"line": line_num, "col": col, "msg": msg}
def _parse_decimal(val: Any) -> Optional[Decimal]:
if val is None:
return None
s = _clip(val)
if not s:
return None
s = str(s).replace(",", "")
try:
return Decimal(s)
except (InvalidOperation, ValueError):
return None
def _check_factura_expo_vacia(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
val = _get(row, "NUMERO FACTURA EXPO", "NUMERO FACTURA EXPO.", "NUM FACTURA EXPO", "FACTURA EXPO")
if not val:
return _err(
line_num,
"NUMERO FACTURA EXPO",
f"Error: (Celda A{line_num}) La Factura de Exportación está vacía y no se pueden hacer las validaciones. "
"Capturar en la Celda A un número de Factura existente al cual desee agregar o actualizar partidas.",
)
return None
def _check_factura_expo_existe(
invoice_number: str,
line_num: int,
invoice_id_by_number: Dict[str, int],
) -> Optional[Dict[str, Any]]:
if invoice_number not in invoice_id_by_number:
return _err(
line_num,
"NUMERO FACTURA EXPO",
f"Error: (Celda A{line_num}) La Factura de Exportación {invoice_number} no existe en SCAII y no se pueden hacer las validaciones. "
"Capturar en la Celda A un número de Factura existente al cual desee agregar o actualizar partidas.",
)
return None
def _check_factura_expo_no_actualizada(
invoice_number: str,
line_num: int,
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
) -> Optional[Dict[str, Any]]:
if invoice_number in rfc_exception_updated:
return None
if invoice_updated_by_number.get(invoice_number, False):
return _err(
line_num,
"NUMERO FACTURA EXPO",
f"Error: (Celda A{line_num}) La Factura de Exportación: {invoice_number} ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas. "
"Capturar otro número de Factura de Exportación o Desactualizar la factura.",
)
return None
def _check_linea_expo_si_no_autonumerar(
row: Dict[str, Any], line_num: int, autonumerar: bool
) -> Optional[Dict[str, Any]]:
if autonumerar:
return None
val = _get(row, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO")
if not val:
return _err(
line_num,
"LINEA EXPO",
f"Error: (Celda B{line_num}) El campo de la línea de la partida está vacío y no se pueden hacer las validaciones, ya que se tiene la opción de Autonumerar como NO. "
"Capturar en la Celda B la línea de la partida al cual desee agregar o actualizar información.",
)
return None
def _check_levantar_subpartidas_expo(
row: Dict[str, Any],
line_num: int,
levantar_subpartidas: bool,
rfc_exception_egm: bool,
) -> Optional[Dict[str, Any]]:
"""Clarion: si LevantarSubpartidas=S, obligatorios ES PARTIDA/SUBPARTIDA y LINEA PRINCIPAL (col S/T o T/U para EGM0303257J1)."""
if not levantar_subpartidas:
return None
es_sub = _get(row, "ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA")
if not es_sub:
col = "T" if rfc_exception_egm else "S"
return _err(
line_num,
"ES PARTIDA/SUBPARTIDA",
f"Error: (Celda {col}{line_num}) El campo del tipo de la partida (partida o subpartida) está vacío. "
"Capturar el tipo de la partida. [P] = Partida o [S] = Subpartida.",
)
linea_principal = _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL")
if not linea_principal:
col = "U" if rfc_exception_egm else "T"
return _err(
line_num,
"LINEA PRINCIPAL",
f"Error: (Celda {col}{line_num}) El campo de la partida principal está vacío. "
f"Capturar en la Celda {col} la partida principal.",
)
return None
def _valida_toda_obligatorios_expo(
row: Dict[str, Any],
line_num: int,
levantar_subpartidas: bool,
rfc_exception_egm: bool,
) -> Optional[Dict[str, Any]]:
"""VALIDA_TODA_PAR_EXPO obligatorios: C (tipo impo), F (descarga), G (cantidad); si descarga=SI: D, E; si subpartidas: S/T o T/U."""
obligatorios: List[str] = []
tipo_impo = _get(row, "TIPO DE IMPO", "TIPO DE IMPO.", "TIPO IMPO", "PROCEDENCIA")
if not tipo_impo:
obligatorios.append("(Col.C) Procedencia de la Importación.")
descarga = _get(row, "GENERA DESCARGA", "GENERA DESCARGA?", "DESCARGA")
if descarga == "SI":
if not _get(row, "FACTURA IMPO", "FACTURA IMPO.", "FACTURA IMPORTACION"):
obligatorios.append("(Col.D) Factura de Impo.")
if not _get(row, "LINEA IMPO", "LINEA IMPO.", "LINEA IMPORTACION"):
obligatorios.append("(Col.E) Línea de Impo.")
if not descarga:
obligatorios.append("(Col.F) Descarga? SI o NO")
if not _get(row, "CANTIDAD EXPORTADA/DESCARGAR", "CANTIDAD EXPORTADA", "CANTIDAD"):
obligatorios.append("(Col.G) Cantidad Expo.")
if levantar_subpartidas:
if rfc_exception_egm:
if not _get(row, "ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA"):
obligatorios.append("(Col.T) EsSubpartida?.")
if not _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL"):
obligatorios.append("(Col.U) Partida Principal.")
else:
if not _get(row, "ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA"):
obligatorios.append("(Col.S) EsSubpartida?.")
if not _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL"):
obligatorios.append("(Col.T) Partida Principal.")
if obligatorios:
return _err(
line_num,
"TIPO DE IMPO",
f"Existen campos vacíos que son obligatorios, es la {', '.join(obligatorios)}. "
"Revisar la línea del archivo y capturar los campos con la información correcta.",
)
return None
def _valida_subpartidas_duplicados_expo(
factura_expo: str,
linea_expo: str,
line_num: int,
line_counts_csv: Dict[Tuple[str, str], int],
) -> Optional[Dict[str, Any]]:
key = (factura_expo.strip(), _clip(linea_expo))
if line_counts_csv.get(key, 0) > 1:
return _err(
line_num,
"LINEA EXPO",
f"Error: (Celda B{line_num}) El campo de la partida está duplicado entre las partidas. "
f"Capturar en la Celda B{line_num} otro número de partida.",
)
return None
def _valida_subpartida_tiene_principal_expo(
row: Dict[str, Any],
line_num: int,
factura_expo: str,
partidas_principales_csv: Set[Tuple[str, str]],
partidas_principales_bd: Set[Tuple[str, str]],
rfc_exception_egm: bool,
) -> Optional[Dict[str, Any]]:
es_sub = _get(row, "ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA").upper()
linea_principal = _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL")
if es_sub != "S" or not linea_principal or linea_principal == "0":
return None
key_principal = (factura_expo.strip(), _clip(linea_principal))
if key_principal in partidas_principales_csv or key_principal in partidas_principales_bd:
return None
col = "U" if rfc_exception_egm else "T"
return _err(
line_num,
"LINEA PRINCIPAL",
f"Error: (Celda {col}{line_num}) La partida principal {linea_principal} no existe. "
f"Capturar en la Celda {col} la partida principal y/o verificar que si permita contener subpartidas.",
)
def _valida_subpartida_linea_principal_no_cero_expo(
row: Dict[str, Any], line_num: int, rfc_exception_egm: bool
) -> Optional[Dict[str, Any]]:
es_sub = _get(row, "ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA").upper()
linea_principal = _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL")
if es_sub == "S" and linea_principal == "0":
col = "U" if rfc_exception_egm else "T"
return _err(
line_num,
"LINEA PRINCIPAL",
f"Error: (Celda {col}{line_num}) La SubPartida no tiene asignada una partida principal. "
f"Capturar en la Celda {col} una partida principal.",
)
return None
def _validaciones_par_expo(
row: Dict[str, Any],
line_num: int,
factura_expo: str,
valid_uom_codes: Set[str],
valid_bulks_codes: Set[str],
valid_payment_methods: Set[str],
valid_fraction_ame: Set[str],
valid_part_numbers: Optional[Set[str]],
factura_impo_tem_by_number: Dict[str, int],
factura_impo_def_by_number: Dict[str, int],
line_exists_tem: Set[Tuple[int, str]],
line_exists_def: Set[Tuple[int, str]],
validar_decimales_pza: bool = False,
) -> Optional[Dict[str, Any]]:
"""VALIDACIONES_PAR_EXPO: longitudes, TEM/DEF, FK factura impo + línea, descarga SI/NO, cantidad, U.M., bultos, forma pago, impuesto, fracción ame, num parte, decimales PZA."""
tipo_impo = _get(row, "TIPO DE IMPO", "TIPO DE IMPO.", "TIPO IMPO", "PROCEDENCIA").strip().upper()
if not tipo_impo:
tipo_impo = "TEM"
factura_impo = _get(row, "FACTURA IMPO", "FACTURA IMPO.", "FACTURA IMPORTACION")
linea_impo = _get(row, "LINEA IMPO", "LINEA IMPO.", "LINEA IMPORTACION")
descarga = _get(row, "GENERA DESCARGA", "GENERA DESCARGA?", "DESCARGA").upper()
# Longitud A
if factura_expo and len(factura_expo) > MAX_LEN_FACTURA_EXPO:
return _err(
line_num,
"NUMERO FACTURA EXPO",
f"Error: (Celda A{line_num}) La Factura de Exportación: {factura_expo} supera la longitud de caracteres. "
f"Capturar en la Celda A el campo Factura de Exportación con un máximo de {MAX_LEN_FACTURA_EXPO} caracteres.",
)
# Longitud B
linea_expo = _get(row, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO")
if linea_expo and len(linea_expo) > MAX_LEN_LINEA_EXPO:
return _err(
line_num,
"LINEA EXPO",
f"Error: (Celda B{line_num}) La Línea de Exportación: {linea_expo} supera la longitud de caracteres. "
"Capturar en la Celda B una Línea de Exportación con formato #####.",
)
# Longitud C
if tipo_impo and len(tipo_impo) > MAX_LEN_TIPO_IMPO:
return _err(
line_num,
"TIPO DE IMPO",
f"Error: (Celda C{line_num}) La Procedencia debe ser especificada como TEM o DEF. "
"Capturar en la Celda C una procedencia no mayor de 3 caracteres.",
)
# C: TEM o DEF
if tipo_impo and tipo_impo not in TIPO_IMPO_VALIDOS:
return _err(
line_num,
"TIPO DE IMPO",
f"Error: (Celda C{line_num}) El Tipo de Descargo: {tipo_impo} no es valido. Capturar uno valido como TEM o DEF.",
)
# D, E: factura impo + línea existen en catálogo TEM o DEF
if factura_impo and tipo_impo:
consec_tem = factura_impo_tem_by_number.get(factura_impo.strip())
consec_def = factura_impo_def_by_number.get(factura_impo.strip())
if tipo_impo == "TEM":
if consec_tem is None:
return _err(
line_num,
"FACTURA IMPO",
f"Error: (Celda D{line_num}) La Factura: {factura_impo} de Importación Temporal no existe. "
"Capturar un Número de Factura que exista en el Catálogo de Importaciones Temporales.",
)
key_line = (consec_tem, _clip(linea_impo))
if linea_impo and key_line not in line_exists_tem:
return _err(
line_num,
"LINEA IMPO",
f"Error: (Celda D{line_num}, E{line_num}) La Factura: {factura_impo} con línea: {linea_impo} de Importación Temporal no existe. "
"Capturar un Número de Factura con diferente línea que este en el Catálogo de Importaciones Temporales.",
)
elif tipo_impo == "DEF":
if consec_def is None:
return _err(
line_num,
"FACTURA IMPO",
f"Error: (Celda D{line_num}) La Factura: {factura_impo} de Importación Definitiva no existe. "
"Capturar un Número de Factura que exista en el Catálogo de Importaciones Definitivas.",
)
key_line = (consec_def, _clip(linea_impo))
if linea_impo and key_line not in line_exists_def:
return _err(
line_num,
"LINEA IMPO",
f"Error: (Celda D{line_num}, E{line_num}) La Factura: {factura_impo} con línea: {linea_impo} de Importación Definitiva no existe. "
"Capturar un Número de Factura con diferente línea que este en el Catálogo de Importaciones Definitivas.",
)
# F: SI o NO
if descarga and descarga not in GENERA_DESCARGA_VALIDOS:
return _err(
line_num,
"GENERA DESCARGA",
f"Error: (Celda F{line_num}) La captura: {descarga} no es valido para la opción de que la partida genere descarga. "
"Capturar un valor valido como SI o NO o dejar vacio y lo tomará como un SI.",
)
# G: cantidad no cero
cant_str = _get(row, "CANTIDAD EXPORTADA/DESCARGAR", "CANTIDAD EXPORTADA", "CANTIDAD")
if cant_str:
cant = _parse_decimal(cant_str)
if cant is not None and cant == 0:
return _err(
line_num,
"CANTIDAD EXPORTADA/DESCARGAR",
f"Error: (Celda G{line_num}) La Cantidad a Exportar: {cant_str} no puede ser cero. Capturar una cantidad a exportar valida.",
)
# H: U.M. en catálogo (si no se toma de impo)
um = _get(row, "UNIDAD DE MEDIDA", "U.M.", "UNIDAD MEDIDA")
if um and valid_uom_codes and um.upper() not in valid_uom_codes:
return _err(
line_num,
"UNIDAD DE MEDIDA",
f"Error: (Celda H{line_num}) La U.M.: {um} no existe en catálogo de Unidades de Medida. "
"Capturar una Clave de Unidad de Medida que exista en el Catálogo.",
)
# Bultos: cantidad + clave (Clarion Col L, M)
clave_bultos = _get(row, "CLAVE BULTOS", "CLAVEBULTOS")
cant_bultos = row.get("CANTIDAD BULTOS") or row.get("CANTIDADBULTOS")
if clave_bultos:
if valid_bulks_codes and clave_bultos not in valid_bulks_codes:
return _err(
line_num,
"CLAVE BULTOS",
f"Error: (Celda M{line_num}) La Clave de Bulto: {clave_bultos} no existe en el Catálogo de Claves de Bultos. "
"Darlo de alta en el Catálogo de Claves de Bultos o capturar uno ya existente.",
)
cant_bultos_val = _parse_decimal(cant_bultos)
if cant_bultos_val is None:
return _err(
line_num,
"CANTIDAD BULTOS",
f"Error: (Celda L{line_num}) La Cantidad de Bultos está vacía y en la Celda M{line_num} se tienen la Clave de Bulto: {clave_bultos}.",
)
if cant_bultos_val == 0:
return _err(
line_num,
"CANTIDAD BULTOS",
f"Error: (Celda L{line_num}) La Cantidad de Bultos es cero y en la Celda M{line_num} se tienen la Clave de Bulto: {clave_bultos}.",
)
else:
cant_bultos_val = _parse_decimal(cant_bultos)
if cant_bultos_val is not None and cant_bultos_val > 0:
return _err(
line_num,
"CANTIDAD BULTOS",
f"Error: (Celda L{line_num}) La Cantidad de Bultos es {cant_bultos} y en la Celda M{line_num} no se tienen la Clave de Bulto.",
)
# L: Se pagó impuesto SI/NO
se_pago = _get(row, "SE PAGO IMPUESTO", "SE PAGO IMPUESTO? (SI o NO)", "SEPAGOIMPUESTO")
if se_pago and se_pago.upper() not in SE_PAGO_IMPUESTO_VALIDOS:
return _err(
line_num,
"SE PAGO IMPUESTO",
f"Error: (Celda L{line_num}) El Valor Capturado para Se Pago Impuesto no es Válido. Capturar en la Celda L{line_num} SI o NO.",
)
# M: Forma de pago en catálogo
forma_pago = _get(row, "FORMA DE PAGO", "FORMADEPAGO", "FORMA PAGO")
if forma_pago and valid_payment_methods and forma_pago not in valid_payment_methods:
return _err(
line_num,
"FORMA DE PAGO",
f"Error: (Celda M{line_num}) La Forma de Pago Capturado no es Válido. "
"Capturar en la Celda M una Forma de Pago dentro del Catálogo General de Formas de Pago.",
)
# Fracción americana (Clarion Col R)
frac_ame = _get(row, "FRACCION AMERICANA", "FRACCIONAMERICANA")
if frac_ame and valid_fraction_ame and frac_ame not in valid_fraction_ame:
return _err(
line_num,
"FRACCION AMERICANA",
f"Error: (Celda R{line_num}) La Fracción Americana: {frac_ame} no está en el Catálogo de Fracciones Americanas. "
"Capturar en la Celda R una fracción que se encuentre en el catálogo o dar la de alta.",
)
# Orden de compra / orden venta (Clarion Col S) máx 20
orden = _get(row, "ORDEN DE COMPRA", "ORDENCOMPRA", "ORDEN DE VENTA")
if orden and len(orden) > MAX_LEN_ORDEN_COMPRA:
return _err(
line_num,
"ORDEN DE COMPRA",
f"Error: (Celda S{line_num}) La Orden de Venta: {orden} supera la cantidad de caracteres permitidos. "
"Capturar en la Celda S una orden de compra no mayor de 20 caracteres.",
)
# Número de parte (Clarion Col T): longitud y catálogo
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if num_parte:
if len(num_parte) > MAX_LEN_NUM_PARTE:
return _err(
line_num,
"NUM. PARTE",
f"Error: (Celda T{line_num}) El Número de Parte: {num_parte} supera la longitud de caracteres. "
"Capturar en la Celda T el Número de Parte no mayor de 30 caracteres.",
)
if valid_part_numbers is not None and num_parte.upper() not in valid_part_numbers:
return _err(
line_num,
"NUM. PARTE",
f"Error: (Celda T{line_num}) El Número de Parte: {num_parte} no existe en el Catálogo de Partes. Darlo de alta en el Catálogo de Partes.",
)
# Decimales PZA
if validar_decimales_pza and um and um.upper() == "PZA" and cant_str:
d = _parse_decimal(cant_str)
if d is not None and d != int(d):
return _err(
line_num,
"CANTIDAD EXPORTADA/DESCARGAR",
f"Error: (Celda G{line_num}) La Unidad de Medida es PZA, Por lo Tanto no es Válida la Captura de Decimales. Asignar una Cantidad sin decimales.",
)
return None
def validate_row_partidas_expo(
row: Dict[str, Any],
line_num: int,
autonumerar: bool,
actualizar: bool,
levantar_subpartidas: bool,
invoice_id_by_number: Dict[str, int],
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
existing_line_keys_by_invoice: Dict[str, Set[str]],
line_counts_csv: Dict[Tuple[str, str], int],
partidas_principales_csv: Set[Tuple[str, str]],
partidas_principales_bd: Set[Tuple[str, str]],
valid_uom_codes: Set[str],
valid_bulks_codes: Set[str],
valid_payment_methods: Set[str],
valid_fraction_ame: Set[str],
valid_part_numbers: Optional[Set[str]],
factura_impo_tem_by_number: Dict[str, int],
factura_impo_def_by_number: Dict[str, int],
line_exists_tem: Set[Tuple[int, str]],
line_exists_def: Set[Tuple[int, str]],
rfc_exception_egm: bool = False,
validar_decimales_pza: bool = False,
) -> Optional[Dict[str, Any]]:
"""
Valida una fila de CSV de Partidas de Exportación Definitiva (o Cambio de Régimen).
Clarion: VALIDA_TODA_PAR_EXPO vs VALIDA_PARCIAL_PAR_EXPO según autonumerar, actualizar y si la partida existe.
"""
err = _check_factura_expo_vacia(row, line_num)
if err:
return err
factura_expo = _get(row, "NUMERO FACTURA EXPO", "NUMERO FACTURA EXPO.", "NUM FACTURA EXPO", "FACTURA EXPO")
if not factura_expo:
return _err(line_num, "NUMERO FACTURA EXPO", "Requerido")
err = _check_factura_expo_existe(factura_expo, line_num, invoice_id_by_number)
if err:
return err
err = _check_factura_expo_no_actualizada(
factura_expo, line_num, invoice_updated_by_number, rfc_exception_updated
)
if err:
return err
err = _check_linea_expo_si_no_autonumerar(row, line_num, autonumerar)
if err:
return err
err = _check_levantar_subpartidas_expo(row, line_num, levantar_subpartidas, rfc_exception_egm)
if err:
return err
linea_expo = _get(row, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO")
existing_lines = existing_line_keys_by_invoice.get(factura_expo.strip(), set())
partida_existe = bool(linea_expo and linea_expo in existing_lines)
use_partial = actualizar and not autonumerar and partida_existe
if use_partial:
return _validaciones_par_expo(
row,
line_num,
factura_expo,
valid_uom_codes=valid_uom_codes,
valid_bulks_codes=valid_bulks_codes,
valid_payment_methods=valid_payment_methods,
valid_fraction_ame=valid_fraction_ame,
valid_part_numbers=valid_part_numbers,
factura_impo_tem_by_number=factura_impo_tem_by_number,
factura_impo_def_by_number=factura_impo_def_by_number,
line_exists_tem=line_exists_tem,
line_exists_def=line_exists_def,
validar_decimales_pza=validar_decimales_pza,
)
else:
err = _valida_toda_obligatorios_expo(row, line_num, levantar_subpartidas, rfc_exception_egm)
if err:
return err
if levantar_subpartidas:
err = _valida_subpartidas_duplicados_expo(
factura_expo, linea_expo or "", line_num, line_counts_csv
)
if err:
return err
err = _valida_subpartida_tiene_principal_expo(
row, line_num, factura_expo,
partidas_principales_csv, partidas_principales_bd, rfc_exception_egm
)
if err:
return err
err = _valida_subpartida_linea_principal_no_cero_expo(row, line_num, rfc_exception_egm)
if err:
return err
return _validaciones_par_expo(
row,
line_num,
factura_expo,
valid_uom_codes=valid_uom_codes,
valid_bulks_codes=valid_bulks_codes,
valid_payment_methods=valid_payment_methods,
valid_fraction_ame=valid_fraction_ame,
valid_part_numbers=valid_part_numbers,
factura_impo_tem_by_number=factura_impo_tem_by_number,
factura_impo_def_by_number=factura_impo_def_by_number,
line_exists_tem=line_exists_tem,
line_exists_def=line_exists_def,
validar_decimales_pza=validar_decimales_pza,
)

View File

@@ -0,0 +1,199 @@
"""
Validaciones CSV para Partidas de Importación Definitiva.
Paridad Clarion: VALIDA_TODA_PARIMPO_DEF, VALIDA_PARCIAL_PARIMPO_DEF, VALIDACIONES_PARIMPO_DEF.
Reutiliza la lógica de partidas_impo_temp; solo cambia el mensaje cuando la factura no existe
(«no existe en el catálogo de Importación Definitiva») y el origen de facturas (DEF/MATDE/EXDEF en tasks.py).
Estructura de columnas: misma que partidas TEM (NUMERO FACTURA, LINEA, CLASE, ... ID TYPE).
"""
from typing import Dict, Any, Optional, Set, Tuple, List
from .partidas_impo_temp import (
_clip,
_get,
_check_factura_vacia,
_check_factura_no_actualizada,
_check_linea_si_no_autonumerar,
_check_levantar_subpartidas_uv,
_valida_toda_obligatorios,
_valida_toda_numericos,
_valida_subpartidas_duplicados,
_valida_subpartida_tiene_principal,
_valida_subpartida_v_no_cero,
_validaciones_parimpo_tem,
_warn_apostrofes_num_parte,
)
def _check_factura_existe_def(
invoice_number: str,
line_num: int,
invoice_id_by_number: Dict[str, int],
catalog_label: str = "Importación Definitiva",
) -> Optional[Dict[str, Any]]:
"""Misma lógica que _check_factura_existe; mensaje específico según catalog_label (DEF o Compras Mexicanas)."""
if invoice_number not in invoice_id_by_number:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": (
f"Error: (Celda A{line_num}) La Factura de Importación {invoice_number} "
f"no existe en el catálogo de {catalog_label} y no se pueden hacer las validaciones. "
),
}
return None
def validate_row_partidas_impo_def(
row: Dict[str, Any],
line_num: int,
autonumerar: bool,
actualizar: bool,
levantar_subpartidas: bool,
calcular_costo_en_base_a_total: bool,
validar_decimales_pza: bool,
invoice_id_by_number: Dict[str, int],
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
existing_line_keys_by_invoice: Dict[str, Set[str]],
line_counts_csv: Dict[Tuple[str, str], int],
partidas_principales_csv: Set[Tuple[str, str]],
partidas_principales_bd: Set[Tuple[str, str]],
valid_class_codes: Set[str],
class_um_by_code: Dict[str, str],
class_fraction_by_code: Dict[str, str],
class_desc_es_by_code: Dict[str, str],
class_desc_en_by_code: Dict[str, str],
valid_uom_codes: Set[str],
valid_bulks_codes: Set[str],
valid_country_keys: Set[str],
valid_fraction_ame: Set[str],
valid_payment_methods: Set[str],
valid_valuation_methods: Set[str],
authorized_sectors: Set[str],
company_has_prosec: bool,
rfc_exception_num_parte: Optional[Set[str]],
valid_part_numbers: Optional[Set[str]],
warnings: Optional[List[Dict[str, Any]]] = None,
catalog_label: str = "Importación Definitiva",
) -> Optional[Dict[str, Any]]:
"""
Valida una fila de CSV de Partidas de Importación Definitiva.
Clarion: VALIDA_TODA_PARIMPO_DEF vs VALIDA_PARCIAL_PARIMPO_DEF según autonumerar, actualizar y si la partida existe.
Reutiliza todo de partidas_impo_temp salvo el check de factura existente (mensaje DEF).
"""
err = _check_factura_vacia(row, line_num)
if err:
return err
invoice_number = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not invoice_number:
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
err = _check_factura_existe_def(invoice_number, line_num, invoice_id_by_number, catalog_label)
if err:
return err
err = _check_factura_no_actualizada(
invoice_number, line_num, invoice_updated_by_number, rfc_exception_updated
)
if err:
return err
err = _check_linea_si_no_autonumerar(row, line_num, autonumerar)
if err:
return err
err = _check_levantar_subpartidas_uv(row, line_num, levantar_subpartidas)
if err:
return err
_warn_apostrofes_num_parte(row, line_num, warnings)
linea = _get(row, "LINEA", "RENGLON", "PARTIDA")
existing_lines = existing_line_keys_by_invoice.get(invoice_number.strip(), set())
partida_existe = bool(linea and linea in existing_lines)
use_partial = actualizar and not autonumerar and partida_existe
if use_partial:
return _validaciones_parimpo_tem(
row,
line_num,
valid_class_codes=valid_class_codes,
class_um_by_code=class_um_by_code,
class_fraction_by_code=class_fraction_by_code,
class_desc_es_by_code=class_desc_es_by_code,
class_desc_en_by_code=class_desc_en_by_code,
valid_uom_codes=valid_uom_codes,
valid_bulks_codes=valid_bulks_codes,
valid_country_keys=valid_country_keys,
valid_fraction_ame=valid_fraction_ame,
valid_payment_methods=valid_payment_methods,
valid_valuation_methods=valid_valuation_methods,
authorized_sectors=authorized_sectors,
company_has_prosec=company_has_prosec,
validar_decimales_pza=validar_decimales_pza,
rfc_exception_num_parte=rfc_exception_num_parte,
invoice_number=invoice_number,
)
else:
err = _valida_toda_obligatorios(
row, line_num, levantar_subpartidas, calcular_costo_en_base_a_total
)
if err:
return err
# Importación Definitiva / Compras Mexicanas: NUM. PARTE es obligatorio en todas las partidas (el insert lo exige).
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if not (num_parte and str(num_parte).strip()):
return {
"line": line_num,
"col": "NUM. PARTE",
"msg": f"NUM. PARTE: Requerido (obligatorio para partidas de {catalog_label}).",
}
err = _valida_toda_numericos(row, line_num, calcular_costo_en_base_a_total)
if err:
return err
if levantar_subpartidas:
err = _valida_subpartidas_duplicados(
invoice_number, linea, line_num, line_counts_csv
)
if err:
return err
err = _valida_subpartida_tiene_principal(
row, line_num, partidas_principales_csv, partidas_principales_bd
)
if err:
return err
err = _valida_subpartida_v_no_cero(row, line_num)
if err:
return err
err = _validaciones_parimpo_tem(
row,
line_num,
valid_class_codes=valid_class_codes,
class_um_by_code=class_um_by_code,
class_fraction_by_code=class_fraction_by_code,
class_desc_es_by_code=class_desc_es_by_code,
class_desc_en_by_code=class_desc_en_by_code,
valid_uom_codes=valid_uom_codes,
valid_bulks_codes=valid_bulks_codes,
valid_country_keys=valid_country_keys,
valid_fraction_ame=valid_fraction_ame,
valid_payment_methods=valid_payment_methods,
valid_valuation_methods=valid_valuation_methods,
authorized_sectors=authorized_sectors,
company_has_prosec=company_has_prosec,
validar_decimales_pza=validar_decimales_pza,
rfc_exception_num_parte=rfc_exception_num_parte,
invoice_number=invoice_number,
)
if err:
return err
if rfc_exception_num_parte and invoice_number in rfc_exception_num_parte and valid_part_numbers is not None:
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if num_parte and num_parte.upper() not in valid_part_numbers:
return {
"line": line_num,
"col": "NUM. PARTE",
"msg": f"Error: (Celda W{line_num}) El número de parte Capturado: {num_parte} no existe.",
}
return None

View File

@@ -0,0 +1,535 @@
"""
Validaciones CSV para Partidas de Importación Temporal.
Paridad Clarion: VALIDA_TODA_PARIMPO_TEM, VALIDA_PARCIAL_PARIMPO_TEM, VALIDACIONES_PARIMPO_TEM.
Estructura: NUMERO FACTURA, LINEA, CLASE, CANTIDAD IMPORTADA, ... hasta ID TYPE (columnas A-AG).
"""
from decimal import Decimal, InvalidOperation
from typing import Dict, Any, Optional, Set, Tuple, List
# Longitudes máximas Clarion
MAX_LEN_FACTURA = 15
MAX_LEN_LINEA = 5
MAX_LEN_CLASE = 9
MAX_LEN_CANTIDAD_STR = 19
MAX_LEN_ORDEN_COMPRA = 20
PREFERENCIAS_VALIDAS = frozenset({"GENERAL", "TLCS", "PROSEC", "ALADI"})
SE_PAGO_IMPUESTO_VALIDOS = frozenset({"SI", "NO"})
APOSTROFE = "'"
def _clip(val: Any) -> str:
if val is None:
return ""
return str(val).strip()
def _parse_decimal(val: Any) -> Optional[Decimal]:
if val is None:
return None
s = _clip(val)
if not s:
return None
s = s.replace(",", "")
try:
return Decimal(s)
except (InvalidOperation, ValueError):
return None
def _get(row: Dict[str, Any], *keys: str) -> str:
for k in keys:
v = row.get(k)
if v is not None and str(v).strip():
return _clip(v)
return ""
def _check_factura_vacia(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
val = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not val:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": "Error: (Celda A) La Factura de Importación está vacía y no se pueden hacer las validaciones.",
}
return None
def _check_factura_existe(
invoice_number: str,
line_num: int,
invoice_id_by_number: Dict[str, int],
) -> Optional[Dict[str, Any]]:
if invoice_number not in invoice_id_by_number:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": f"Error: (Celda A) La Factura de Importación {invoice_number} no existe en SCAII y no se pueden hacer las validaciones.",
}
return None
def _check_factura_no_actualizada(
invoice_number: str,
line_num: int,
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
) -> Optional[Dict[str, Any]]:
if invoice_number in rfc_exception_updated:
return None
if invoice_updated_by_number.get(invoice_number, False):
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": f"Error: (Celda A) La Factura de Importación: {invoice_number} ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas.",
}
return None
def _check_linea_si_no_autonumerar(row: Dict[str, Any], line_num: int, autonumerar: bool) -> Optional[Dict[str, Any]]:
if autonumerar:
return None
val = _get(row, "LINEA", "RENGLON", "PARTIDA")
if not val:
return {
"line": line_num,
"col": "LINEA",
"msg": "Error: (Celda B) El campo de la línea de la partida está vacío y no se pueden hacer las validaciones, ya que se tiene la opción de Autonumerar como NO.",
}
return None
def _check_levantar_subpartidas_uv(
row: Dict[str, Any], line_num: int, levantar_subpartidas: bool
) -> Optional[Dict[str, Any]]:
if not levantar_subpartidas:
return None
u = _get(row, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA")
if not u:
return {
"line": line_num,
"col": "ES PARTIDA O SUBPARTIDA",
"msg": "Error: (Celda U) El campo del tipo de la partida (partida o subpartida) está vacío y no se pueden hacer las validaciones, ya que se tiene la opción de Levantar Subpartidas como Si.",
}
v = _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL")
if not v:
return {
"line": line_num,
"col": "LINEA PRINCIPAL",
"msg": "Error: (Celda V) El campo de la partida principal está vacío y no se pueden hacer las validaciones, ya que se tiene la opción de Levantar Subpartidas como Si.",
}
return None
def _valida_toda_obligatorios(
row: Dict[str, Any],
line_num: int,
levantar_subpartidas: bool,
calcular_costo_en_base_a_total: bool,
) -> Optional[Dict[str, Any]]:
"""Obligatorios vacíos: C, D, F (condicional), G, K, M; U, V si LevantarSubpartidas."""
obligatorios: List[str] = []
if not _get(row, "CLASE"):
obligatorios.append("(Col.C) Clases")
if not _get(row, "CANTIDAD IMPORTADA", "CANTIDAD"):
obligatorios.append("(Col.D) Cantidad Importada")
es_subpartida = _get(row, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper() == "S"
if not calcular_costo_en_base_a_total and not es_subpartida:
if not _get(row, "COSTO UNITARIO", "COSTOUNITARIO", "PRECIO UNITARIO", "PRECIOUNITARIO"):
obligatorios.append("(Col.F) Costo Unitario")
if not _get(row, "PESO NETO", "PESONETO"):
obligatorios.append("(Col.G) Peso Neto")
if not _get(row, "PAIS ORIGEN", "PAISORIGEN", "PAIS"):
obligatorios.append("(Col.K) País")
if not _get(row, "PREFERENCIA ARANCELARIA", "PREFERENCIA", "PREFERENCIAARANCELARIA"):
obligatorios.append("(Col.M) Preferencia.")
if levantar_subpartidas:
if not _get(row, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA"):
obligatorios.append("(Col.U) EsSubpartida?.")
if not _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL"):
obligatorios.append("(Col.V) Partida Principal.")
if obligatorios:
return {
"line": line_num,
"col": "CLASE",
"msg": f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}.",
}
return None
def _valida_toda_numericos(
row: Dict[str, Any],
line_num: int,
calcular_costo_en_base_a_total: bool,
) -> Optional[Dict[str, Any]]:
"""Costo unitario y peso neto no pueden ser cero (salvo subpartida / costo por total)."""
es_subpartida = _get(row, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper() == "S"
if not calcular_costo_en_base_a_total and not es_subpartida:
costo = _parse_decimal(row.get("COSTO UNITARIO") or row.get("COSTOUNITARIO") or row.get("PRECIO UNITARIO") or row.get("PRECIOUNITARIO"))
if costo is not None and costo == 0:
return {
"line": line_num,
"col": "COSTO UNITARIO",
"msg": f"Error: (Celda F{line_num}) El Costo Unitario no puede ser cero.",
}
peso_neto = _parse_decimal(row.get("PESO NETO") or row.get("PESONETO"))
if peso_neto is not None and peso_neto == 0:
return {
"line": line_num,
"col": "PESO NETO",
"msg": f"Error: (Celda G{line_num}) El Peso Neto no puede ser cero.",
}
return None
def _valida_subpartidas_duplicados(
invoice_number: str,
linea: str,
line_num: int,
line_counts: Dict[Tuple[str, str], int],
) -> Optional[Dict[str, Any]]:
key = (invoice_number.strip(), _clip(linea))
if line_counts.get(key, 0) > 1:
return {
"line": line_num,
"col": "LINEA",
"msg": f"Error: (Celda B{line_num}) El campo de la partida está duplicado entre las partidas.",
}
return None
def _valida_subpartida_tiene_principal(
row: Dict[str, Any],
line_num: int,
partidas_principales_en_csv: Set[Tuple[str, str]],
partidas_principales_en_bd: Set[Tuple[str, str]],
) -> Optional[Dict[str, Any]]:
u = _get(row, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper()
v = _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL")
if u != "S" or not v or v == "0":
return None
inv = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not inv:
return None
key_principal = (inv.strip(), _clip(v))
if key_principal in partidas_principales_en_csv or key_principal in partidas_principales_en_bd:
return None
return {
"line": line_num,
"col": "LINEA PRINCIPAL",
"msg": f"Error: (Celda V{line_num}) La partida principal {v} no existe.",
}
def _valida_subpartida_v_no_cero(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
u = _get(row, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper()
v = _get(row, "LINEA PRINCIPAL", "LINEAPRINCIPAL", "PARTIDA PRINCIPAL")
if u == "S" and v == "0":
return {
"line": line_num,
"col": "LINEA PRINCIPAL",
"msg": f"Error: (Celda V{line_num}) La SubPartida no tiene asignada una partida principal.",
}
return None
def _validaciones_parimpo_tem(
row: Dict[str, Any],
line_num: int,
valid_class_codes: Set[str],
class_um_by_code: Dict[str, str],
class_fraction_by_code: Dict[str, str],
class_desc_es_by_code: Dict[str, str],
class_desc_en_by_code: Dict[str, str],
valid_uom_codes: Set[str],
valid_bulks_codes: Set[str],
valid_country_keys: Set[str],
valid_fraction_ame: Set[str],
valid_payment_methods: Set[str],
valid_valuation_methods: Set[str],
authorized_sectors: Set[str],
company_has_prosec: bool,
validar_decimales_pza: bool,
rfc_exception_num_parte: Optional[Set[str]],
invoice_number: str,
) -> Optional[Dict[str, Any]]:
"""VALIDACIONES_PARIMPO_TEM: longitudes, catálogos, reglas de negocio."""
def err(col: str, msg: str) -> Dict[str, Any]:
return {"line": line_num, "col": col, "msg": msg}
clase = _get(row, "CLASE")
um = _get(row, "UNIDAD DE MEDIDA", "UNIDAD MEDIDA")
# Longitudes
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if factura and len(factura) > MAX_LEN_FACTURA:
return err("NUMERO FACTURA", f"Error: (Celda A{line_num}) La Factura de Importación: {factura} supera la longitud de caracteres.")
linea = _get(row, "LINEA", "RENGLON", "PARTIDA")
if linea and len(linea) > MAX_LEN_LINEA:
return err("LINEA", f"Error: (Celda B{line_num}) La Línea de Importación: {linea} supera la longitud de caracteres.")
if clase:
if len(clase) > MAX_LEN_CLASE:
return err("CLASE", f"Error: (Celda C{line_num}) La Clase: {clase} supera la longitud de caracteres.")
if clase.upper() not in valid_class_codes:
return err("CLASE", f"Error: (Celda C{line_num}) La Clase: {clase} no existe en el Catálogo de Clases.")
if not um and not class_um_by_code.get(clase.upper()):
return err("UNIDAD DE MEDIDA", f"Error: (Celda E{line_num}) Debido a que esta celda es vacía, se asignará la unidad de medida de la Clase pero también está vacía.")
frac = _get(row, "FRACCION ARANCELARIA", "FRACCION", "FRACCIONARANCELARIA")
if not frac and not class_fraction_by_code.get(clase.upper()):
return err("FRACCION ARANCELARIA", f"Error: (Celda L{line_num}) Debido a que esta celda es vacía, se asignará la fracción de la Clase pero también está vacía.")
if not _get(row, "DESCRIPCION ESPAÑOL", "DESCRIPCIONE", "DESCRIPCION") and not class_desc_es_by_code.get(clase.upper()):
return err("DESCRIPCION ESPAÑOL", f"Error: (Celda Q{line_num}) Debido a que esta celda es vacía, se asignará la Descripción en Español de la Clase pero también está vacía.")
if not _get(row, "DESCRIPCION INGLES", "DESCRIPCIONI") and not class_desc_en_by_code.get(clase.upper()):
return err("DESCRIPCION INGLES", f"Error: (Celda R{line_num}) Debido a que esta celda es vacía, se asignará la Descripción en Inglés de la Clase pero también está vacía.")
# D: Cantidad importada
cant_str = _get(row, "CANTIDAD IMPORTADA", "CANTIDAD")
if cant_str:
cant = _parse_decimal(cant_str)
if cant is not None and cant <= 0:
return err("CANTIDAD IMPORTADA", f"Error: (Celda D{line_num}) La Cantidad Importada: {cant_str} es cero.")
if len(cant_str) > MAX_LEN_CANTIDAD_STR:
return err("CANTIDAD IMPORTADA", f"Error: (Celda D{line_num}) La Cantidad Importada: {cant_str} supera la cantidad de caracteres permitidos.")
# E: Unidad de medida en catálogo
if um and um.upper() not in valid_uom_codes:
return err("UNIDAD DE MEDIDA", f"Error: (Celda E{line_num}) La Unidad de Medida: {um} no existe en el Catálogo de Unidades de Medida.")
# I, J: Bultos condicional
clave_bultos = _get(row, "CLAVE BULTOS", "CLAVEBULTOS")
cant_bultos = row.get("CANTIDAD BULTOS") or row.get("CANTIDADBULTOS")
if clave_bultos:
if clave_bultos not in valid_bulks_codes:
return err("CLAVE BULTOS", f"Error: (Celda J{line_num}) La Clave de Bulto: {clave_bultos} no existe en el Catálogo de Claves de Bultos.")
cant_bultos_val = _parse_decimal(cant_bultos)
if cant_bultos_val is None:
return err("CANTIDAD BULTOS", f"Error: (Celda I{line_num}) La Cantidad de Bultos está vacía y en la Celda J{line_num} se tiene la Clave de Bulto.")
if cant_bultos_val == 0:
return err("CANTIDAD BULTOS", f"Error: (Celda I{line_num}) La Cantidad de Bultos es cero y en la Celda J{line_num} se tiene la Clave de Bulto.")
else:
cant_bultos_val = _parse_decimal(cant_bultos)
if cant_bultos_val is not None and cant_bultos_val > 0:
return err("CANTIDAD BULTOS", f"Error: (Celda I{line_num}) La Cantidad de Bultos es {cant_bultos} y en la Celda J{line_num} no se tiene la Clave de Bulto.")
# K: País SAAIM3 o americana
pais = _get(row, "PAIS ORIGEN", "PAISORIGEN", "PAIS")
if pais and pais.upper() not in valid_country_keys:
return err("PAIS ORIGEN", f"Error: (Celda K{line_num}) El País: {pais} no se encontró como Clave SAAIM3 ni Clave Americana en el Catálogo de Paises.")
# M: Preferencia
pref = _get(row, "PREFERENCIA ARANCELARIA", "PREFERENCIA", "PREFERENCIAARANCELARIA").upper()
if pref and pref not in PREFERENCIAS_VALIDAS:
return err("PREFERENCIA ARANCELARIA", f"Error: (Celda M{line_num}) La Preferencia Arancelaria: {pref} no es correcta para el sistema SCAF.")
sector = _get(row, "SECTOR")
if pref == "PROSEC":
if not sector:
return err("PREFERENCIA ARANCELARIA", f"Error: (Celda M{line_num} y N{line_num}) La Preferencia Arancelaria es: {pref} y en la columna N no tiene sector.")
if sector not in authorized_sectors:
return err("SECTOR", f"Error: (Celda N{line_num}) El Sector: {sector} no existe en el Catálogo de Sectores.")
if not company_has_prosec:
return err("SECTOR", f"Error: (Celda N{line_num}) La empresa no cuenta con autorización PROSEC.")
elif pref and pref != "PROSEC" and sector:
return err("PREFERENCIA ARANCELARIA", f"Error: (Celda M{line_num} y N{line_num}) La Preferencia Arancelaria es: {pref} y en la columna N tiene sector.")
# O: Fracción americana
frac_ame = _get(row, "FRACCION AMERICANA", "FRACCIONAMERICANA")
if frac_ame and frac_ame not in valid_fraction_ame:
return err("FRACCION AMERICANA", f"Advertencia: (Celda O{line_num}) La Fracción Americana: {frac_ame} no existe en el Catálogo de Fracciones Americanas.")
# P: Orden de compra máx 20
orden = _get(row, "ORDEN DE COMPRA", "ORDENCOMPRA")
if orden and len(orden) > MAX_LEN_ORDEN_COMPRA:
return err("ORDEN DE COMPRA", f"Error: (Celda P{line_num}) La Orden de Compra: {orden} supera la cantidad de caracteres permitidos.")
# Decimales PZA
if validar_decimales_pza:
um_code = (um or class_um_by_code.get(clase.upper() or "") or "").upper()
if um_code == "PZA" and cant_str:
d = _parse_decimal(cant_str)
if d is not None and d != int(d):
return err("CANTIDAD IMPORTADA", "Error: (Celda D) La Unidad de Medida es PZA, Por lo Tanto no es Válida la Captura de Decimales.")
# Z: Método de valoración
met_val = _get(row, "METODO DE VALORACION", "METODODEVALORACION", "METODO VALORACION")
if met_val and met_val not in valid_valuation_methods:
return err("METODO DE VALORACION", f"Error: (Celda Z{line_num}) El Método de Valoración Capturado: {met_val} No es Válido.")
# RFC excepción: W obligatorio
if rfc_exception_num_parte and invoice_number in rfc_exception_num_parte:
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if not num_parte:
return err("NUM. PARTE", f"Error: (Celda W{line_num}) No está capturado el número de parte.")
# X: Se pagó impuesto SI/NO
x = _get(row, "SE PAGO IMPUESTO", "SEPAGOIMPUESTO")
if x and x.upper() not in SE_PAGO_IMPUESTO_VALIDOS:
return err("SE PAGO IMPUESTO", f"Error: (Celda X{line_num}) El Valor Capturado para Se Pago Impuesto no es Válido. Capturar SI o NO.")
# Y: Forma de pago en catálogo
forma_pago = _get(row, "FORMA DE PAGO", "FORMADEPAGO", "FORMA PAGO")
if forma_pago and forma_pago not in valid_payment_methods:
return err("FORMA DE PAGO", f"Error: (Celda Y{line_num}) La Forma de Pago Capturado no es Válido.")
return None
def _warn_apostrofes_num_parte(
row: Dict[str, Any], line_num: int, warnings: Optional[List[Dict[str, Any]]]
) -> None:
val = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if val and APOSTROFE in val and warnings is not None:
warnings.append({
"line": line_num,
"col": "NUM. PARTE",
"msg": f"Advertencia: El Número de Parte: {val} Contiene Apostrofes.",
"warning": True,
})
def validate_row_partidas_impo_temp(
row: Dict[str, Any],
line_num: int,
autonumerar: bool,
actualizar: bool,
levantar_subpartidas: bool,
calcular_costo_en_base_a_total: bool,
validar_decimales_pza: bool,
invoice_id_by_number: Dict[str, int],
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
existing_line_keys_by_invoice: Dict[str, Set[str]],
line_counts_csv: Dict[Tuple[str, str], int],
partidas_principales_csv: Set[Tuple[str, str]],
partidas_principales_bd: Set[Tuple[str, str]],
valid_class_codes: Set[str],
class_um_by_code: Dict[str, str],
class_fraction_by_code: Dict[str, str],
class_desc_es_by_code: Dict[str, str],
class_desc_en_by_code: Dict[str, str],
valid_uom_codes: Set[str],
valid_bulks_codes: Set[str],
valid_country_keys: Set[str],
valid_fraction_ame: Set[str],
valid_payment_methods: Set[str],
valid_valuation_methods: Set[str],
authorized_sectors: Set[str],
company_has_prosec: bool,
rfc_exception_num_parte: Optional[Set[str]],
valid_part_numbers: Optional[Set[str]],
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Partidas de Importación Temporal.
Clarion: VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la partida existe.
"""
err = _check_factura_vacia(row, line_num)
if err:
return err
invoice_number = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not invoice_number:
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
err = _check_factura_existe(invoice_number, line_num, invoice_id_by_number)
if err:
return err
err = _check_factura_no_actualizada(
invoice_number, line_num, invoice_updated_by_number, rfc_exception_updated
)
if err:
return err
err = _check_linea_si_no_autonumerar(row, line_num, autonumerar)
if err:
return err
err = _check_levantar_subpartidas_uv(row, line_num, levantar_subpartidas)
if err:
return err
_warn_apostrofes_num_parte(row, line_num, warnings)
linea = _get(row, "LINEA", "RENGLON", "PARTIDA")
existing_lines = existing_line_keys_by_invoice.get(invoice_number.strip(), set())
partida_existe = bool(linea and linea in existing_lines)
use_partial = actualizar and not autonumerar and partida_existe
if use_partial:
return _validaciones_parimpo_tem(
row,
line_num,
valid_class_codes=valid_class_codes,
class_um_by_code=class_um_by_code,
class_fraction_by_code=class_fraction_by_code,
class_desc_es_by_code=class_desc_es_by_code,
class_desc_en_by_code=class_desc_en_by_code,
valid_uom_codes=valid_uom_codes,
valid_bulks_codes=valid_bulks_codes,
valid_country_keys=valid_country_keys,
valid_fraction_ame=valid_fraction_ame,
valid_payment_methods=valid_payment_methods,
valid_valuation_methods=valid_valuation_methods,
authorized_sectors=authorized_sectors,
company_has_prosec=company_has_prosec,
validar_decimales_pza=validar_decimales_pza,
rfc_exception_num_parte=rfc_exception_num_parte,
invoice_number=invoice_number,
)
else:
err = _valida_toda_obligatorios(
row, line_num, levantar_subpartidas, calcular_costo_en_base_a_total
)
if err:
return err
err = _valida_toda_numericos(row, line_num, calcular_costo_en_base_a_total)
if err:
return err
if levantar_subpartidas:
err = _valida_subpartidas_duplicados(
invoice_number, linea, line_num, line_counts_csv
)
if err:
return err
err = _valida_subpartida_tiene_principal(
row, line_num, partidas_principales_csv, partidas_principales_bd
)
if err:
return err
err = _valida_subpartida_v_no_cero(row, line_num)
if err:
return err
err = _validaciones_parimpo_tem(
row,
line_num,
valid_class_codes=valid_class_codes,
class_um_by_code=class_um_by_code,
class_fraction_by_code=class_fraction_by_code,
class_desc_es_by_code=class_desc_es_by_code,
class_desc_en_by_code=class_desc_en_by_code,
valid_uom_codes=valid_uom_codes,
valid_bulks_codes=valid_bulks_codes,
valid_country_keys=valid_country_keys,
valid_fraction_ame=valid_fraction_ame,
valid_payment_methods=valid_payment_methods,
valid_valuation_methods=valid_valuation_methods,
authorized_sectors=authorized_sectors,
company_has_prosec=company_has_prosec,
validar_decimales_pza=validar_decimales_pza,
rfc_exception_num_parte=rfc_exception_num_parte,
invoice_number=invoice_number,
)
if err:
return err
if rfc_exception_num_parte and invoice_number in rfc_exception_num_parte and valid_part_numbers is not None:
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if num_parte and num_parte.upper() not in valid_part_numbers:
return {
"line": line_num,
"col": "NUM. PARTE",
"msg": f"Error: (Celda W{line_num}) El número de parte Capturado: {num_parte} no existe.",
}
return None

View File

@@ -0,0 +1,296 @@
"""
Validaciones CSV para Series de Exportación Definitiva.
Paridad Clarion: VALIDA_TODA_SERIES_EXPO, VALIDA_PARCIAL_SERIES_EXPO, LLENA_SERIES_EXPO.
Estructura: NUMERO FACTURA, LINEA FACTURA, LINEA SERIE, SERIE, MODELO, NUM PARTE, SUB MODELO, NUMERO ID.
Reutiliza helpers de series_impo_temp y series_impo_def; factura = exportación (FAC_EXPO), rfc_exception_updated.
"""
from typing import Dict, Any, Optional, Set, Tuple, List
from .series_impo_temp import (
_clip,
normalize_sacarcomasenters,
_check_desfase,
_check_linea_factura_vacia,
_check_linea_serie_si_no_autonumerar,
_warn_apostrofes,
_check_max_length,
MAX_LEN,
)
from .series_impo_def import (
_check_partida_existe_en_factura,
_check_cantidad_series_vs_partida,
_get_val_def,
)
def _check_factura_vacia_expo(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""NUMERO FACTURA (A) vacío → error. Mensaje Factura de Exportación."""
val = _clip(row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or row.get("FACTURA EXPO"))
if not val:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": (
f"Error: (Celda A{line_num}) La Factura de Exportación está vacía y no se pueden hacer las validaciones. "
),
"solution": (
f"Capturar en la Celda A{line_num} un número de Factura existente "
f"al cual desee agregar o actualizar series"
),
}
return None
def _check_factura_existe_expo(
invoice_number: str,
line_num: int,
invoice_id_by_number: Dict[str, int],
) -> Optional[Dict[str, Any]]:
"""Factura debe existir en BD (catálogo exportación)."""
if invoice_number not in invoice_id_by_number:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": (
f"Error: (Celda A{line_num}) La Factura de Exportación {invoice_number} "
f"no existe en SCAII y no se pueden hacer las validaciones. "
),
"solution": (
f"Capturar en la Celda A{line_num} un número de Factura existente "
f"al cual desee agregar o actualizar series"
),
"identifier": "FAC_EXPO",
"fields": invoice_number,
}
return None
def _check_factura_no_actualizada_expo(
invoice_number: str,
line_num: int,
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
) -> Optional[Dict[str, Any]]:
"""Si factura ya actualizada (Estatus AC) no se pueden hacer cambios; excepción por RFC (ej. EGM0303257J1)."""
if invoice_number in rfc_exception_updated:
return None
if invoice_updated_by_number.get(invoice_number, False):
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": (
f"Error: (Celda A{line_num}) La Factura de Exportación: {invoice_number} "
"ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas."
),
"solution": "Capturar otro número de Factura de Exportación o Desactualizar la factura.",
"identifier": "FAC_EXPO",
}
return None
def valida_toda_series_expo(
row: Dict[str, Any],
line_num: int,
validar_series_exception: bool,
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""
VALIDA_TODA_SERIES_EXPO: cuando no existe la serie o autonumerar=SI.
Si D+E+F están todos vacíos y no aplica excepción ValidarSeries → error obligatorios.
Valida longitudes máximas.
"""
d = _get_val_def(row, "SERIE")
e = _get_val_def(row, "MODELO")
f = _get_val_def(row, "NUM PARTE")
campos = d + e + f
if not campos and not validar_series_exception:
obligatorios = []
if not d:
obligatorios.append("(Col.D) Serie")
if not e:
obligatorios.append("(Col.E) Modelo")
if not f:
obligatorios.append("(Col.F) Num. Parte")
if obligatorios:
return {
"line": line_num,
"col": "SERIE",
"msg": (
f"Existen campos vacíos que son obligatorios al no tener ningun campo, "
f"es la {', '.join(obligatorios)}."
),
"solution": "Revisar la línea del archivo y capturar los campos con la información correcta.",
"identifier": "ARCHIVO CSV",
}
for col, key, max_len in [
("SERIE", "SERIE", MAX_LEN["serial_numbers"]),
("MODELO", "MODELO", MAX_LEN["model"]),
("NUM PARTE", "NUM PARTE", 50),
("SUB MODELO", "SUB MODELO", MAX_LEN["sub_model"]),
("NUMERO ID", "NUMERO ID", MAX_LEN["number_id"]),
]:
val = _get_val_def(row, key)
if val:
err = _check_max_length(col, val, line_num, max_len)
if err:
return err
return None
def valida_parcial_series_expo(
row: Dict[str, Any],
line_num: int,
existing_series_data: Dict[str, Any],
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""
VALIDA_PARCIAL_SERIES_EXPO: actualizar serie existente; campos vacíos se rellenan con existente.
Solo validar longitudes en campos no vacíos.
"""
for col, key, max_len in [
("SERIE", "SERIE", MAX_LEN["serial_numbers"]),
("MODELO", "MODELO", MAX_LEN["model"]),
("NUM PARTE", "NUM PARTE", 50),
("SUB MODELO", "SUB MODELO", MAX_LEN["sub_model"]),
("NUMERO ID", "NUMERO ID", MAX_LEN["number_id"]),
]:
val = _get_val_def(row, key)
if val:
err = _check_max_length(col, val, line_num, max_len)
if err:
return err
return None
def _series_key(invoice_number: str, linea_factura: str, linea_serie: str) -> Tuple[str, str, str]:
return (invoice_number.strip(), _clip(linea_factura), _clip(linea_serie))
def validate_row_series_expo(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
autonumerar: bool,
validar_series_exception: bool,
invoice_id_by_number: Dict[str, int],
invoice_updated_by_number: Dict[str, bool],
rfc_exception_updated: Set[str],
partida_max_series: Dict[Tuple[str, str], int],
csv_series_count_so_far: Dict[Tuple[str, str], int],
existing_series_keys: Set[Tuple[str, str, str]],
existing_series_data: Optional[Dict[Tuple[str, str, str], Dict[str, Any]]],
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Series de Exportación Definitiva.
Clarion: decisión VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la serie existe.
rfc_exception_updated: set de números de factura que se consideran no actualizadas (ej. EGM0303257J1).
"""
desfase = _check_desfase(row, line_num)
if desfase and warnings is not None:
warnings.append(desfase)
err = _check_factura_vacia_expo(row, line_num)
if err:
return err
invoice_number = _clip(
row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or row.get("FACTURA EXPO")
)
if not invoice_number:
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
err = _check_factura_existe_expo(invoice_number, line_num, invoice_id_by_number)
if err:
return err
err = _check_factura_no_actualizada_expo(
invoice_number, line_num, invoice_updated_by_number, rfc_exception_updated
)
if err:
return err
err = _check_linea_factura_vacia(row, line_num)
if err:
return err
linea_factura = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
err = _check_partida_existe_en_factura(
invoice_number,
linea_factura,
line_num,
partida_max_series,
)
if err:
return err
err = _check_cantidad_series_vs_partida(
invoice_number,
linea_factura,
line_num,
partida_max_series,
csv_series_count_so_far,
)
if err:
return err
err = _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar)
if err:
return err
_warn_apostrofes(row, line_num, warnings)
linea_serie = _clip(row.get("LINEA SERIE") or row.get("RENGLON"))
key = _series_key(invoice_number, linea_factura, linea_serie)
use_partial = (
actualizar
and not autonumerar
and bool(linea_serie)
and key in (existing_series_keys or set())
)
if use_partial and existing_series_data and key in existing_series_data:
return valida_parcial_series_expo(
row, line_num, existing_series_data[key], warnings
)
return valida_toda_series_expo(
row, line_num, validar_series_exception, warnings
)
def row_to_series_normalized_expo(row: Dict[str, Any]) -> Dict[str, Any]:
"""
Normaliza fila para guardar: SACARCOMASENTERS en D, E, F, G, H.
Clarion LLENA_SERIES_EXPO: asigna QueCSV a SerExpo.
NUM PARTE se valida pero el modelo Serie no tiene campo parte.
"""
def clip(col: str, alt: Optional[List[str]] = None) -> str:
v = row.get(col)
if alt:
for k in alt:
if v is None or (isinstance(v, str) and not v.strip()):
v = row.get(k)
return _clip(v) if v is not None else ""
def norm(col: str, alt: Optional[List[str]] = None, max_len: int = 50) -> str:
v = row.get(col)
if alt:
for k in alt:
if v is None or (isinstance(v, str) and not v.strip()):
v = row.get(k)
s = normalize_sacarcomasenters(v) if v is not None else ""
return s[:max_len] if s else ""
return {
"NUMERO FACTURA": clip("NUMERO FACTURA", ["NUM FACTURA", "FACTURA", "FACTURA EXPO"]),
"LINEA FACTURA": clip("LINEA FACTURA", ["LINEA", "PARTIDA"]),
"LINEA SERIE": clip("LINEA SERIE", ["RENGLON"]),
"SERIE": norm("SERIE", max_len=MAX_LEN["serial_numbers"]),
"MODELO": norm("MODELO", max_len=MAX_LEN["model"]),
"NUM PARTE": norm("NUM PARTE", ["NUMPARTE", "NUMERO PARTE"]),
"SUB MODELO": norm("SUB MODELO", ["SUBMODELO"], MAX_LEN["sub_model"]),
"NUMERO ID": norm("NUMERO ID", ["NUMEROID"], MAX_LEN["number_id"]),
}

View File

@@ -0,0 +1,338 @@
"""
Validaciones CSV para Series de Importación Definitiva.
Paridad Clarion: VALIDA_TODA_SERIES_IMPO_DEF, VALIDA_PARCIAL_SERIES_IMPO_DEF, LLENA_SERIES_IMPO_DEF.
Estructura: NUMERO FACTURA, LINEA FACTURA, LINEA SERIE, SERIE, MODELO, NUM PARTE, SUB MODELO, NUMERO ID.
Reutiliza helpers de series_impo_temp; añade reglas DEF: factura en catálogo DEF, cantidad series vs partida.
"""
from typing import Dict, Any, Optional, Set, Tuple, List
from .series_impo_temp import (
_clip,
normalize_sacarcomasenters,
_check_desfase,
_check_factura_vacia,
_check_linea_factura_vacia,
_check_linea_serie_si_no_autonumerar,
_warn_apostrofes,
_check_max_length,
MAX_LEN,
)
def _check_factura_existe_def(
invoice_number: str,
line_num: int,
invoice_id_by_number: Dict[str, int],
catalog_label: str = "Importación Definitiva",
) -> Optional[Dict[str, Any]]:
"""Factura debe existir en BD (Importación Definitiva o Compras Mexicanas según catalog_label)."""
if invoice_number not in invoice_id_by_number:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": (
f"Error: (Celda A{line_num}) La Factura de Importación {invoice_number} "
f"no existe en SCAII y no se pueden hacer las validaciones. "
),
"solution": (
f"Capturar en la Celda A{line_num} un número de Factura existente "
f"al cual desee agregar o actualizar series"
),
"identifier": "FAC_IMPO_DEF",
"fields": invoice_number,
}
return None
def _check_factura_no_actualizada_def(
invoice_number: str,
line_num: int,
invoice_updated_by_number: Dict[str, bool],
catalog_label: str = "Importación Definitiva",
) -> Optional[Dict[str, Any]]:
"""Si factura ya actualizada (Estatus AC) no se pueden hacer cambios. Mensaje según catalog_label."""
if invoice_updated_by_number.get(invoice_number, False):
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": (
f"Error: (Celda A{line_num}) La Factura de {catalog_label}: {invoice_number} "
"ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas."
),
"solution": (
"Capturar otro número de Factura de Importación Temporal o Desactualizar la factura."
),
"identifier": "FAC_IMPO_DEF",
}
return None
def _check_partida_existe_en_factura(
invoice_number: str,
linea_factura: str,
line_num: int,
partida_max_series: Dict[Tuple[str, str], int],
) -> Optional[Dict[str, Any]]:
"""
Clarion: la partida (línea de factura) debe existir en la factura.
partida_max_series tiene como claves (invoice_number, line_number) de las partidas existentes.
"""
if not linea_factura:
return None
key = (invoice_number.strip(), _clip(linea_factura))
if key in partida_max_series:
return None
return {
"line": line_num,
"col": "LINEA FACTURA",
"msg": f"Partida línea {linea_factura} no existe en la factura.",
"solution": "Usar un número de línea de partida que exista en la factura (capturar partidas antes de cargar series).",
"identifier": "ARCHIVO CSV",
}
def _check_cantidad_series_vs_partida(
invoice_number: str,
linea_factura: str,
line_num: int,
partida_max_series: Dict[Tuple[str, str], int],
csv_series_count_so_far: Dict[Tuple[str, str], int],
) -> Optional[Dict[str, Any]]:
"""
Clarion: si cantidad de series en CSV para (factura, partida) excede CantImpoDef → error.
partida_max_series[(inv, line)] = máximo permitido (desde LineQuantity.quantity).
csv_series_count_so_far = conteo actual de filas válidas ya procesadas por (inv, line).
"""
key = (invoice_number.strip(), _clip(linea_factura))
max_allowed = partida_max_series.get(key)
if max_allowed is None or max_allowed <= 0:
return None
current = csv_series_count_so_far.get(key, 0)
if current + 1 > max_allowed:
return {
"line": line_num,
"col": "LINEA FACTURA",
"msg": (
f"Error: La cantidad de Series de la partida {_clip(linea_factura)} en la factura "
f"{invoice_number} es menor al número de Series en el archivo. "
),
"solution": "Nivelar la cantidad de la Partida o el número de Series.",
"identifier": "ARCHIVO CSV",
}
return None
def _get_val_def(row: Dict[str, Any], k: str) -> str:
"""Obtener valor normalizado por clave (aliases DEF/TEM)."""
if k == "NUM PARTE":
return _clip(row.get("NUM PARTE") or row.get("NUMPARTE") or row.get("NUMERO PARTE"))
if k == "SUB MODELO":
return _clip(row.get("SUB MODELO") or row.get("SUBMODELO"))
if k == "NUMERO ID":
return _clip(row.get("NUMERO ID") or row.get("NUMEROID"))
return _clip(row.get(k))
def valida_toda_series_impo_def(
row: Dict[str, Any],
line_num: int,
validar_series_exception: bool,
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""
VALIDA_TODA_SERIES_IMPO_DEF: cuando no existe la serie o autonumerar=SI.
Si D+E+F están todos vacíos y no aplica excepción ValidarSeries → error obligatorios.
Valida longitudes máximas.
"""
d = _get_val_def(row, "SERIE")
e = _get_val_def(row, "MODELO")
f = _get_val_def(row, "NUM PARTE")
campos = d + e + f
if not campos and not validar_series_exception:
obligatorios = []
if not d:
obligatorios.append("(Col.D) Serie")
if not e:
obligatorios.append("(Col.E) Modelo")
if not f:
obligatorios.append("(Col.F) Num. Parte")
if obligatorios:
return {
"line": line_num,
"col": "SERIE",
"msg": (
f"Existen campos vacíos que son obligatorios al no tener ningun campo, "
f"es la {', '.join(obligatorios)}."
),
"solution": "Revisar la línea del archivo y capturar los campos con la información correcta.",
"identifier": "ARCHIVO CSV",
}
for col, key, max_len in [
("SERIE", "SERIE", MAX_LEN["serial_numbers"]),
("MODELO", "MODELO", MAX_LEN["model"]),
("NUM PARTE", "NUM PARTE", 50),
("SUB MODELO", "SUB MODELO", MAX_LEN["sub_model"]),
("NUMERO ID", "NUMERO ID", MAX_LEN["number_id"]),
]:
val = _get_val_def(row, key)
if val:
err = _check_max_length(col, val, line_num, max_len)
if err:
return err
return None
def valida_parcial_series_impo_def(
row: Dict[str, Any],
line_num: int,
existing_series_data: Dict[str, Any],
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""
VALIDA_PARCIAL_SERIES_IMPO_DEF: actualizar serie existente; campos vacíos se rellenan con existente.
Solo validar longitudes en campos no vacíos.
"""
for col, key, max_len in [
("SERIE", "SERIE", MAX_LEN["serial_numbers"]),
("MODELO", "MODELO", MAX_LEN["model"]),
("NUM PARTE", "NUM PARTE", 50),
("SUB MODELO", "SUB MODELO", MAX_LEN["sub_model"]),
("NUMERO ID", "NUMERO ID", MAX_LEN["number_id"]),
]:
val = _get_val_def(row, key)
if val:
err = _check_max_length(col, val, line_num, max_len)
if err:
return err
return None
def _series_key(invoice_number: str, linea_factura: str, linea_serie: str) -> Tuple[str, str, str]:
return (invoice_number.strip(), _clip(linea_factura), _clip(linea_serie))
def validate_row_series_impo_def(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
autonumerar: bool,
validar_series_exception: bool,
invoice_id_by_number: Dict[str, int],
invoice_updated_by_number: Dict[str, bool],
partida_max_series: Dict[Tuple[str, str], int],
csv_series_count_so_far: Dict[Tuple[str, str], int],
existing_series_keys: Set[Tuple[str, str, str]],
existing_series_data: Optional[Dict[Tuple[str, str, str], Dict[str, Any]]],
warnings: Optional[List[Dict[str, Any]]] = None,
catalog_label: str = "Importación Definitiva",
) -> Optional[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Series de Importación Definitiva.
Clarion: decisión VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la serie existe.
partida_max_series: máximo de series por (invoice_number, linea_factura); 0 o ausente = no validar.
csv_series_count_so_far: conteo de filas ya aceptadas por (invoice_number, linea_factura); el caller debe incrementar al aceptar.
"""
desfase = _check_desfase(row, line_num)
if desfase and warnings is not None:
warnings.append(desfase)
err = _check_factura_vacia(row, line_num)
if err:
return err
invoice_number = _clip(row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA"))
if not invoice_number:
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
err = _check_factura_existe_def(invoice_number, line_num, invoice_id_by_number, catalog_label)
if err:
return err
err = _check_factura_no_actualizada_def(invoice_number, line_num, invoice_updated_by_number, catalog_label)
if err:
return err
err = _check_linea_factura_vacia(row, line_num)
if err:
return err
linea_factura = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
err = _check_partida_existe_en_factura(
invoice_number,
linea_factura,
line_num,
partida_max_series,
)
if err:
return err
err = _check_cantidad_series_vs_partida(
invoice_number,
linea_factura,
line_num,
partida_max_series,
csv_series_count_so_far,
)
if err:
return err
err = _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar)
if err:
return err
_warn_apostrofes(row, line_num, warnings)
linea_serie = _clip(row.get("LINEA SERIE") or row.get("RENGLON"))
key = _series_key(invoice_number, linea_factura, linea_serie)
use_partial = (
actualizar
and not autonumerar
and bool(linea_serie)
and key in (existing_series_keys or set())
)
if use_partial and existing_series_data and key in existing_series_data:
return valida_parcial_series_impo_def(
row, line_num, existing_series_data[key], warnings
)
return valida_toda_series_impo_def(
row, line_num, validar_series_exception, warnings
)
def row_to_series_normalized_def(row: Dict[str, Any]) -> Dict[str, Any]:
"""
Normaliza fila para guardar: SACARCOMASENTERS en D, E, F, G, H.
Clarion LLENA_SERIES_IMPO_DEF: asigna QueCSV a SerDef.
Incluye NUM PARTE para validación; el modelo Serie actual no tiene campo parte (documentar si se añade).
"""
def clip(col: str, alt: Optional[List[str]] = None) -> str:
v = row.get(col)
if alt:
for k in alt:
if v is None or (isinstance(v, str) and not v.strip()):
v = row.get(k)
return _clip(v) if v is not None else ""
def norm(col: str, alt: Optional[List[str]] = None, max_len: int = 50) -> str:
v = row.get(col)
if alt:
for k in alt:
if v is None or (isinstance(v, str) and not v.strip()):
v = row.get(k)
s = normalize_sacarcomasenters(v) if v is not None else ""
return s[:max_len] if s else ""
return {
"NUMERO FACTURA": clip("NUMERO FACTURA", ["NUM FACTURA", "FACTURA"]),
"LINEA FACTURA": clip("LINEA FACTURA", ["LINEA", "PARTIDA"]),
"LINEA SERIE": clip("LINEA SERIE", ["RENGLON"]),
"SERIE": norm("SERIE", max_len=MAX_LEN["serial_numbers"]),
"MODELO": norm("MODELO", max_len=MAX_LEN["model"]),
"NUM PARTE": norm("NUM PARTE", ["NUMPARTE", "NUMERO PARTE"]),
"SUB MODELO": norm("SUB MODELO", ["SUBMODELO"], MAX_LEN["sub_model"]),
"NUMERO ID": norm("NUMERO ID", ["NUMEROID"], MAX_LEN["number_id"]),
}

View File

@@ -0,0 +1,362 @@
"""
Validaciones CSV para Series de Importación Temporal.
Paridad Clarion: VALIDA_TODA_SERIES_IMPO_TEM, VALIDA_PARCIAL_SERIES_IMPO_TEM, LLENA_SERIES_IMPO_TEM.
Estructura: NUMERO FACTURA, LINEA FACTURA, LINEA SERIE, SERIE, MODELO, NUM PARTE, SUB MODELO, NUMERO ID.
"""
from typing import Dict, Any, Optional, Set, Tuple, List
# Longitudes máximas según modelo Serie (item_line_series)
MAX_LEN = {
"serial_numbers": 50,
"model": 50,
"sub_model": 50,
"number_id": 25,
}
APOSTROFE = "'"
def _clip(val: Any) -> str:
if val is None:
return ""
return str(val).strip()
def normalize_sacarcomasenters(val: Any) -> str:
"""Clarion SACARCOMASENTERS: quitar comas y saltos de línea."""
s = _clip(val)
s = s.replace(",", " ").replace("\n", " ").replace("\r", " ")
return " ".join(s.split())
def _check_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Si COL_EXTRA tiene valor → advertencia desfase (no bloqueante)."""
val = _clip(row.get("COL_EXTRA"))
if not val:
return None
return {
"line": line_num,
"col": "COL_EXTRA",
"msg": "Advertencia: Podría existir un desfase en esta línea.",
"solution": "Revisar esta línea del archivo CSV y verificar cada campo esté en la posición correcta.",
"warning": True,
}
def _check_factura_vacia(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""NUMERO FACTURA (A) vacío → error bloqueante."""
val = _clip(row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA"))
if not val:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": "Error: (Celda A) La Factura de Importación está vacía y no se pueden hacer las validaciones.",
"solution": "Capturar en la Celda A un número de Factura existente al cual desee agregar o actualizar series.",
}
return None
def _check_factura_existe(
invoice_number: str,
line_num: int,
invoice_id_by_number: Dict[str, int],
) -> Optional[Dict[str, Any]]:
"""Factura debe existir en BD (imp + TEM)."""
if invoice_number not in invoice_id_by_number:
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": f"Error: (Celda A) La Factura de Importación {invoice_number} no existe en SCAII y no se pueden hacer las validaciones.",
"solution": "Capturar en la Celda A un número de Factura existente al cual desee agregar o actualizar partidas.",
"identifier": "FAC_IMPO_TEM",
"fields": invoice_number,
}
return None
def _check_factura_no_actualizada(
invoice_number: str,
line_num: int,
invoice_updated_by_number: Dict[str, bool],
) -> Optional[Dict[str, Any]]:
"""Si factura ya actualizada (Estatus AC) no se pueden hacer cambios."""
if invoice_updated_by_number.get(invoice_number, False):
return {
"line": line_num,
"col": "NUMERO FACTURA",
"msg": f"Error: (Celda A) La Factura de Importación: {invoice_number} ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas.",
"solution": "Capturar otro número de Factura de Importación Temporal o Desactualizar la factura.",
"identifier": "FAC_IMPO_TEM",
}
return None
def _check_linea_factura_vacia(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""LINEA FACTURA (B) vacía → error."""
val = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
if not val:
return {
"line": line_num,
"col": "LINEA FACTURA",
"msg": "Error: (Celda B) El campo de la línea de la partida está vacío y no se pueden hacer las validaciones.",
"solution": "Capturar en la Celda B la línea de la partida al cual desee agregar o actualizar información.",
}
return None
def _check_linea_serie_si_no_autonumerar(
row: Dict[str, Any],
line_num: int,
autonumerar: bool,
) -> Optional[Dict[str, Any]]:
"""Si Autonumerar = NO, LINEA SERIE (C) es obligatoria."""
if autonumerar:
return None
val = _clip(row.get("LINEA SERIE") or row.get("RENGLON"))
if not val:
return {
"line": line_num,
"col": "LINEA SERIE",
"msg": "Error: (Celda C) El campo de Renglón está vacío y no se pueden hacer las validaciones, ya que se tiene la opción de Autonumerar como NO.",
"solution": "Capturar en la Celda C el renglón de la serie la cual desee agregar o actualizar información.",
}
return None
def _warn_apostrofes(
row: Dict[str, Any],
line_num: int,
warnings: Optional[List[Dict[str, Any]]],
) -> None:
"""Advertencias si SERIE, MODELO o NUM PARTE contienen apostrofe (no bloqueante)."""
if warnings is None:
return
checks = [
("SERIE", "Número de Serie", row.get("SERIE")),
("MODELO", "Número de Modelo", row.get("MODELO")),
("NUM PARTE", "Número de Parte", row.get("NUM PARTE") or row.get("NUMPARTE") or row.get("NUMERO PARTE")),
]
for col, label, val in checks:
v = _clip(val) if val is not None else ""
if v and APOSTROFE in v:
warnings.append({
"line": line_num,
"col": col,
"msg": f"Advertencia: El {label}: {v} Contiene Apostrofes.",
"solution": "Se Omitirá el Apostrofe para Subir.",
"warning": True,
})
def _check_max_length(col: str, val: str, line_num: int, max_len: int) -> Optional[Dict[str, Any]]:
if not val or len(val) <= max_len:
return None
return {
"line": line_num,
"col": col,
"msg": f"Máximo {max_len} caracteres",
}
def valida_toda_series_impo_tem(
row: Dict[str, Any],
line_num: int,
validar_series_exception: bool,
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""
VALIDA_TODA_SERIES_IMPO_TEM: cuando no existe la partida/serie o autonumerar=SI.
Si D+E+F están todos vacíos y no aplica excepción ValidarSeries → error obligatorios.
Valida longitudes máximas.
"""
d = _clip(row.get("SERIE"))
e = _clip(row.get("MODELO"))
f = _clip(row.get("NUM PARTE") or row.get("NUMPARTE") or row.get("NUMERO PARTE"))
campos = d + e + f
if not campos and not validar_series_exception:
obligatorios = []
if not d:
obligatorios.append("(Col.D) Serie")
if not e:
obligatorios.append("(Col.E) Modelo")
if not f:
obligatorios.append("(Col.F) Num. Parte")
if obligatorios:
return {
"line": line_num,
"col": "SERIE",
"msg": f"Existen campos vacíos que son obligatorios al no tener ningun campo: {', '.join(obligatorios)}.",
"solution": "Revisar la línea del archivo y capturar los campos con la información correcta.",
}
# Longitudes (solo si hay valor)
def get_val(k: str) -> str:
if k == "NUM PARTE":
return _clip(row.get("NUM PARTE") or row.get("NUMPARTE") or row.get("NUMERO PARTE"))
if k == "SUB MODELO":
return _clip(row.get("SUB MODELO") or row.get("SUBMODELO"))
if k == "NUMERO ID":
return _clip(row.get("NUMERO ID") or row.get("NUMEROID"))
return _clip(row.get(k))
for col, key, max_len in [
("SERIE", "SERIE", MAX_LEN["serial_numbers"]),
("MODELO", "MODELO", MAX_LEN["model"]),
("NUM PARTE", "NUM PARTE", 50),
("SUB MODELO", "SUB MODELO", MAX_LEN["sub_model"]),
("NUMERO ID", "NUMERO ID", MAX_LEN["number_id"]),
]:
val = get_val(key)
if val:
err = _check_max_length(col, val, line_num, max_len)
if err:
return err
return None
def valida_parcial_series_impo_tem(
row: Dict[str, Any],
line_num: int,
existing_series_data: Dict[str, Any],
warnings: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""
VALIDA_PARCIAL_SERIES_IMPO_TEM: actualizar serie existente; campos vacíos se rellenan con existente.
Solo validar longitudes en campos no vacíos.
"""
def get_val(k: str) -> str:
if k == "NUM PARTE":
return _clip(row.get("NUM PARTE") or row.get("NUMPARTE") or row.get("NUMERO PARTE"))
if k == "SUB MODELO":
return _clip(row.get("SUB MODELO") or row.get("SUBMODELO"))
if k == "NUMERO ID":
return _clip(row.get("NUMERO ID") or row.get("NUMEROID"))
return _clip(row.get(k))
for col, key, max_len in [
("SERIE", "SERIE", MAX_LEN["serial_numbers"]),
("MODELO", "MODELO", MAX_LEN["model"]),
("NUM PARTE", "NUM PARTE", 50),
("SUB MODELO", "SUB MODELO", MAX_LEN["sub_model"]),
("NUMERO ID", "NUMERO ID", MAX_LEN["number_id"]),
]:
val = get_val(key)
if val:
err = _check_max_length(col, val, line_num, max_len)
if err:
return err
return None
def _series_key(invoice_number: str, linea_factura: str, linea_serie: str) -> Tuple[str, str, str]:
return (invoice_number.strip(), _clip(linea_factura), _clip(linea_serie))
def validate_row_series_impo_temp(
row: Dict[str, Any],
line_num: int,
actualizar: bool,
autonumerar: bool,
validar_series_exception: bool,
invoice_id_by_number: Dict[str, int],
invoice_updated_by_number: Dict[str, bool],
existing_series_keys: Set[Tuple[str, str, str]],
existing_series_data: Optional[Dict[Tuple[str, str, str], Dict[str, Any]]],
warnings: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Series de Importación Temporal.
Clarion: decisión VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la serie existe.
"""
# Desfase (solo advertencia)
desfase = _check_desfase(row, line_num)
if desfase and warnings is not None:
warnings.append(desfase)
# Factura vacía
err = _check_factura_vacia(row, line_num)
if err:
return err
invoice_number = _clip(row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA"))
if not invoice_number:
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
# Factura existe
err = _check_factura_existe(invoice_number, line_num, invoice_id_by_number)
if err:
return err
# Factura no actualizada
err = _check_factura_no_actualizada(invoice_number, line_num, invoice_updated_by_number)
if err:
return err
# LINEA FACTURA vacía
err = _check_linea_factura_vacia(row, line_num)
if err:
return err
# Autonumerar NO y LINEA SERIE vacía
err = _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar)
if err:
return err
# Advertencias apostrofes (no bloqueante)
_warn_apostrofes(row, line_num, warnings)
# Decisión VALIDA_TODA vs VALIDA_PARCIAL
linea_factura = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
linea_serie = _clip(row.get("LINEA SERIE") or row.get("RENGLON"))
key = _series_key(invoice_number, linea_factura, linea_serie)
use_partial = (
actualizar
and not autonumerar
and bool(linea_serie)
and key in (existing_series_keys or set())
)
if use_partial and existing_series_data and key in existing_series_data:
return valida_parcial_series_impo_tem(
row, line_num, existing_series_data[key], warnings
)
return valida_toda_series_impo_tem(
row, line_num, validar_series_exception, warnings
)
def row_to_series_normalized(row: Dict[str, Any]) -> Dict[str, Any]:
"""
Normaliza fila para guardar: SACARCOMASENTERS en D, E, F, G, H.
Clarion LLENA_SERIES_IMPO_TEM: asigna QueCSV a SerImp.
"""
def clip(col: str, alt: Optional[List[str]] = None) -> str:
v = row.get(col)
if alt:
for k in alt:
if v is None or (isinstance(v, str) and not v.strip()):
v = row.get(k)
return _clip(v) if v is not None else ""
def norm(col: str, alt: Optional[List[str]] = None, max_len: int = 50) -> str:
v = row.get(col)
if alt:
for k in alt:
if v is None or (isinstance(v, str) and not v.strip()):
v = row.get(k)
s = normalize_sacarcomasenters(v) if v is not None else ""
return s[:max_len] if s else ""
return {
"NUMERO FACTURA": clip("NUMERO FACTURA", ["NUM FACTURA", "FACTURA"]),
"LINEA FACTURA": clip("LINEA FACTURA", ["LINEA", "PARTIDA"]),
"LINEA SERIE": clip("LINEA SERIE", ["RENGLON"]),
"SERIE": norm("SERIE", max_len=MAX_LEN["serial_numbers"]),
"MODELO": norm("MODELO", max_len=MAX_LEN["model"]),
"NUM PARTE": norm("NUM PARTE", ["NUMPARTE", "NUMERO PARTE"]),
"SUB MODELO": norm("SUB MODELO", ["SUBMODELO"], MAX_LEN["sub_model"]),
"NUMERO ID": norm("NUMERO ID", ["NUMEROID"], MAX_LEN["number_id"]),
}

View File

@@ -6,6 +6,8 @@ import csv
import io
from typing import Dict, List, Any, Optional, Tuple
from ..common.cell_value import cell_to_str
# Valores que indican que la primera fila es cabecera (primera columna normalizada)
FIRST_COLUMN_HEADER_VALUES = ("NUMERO DE PARTE", "NUMPARTE")
@@ -133,10 +135,10 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -7,6 +7,11 @@ import csv
import io
from typing import Dict, List, Any, Optional, Tuple
# Convierte valor de celda a str; si es lista (p. ej. CSV con columnas duplicadas), toma el primer elemento.
# Re-exportado desde common para uso en validators; ver layouts_csv.common.cell_value.
from ..common.cell_value import cell_to_str as _cell_to_str
# Longitudes para validación (sin afectar modelos)
AÑO_LEN = 2
PATENTE_LEN = 4
@@ -171,15 +176,15 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "pedimentos"
def row_from_template(row: Dict[str, Any], normalize_header_fn, template_id: str = "pedimentos") -> Dict[str, Any]:
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos."""
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos. Valores siempre str (listas convertidas)."""
lookup = build_normalized_lookup(normalize_header_fn, template_id)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): _cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = _cell_to_str(value)
return out

View File

@@ -12,6 +12,7 @@ from ..template_config import (
PEDIMENTO_LEN,
PEDIMENTO_DASH_POSITIONS,
parse_pedimento_col_a,
_cell_to_str,
)
from ..common.common_validators import (
check_required_max,
@@ -79,12 +80,14 @@ def validate_row_desfase_pedimento(
"""Si la fila tiene al menos 16 columnas y la 16ª (Col N, IEPS/desfase) tiene valor, error de desfase. Orden: AÑO,PATENTE,NUMERO,TIPO,...,IEPS en índice 15."""
values_ordered = list(raw_row.values()) if raw_row else []
desfase_idx = 15 # IEPS en PEDIMENTOS_TEMPLATE_ORDER (tras AÑO,PATENTE,NUMERO + 12 columnas más)
if len(values_ordered) >= (desfase_idx + 1) and (values_ordered[desfase_idx] or "").strip():
return {
"line": line_num,
"col": "",
"msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}",
}
if len(values_ordered) >= (desfase_idx + 1):
cell = _cell_to_str(values_ordered[desfase_idx])
if cell.strip():
return {
"line": line_num,
"col": "",
"msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}",
}
return None

View File

@@ -5,6 +5,8 @@ Mapeo: NUMERO TRAILER → trailer_number, CLAVE ACE → ace_trailer_number, etc.
from typing import Dict, List, Any
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"trailers": [
{"canonical": "NUMERO TRAILER", "aliases": ["CLAVE TRAILER", "TRAILER NUMBER", "TRAILER", "NUMERO"]},
@@ -37,10 +39,10 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -5,6 +5,8 @@ Mapeo Clarion: Col A = CLAVE TRANSPORTISTA, B = NOMBRE, ... R = DIRECTORIO FTP,
from typing import Dict, List, Any
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"transporters": [
{"canonical": "CLAVE TRANSPORTISTA", "aliases": ["TRANSPORTISTA", "CLAVE TRANS", "CARRIER KEY"]},
@@ -46,10 +48,10 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -5,6 +5,8 @@ Solo se leen columnas definidas aquí; el resto se ignora.
from typing import Dict, List, Any, Optional
from ..common.cell_value import cell_to_str as _cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"us_tariff_fractions": [
{"canonical": "FRACCION_ARANCELARIA", "aliases": ["FRACCION ARANCELARIA", "CODE", "FRACCION"]},
@@ -40,13 +42,13 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "us_tariff_f
def row_from_template(
row: Dict[str, Any], normalize_header_fn, template_id: str = "us_tariff_fractions"
) -> Dict[str, Any]:
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos."""
"""Fila CSV con solo columnas de la plantilla, en nombres canónicos. Valores siempre str (listas convertidas)."""
lookup = build_normalized_lookup(normalize_header_fn, template_id)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): _cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = _cell_to_str(value)
return out

View File

@@ -5,6 +5,7 @@ Paridad Clarion: desfase Col H, VALIDA_TODA_FRACCIONAME / VALIDA_PARCIAL_FRACCIO
from typing import Dict, Any, Optional, Set
from ..template_config import DESFASE_COLUMN_INDEX
from ...common.cell_value import cell_to_str
from ..common.common_validators import (
normalize_code,
check_optional_max_length,
@@ -47,7 +48,8 @@ def validate_row_desfase_fa(
values_ordered = list(raw_row.values())
if len(values_ordered) <= DESFASE_COLUMN_INDEX:
return None
if not (values_ordered[DESFASE_COLUMN_INDEX] or "").strip():
cell = cell_to_str(values_ordered[DESFASE_COLUMN_INDEX])
if not cell.strip():
return None
return {
"line": line_num,

View File

@@ -5,6 +5,8 @@ Mapeo: CLAVE → vehicle_key, CLAVE ACE → ace_vehicle_key, etc.
from typing import Dict, List, Any
from ..common.cell_value import cell_to_str
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"vehicles": [
{"canonical": "CLAVE", "aliases": ["CLAVE VEHICULO", "VEHICLE KEY", "KEY"]},
@@ -45,10 +47,10 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
return {normalize_header_fn(k): cell_to_str(v) for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
out[lookup[key_norm]] = cell_to_str(value)
return out

View File

@@ -28,6 +28,6 @@ router.include_router(
id_name="part_id",
enable_list=True,
enable_filters=True,
max_page_size=10000,
max_page_size=1000,
).router
)

View File

@@ -51,16 +51,16 @@ class PartService:
cls,
db: Session,
tenant_id: int,
company_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
) -> tuple[List[Part], int]:
query = db.query(Part).filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id
)
query = db.query(Part).filter(Part.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Part.company_id == company_id)
# Cargar inv_data de forma segura (Solo las columnas que existen)
from api.v1.modules.a24.inv.inv_parts.models import InvPart

View File

@@ -83,7 +83,7 @@ crud_router = TenantCRUDRoutes(
enable_list=True, # Enable GET / with pagination
enable_filters=True, # Enable status, client_id, year filters
default_page_size=50,
max_page_size=100,
max_page_size=1000,
).router
# Include the CRUD routes into our main router

View File

@@ -65,7 +65,7 @@ class PedimentosService:
def get_all(
db: Session,
tenant_id: int,
company_id: int,
company_id: Optional[int],
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None,
@@ -76,6 +76,7 @@ class PedimentosService:
Args:
db: Database session
tenant_id: Tenant ID
company_id: Optional Company ID
skip: Number of records to skip
limit: Maximum number of records to return
filters: Optional filters dict
@@ -83,8 +84,10 @@ class PedimentosService:
Returns:
Tuple of (list of pedimentos, total count)
"""
query = db.query(Pedimentos).filter(
Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id)
query = db.query(Pedimentos).filter(Pedimentos.tenant_id == tenant_id)
if company_id is not None:
query = query.filter(Pedimentos.company_id == company_id)
if filters:
if filters.get("status"):

View File

@@ -1,877 +0,0 @@
"""
SQL Query builders for invoice movement services.
Centralizes all SQL query construction logic.
"""
class TemporaryImportQueries:
"""SQL queries for temporary imports using PostgreSQL tables."""
@staticmethod
def build_aggregated_query(db_name: str, where_str: str) -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
# Note: db_name parameter kept for compatibility but not used in PostgreSQL
return f"""
SELECT
ih.invoice_number AS C1,
COALESCE(ped.pedimento_number, '') AS C2,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4,
COALESCE(ped.pedimento_code, '') AS C5,
COALESCE(ped.regime, '') AS C10,
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11,
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13,
COALESCE(cmp.remesa, 0) AS C14,
COALESCE(fin.exchange_rate, 0) AS C15,
COALESCE(cmp.provider_id::text, '') AS C16,
COALESCE(cmp.sold_to_id::text, '') AS C17,
COALESCE(cmp.customs_broker_id::text, '') AS C18,
COALESCE(cmp.aduana, '') AS C38,
ih.id AS C39,
COALESCE(ped_r1.pedimento_number, '') AS C41,
COALESCE(cmp.edocument, '') AS C42,
COALESCE(cmp.vucem_operation_num, '') AS C43,
COALESCE(fin.exchange_rate, 0) AS C50,
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51,
COALESCE(ih.capture_user, '') AS C52,
COALESCE(ih.who_updated, '') AS C53,
COALESCE(log.carrier_id, '') AS C54,
COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55,
'' AS C56,
'' AS C57,
'' AS C58,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1
LEFT JOIN a76.items i ON i.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = i.id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
WHERE ih.operation_type = 'imp'
AND ih.invoice_type = 'TEM'
AND {where_str}
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
log.entry_exit_date, log.delivery_date, log.payment_date, cmp.remesa, fin.exchange_rate,
cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, cmp.aduana, ped_r1.pedimento_number,
cmp.edocument, cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated,
log.carrier_id, log.transport_num, log.license_plate
ORDER BY ih.invoice_number
"""
@staticmethod
def build_main_query(db_name: str, where_str: str) -> str:
"""Build main SQL query for DETAILED mode (all partidas) from PostgreSQL."""
# Note: db_name parameter is kept for compatibility but not used in PostgreSQL
return f"""
SELECT
ih.invoice_number AS C1,
COALESCE(ped.pedimento_number, '') AS C2,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4,
COALESCE(ped.pedimento_code, '') AS C5,
COALESCE(fin.value_me, 0) AS C6,
COALESCE(fin.value_mn, 0) AS C7,
COALESCE(cmp.provider_id::text, '') AS C8,
COALESCE(cmp.sold_to_id::text, '') AS C9,
COALESCE(ped.regime, '') AS C10,
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11,
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13,
COALESCE(cmp.remesa, 0) AS C14,
COALESCE(fin.exchange_rate, 0) AS C15,
COALESCE(cmp.provider_id::text, '') AS C16,
COALESCE(cmp.sold_to_id::text, '') AS C17,
COALESCE(cmp.customs_broker_id::text, '') AS C18,
'' AS C19,
COALESCE(il.class_id::text, '') AS C20,
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C21,
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C22,
COALESCE(lq.quantity, 0) AS C23,
COALESCE(il.unit_of_measure::text, '') AS C24,
COALESCE(lf.value_mxn, 0) AS C25,
COALESCE(lf.customs_value_mxn, 0) AS C26,
COALESCE(lf.value_usd, 0) AS C27,
COALESCE(lf.customs_value_usd, 0) AS C28,
COALESCE(lq.net_weight, 0) AS C29,
COALESCE(lq.gross_weight, 0) AS C30,
COALESCE(ih.purchase_order, '') AS C31,
COALESCE(lc.fraction, '') AS C32,
COALESCE(lc.fraction_type, '') AS C33,
COALESCE(lc.advalorem_numeric, 0) AS C34,
COALESCE(lc.sector, '') AS C35,
COALESCE(lf.igi_amount_usd, 0) AS C36,
COALESCE(lc.origin_country, '') AS C37,
COALESCE(cmp.aduana, '') AS C38,
ih.id AS C39,
FALSE AS C40,
'' AS C41,
COALESCE(cmp.edocument, '') AS C42,
COALESCE(cmp.vucem_operation_num, '') AS C43,
COALESCE(il.line_number, 0) AS C44,
'' AS C45,
'' AS C46,
COALESCE(cls.us_fraction, '') AS C47,
COALESCE(prt.eccn, '') AS C48,
COALESCE(il.part_number::text, '') AS C49,
COALESCE(fin.exchange_rate, 0) AS C50,
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C51,
COALESCE(ih.capture_user, '') AS C52,
COALESCE(ih.who_updated, '') AS C53,
COALESCE(log.carrier_id, '') AS C54,
COALESCE(log.transport_num || ' ' || log.license_plate, '') AS C55,
'' AS C56,
'' AS C57,
'' AS C58
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.item_lines il ON il.item_id = (
SELECT id FROM a76.items WHERE invoice_id = ih.id LIMIT 1
)
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
LEFT JOIN a76.classes cls ON cls.id = il.class_id
LEFT JOIN a76.parts prt ON prt.id = il.part_number
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
WHERE ih.operation_type = 'imp'
AND ih.invoice_type = 'TEM'
AND {where_str}
"""
@staticmethod
def build_totals_query(db_name: str) -> str:
"""Build query to get totals for an invoice."""
return f"""
SELECT
COALESCE(SUM(EqiPim.ValorImpoME), 0),
COALESCE(SUM(EqiPim.ValorImpoMN), 0)
FROM [{db_name}].dbo.QEqiMaq EqiPim
WHERE EqiPim.Consecutivo = :consecutivo
AND EqiPim.EsSubpartida = 'P'
"""
@staticmethod
def build_series_query(db_name: str) -> str:
"""Build query to get series information."""
return f"""
SELECT SerieImpo, ModeloImpo, ParteImpo
FROM [{db_name}].dbo.QSeriesImpo
WHERE Consecutivo = :consecutivo
AND LineaImpo = :linea
ORDER BY RenImpo
"""
@staticmethod
def build_driver_badge_query(db_name: str) -> str:
"""Build query to get driver badge number."""
return f"""
SELECT TOP 1 NUMGAFETEUNICO
FROM [{db_name}].dbo.GConductor
LEFT JOIN [{db_name}].dbo.QFacImp
ON QFacImp.CONDUCTOR = GConductor.CONDUCTOR
WHERE FacturaImpo = :factura
"""
class DefinitiveImportQueries:
"""SQL queries for definitive imports (PostgreSQL schema)."""
@staticmethod
def build_aggregated_query(db_name: str, where_clause: str) -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
return f"""
SELECT
ih.invoice_number AS C1,
COALESCE(ped.pedimento_number, '') AS C2,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C4,
COALESCE(ped.pedimento_code, '') AS C5,
COALESCE(ped.regime, '') AS C10,
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C11,
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C12,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C13,
COALESCE(log.payment_receipt_num, '') AS C14,
COALESCE(fin.exchange_rate, 0) AS C15,
COALESCE(cmp.provider_id::text, '') AS C16,
COALESCE(cmp.sold_to_id::text, '') AS C17,
COALESCE(cmp.customs_broker_id::text, '') AS C18,
COALESCE(ih.purchase_order, '') AS C31,
COALESCE(cmp.aduana, '') AS C39,
ih.id AS C35,
COALESCE(ped_r1.pedimento_number, '') AS C42,
COALESCE(cmp.edocument, '') AS C43,
COALESCE(cmp.vucem_operation_num, '') AS C44,
COALESCE(fin.exchange_rate, 0) AS C51,
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C52,
COALESCE(ih.capture_user, '') AS C53,
COALESCE(ih.who_updated, '') AS C54,
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56,
'' AS C57,
'' AS C58,
'' AS C59,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.pedimentos ped_r1 ON ped_r1.id = cmp.pedimento_r1
LEFT JOIN a76.items i ON i.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = i.id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
WHERE ih.operation_type = 'imp'
AND ih.invoice_type IN ('DEF', 'EXDEF', 'MATDE')
AND {where_clause}
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num,
fin.exchange_rate, cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id,
ih.purchase_order, cmp.aduana, ped_r1.pedimento_number, cmp.edocument,
cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated,
log.transport_id, log.transport_num
ORDER BY ih.invoice_number
"""
@staticmethod
def build_main_query(db_name: str, where_clause: str) -> str:
return f"""
SELECT
ih.invoice_number AS C1, -- [0]
ped.pedimento_number AS C2, -- [1]
ih.invoice_date AS C3, -- [2]
ped.status AS C4, -- [3]
ped.pedimento_code AS C5, -- [4]
'' AS C6, '' AS C7, '' AS C8, '' AS C9, -- [5-8]
ped.regime AS C10, -- [9]
log.entry_exit_date AS C11, -- [10]
log.delivery_date AS C12, -- [11]
log.payment_date AS C13, -- [12]
log.payment_receipt_num AS C14, -- [13]
'' AS C15, -- [14]
cmp.provider_id AS C16, -- [15]
cmp.sold_to_id AS C17, -- [16]
cmp.customs_broker_id AS C18, -- [17]
'' AS C19, -- [18]
prt.part_number AS C20, -- [19]
ld.description_spanish AS C21, -- [20]
ld.description_english AS C22, -- [21]
lq.quantity AS C23, -- [22]
um.code AS C24, -- [23]
lf.value_mxn AS C25, -- [24]
'' AS C26, -- [25]
lf.value_usd AS C27, -- [26]
'' AS C28, -- [27]
lq.net_weight AS C29, -- [28]
lq.gross_weight AS C30, -- [29]
ih.purchase_order AS C31, -- [30]
lc.fraction AS C32, -- [31]
'' AS C33, '' AS C34, -- [32-33]
ih.id AS C35, -- [34]
'' AS C36, '' AS C37, -- [35-36]
lc.origin_country AS C38, -- [37]
cmp.aduana AS C39, -- [38]
il.material_type AS C40, -- [39]
il.id AS C41, -- [40]
'' AS C42, -- [41] rectification_id
cmp.edocument AS C43, -- [42]
cmp.vucem_operation_num AS C44, -- [43]
il.line_number AS C45, -- [44]
ld.brand AS C46, -- [45]
ld.model AS C47, -- [46]
prt.us_fraction AS C48, -- [47]
prt.eccn AS C49, -- [48]
prt.id AS C50, -- [49]
fin.exchange_rate AS C51, -- [50]
ih.emission_date AS C52, -- [51]
ih.capture_user AS C53, -- [52]
ih.who_updated AS C54, -- [53]
'' AS C55, -- [54]
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C56, -- [55]
'' AS C57, -- [56] Pedimento18 (row[56])
COALESCE(ld.lot, '') AS C58, -- [57] Lote (row[57])
'' AS C59, -- [58] TipoPed (row[58])
'' AS C60 -- [59] Relleno final
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
LEFT JOIN a76.classes cls ON cls.id = il.class_id
LEFT JOIN a76.parts prt ON prt.id = il.part_number
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
WHERE {where_clause}
ORDER BY ih.invoice_number, il.line_number
"""
@staticmethod
def build_totals_query(db_name: str) -> str:
"""Build query to get totals for a definitive import invoice."""
return f"""
SELECT
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.invoice_id = :consecutivo
"""
@staticmethod
def build_series_query(db_name: str) -> str:
"""Build query to get series information for definitive imports."""
# TODO: QSeriesDef table not migrated to PostgreSQL yet
return """
SELECT '' as serie, '' as modelo, '' as parte
WHERE 1=0
"""
@staticmethod
def build_driver_badge_query(db_name: str) -> str:
"""Build query to get driver badge number for definitive imports."""
# TODO: GConductor table not migrated to PostgreSQL yet
return """
SELECT '' as badge
WHERE 1=0
"""
class RepairImportQueries:
"""SQL queries for repair imports (PostgreSQL schema)."""
@staticmethod
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
discharge_filter = "" # Temporarily disabled until schema migration
return f"""
SELECT
ih.invoice_number AS C2,
COALESCE(ped.pedimento_number, '') AS C3,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C4,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C5,
COALESCE(ped.pedimento_code, '') AS C6,
COALESCE(ped.regime, '') AS C7,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C9,
COALESCE(cmp.remesa::text, '') AS C10,
COALESCE(fin.exchange_rate, 0) AS C11,
COALESCE(cmp.provider_id::text, '') AS C12,
COALESCE(cmp.sold_to_id::text, '') AS C13,
COALESCE(cmp.customs_broker_id::text, '') AS C14,
COALESCE(ih.purchase_order, '') AS C24,
COALESCE(ped.customs_office, '') AS C29,
ih.id AS C30,
COALESCE(cmp.edocument, '') AS C33,
COALESCE(cmp.vucem_operation_num, '') AS C34,
COALESCE(fin.exchange_rate, 0) AS C40,
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C41,
COALESCE(ih.capture_user, '') AS C42,
COALESCE(ih.who_updated, '') AS C43,
COALESCE(log.carrier_id, '') AS C44,
COALESCE(log.transport_num, '') AS C45,
COALESCE(ped.pedimento_code, '') AS C47,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items i ON i.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = i.id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
WHERE ih.operation_type = 'imp'
AND ih.invoice_type = 'REP'
AND COALESCE(cmp.is_regime_change, false) = false
{"AND " + where_str if where_str else ""}
{discharge_filter}
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id,
cmp.customs_broker_id, ih.purchase_order, ped.customs_office, cmp.edocument,
cmp.vucem_operation_num, ih.emission_date, ih.capture_user, ih.who_updated,
log.carrier_id, log.transport_num
ORDER BY ih.invoice_number
"""
@staticmethod
def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build main SQL query for repair import data."""
# Note: is_discharged field not yet migrated to PostgreSQL schema
# discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
discharge_filter = "" # Temporarily disabled until schema migration
return f"""
SELECT
il.line_number,
ih.invoice_number,
COALESCE(ped.pedimento_number, ''),
TO_CHAR(ih.invoice_date, 'YYYYMMDD'),
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END,
COALESCE(ped.pedimento_code, ''),
COALESCE(ped.regime, ''),
'',
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), ''),
COALESCE(cmp.remesa::text, ''),
COALESCE(fin.exchange_rate, 0),
COALESCE(cmp.provider_id::text, ''),
COALESCE(cmp.sold_to_id::text, ''),
COALESCE(cmp.customs_broker_id::text, ''),
COALESCE(il.part_number::text, ''),
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_english, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
COALESCE(lq.quantity, 0),
COALESCE(il.unit_of_measure, 0),
COALESCE(lf.value_mxn, 0),
COALESCE(lf.value_usd, 0),
COALESCE(lq.net_weight, 0),
COALESCE(lq.gross_weight, 0),
COALESCE(ih.purchase_order, ''),
COALESCE(lc.fraction, ''),
'',
COALESCE(lc.sector, ''),
COALESCE(lc.origin_country, ''),
COALESCE(ped.customs_office, ''),
ih.id,
'P',
'',
COALESCE(cmp.edocument, ''),
COALESCE(cmp.vucem_operation_num, ''),
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' '),
COALESCE(lc.american_fraction, ''),
COALESCE(prt.eccn, ''),
COALESCE(fin.exchange_rate, 0),
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), ''),
COALESCE(ih.capture_user, ''),
COALESCE(ih.who_updated, ''),
COALESCE(log.carrier_id, ''),
COALESCE(log.transport_num, ''),
'',
COALESCE(ped.pedimento_code, '')
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
LEFT JOIN a76.parts prt ON prt.id = il.part_number
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
WHERE ih.operation_type = 'imp'
AND ih.invoice_type = 'REP'
AND COALESCE(cmp.is_regime_change, false) = false
{"AND " + where_str if where_str else ""}
{discharge_filter}
ORDER BY ih.invoice_number, il.line_number
"""
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for a repair import invoice."""
# Note: is_discharged field not yet migrated to PostgreSQL schema
# discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
discharge_filter = "" # Temporarily disabled until schema migration
return f"""
SELECT
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0),
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.invoice_id = :consecutivo
{discharge_filter}
"""
@staticmethod
def build_series_query(db_name: str) -> str:
"""Build query to get series information for repair imports."""
# TODO: QSeriesImpoRep table not migrated to PostgreSQL yet
return """
SELECT '' as serie, '' as modelo, '' as parte
WHERE 1=0
"""
@staticmethod
def build_driver_badge_query(db_name: str) -> str:
"""Build query to get driver badge number for repair imports."""
# TODO: GConductor table not migrated to PostgreSQL yet
return """
SELECT '' as badge
WHERE 1=0
"""
class ExportQueries:
"""SQL queries for exports (PostgreSQL schema)."""
@staticmethod
def build_aggregated_query(db_name: str, where_clause: str) -> str:
"""
Build optimized query for NORMAL mode (grouped by invoice with totals).
Args:
db_name: Database name (not used in PostgreSQL version)
where_clause: Additional WHERE conditions (without WHERE keyword)
"""
return f"""
SELECT
ih.invoice_number AS C1,
COALESCE(ped.pedimento_number, '') AS C2,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6,
COALESCE(ped.pedimento_code, '') AS C7,
COALESCE(ped.regime, '') AS C8,
COALESCE(TO_CHAR(log.entry_exit_date, 'YYYYMMDD'), '') AS C9,
COALESCE(TO_CHAR(log.delivery_date, 'YYYYMMDD'), '') AS C10,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11,
COALESCE(log.payment_receipt_num, '') AS C12,
COALESCE(cmp.provider_id::text, '') AS C14,
COALESCE(cmp.sold_to_id::text, '') AS C15,
COALESCE(cmp.customs_broker_id::text, '') AS C16,
COALESCE(ih.purchase_order, '') AS C27,
COALESCE(cmp.aduana, '') AS C33,
COALESCE(ih.invoice_type, '') AS C34,
ih.id AS C35,
COALESCE(cmp.edocument, '') AS C40,
COALESCE(cmp.vucem_operation_num, '') AS C41,
COALESCE(fin.exchange_rate, 0) AS C48,
COALESCE(TO_CHAR(ih.emission_date, 'YYYYMMDD'), '') AS C49,
COALESCE(ih.capture_user, '') AS C50,
COALESCE(ih.who_updated, '') AS C51,
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items i ON i.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = i.id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
WHERE {where_clause}
GROUP BY ih.id, ih.invoice_number, ih.is_updated, ped.pedimento_number, ped.pedimento_code, ped.regime,
log.entry_exit_date, log.delivery_date, log.payment_date, log.payment_receipt_num,
cmp.provider_id, cmp.sold_to_id, cmp.customs_broker_id, ih.purchase_order, cmp.aduana,
ih.invoice_type, cmp.edocument, cmp.vucem_operation_num, fin.exchange_rate,
ih.emission_date, ih.capture_user, ih.who_updated, log.transport_id, log.transport_num,
ih.invoice_date
ORDER BY ih.invoice_number
"""
@staticmethod
def build_main_query(db_name: str, where_clause: str) -> str:
return f"""
SELECT
ih.invoice_number AS C1, -- [0]
ped.pedimento_number AS C2, -- [1]
ih.invoice_date AS C3, -- [2]
'' AS C4, -- [3]
'' AS C5, -- [4]
ped.status AS C6, -- [5]
ped.pedimento_code AS C7, -- [6]
ped.regime AS C8, -- [7]
log.entry_exit_date AS C9, -- [8]
log.delivery_date AS C10, -- [9]
log.payment_date AS C11, -- [10]
log.payment_receipt_num AS C12, -- [11]
'' AS C13, -- [12]
cmp.provider_id AS C14, -- [13]
cmp.sold_to_id AS C15, -- [14]
cmp.customs_broker_id AS C16, -- [15]
'' AS C17, -- [16]
prt.part_number AS C18, -- [17]
ld.description_spanish AS C19, -- [18]
ld.description_english AS C20, -- [19]
lq.quantity AS C21, -- [20]
um.code AS C22, -- [21]
'' AS C23, -- [22]
'' AS C24, -- [23]
lq.net_weight AS C25, -- [24]
lq.gross_weight AS C26, -- [25]
ih.purchase_order AS C27, -- [26]
lc.fraction AS C28, -- [27]
'' AS C29, -- [28]
'' AS C30, -- [29]
'' AS C31, -- [30]
'' AS C32, -- [31]
cmp.aduana AS C33, -- [32]
ih.invoice_type AS C34, -- [33]
ih.id AS C35, -- [34]
lf.value_mxn AS C36, -- [35]
lf.value_usd AS C37, -- [36]
il.material_type AS C38, -- [37]
'' AS C39, -- [38] rectification_id
cmp.edocument AS C40, -- [39]
cmp.vucem_operation_num AS C41, -- [40]
il.line_number AS C42, -- [41]
ld.brand AS C43, -- [42]
ld.model AS C44, -- [43]
prt.us_fraction AS C45, -- [44]
prt.eccn AS C46, -- [45]
prt.id AS C47, -- [46]
fin.exchange_rate AS C48, -- [47]
ih.emission_date AS C49, -- [48]
ih.capture_user AS C50, -- [49]
ih.who_updated AS C51, -- [50]
'' AS C52, -- [51]
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53, -- [52] NumCaja
'' AS C54, -- [53] Pedimento18
COALESCE(ld.lot, '') AS C55, -- [54] Lote
'' AS C56, -- [55] TipoPedimentoTransporte
'' AS C57, -- [56]
'' AS C58, -- [57]
'' AS C59, -- [58]
'' AS C60 -- [59] Relleno final
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
LEFT JOIN a76.classes cls ON cls.id = il.class_id
LEFT JOIN a76.parts prt ON prt.id = il.part_number
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
WHERE {where_clause}
ORDER BY ih.invoice_number, il.line_number
"""
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for an export invoice.
Only sums partidas where is_subitem is false (main partidas, not sub-items).
"""
discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
return f"""
SELECT
COALESCE(SUM(lf.value_usd), 0),
COALESCE(SUM(lf.value_mxn), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.invoice_id = :consecutivo
{discharge_filter}
"""
@staticmethod
def build_series_query(db_name: str) -> str:
"""Build query to get series information for exports."""
# TODO: QSeriesExpo table not migrated to PostgreSQL yet
return """
SELECT '' as serie, '' as modelo, '' as parte
WHERE 1=0
"""
@staticmethod
def build_driver_badge_query(db_name: str) -> str:
"""Build query to get driver badge number for exports."""
# TODO: GConductor table not migrated to PostgreSQL yet
return """
SELECT '' as badge
WHERE 1=0
"""
class ExportRepairQueries:
"""SQL queries for export repairs (PostgreSQL schema)."""
@staticmethod
def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str:
"""Build optimized query for NORMAL mode (grouped by invoice with totals)."""
# Note: discharge_clause temporarily disabled until is_discharged field migrated
discharge_filter = "" # Will be: " AND il.is_discharged = true/false" when ready
return f"""
SELECT
ih.invoice_number AS C1,
COALESCE(ped.pedimento_number, '') AS C2,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6,
COALESCE(ped.pedimento_code, '') AS C7,
COALESCE(ped.regime, '') AS C8,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11,
COALESCE(cmp.remesa::text, '') AS C12,
COALESCE(fin.exchange_rate, 0) AS C13,
COALESCE(cmp.provider_id::text, '') AS C14,
COALESCE(cmp.sold_to_id::text, '') AS C15,
COALESCE(cmp.customs_broker_id::text, '') AS C16,
COALESCE(ih.purchase_order, '') AS C27,
COALESCE(ped.customs_office, '') AS C33,
COALESCE(ih.document_type, '') AS C34,
ih.id AS C35,
COALESCE(cmp.edocument, '') AS C40,
COALESCE(cmp.vucem_operation_num, '') AS C41,
COALESCE(fin.exchange_rate, 0) AS C48,
COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49,
COALESCE(ih.capture_user, '') AS C50,
COALESCE(ih.who_updated, '') AS C51,
COALESCE(log.carrier_id, '') AS C52,
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_usd END), 0) AS total_me,
COALESCE(SUM(CASE WHEN COALESCE(fil.is_subitem, false) THEN 0 ELSE lf.value_mxn END), 0) AS total_mn
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items i ON i.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = i.id
LEFT JOIN a24.fa_item_lines fil ON fil.id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
WHERE ih.operation_type = 'exp'
AND ih.invoice_type = 'REP'
{"AND " + where_str if where_str else ""}
GROUP BY ih.id, ih.invoice_number, ped.pedimento_number, ped.pedimento_code, ped.regime,
log.payment_date, cmp.remesa, fin.exchange_rate, cmp.provider_id, cmp.sold_to_id,
cmp.customs_broker_id, ih.purchase_order, ped.customs_office, ih.document_type,
cmp.edocument, cmp.vucem_operation_num, ih.invoice_date, ih.capture_user,
ih.who_updated, log.carrier_id, log.transport_id, log.transport_num
ORDER BY ih.invoice_number
"""
@staticmethod
def build_main_query(db_name: str, where_str: str) -> str:
"""Build main SQL query for export repair data."""
return f"""
SELECT
ih.invoice_number AS C1,
COALESCE(ped.pedimento_number, '') AS C2,
TO_CHAR(ih.invoice_date, 'YYYYMMDD') AS C3,
COALESCE(fin.value_me, 0) AS C4,
COALESCE(fin.value_mn, 0) AS C5,
CASE WHEN ih.is_updated THEN 'AC' ELSE 'NA' END AS C6,
COALESCE(ped.pedimento_code, '') AS C7,
COALESCE(ped.regime, '') AS C8,
'' AS C9,
'' AS C10,
COALESCE(TO_CHAR(log.payment_date, 'YYYYMMDD'), '') AS C11,
COALESCE(cmp.remesa::text, '') AS C12,
COALESCE(fin.exchange_rate, 0) AS C13,
COALESCE(cmp.provider_id::text, '') AS C14,
COALESCE(cmp.sold_to_id::text, '') AS C15,
COALESCE(cmp.customs_broker_id::text, '') AS C16,
'' AS C17,
COALESCE(cls.class_code, '') AS C18,
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.description_spanish, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C19,
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(cls.description_en, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C20,
COALESCE(lq.quantity, 0) AS C21,
COALESCE(il.unit_of_measure, 0) AS C22,
COALESCE(lf.customs_value_mxn, 0) AS C23,
COALESCE(lf.customs_value_usd, 0) AS C24,
COALESCE(lq.net_weight, 0) AS C25,
COALESCE(lq.gross_weight, 0) AS C26,
COALESCE(ih.purchase_order, '') AS C27,
COALESCE(lc.fraction, '') AS C28,
COALESCE(lc.fraction_type, '') AS C29,
COALESCE(lc.advalorem_numeric, 0) AS C30,
COALESCE(lc.sector, '') AS C31,
COALESCE(lc.origin_country, '') AS C32,
COALESCE(ped.customs_office, '') AS C33,
COALESCE(ih.document_type, '') AS C34,
ih.id AS C35,
COALESCE(lf.value_mxn, 0) AS C36,
COALESCE(lf.value_usd, 0) AS C37,
'P' AS C38,
'' AS C39,
COALESCE(cmp.edocument, '') AS C40,
COALESCE(cmp.vucem_operation_num, '') AS C41,
COALESCE(il.line_number, 0) AS C42,
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.brand, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C43,
REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(ld.model, ''), CHR(44), ' '), CHR(9), ' '), CHR(10), ' '), CHR(13), ' ') AS C44,
COALESCE(cls.us_fraction, '') AS C45,
COALESCE(prt.eccn, '') AS C46,
COALESCE(il.part_number::text, '') AS C47,
COALESCE(fin.exchange_rate, 0) AS C48,
COALESCE(TO_CHAR(ih.invoice_date, 'YYYYMMDD'), '') AS C49,
COALESCE(ih.capture_user, '') AS C50,
COALESCE(ih.who_updated, '') AS C51,
COALESCE(log.carrier_id, '') AS C52,
COALESCE(log.transport_id || ' ' || log.transport_num, '') AS C53,
'' AS C54,
COALESCE(ld.lot, '') AS C55,
'' AS C56,
'' AS C57,
'' AS C58,
'' AS C59
FROM a76.invoice_header ih
LEFT JOIN a76.invoice_compliance_mx cmp ON cmp.invoice_id = ih.id
LEFT JOIN a76.invoice_financials fin ON fin.invoice_id = ih.id
LEFT JOIN a76.invoice_logistics log ON log.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = cmp.pedimento_id
LEFT JOIN a76.items itm ON itm.invoice_id = ih.id
LEFT JOIN a76.item_lines il ON il.item_id = itm.id
LEFT JOIN a76.item_line_descriptions ld ON ld.item_line_id = il.id
LEFT JOIN a76.item_line_quantities lq ON lq.item_line_id = il.id
LEFT JOIN a76.item_line_financials lf ON lf.item_line_id = il.id
LEFT JOIN a76.item_line_customs lc ON lc.item_line_id = il.id
LEFT JOIN a76.classes cls ON cls.id = il.class_id
LEFT JOIN a76.parts prt ON prt.id = il.part_number
LEFT JOIN a76.units_of_measure um ON um.id = il.unit_of_measure
WHERE UPPER(ih.operation_type) IN ('EXP', 'TRA', 'RET')
AND UPPER(ih.invoice_type) IN ('DEF', 'REP', 'EXDEF', 'MATDE')
AND {where_str}
ORDER BY ih.invoice_number, il.line_number
"""
@staticmethod
def build_totals_query(db_name: str, discharge_clause: str = "") -> str:
"""Build query to get totals for an export repair invoice."""
discharge_filter = "AND il.is_discharged = true" if "descargado" in discharge_clause.lower() else ""
return f"""
SELECT
COALESCE(SUM(lf.value_usd), 0),
COALESCE(SUM(lf.value_mxn), 0)
FROM a76.item_line_financials lf
INNER JOIN a76.item_lines il ON il.id = lf.item_line_id
INNER JOIN a76.items itm ON itm.id = il.item_id
WHERE itm.invoice_id = :consecutivo
{discharge_filter}
"""
@staticmethod
def build_series_query(db_name: str) -> str:
"""Build query to get series information for export repairs."""
# TODO: QSeriesExpoRep table not migrated to PostgreSQL yet
return """
SELECT '' as serie, '' as modelo, '' as parte
WHERE 1=0
"""
@staticmethod
def build_driver_badge_query(db_name: str) -> str:
"""Build query to get driver badge number for export repairs."""
# TODO: GConductor table not migrated to PostgreSQL yet
return """
SELECT '' as badge
WHERE 1=0
"""

View File

@@ -0,0 +1,886 @@
"""
CSV generation utilities for Saldos Temporales report.
Translated from Clarion routines:
GENERA_EXCEL_CSV_PEDIMENTO (range_type = 'pedimento')
GENERA_EXCEL_CSV_FECHA (range_type = 'payment_date' | 'invoice_date')
GENERA_EXCEL_CSV_PARTE (range_type = 'parts')
GENERA_EXCEL_CSV_CLASE (range_type = 'classes')
Real Anexo76 tables (schema a76):
QEqiMaq / SPartidasImp → a76.item_lines
QFacImp → a76.invoice_header + a76.invoice_compliance_mx
QPedimentos → a76.pedimentos + a76.pedimento_dates
QClaAct → a76.classes
QSeriesImpo → a76.item_line_series
sFracciones → a76.tariff_fractions (column: umt = UMAbreviacion)
GTipoCambio → a76.exchange_rate
Line quantities → a76.item_line_quantities
Line financials → a76.item_line_financials
Line customs → a76.item_line_customs
Line descriptions → a76.item_line_descriptions
"""
import csv
import io
import logging
import pytz
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
from datetime import datetime, date
from typing import Dict, List, Optional
from sqlalchemy import text
from sqlalchemy.orm import Session
from .schemas import SaldosFilter
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _d(value, decimals: int = 8) -> Decimal:
try:
exp = Decimal(10) ** -decimals
return Decimal(str(value or 0)).quantize(exp, rounding=ROUND_HALF_UP)
except (InvalidOperation, TypeError):
return Decimal(0)
def _fmt_date(val) -> str:
"""Format a date value to MM/DD/YY (Clarion @D06 equivalent)."""
if val is None:
return ""
if isinstance(val, (datetime, date)):
return val.strftime("%m/%d/%y")
return str(val)
def _fmt_num(d: Decimal) -> str:
"""Format Decimal without scientific notation and trailing zeros."""
if d is None:
return ""
s = "{:f}".format(d)
if "." in s:
s = s.rstrip("0").rstrip(".")
if s == "-0":
return "0"
return s
def _clean(text: str) -> str:
"""Remove newlines/carriage returns (Clarion SACARCOMASENTERS equivalent)."""
return str(text or "").replace("\n", " ").replace("\r", "").replace(",", " ").strip()
def _subpartida_sql(level: str) -> str:
# NOTE: is_sub_part column does not exist yet in item_lines — returns empty
return ""
# ---------------------------------------------------------------------------
# Per-row lookups (per Clarion row-level ACCESS calls)
# ---------------------------------------------------------------------------
def _get_exchange_rate(db: Session, fecha, tenant_id: int, company_id: int) -> Optional[Decimal]:
"""GTipoCambio → a76.exchange_rate"""
if not fecha:
return None
try:
row = db.execute(
text(
"SELECT value FROM a76.exchange_rate "
"WHERE tenant_id = :tid AND company_id = :cid AND date::date = :fecha "
"LIMIT 1"
),
{"tid": tenant_id, "cid": company_id, "fecha": fecha},
).fetchone()
if row:
return _d(row[0])
except Exception as e:
logger.warning(f"exchange_rate not found for {fecha}: {e}")
return None
def _get_series_data(db: Session, line_item_id) -> Dict:
"""
QSeriesImpo → a76.item_line_series
Returns: SeriesSolas, ModeloSeries, NumParteSeries, NoId
"""
out = {"SeriesSolas": None, "ModeloSeries": None, "NumParteSeries": None, "NoId": None}
if not line_item_id:
return out
try:
rows = db.execute(
text(
"SELECT serial_numbers, model, sub_model, number_id "
"FROM a76.item_line_series "
"WHERE line_item_id = :lid ORDER BY id"
),
{"lid": line_item_id},
).fetchall()
except Exception:
return out
serials, models, numbers_id = [], [], []
for i, r in enumerate(rows, 1):
if r[0]:
serials.append(f"{i}) {r[0]}")
if r[1]:
models.append(f"{i}) {r[1]}")
if r[3]:
numbers_id.append(f"{i}) {r[3]}")
out["SeriesSolas"] = ", ".join(serials) if serials else None
out["ModeloSeries"] = ", ".join(models) if models else None
out["NumParteSeries"] = None # sub_model used as num-parte-series if relevant
out["NoId"] = ", ".join(numbers_id) if numbers_id else None
return out
def _get_um_tarifa(db: Session, fraccion: str) -> str:
"""
sFracciones → a76.tariff_fractions
Clarion: SUB(fraccion,1,8) for code and SUB(fraccion,9,2) for historico
We match on the first 8 chars of the code.
"""
if not fraccion:
return ""
frac_code = fraccion.replace("'", "")[:8] # strip leading quote and take 8 chars
try:
row = db.execute(
text(
"SELECT umt FROM a76.tariff_fractions "
"WHERE LEFT(code, 8) = :frac LIMIT 1"
),
{"frac": frac_code},
).fetchone()
if row and row[0]:
return str(row[0])
except Exception as e:
logger.debug(f"UMTarifa not found for {frac_code}: {e}")
return ""
def _get_fraccion_ame(db: Session, class_id) -> str:
"""QClaAct.FraccionAme → a76.classes.us_fraction"""
if not class_id:
return ""
try:
row = db.execute(
text("SELECT us_fraction FROM a76.classes WHERE id = :cid LIMIT 1"),
{"cid": class_id},
).fetchone()
if row and row[0]:
return str(row[0])
except Exception:
pass
return ""
# ---------------------------------------------------------------------------
# Base SQL (shared across all range types)
# ---------------------------------------------------------------------------
BASE_SELECT = """
ih.invoice_number AS "C1",
ih.company_id AS "company_id",
CONCAT(ped.year,'-',ped.license,'-',ped.pedimento_number) AS "C2",
COALESCE(icm.provider_header,'') AS "C3",
COALESCE(icm.sold_to_header,'') AS "C_sold_to",
il.rectification AS "C4",
icm.pedimento_id AS "C5",
icm.pedimento_r1 AS "C5R1",
pd.payment_date AS "C7",
ped.customs_office AS "C8",
pd.start_date AS "C9",
ih.invoice_date AS "C11",
ih.emission_date AS "C12_emission",
cl.id AS "C13_class_id",
COALESCE(cl.class_code,'') AS "C13",
REPLACE(REPLACE(COALESCE(ild.description_spanish,''),CHR(10),''),CHR(13),' ') AS "C14",
REPLACE(REPLACE(COALESCE(ild.description_english,''),CHR(10),''),CHR(13),' ') AS "C15",
COALESCE(ilc.origin_country,'') AS "C16",
COALESCE(ilq.quantity, 0) AS "C17",
COALESCE(ilq.quantity_returned, 0) AS "C18",
COALESCE(uom.code,'') AS "C19",
COALESCE(ilf.value_mxn, 0) AS "C20",
COALESCE(ilf.value_returned_mxn, 0) AS "C21",
COALESCE(ilf.value_usd, 0) AS "C22",
COALESCE(ilf.value_returned_usd, 0) AS "C23",
COALESCE(ilq.net_weight, 0) AS "C24",
COALESCE(ilc.fraction,'') AS "C26",
COALESCE(ilc.fraction_type,'') AS "C27",
COALESCE(ilc.advalorem,'') AS "C28",
COALESCE(ilc.sector,'') AS "C29",
COALESCE(ilq.net_weight, 0) AS "C30",
COALESCE(ild.brand,'') AS "C32",
COALESCE(ild.model,'') AS "C33",
il.id AS "C34",
il.line_number AS "C35",
COALESCE(p.part_number,'') AS "C36",
COALESCE(ilq.quantity_returned_temp, 0) AS "C37",
COALESCE(il.location,'') AS "C38",
'' AS "C39",
COALESCE(icm.edocument,'') AS "C40",
COALESCE(icm.vucem_operation_num,'') AS "C41",
COALESCE(cl.material_key,'') AS "C42",
CONCAT(ped.year,'-',ped.customs_office,'-',ped.license,'-',ped.pedimento_number) AS "C43",
'' AS "C44",
COALESCE(ilc.octave_fraction,'') AS "C45",
'' AS "C47",
COALESCE(ped.pedimento_code,'') AS "C48",
COALESCE(ilc.rate,'') AS "C49",
COALESCE(il.iv32_type_key,'') AS "C50",
COALESCE(il.guide_number,'') AS "C_embarque",
COALESCE(c_proj.name, '') AS "C_proyecto"
"""
BASE_JOINS = """
JOIN a76.invoice_header ih ON ih.id = il.invoice_id
LEFT JOIN a76.company c_proj ON c_proj.id = ih.company_id
JOIN a76.invoice_compliance_mx icm ON icm.invoice_id = ih.id
LEFT JOIN a76.pedimentos ped ON ped.id = icm.pedimento_id
LEFT JOIN a76.pedimento_dates pd ON pd.pedimento_id = ped.id
LEFT JOIN a76.classes cl ON cl.id = il.class_id
LEFT JOIN a76.item_line_quantities ilq ON ilq.item_line_id = il.id
LEFT JOIN a76.item_line_financials ilf ON ilf.item_line_id = il.id
LEFT JOIN a76.item_line_customs ilc ON ilc.item_line_id = il.id
LEFT JOIN a76.item_line_descriptions ild ON ild.item_line_id = il.id
LEFT JOIN a76.parts p ON p.id = il.part_number_id
LEFT JOIN a76.units_of_measure uom ON uom.id = il.unit_of_measure
"""
# ---------------------------------------------------------------------------
# 5 query builders
# ---------------------------------------------------------------------------
def _get_base_query_filters(filters: SaldosFilter) -> tuple:
"""Helper to return base where-clause and parameters for company/tenant."""
company_filter = ""
params: dict = {"tenant_id": filters.tenant_id}
# If Shelter option is true, ignore company_id to fetch all projects in the tenant.
if not filters.shelter:
company_filter = "AND ih.company_id = :company_id"
params["company_id"] = filters.company_id
return company_filter, params
def _query_ped(filters: SaldosFilter) -> tuple:
level_filter = _subpartida_sql(filters.level)
date_filter = ""
company_filter, params = _get_base_query_filters(filters)
if filters.start_date and filters.end_date:
s, e = filters.start_date, filters.end_date
if s > e:
s, e = e, s
date_filter = "AND CONCAT(ped.year,'-',ped.license,'-',ped.pedimento_number) BETWEEN :start AND :end"
params["start"] = s
params["end"] = e
sql = f"""
SELECT {BASE_SELECT}
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
{company_filter}
{date_filter}
{level_filter}
ORDER BY CONCAT(ped.year,ped.customs_office,ped.license,ped.pedimento_number),
ih.invoice_date
"""
return sql, params
def _query_fpp(filters: SaldosFilter) -> tuple:
level_filter = _subpartida_sql(filters.level)
date_filter = ""
company_filter, params = _get_base_query_filters(filters)
if filters.start_date and filters.end_date:
date_filter = "AND pd.payment_date BETWEEN :start AND :end"
params["start"] = filters.start_date
params["end"] = filters.end_date
sql = f"""
SELECT {BASE_SELECT}
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
{company_filter}
{date_filter}
{level_filter}
ORDER BY ih.invoice_date
"""
return sql, params
def _query_ffa(filters: SaldosFilter) -> tuple:
level_filter = _subpartida_sql(filters.level)
date_filter = ""
company_filter, params = _get_base_query_filters(filters)
if filters.start_date and filters.end_date:
date_filter = "AND ih.invoice_date BETWEEN :start AND :end"
params["start"] = filters.start_date
params["end"] = filters.end_date
sql = f"""
SELECT {BASE_SELECT}
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
{company_filter}
{date_filter}
{level_filter}
ORDER BY ih.invoice_date
"""
return sql, params
def _query_par(filters: SaldosFilter) -> tuple:
level_filter = _subpartida_sql(filters.level)
date_filter = ""
id_filter = ""
company_filter, params = _get_base_query_filters(filters)
if filters.start_date and filters.end_date:
s, e = filters.start_date, filters.end_date
if s > e:
s, e = e, s
id_filter = "AND p.part_number BETWEEN :start AND :end"
params["start"] = s
params["end"] = e
if filters.date_start and filters.date_end:
date_filter = "AND ih.invoice_date BETWEEN :date_start AND :date_end"
params["date_start"] = filters.date_start
params["date_end"] = filters.date_end
sql = f"""
SELECT {BASE_SELECT}
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
{company_filter}
{id_filter}
{date_filter}
{level_filter}
ORDER BY p.part_number, ih.invoice_date
"""
return sql, params
def _query_cla(filters: SaldosFilter) -> tuple:
level_filter = _subpartida_sql(filters.level)
date_filter = ""
id_filter = ""
company_filter, params = _get_base_query_filters(filters)
if filters.start_date and filters.end_date:
s, e = filters.start_date, filters.end_date
if s > e:
s, e = e, s
id_filter = "AND cl.class_code BETWEEN :start AND :end"
params["start"] = s
params["end"] = e
if filters.date_start and filters.date_end:
date_filter = "AND ih.invoice_date BETWEEN :date_start AND :date_end"
params["date_start"] = filters.date_start
params["date_end"] = filters.date_end
sql = f"""
SELECT {BASE_SELECT}
FROM a76.item_lines il
{BASE_JOINS}
WHERE ih.tenant_id = :tenant_id
{company_filter}
{id_filter}
{date_filter}
{level_filter}
ORDER BY cl.class_code, ih.invoice_date
"""
return sql, params
# ---------------------------------------------------------------------------
# Row-level business logic (Clarion _build_row equivalent)
# ---------------------------------------------------------------------------
def _fraccion(row: Dict, include_regla_octava: bool) -> str:
c26 = str(row.get("C26") or "")
c45 = str(row.get("C45") or "")
target = c45 if (include_regla_octava and c45) else c26
if target and target[0] == "0":
return "'" + target
return target
def _build_row(
row: Dict,
db: Session,
filters: SaldosFilter,
*,
num_parte_field: str = "C36",
) -> Optional[Dict]:
"""
Apply business logic and enrich with per-row DB lookups.
Returns None if the row should be filtered out.
"""
use_mn = filters.currency == "national"
use_fp = filters.exchange_rate == "payment_date"
# CANTIDADES
cant_orig = _d(row.get("C17"))
cant_ret = _d(row.get("C18")) + _d(row.get("C37"))
cant_saldo = cant_orig - cant_ret
if filters.omit_low_balance and cant_saldo <= Decimal(0):
return None
# PESO
peso_neto = _d(row.get("C30"))
peso_usado = (cant_ret * peso_neto / cant_orig) if cant_orig != 0 else Decimal(0)
peso_saldo = peso_neto - peso_usado
# TIPO DE CAMBIO — per Clarion logic:
# Si TipoPedimentoTransporteE IN ('4','1','98E') → usar Fecha_Inicio, else Fecha_Pago
# If explicitly "invoice_date", we use invoice_date (C11) instead.
tc = Decimal(1)
fecha_pago = row.get("C7")
fecha_inicio = row.get("C9")
fecha_factura= row.get("C11")
transport_type = str(row.get("C44") or "")
tc_fecha_display = None
# TIPO DE CAMBIO
tc_fecha = None
if use_fp:
tc_fecha = fecha_inicio if transport_type in ("1", "4", "98E") else fecha_pago
else:
tc_fecha = fecha_factura
if tc_fecha:
target_company_id = row.get("company_id") or filters.company_id
tc_val = _get_exchange_rate(db, tc_fecha, filters.tenant_id, target_company_id)
if tc_val:
tc = tc_val
elif filters.shelter:
# In Shelter mode, missing exchange rate skips the row (enforce strict data)
return None
# VALOR ORIGINAL
if use_mn:
valor_orig = _d(row.get("C22")) * tc if use_fp and fecha_pago else _d(row.get("C20"))
else:
valor_orig = _d(row.get("C22"))
# VALOR USADO
if cant_orig != 0:
if use_mn:
if use_fp and fecha_pago:
valor_usado = cant_ret * _d(row.get("C22")) * tc / cant_orig
else:
valor_usado = cant_ret * _d(row.get("C20")) / cant_orig
else:
valor_usado = cant_ret * _d(row.get("C22")) / cant_orig
else:
valor_usado = Decimal(0)
# VALOR SALDO — SubPartida logic
es_subpartida = str(row.get("C39") or "")
contiene_subp = str(row.get("C47") or "")
if es_subpartida == "S":
valor_saldo = (
_d(row.get("C20")) - _d(row.get("C21"))
if use_mn else
_d(row.get("C22")) - _d(row.get("C23"))
)
elif es_subpartida == "P" and contiene_subp == "S":
valor_saldo = (
valor_orig - _d(row.get("C21"))
if use_mn else
valor_orig - _d(row.get("C23"))
)
else:
valor_saldo = valor_orig - valor_usado
# BALANCE TYPE filter
is_repair = row.get("C4")
if filters.balance_type == "normal" and is_repair:
return None
if filters.balance_type == "repair" and not is_repair:
return None
# FRACCION ARANCELARIA
fraccion = _fraccion(row, filters.include_regla_octava)
# NUM PARTE display
if num_parte_field == "C13" or filters.print_class:
num_parte_display = str(row.get("C13") or "")
else:
num_parte_display = str(row.get("C36") or "")
# Series (per-row DB lookup)
series_data = _get_series_data(db, row.get("C34"))
# UMTarifa (Clarion: query sFracciones with fraccion)
um_tarifa = _get_um_tarifa(db, fraccion)
# FraccionAme (Clarion: ACCESS:QClaAct → EqiCla:FraccionAme)
fraccion_ame = _get_fraccion_ame(db, row.get("C13_class_id"))
# TipoCambio value to embed in CSV
tc_value = tc if tc != Decimal(1) else (tc if _get_exchange_rate(db, tc_fecha, filters.tenant_id, target_company_id) else None)
tipo_cambio_str = str(tc_value) if tc_value else ""
# Pedimento R1 logic (Clarion: some companies invert ped / pedR1)
ped_impo = str(row.get("C2") or "")
ped_r1 = str(row.get("C5R1") or "")
# Fechas formateadas
fecha_pago_fmt = _fmt_date(fecha_pago)
fecha_entrada_fmt = _fmt_date(row.get("C11"))
fecha_emision_fmt = _fmt_date(row.get("C12_emission"))
# Aduana cruce (first 2-3 chars per Clarion)
aduana = str(row.get("C8") or "")
return {
"TipoMovimiento": "Importación Temporal:",
"Pedimento": ped_impo,
"PedimentoR1": ped_r1,
"ClavePed": str(row.get("C48") or ""), # pedimento_code = Clave
"FechaPago": fecha_pago_fmt,
"FechaEntrada": fecha_entrada_fmt,
"FechaEmision": fecha_emision_fmt,
"Factura": "'" + str(row.get("C1") or ""), # leading ' for Excel
"Linea": str(row.get("C35") or ""),
"Tipo": "P" if es_subpartida == "P" else ("S" if es_subpartida == "S" else ""),
"NumParteClase": num_parte_display,
"NumParteFijo": str(row.get("C36") or ""),
"DescripcionEsp": _clean(row.get("C14") or ""),
"DescripcionIng": _clean(row.get("C15") or ""),
"CantidadOriginal": _fmt_num(cant_orig),
"UM": str(row.get("C19") or ""),
"PesoNeto": _fmt_num(peso_neto),
"ValorOriginal": _fmt_num(valor_orig),
"CantidadUsada": _fmt_num(cant_ret),
"PesoUsado": _fmt_num(peso_usado),
"ValorUsado": _fmt_num(valor_usado),
"CantidadSaldo": _fmt_num(cant_saldo),
"PesoSaldo": _fmt_num(peso_saldo),
"ValorSaldo": _fmt_num(valor_saldo),
"FraccionImpo": fraccion,
"Preferencia": str(row.get("C27") or ""),
"PaisOrigen": str(row.get("C16") or ""),
"Sector": str(row.get("C29") or ""),
"Marca": str(row.get("C32") or ""),
"Modelo": str(row.get("C33") or ""),
"SeriesSolas": series_data["SeriesSolas"],
"Assets": "", # QAssetTag — pending mapping
"EDocument": str(row.get("C40") or ""),
"NumOperacionVU": str(row.get("C41") or ""),
"TipoMaqEquipo": str(row.get("C42") or ""),
"UbicacionMaq": str(row.get("C38") or ""),
"ModeloSeries": series_data["ModeloSeries"],
"NumParteSeries": series_data["NumParteSeries"],
"NoId": series_data["NoId"],
"Pedimento18": str(row.get("C43") or ""),
"AduanaCruce": "'" + aduana,
"NumEmbarque": str(row.get("C_embarque") or ""),
"UMTarifa": um_tarifa,
"IdType": str(row.get("C50") or ""),
"Secuencia": "", # not in Anexo76 models, leave blank
"FraccionAmericana": fraccion_ame,
"Proyecto": str(row.get("C_proyecto") or "") if filters.shelter else "",
"TipoCambio": tipo_cambio_str,
# Internal only (not written to CSV, used for ordering)
"_FechaFacturaRaw": row.get("C11"),
"_PedimentoRaw": ped_impo,
"_NumParteRaw": str(row.get("C36") or ""),
"_ClaseRaw": str(row.get("C13") or ""),
}
# ---------------------------------------------------------------------------
# CSV header definitions per range type (mirrors Clarion GTxt:Linea headers)
# ---------------------------------------------------------------------------
# Common columns (all range types end with these)
_COMMON_END = [
"FraccionImpo", "Preferencia", "PaisOrigen", "Sector",
"Marca", "Modelo", "SeriesSolas", "Assets",
"EDocument", "NumOperacionVU", "TipoMaqEquipo",
"UbicacionMaq", "ModeloSeries", "NumParteSeries", "NoId",
"Pedimento18", "AduanaCruce", "NumEmbarque",
"UMTarifa", "IdType", "Secuencia", "FraccionAmericana",
"Proyecto", "TipoCambio",
]
# Column list per range type
COLUMNS_PED = [
"TipoMovimiento", "Pedimento", "ClavePed", "FechaPago", "PedimentoR1",
"NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng",
"FechaEntrada", "FechaEmision", "Factura", "Linea", "Tipo",
"CantidadOriginal", "UM", "PesoNeto", "ValorOriginal",
"CantidadUsada", "PesoUsado", "ValorUsado",
"CantidadSaldo", "PesoSaldo", "ValorSaldo",
] + _COMMON_END
COLUMNS_FECHA = [
"TipoMovimiento", "FechaEntrada", "Pedimento", "FechaPago", "ClavePed", "PedimentoR1",
"NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng",
"Factura", "FechaEmision", "Linea", "Tipo",
"CantidadOriginal", "UM", "PesoNeto", "ValorOriginal",
"CantidadUsada", "PesoUsado", "ValorUsado",
"CantidadSaldo", "PesoSaldo", "ValorSaldo",
] + _COMMON_END
COLUMNS_PAR = [
"TipoMovimiento", "NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng",
"FechaEntrada", "FechaEmision", "Factura", "Linea", "Tipo",
"Pedimento", "ClavePed", "FechaPago", "PedimentoR1",
"CantidadOriginal", "UM", "PesoNeto", "ValorOriginal",
"CantidadUsada", "PesoUsado", "ValorUsado",
"CantidadSaldo", "PesoSaldo", "ValorSaldo",
] + _COMMON_END
COLUMNS_CLA = [
"TipoMovimiento", "NumParteClase", "NumParteFijo", "DescripcionEsp", "DescripcionIng",
"FechaEntrada", "FechaEmision", "Factura", "Linea", "Tipo",
"Pedimento", "ClavePed", "FechaPago", "PedimentoR1",
"CantidadOriginal", "UM", "PesoNeto", "ValorOriginal",
"CantidadUsada", "PesoUsado", "ValorUsado",
"CantidadSaldo", "PesoSaldo", "ValorSaldo",
] + _COMMON_END
_HEADER_LABELS: Dict[str, str] = {
"TipoMovimiento": "Tipo Movimiento",
"Pedimento": "Pedimento",
"PedimentoR1": "Pedimento Rectificacion",
"ClavePed": "Clave",
"FechaPago": "Fecha Pago",
"FechaEntrada": "Fecha Entrada",
"FechaEmision": "Fecha de Emisión",
"Factura": "Factura",
"Linea": "Línea",
"Tipo": "Tipo",
"NumParteClase": "Num. Parte/Clase",
"NumParteFijo": "Num.Parte",
"DescripcionEsp": "Descripción Español",
"DescripcionIng": "Descripción Inglés",
"CantidadOriginal": "Cantidad Orig.",
"UM": "U.M.",
"PesoNeto": "Peso Orig.",
"ValorOriginal": "Valor Orig.",
"CantidadUsada": "Cantidad Usada",
"PesoUsado": "Peso Usado",
"ValorUsado": "Valor Usado",
"CantidadSaldo": "Cantidad Saldo",
"PesoSaldo": "Peso Saldo",
"ValorSaldo": "Valor Saldo",
"FraccionImpo": "Fracción Arancelaria",
"Preferencia": "Preferencia",
"PaisOrigen": "País",
"Sector": "Sector",
"Marca": "Marca",
"Modelo": "Modelo",
"SeriesSolas": "Series",
"Assets": "Asset Tags",
"EDocument": "E-Document",
"NumOperacionVU": "Núm. Operación",
"TipoMaqEquipo": "Tipo de Activo Fijo",
"UbicacionMaq": "Ubicacion",
"ModeloSeries": "Modelo",
"NumParteSeries": "Num. Parte Serie",
"NoId": "Num ID",
"Pedimento18": "Pedimento 18",
"AduanaCruce": "Aduana Cruce",
"NumEmbarque": "Advalorem", # Clarion col position: NumEmbarque after AduanaCruce
"UMTarifa": "U.M. Tarifa",
"IdType": "ID TYPE",
"Secuencia": "Secuencia",
"FraccionAmericana": "Fracción Americana",
"Proyecto": "", # conditional (Shelter)
"TipoCambio": "Tipo de Cambio",
}
_COLUMNS_BY_RANGE = {
"pedimento": COLUMNS_PED,
"payment_date": COLUMNS_FECHA,
"invoice_date": COLUMNS_FECHA,
"parts": COLUMNS_PAR,
"classes": COLUMNS_CLA,
}
# ---------------------------------------------------------------------------
# Main fetch
# ---------------------------------------------------------------------------
def fetch_saldos_data(db: Session, filters: SaldosFilter) -> List[Dict]:
range_map = {
"pedimento": _query_ped,
"payment_date": _query_fpp,
"invoice_date": _query_ffa,
"parts": _query_par,
"classes": _query_cla,
}
builder = range_map.get(filters.range_type, _query_ped)
sql, params = builder(filters)
if filters.asset_type:
sql = sql.replace(
"ORDER BY",
"AND cl.material_key = :asset_type\n ORDER BY",
)
params["asset_type"] = filters.asset_type
try:
result = db.execute(text(sql), params)
raw_rows = [dict(r._mapping) for r in result]
except Exception as e:
logger.error(f"fetch_saldos_data SQL error [{filters.range_type}]: {e}", exc_info=True)
return []
num_parte_field = "C13" if filters.range_type == "classes" else "C36"
processed: List[Dict] = []
for raw in raw_rows:
# In-memory filters (Clarion CYCLE)
if filters.buyer and str(raw.get("C_sold_to") or "") != filters.buyer:
continue
if filters.provider and str(raw.get("C3") or "") != filters.provider:
continue
if filters.location and str(raw.get("C38") or "") != filters.location:
continue
if filters.asset_class and filters.range_type != "classes":
if str(raw.get("C13") or "") != filters.asset_class:
continue
if filters.pedimento_code and str(raw.get("C48") or "") != filters.pedimento_code:
continue
row_data = _build_row(raw, db, filters, num_parte_field=num_parte_field)
if row_data is not None:
processed.append(row_data)
logger.info(f"fetch_saldos_data: {len(processed)} rows (range_type={filters.range_type})")
return processed
# ---------------------------------------------------------------------------
# CSV builder
# ---------------------------------------------------------------------------
def generate_saldos_csv(filters: SaldosFilter, db: Optional[Session] = None) -> str:
"""Build the CSV string matching Clarion's GENERA_EXCEL_CSV_* output."""
output = io.StringIO()
writer = csv.writer(output)
columns = _COLUMNS_BY_RANGE.get(filters.range_type, COLUMNS_PED)
headers = [_HEADER_LABELS.get(c, c) for c in columns]
if filters.shelter:
# Enforce header label if Shelter is active
for i, col in enumerate(columns):
if col == "Proyecto":
headers[i] = "Proyecto"
# Dynamically build legacy title
range_map_title = {
"pedimento": "Por RANGO de PEDIMENTO",
"payment_date": "Por RANGO de FECHA PAGO",
"invoice_date": "Por RANGO de FECHA FAC.",
"parts": "Por PARTE",
"classes": "Por CLASE",
}
rango_str = range_map_title.get(filters.range_type, "")
report_type_map = {
"normal": "Normal",
"detailed": "Detallado",
"with_download": "Con Descargas",
}
tipo_reporte_str = report_type_map.get(filters.report_type, "Normal")
agrupacion_str = "Por CLASE" if filters.print_class else "Por PARTE"
title_row = f"REPORTE DE SALDOS DE ACTIVO FIJO {rango_str}, {tipo_reporte_str} {agrupacion_str}"
# Fetch company profile if DB available
c_name = ""
c_rfc = ""
c_immex = ""
main_addr_str1 = ""
main_addr_str2 = ""
main_addr_str3 = ""
ind_addr_str1 = ""
ind_addr_str2 = ""
ind_addr_str3 = ""
if db is not None:
try:
from api.v1.modules.a76.general_catalogs.company.models import Company
query = db.query(Company)
if filters.company_id:
query = query.filter(Company.id == filters.company_id)
company = query.first()
if company:
c_name = company.name or ""
c_rfc = company.rfc or ""
# IMMEX often uses both program and program_number
p_base = company.program or ""
p_num = company.program_number or ""
if p_base and p_num:
c_immex = f"{p_base}-{p_num}"
else:
c_immex = p_base or p_num or ""
for addr in company.addresses:
if addr.address_type == "main":
main_addr_str1 = f"Domicilio Fiscal: {addr.street or ''} Ext. Num: {addr.exterior_number or ''}".strip()
main_addr_str2 = f"{addr.neighborhood or ''} Código Postal: {addr.postal_code or ''}".strip()
main_addr_str3 = f"{addr.city or ''} {addr.state or ''}".strip()
elif addr.address_type == "industrial":
ind_addr_str1 = f"Domicilio Industrial: {addr.street or ''} Ext. Num: {addr.exterior_number or ''}".strip()
ind_addr_str2 = f"{addr.neighborhood or ''} Código Postal: {addr.postal_code or ''}".strip()
ind_addr_str3 = f"{addr.city or ''} {addr.state or ''}".strip()
except Exception as e:
logger.warning(f"Could not load company info for headers: {e}")
# Format Spanish dates correctly (e.g. 5 MAR 2026 Hora Generación: 09:09PM)
# Use America/Mexico_City timezone to match user's local time (-06:00)
tz = pytz.timezone('America/Mexico_City')
now = datetime.now(tz)
mo_es = {1:"ENE", 2:"FEB", 3:"MAR", 4:"ABR", 5:"MAY", 6:"JUN", 7:"JUL", 8:"AGO", 9:"SEP", 10:"OCT", 11:"NOV", 12:"DIC"}[now.month]
fecha_gen = f"{now.day} {mo_es} {now.year}"
hora_gen = now.strftime("%I:%M%p").upper()
# Write Title block strictly matching Clarion
writer.writerow([title_row])
if c_name: writer.writerow([c_name])
if main_addr_str1: writer.writerow([main_addr_str1])
if main_addr_str2: writer.writerow([main_addr_str2])
if main_addr_str3: writer.writerow([main_addr_str3])
if ind_addr_str1: writer.writerow([ind_addr_str1])
if ind_addr_str2: writer.writerow([ind_addr_str2])
if ind_addr_str3: writer.writerow([ind_addr_str3])
writer.writerow([f"R.F.C: {c_rfc}"])
writer.writerow([f"IMMEX: {c_immex}"])
writer.writerow([f"Fecha Generación: {fecha_gen} Hora Generación: {hora_gen}"])
writer.writerow(["PROVEEDOR DE SOFTWARE: ADUANASOFT"])
writer.writerow([])
writer.writerow([])
writer.writerow(headers)
writer.writerow([]) # blank second header row (Clarion: ConDescarga=0 → blank)
if db is not None:
rows = fetch_saldos_data(db, filters)
else:
rows = [{c: f"DEMO-{c}" for c in columns}]
for row in rows:
writer.writerow([row.get(col, "") for col in columns])
writer.writerow([])
return output.getvalue()

View File

@@ -0,0 +1,81 @@
"""
FastAPI routes for Saldos Temporales report.
Endpoints:
POST /generate trigger async CSV generation (returns task_id)
GET /task/{id} poll task status
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from .schemas import SaldosFilter
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Reports - Saldos Temporales"])
@router.post(
"/generate",
summary="Generate Saldos Temporales CSV (Async)",
description=(
"Triggers a background Celery task to generate the Saldos Temporales CSV. "
"Returns a `task_id` that can be polled via `/task/{task_id}`."
),
)
def generate_saldos_report_async(
filters: SaldosFilter,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Enqueue the Celery task and return its ID."""
from .tasks import generate_saldos_temporales_async
from core.security import validate_access_to_resource
logger.info(
f"User {current_user.get('preferred_username', 'unknown')} "
f"triggering Saldos Temporales report generation"
)
# validate_access_to_resource returns the integer tenant_id from DB
tenant_id = validate_access_to_resource(db, company_id, current_user)
# Inject scoping fields (not from the UI body)
filters.company_id = company_id
filters.tenant_id = tenant_id
filter_data = filters.model_dump()
user_email = current_user.get("email")
task = generate_saldos_temporales_async.delay(filter_data, user_email)
return {"task_id": task.id}
@router.get(
"/task/{task_id}",
summary="Get Saldos Temporales Task Status",
description="Poll the status of a background Saldos Temporales generation task.",
)
def get_saldos_task_status(task_id: str):
"""Return current status and (when ready) the result of the Celery task."""
from celery.result import AsyncResult
from core.celery_app import celery_app
task_result = AsyncResult(task_id, app=celery_app)
response: dict = {
"task_id": task_id,
"status": task_result.status,
}
if task_result.state == "PROCESSING":
response["meta"] = task_result.info
if task_result.ready():
response["result"] = task_result.result
return response

View File

@@ -0,0 +1,52 @@
"""
Schemas for Saldos Temporales report.
"""
from typing import Optional
from pydantic import BaseModel
class SaldosFilter(BaseModel):
"""
Filter parameters for the Saldos Temporales CSV report.
Maps directly to the UI options chosen by the user.
"""
# Range / Period
range_type: str = "pedimento" # pedimento | payment_date | invoice_date | parts | classes
start_date: Optional[str] = None # identifier "from" (e.g. pedimento number or part string)
end_date: Optional[str] = None # identifier "to"
date_start: Optional[str] = None # Explicit start date
date_end: Optional[str] = None # Explicit end date
# Column 2 Filtros e Identificadores
currency: str = "foreign" # foreign | national
report_type: str = "normal" # normal | detailed | with_download
exchange_rate: str = "invoice_date" # invoice_date | payment_date
omit_low_balance: bool = False
balance_type: str = "normal" # normal | repair | both
level: str = "all" # partida | subpartida | all
# Column 3 Configuración Final
end_date_as_cutoff: bool = False
show_pending_series: bool = False
include_asset_tag_images: bool = False
use_large_asset_tag_icons: bool = False
send_email: bool = False
julian_date: bool = False
include_regla_octava: bool = False
shelter: bool = False
# Print options (Column 1)
print_part: bool = False
print_class: bool = False
# Optional identifiers populated from catalog selectors
provider: Optional[str] = None
buyer: Optional[str] = None
asset_class: Optional[str] = None # Clase
asset_type: Optional[str] = None # Parte
location: Optional[str] = None
pedimento_code: Optional[str] = None # Clave de Pedimento
# Security / scoping (set by the route, not by the UI)
company_id: Optional[int] = None
tenant_id: Optional[int] = None

View File

@@ -0,0 +1,103 @@
"""
Celery task for asynchronous Saldos Temporales CSV generation.
"""
import base64
import logging
from typing import Dict, Any
from core.celery_app import celery_app
from core.email import EmailService
from datetime import datetime
from .schemas import SaldosFilter
from .csv_utils import generate_saldos_csv
logger = logging.getLogger(__name__)
@celery_app.task(bind=True, name="generate_saldos_temporales_async")
def generate_saldos_temporales_async(
self,
filter_data: Dict[str, Any],
user_email: str = None
):
"""
Async Celery task: generate Saldos Temporales CSV and optionally e-mail it.
"""
try:
# 1. Progress initialising
self.update_state(
state="PROCESSING",
meta={"current": 10, "total": 100, "status": "Inicializando reporte de Saldos Temporales..."},
)
# 2. Re-construct filter
filters = SaldosFilter(**filter_data)
# 3. Fetch + generate CSV (real DB session)
self.update_state(
state="PROCESSING",
meta={"current": 40, "total": 100, "status": "Generando datos de Saldos Temporales..."},
)
logger.info(f"Saldos task: building CSV (range_type={filters.range_type})")
from core.database import CoreSessionLocal
db = CoreSessionLocal()
try:
csv_content = generate_saldos_csv(filters, db=db)
finally:
db.close()
# 4. Optional e-mail
email_sent = False
if filters.send_email and user_email:
self.update_state(
state="PROCESSING",
meta={"current": 85, "total": 100, "status": "Enviando correo electrónico..."},
)
try:
import asyncio
from asgiref.sync import async_to_sync
filename = (
f"saldos_temporales_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
)
result = async_to_sync(EmailService.send_report_email)(
recipient_email=user_email,
subject=f"Saldos Temporales {datetime.now().strftime('%d/%m/%Y')}",
body_text="Se adjunta el reporte de Saldos Temporales generado.",
csv_content=csv_content,
filename=filename,
)
email_sent = bool(result)
except Exception as e:
logger.error(f"Saldos task: email error: {e}")
# 5. Encode to base64 and return
self.update_state(
state="PROCESSING",
meta={"current": 95, "total": 100, "status": "Finalizando..."},
)
content_b64 = base64.b64encode(csv_content.encode("utf-8")).decode("utf-8")
filename = f"saldos_temporales_{datetime.now().strftime('%Y%m%d')}.csv"
return {
"status": "success",
"file_name": filename,
"content": content_b64,
"media_type": "text/csv",
"email_sent": email_sent,
}
except Exception as e:
logger.error(f"Error in generate_saldos_temporales_async: {e}", exc_info=True)
self.update_state(
state="FAILURE",
meta={
"exc_type": type(e).__name__,
"exc_message": str(e),
"custom": "Error generating Saldos Temporales report",
},
)
raise

View File

@@ -40,6 +40,7 @@ from .reports.importacion.consolidados.routes import router as consolidated_repo
from .reports.importacion.packing_list.routes import router as packing_list_router
from .reports.exportacion.aviso_consolidado.routes import router as aviso_consolidado_export_router
from .reports.movements.invoices.routes import router as movement_invoices_router
from .reports.movements.saldos.routes import router as movement_saldos_router
from .reports.exportacion.descargo.routes import router as discharge_reports_router
from .manifests.manifest.routes import router as manifests_router
from .manifests.driver.routes import router as manifest_drivers_router
@@ -117,6 +118,12 @@ router.include_router(
tags=["a76 / reports"]
)
router.include_router(
movement_saldos_router,
prefix="/a76/reports/movements/saldos",
tags=["a76 / reports"]
)
router.include_router(
discharge_reports_router,
prefix="/a76/reports/exportacion/descargo",

View File

@@ -12,7 +12,9 @@ class LoginRequestDTO(BaseModel):
username: str = Field(..., description="Usuario o email")
password: str = Field(..., min_length=6, description="Contraseña")
tenant_slug: str = Field(..., description="Slug del tenant")
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
# y devuelve la lista de tenants disponibles en lugar de tokens.
tenant_slug: Optional[str] = Field(None, description="Slug del tenant")
class Config:
json_schema_extra = {
@@ -57,6 +59,7 @@ class UserInfoResponseDTO(BaseModel):
name: Optional[str] = None
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
roles: list[str] = []
class Config:
@@ -146,10 +149,49 @@ class SetCookieRequestDTO(BaseModel):
access_token: str = Field(..., description="Access token JWT")
refresh_token: str = Field(..., description="Refresh token JWT")
class SwitchTenantRequestDTO(BaseModel):
"""DTO para cambiar de tenant estando autenticado"""
tenant_slug: str = Field(..., description="Slug del tenant destino")
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
class DiscoverTenantsRequestDTO(BaseModel):
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
username: str = Field(..., description="Nombre de usuario o email")
class Config:
json_schema_extra = {
"example": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"username": "jperez",
}
}
class TenantInfoDTO(BaseModel):
"""Información básica de un tenant para mostrar en el selector de login"""
id: int
name: str
slug: str
class Config:
from_attributes = True
class DiscoverTenantsResponseDTO(BaseModel):
"""Respuesta con los tenants disponibles para un usuario"""
tenants: list[TenantInfoDTO]
class LoginChoiceResponseDTO(BaseModel):
"""
Respuesta del login cuando el usuario pertenece a varios tenants.
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
"""
status: str = "choose_tenant"
tenants: list[TenantInfoDTO]

View File

@@ -10,12 +10,14 @@ from sqlalchemy.orm import Session
from .dto import (
ExchangeCodeRequestDTO,
LoginChoiceResponseDTO,
LoginRequestDTO,
LogoutRequestDTO,
RefreshTokenRequestDTO,
RegisterRequestDTO,
RegisterResponseDTO,
SetCookieRequestDTO,
SwitchTenantRequestDTO,
TokenResponseDTO,
UserInfoResponseDTO,
)
@@ -49,7 +51,7 @@ async def register(
return service.register(register_data)
@router.post("/login", response_model=TokenResponseDTO)
@router.post("/login", response_model=None)
async def login(
login_data: LoginRequestDTO,
request: Request, # Inject Request
@@ -73,6 +75,42 @@ async def login(
)
@router.post("/switch-tenant", response_model=TokenResponseDTO)
async def switch_tenant(
data: SwitchTenantRequestDTO,
db: Session = Depends(get_core_db),
credentials: HTTPAuthorizationCredentials = Depends(security),
):
"""
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
Requiere:
- Authorization: Bearer <access_token> (para identificar al usuario)
- Body: { tenant_slug, refresh_token }
"""
service = AuthService(db)
# Obtener info del usuario desde el access token actual
user_info = service.get_user_info(credentials.credentials)
keycloak_user_id = user_info.sub
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
# Todos los tenants comparten el mismo realm en esta arquitectura.
from api.v1.modules.core.tenants.models import Tenant
from core.database import get_core_db as _gcdb
# Obtener el realm del tenant destino (o default)
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
if not tenant:
raise HTTPException(status_code=403, detail="Access denied")
return service.switch_tenant(
keycloak_user_id=keycloak_user_id,
keycloak_realm=tenant.keycloak_realm,
tenant_slug=data.tenant_slug,
refresh_token=data.refresh_token,
)
@router.post("/refresh", response_model=TokenResponseDTO)
async def refresh_token(
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)

View File

@@ -43,32 +43,45 @@ class AuthService:
login_data: LoginRequestDTO,
ip_address: str = None,
user_agent: str = None
) -> TokenResponseDTO:
):
"""
Autentica usuario y obtiene tokens
Autentica usuario y obtiene tokens.
Si se omite tenant_slug, verifica credenciales primero y devuelve
la lista de tenants disponibles (LoginChoiceResponseDTO) en lugar de tokens.
Args:
login_data: Credenciales de login
login_data: Credenciales de login (tenant_slug es opcional)
ip_address: Dirección IP del cliente
user_agent: User Agent del cliente
Returns:
TokenResponseDTO con access_token y refresh_token
TokenResponseDTO si tenant_slug fue provisto,
LoginChoiceResponseDTO si no se proveyó tenant_slug.
Raises:
HTTPException: Si las credenciales son inválidas
"""
# PRIMER PASO: sin tenant_slug → verificar creds y devolver lista de orgs
if not login_data.tenant_slug:
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
tenants = self._verify_credentials_and_list_tenants(
login_data.username, login_data.password
)
# Siempre devolver LoginChoiceResponseDTO; el frontend decide si
# auto-seleccionar (1 tenant) o mostrar selector (>1 tenants).
return LoginChoiceResponseDTO(
tenants=[TenantInfoDTO(**t) for t in tenants]
)
try:
# Verificar que el tenant existe
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
if not tenant.is_active:
raise HTTPException(status_code=403, detail="Tenant is not active")
if not tenant or not tenant.is_active:
raise HTTPException(status_code=401, detail="Invalid credentials")
# Crear nueva instancia de KeycloakOpenID con el realm del tenant
keycloak_client = KeycloakOpenID(
@@ -110,8 +123,8 @@ class AuthService:
f"User {user_id} tried to access tenant {tenant.id} without permission"
)
raise HTTPException(
status_code=403,
detail="You don't have access to this tenant",
status_code=401,
detail="Invalid credentials",
)
# Obtener los datos actuales del usuario
@@ -228,17 +241,25 @@ class AuthService:
if "realm_access" in user_info:
roles = user_info["realm_access"].get("roles", [])
# Extraer tenant_id si está presente
# Extraer tenant_id y tenant_slug si están presentes
tenant_id = user_info.get("tenant_id")
if not tenant_id and "attributes" in user_info:
tenant_id = user_info["attributes"].get("tenant_id")
tenant_slug = user_info.get("tenant_slug")
if not tenant_slug and "attributes" in user_info:
tenant_slug = user_info["attributes"].get("tenant_slug")
# Puede venir como lista de Keycloak attributes
if isinstance(tenant_slug, list):
tenant_slug = tenant_slug[0] if tenant_slug else None
return UserInfoResponseDTO(
sub=user_info.get("sub"),
email=user_info.get("email"),
name=user_info.get("name"),
preferred_username=user_info.get("preferred_username"),
tenant_id=int(tenant_id) if tenant_id else None,
tenant_slug=tenant_slug,
roles=roles,
)
@@ -455,3 +476,252 @@ class AuthService:
except Exception as e:
logger.error(f"Code exchange error: {str(e)}")
raise HTTPException(status_code=500, detail="Code exchange error")
def switch_tenant(
self,
keycloak_user_id: str,
keycloak_realm: str,
tenant_slug: str,
refresh_token: str,
) -> TokenResponseDTO:
"""
Cambia el tenant activo de un usuario autenticado sin requerir su contraseña.
Pasos:
1. Verifica que el tenant existe y está activo.
2. Verifica que el usuario tiene acceso a ese tenant.
3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak.
4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados.
"""
from api.v1.modules.core.tenants.models import Tenant
tenant_service = TenantService(self.db)
user_tenant_service = UserTenantService(self.db)
tenant = tenant_service.get_tenant_by_slug(tenant_slug)
if not tenant or not tenant.is_active:
raise HTTPException(status_code=403, detail="Access denied")
# Verificar acceso
has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id)
if not has_access:
raise HTTPException(status_code=403, detail="Access denied")
# Actualizar atributos en Keycloak antes de emitir el nuevo token
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=keycloak_realm,
user_realm_name="master",
verify=True,
)
current_user = keycloak_admin.get_user(keycloak_user_id)
attrs = current_user.get("attributes", {})
attrs["tenant_id"] = [str(tenant.id)]
attrs["tenant_slug"] = [tenant.slug]
keycloak_admin.update_user(
user_id=keycloak_user_id,
payload={
"email": current_user.get("email"),
"firstName": current_user.get("firstName"),
"lastName": current_user.get("lastName"),
"enabled": current_user.get("enabled", True),
"emailVerified": current_user.get("emailVerified", False),
"attributes": attrs,
},
)
except KeycloakError as e:
logger.warning(f"switch_tenant: could not update user attributes: {e}")
raise HTTPException(status_code=500, detail="Could not update tenant attributes")
# Emitir nuevos tokens usando el refresh_token existente
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=keycloak_realm,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
try:
token_response = keycloak_client.refresh_token(refresh_token)
except KeycloakError as e:
logger.warning(f"switch_tenant: token refresh failed: {e}")
raise HTTPException(status_code=401, detail="Token refresh failed; please log in again")
return TokenResponseDTO(
access_token=token_response["access_token"],
refresh_token=token_response["refresh_token"],
token_type="bearer",
expires_in=token_response["expires_in"],
)
def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list:
"""
Verifica las credenciales del usuario contra Keycloak y, solo si son válidas,
devuelve la lista de tenants a los que tiene acceso.
Esto evita el oráculo de enumeración de usuarios del antiguo endpoint
/discover-tenants que no requería contraseña.
Args:
username: Nombre de usuario o email
password: Contraseña en texto plano
Returns:
Lista de dicts {id, name, slug} con los tenants del usuario
Raises:
HTTPException 401: Si las credenciales son inválidas
"""
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
from sqlalchemy import and_
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
if not tenants:
raise HTTPException(status_code=401, detail="Invalid credentials")
realms: dict[str, list] = {}
for tenant in tenants:
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
credentials_verified = False
matched_tenants = []
for realm_name, realm_tenants in realms.items():
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=realm_name,
user_realm_name="master",
verify=True,
)
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
users = keycloak_admin.get_users({"email": username, "exact": True})
if not users:
continue
keycloak_user_id = users[0]["id"]
# Verificar la contraseña contra este realm (una sola vez)
if not credentials_verified:
keycloak_client = KeycloakOpenID(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=realm_name,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
)
try:
keycloak_client.token(
username=username,
password=password,
grant_type=["password"],
)
credentials_verified = True
except KeycloakError:
# Contraseña incorrecta — no revelar que el usuario existe
raise HTTPException(status_code=401, detail="Invalid credentials")
# Recopilar tenants con acceso confirmado
for tenant in realm_tenants:
has_access = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant.id,
UserTenant.is_active,
)
)
.first()
)
if has_access:
matched_tenants.append(
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
)
except HTTPException:
raise
except Exception as e:
logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}")
continue
if not credentials_verified:
raise HTTPException(status_code=401, detail="Invalid credentials")
return matched_tenants
def discover_user_tenants(self, username: str) -> list:
"""
[DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar.
Descubre los tenants activos a los que pertenece un usuario dado su username.
"""
from api.v1.modules.core.tenants.models import Tenant
from api.v1.modules.core.user_tenant.models import UserTenant
from sqlalchemy import and_
# 1. Obtener todos los tenants activos
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
if not tenants:
return []
# 2. Agrupar tenants por keycloak_realm para no repetir consultas admin
realms: dict[str, list] = {}
for tenant in tenants:
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
matched_tenants = []
for realm_name, realm_tenants in realms.items():
try:
keycloak_admin = KeycloakAdmin(
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
username=settings.KEYCLOAK_ADMIN_USERNAME,
password=settings.KEYCLOAK_ADMIN_PASSWORD,
realm_name=realm_name,
user_realm_name="master",
verify=True,
)
# Buscar por username exacto
users = keycloak_admin.get_users({"username": username, "exact": True})
if not users:
# Intentar por email
users = keycloak_admin.get_users({"email": username, "exact": True})
if not users:
continue
keycloak_user_id = users[0]["id"]
# 3. Para cada tenant en este realm, verificar UserTenant
for tenant in realm_tenants:
has_access = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.tenant_id == tenant.id,
UserTenant.is_active,
)
)
.first()
)
if has_access:
matched_tenants.append(
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
)
except Exception as e:
logger.warning(
f"Could not query realm '{realm_name}' during tenant discovery: {e}"
)
continue
return matched_tenants

View File

@@ -14,7 +14,7 @@ router = APIRouter(prefix="/material-types")
@router.get("/", response_model=Dict[str, Any])
async def list_material_types(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"),
type: str = Query(None, description="Filtrar por tipo (ACTIVO FIJO, MATERIALES, PRODUCTOS)"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),

View File

@@ -14,7 +14,7 @@ router = APIRouter(prefix="/pedimento-codes")
@router.get("/", response_model=Dict[str, Any])
def list_pedimento_codes(
page: int = Query(1, ge=1, description="Número de página"),
page_size: int = Query(50, ge=1, le=100, description="Tamaño de página"),
page_size: int = Query(50, ge=1, le=1000, description="Tamaño de página"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):