Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/partePais_BOM
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ router = TenantCRUDRoutes(
|
||||
tags=["a76.general_catalogs.ports"],
|
||||
resource_name="Port",
|
||||
enable_list=True,
|
||||
max_page_size=1000,
|
||||
).router
|
||||
|
||||
@@ -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
|
||||
|
||||
37
backend/api/v1/modules/a76/invoices/common/calculations.py
Normal file
37
backend/api/v1/modules/a76/invoices/common/calculations.py
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
284
backend/api/v1/modules/a76/invoices/exports/validators/update.py
Normal file
284
backend/api/v1/modules/a76/invoices/exports/validators/update.py
Normal 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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
259
backend/api/v1/modules/a76/invoices/imports/validators/update.py
Normal file
259
backend/api/v1/modules/a76/invoices/imports/validators/update.py
Normal 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
417
backend/api/v1/modules/a76/items/exports/validators/common.py
Normal file
417
backend/api/v1/modules/a76/items/exports/validators/common.py
Normal 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)
|
||||
394
backend/api/v1/modules/a76/items/exports/validators/create.py
Normal file
394
backend/api/v1/modules/a76/items/exports/validators/create.py
Normal 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)
|
||||
251
backend/api/v1/modules/a76/items/exports/validators/update.py
Normal file
251
backend/api/v1/modules/a76/items/exports/validators/update.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
27
backend/api/v1/modules/a76/items/series/schemas.py
Normal file
27
backend/api/v1/modules/a76/items/series/schemas.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
19
backend/api/v1/modules/a76/layouts_csv/common/cell_value.py
Normal file
19
backend/api/v1/modules/a76/layouts_csv/common/cell_value.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: A–V)
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 A–V) ---
|
||||
"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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]),
|
||||
}
|
||||
@@ -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"]),
|
||||
}
|
||||
@@ -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"]),
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
886
backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py
Normal file
886
backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py
Normal 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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
103
backend/api/v1/modules/a76/reports/movements/saldos/tasks.py
Normal file
103
backend/api/v1/modules/a76/reports/movements/saldos/tasks.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user