diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 1559a06f..dd389503 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -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 { diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 904adddf..48a94a2d 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -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"): diff --git a/backend/api/v1/modules/a76/clients_and_providers/service.py b/backend/api/v1/modules/a76/clients_and_providers/service.py index 417228ee..aaf76471 100644 --- a/backend/api/v1/modules/a76/clients_and_providers/service.py +++ b/backend/api/v1/modules/a76/clients_and_providers/service.py @@ -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: diff --git a/backend/api/v1/modules/a76/csv_templates/registry.py b/backend/api/v1/modules/a76/csv_templates/registry.py index 076363c5..b14ae3a6 100644 --- a/backend/api/v1/modules/a76/csv_templates/registry.py +++ b/backend/api/v1/modules/a76/csv_templates/registry.py @@ -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", } diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/routes.py b/backend/api/v1/modules/a76/general_catalogs/ports/routes.py index 3d03c33d..e90d8ab6 100644 --- a/backend/api/v1/modules/a76/general_catalogs/ports/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/ports/routes.py @@ -13,4 +13,5 @@ router = TenantCRUDRoutes( tags=["a76.general_catalogs.ports"], resource_name="Port", enable_list=True, + max_page_size=1000, ).router diff --git a/backend/api/v1/modules/a76/general_catalogs/ports/service.py b/backend/api/v1/modules/a76/general_catalogs/ports/service.py index e10f3529..7e5e1af2 100644 --- a/backend/api/v1/modules/a76/general_catalogs/ports/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/ports/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/common/calculations.py b/backend/api/v1/modules/a76/invoices/common/calculations.py new file mode 100644 index 00000000..f3278732 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/common/calculations.py @@ -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 + + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/common/common_validators.py b/backend/api/v1/modules/a76/invoices/common/common_validators.py index 72a5abdf..f6e249bd 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -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) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/common/create_validators.py b/backend/api/v1/modules/a76/invoices/common/create_validators.py deleted file mode 100644 index 8b137891..00000000 --- a/backend/api/v1/modules/a76/invoices/common/create_validators.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/invoices/exports/validators/create.py similarity index 92% rename from backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py rename to backend/api/v1/modules/a76/invoices/exports/validators/create.py index 79a6f0cb..9afedbc2 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/invoices/exports/validators/create.py @@ -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, diff --git a/backend/api/v1/modules/a76/invoices/exports/validators/update.py b/backend/api/v1/modules/a76/invoices/exports/validators/update.py new file mode 100644 index 00000000..9a2ceec2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/validators/update.py @@ -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) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py deleted file mode 100644 index a690343d..00000000 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/common.py +++ /dev/null @@ -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, - ) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py deleted file mode 100644 index ac85ff24..00000000 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ /dev/null @@ -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) - diff --git a/backend/api/v1/modules/a76/invoices/imports/validators/create.py b/backend/api/v1/modules/a76/invoices/imports/validators/create.py new file mode 100644 index 00000000..9afedbc2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/validators/create.py @@ -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() + + + + + + + + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/imports/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/validators/update.py new file mode 100644 index 00000000..606df18a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/validators/update.py @@ -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) + diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index 55124a29..5d05e82e 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 90e6a84a..77f4b2fb 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -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") diff --git a/backend/api/v1/modules/a76/items/exports/validators/calculations.py b/backend/api/v1/modules/a76/items/exports/validators/calculations.py new file mode 100644 index 00000000..4986b126 --- /dev/null +++ b/backend/api/v1/modules/a76/items/exports/validators/calculations.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/exports/validators/common.py b/backend/api/v1/modules/a76/items/exports/validators/common.py new file mode 100644 index 00000000..31ace4a5 --- /dev/null +++ b/backend/api/v1/modules/a76/items/exports/validators/common.py @@ -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) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py new file mode 100644 index 00000000..d7b2328a --- /dev/null +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -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) diff --git a/backend/api/v1/modules/a76/items/exports/validators/update.py b/backend/api/v1/modules/a76/items/exports/validators/update.py new file mode 100644 index 00000000..e4f6c121 --- /dev/null +++ b/backend/api/v1/modules/a76/items/exports/validators/update.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/calculations.py b/backend/api/v1/modules/a76/items/imports/validators/calculations.py similarity index 85% rename from backend/api/v1/modules/a76/items/imports/temporary/validators/calculations.py rename to backend/api/v1/modules/a76/items/imports/validators/calculations.py index 7bc9c5af..565b4ed2 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/imports/validators/calculations.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/items/imports/validators/common.py similarity index 96% rename from backend/api/v1/modules/a76/items/imports/temporary/validators/common.py rename to backend/api/v1/modules/a76/items/imports/validators/common.py index f1600bff..ce5723e6 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/validators/common.py @@ -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) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/validators/create.py similarity index 95% rename from backend/api/v1/modules/a76/items/imports/temporary/validators/create.py rename to backend/api/v1/modules/a76/items/imports/validators/create.py index 677df0ed..a925d407 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/validators/create.py @@ -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: diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/items/imports/validators/update.py similarity index 98% rename from backend/api/v1/modules/a76/items/imports/temporary/validators/update.py rename to backend/api/v1/modules/a76/items/imports/validators/update.py index 23717f2d..c1343a0d 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/validators/update.py @@ -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, diff --git a/backend/api/v1/modules/a76/items/schemas.py b/backend/api/v1/modules/a76/items/schemas.py index b54c914b..62072356 100644 --- a/backend/api/v1/modules/a76/items/schemas.py +++ b/backend/api/v1/modules/a76/items/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/items/series/schemas.py b/backend/api/v1/modules/a76/items/series/schemas.py new file mode 100644 index 00000000..e967a1d2 --- /dev/null +++ b/backend/api/v1/modules/a76/items/series/schemas.py @@ -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) diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index f75fa1d6..343bd5e4 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -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: diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py b/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py index 1d4efa48..eb50d89f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py index 642e413e..b780736c 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py index b4c9623e..f4d9d2e5 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py index be387efc..c06c1e2a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/common/cell_value.py b/backend/api/v1/modules/a76/layouts_csv/common/cell_value.py new file mode 100644 index 00000000..a5989742 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/cell_value.py @@ -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) diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py index 65292796..24b2c573 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py @@ -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, diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py index b656b59a..b1a3afa6 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py index 867bbe70..8edfdefd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py @@ -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) diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py index 891dccb1..6c4be901 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py index c907f9a0..9f32c526 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py index 6ba36f2e..314af8c9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py index a357f1f0..8892781e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py @@ -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: diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/schemas.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/schemas.py index 2a043f30..ca97e233 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/schemas.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/schemas.py @@ -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): diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py index 0d43f5b1..a2b61da2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py @@ -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: diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py index d6d598fc..e7b6124e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index c7e9c570..cda7106d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/schemas.py b/backend/api/v1/modules/a76/layouts_csv/facturas/schemas.py index 63c0202a..4cf4db4d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/schemas.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index a3fe37be..1b338e51 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -1,1377 +1,5449 @@ -import os -from datetime import datetime -from decimal import Decimal -import csv -import json -import logging -import re -import unicodedata -from typing import Dict, Any, Optional, List - -from core.celery_app import celery_app -from core.database import CoreSessionLocal -from core.paths import layout_path - -from ..common import storage as common_storage -from ..common import meta as common_meta -from ..common import responses as common_responses -from .template_config import row_from_template -# Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process - -logger = logging.getLogger(__name__) - -# Job type vacío para facturas (prefijo Redis "import_" sin tipo, ver common/storage.py) -JOB_TYPE = "" - -# Redis keys and TTL for import file/meta (exportados para routes; coinciden con common_storage cuando job_type="") -IMPORT_FILE_KEY_PREFIX = "import_file:" -IMPORT_META_KEY_PREFIX = "import_meta:" -IMPORT_ERROR_LINES_KEY_PREFIX = "import_error_lines:" -IMPORT_REDIS_TTL = common_storage.IMPORT_REDIS_TTL - - -def _ensure_worker_has_file_from_redis(job_id: str) -> Optional[str]: - """Usa common storage con job_type vacío (prefijo import_).""" - return common_storage.ensure_file_from_redis(JOB_TYPE, job_id, "Invoices import") - - -def _ensure_worker_has_meta_from_redis(job_id: str, file_path: str) -> bool: - return common_storage.ensure_meta_from_redis(JOB_TYPE, job_id, file_path, "Invoices import") - - -def _delete_import_from_redis(job_id: str) -> None: - common_storage.delete_import_from_redis(JOB_TYPE, job_id) - -class ForeignKeyValidator: - def __init__(self, session, tenant_id, company_id): - self.session = session - self.tenant_id = tenant_id - self.company_id = company_id - self.cache = {} # {(model_name, value): bool} - - def check_exists(self, model, value, field_name="id", is_public=False): - if value is None: - return True # Assume optional if None, or let DB handle not-null - - key = (model.__name__, value) - if key in self.cache: - return self.cache[key] - - query = self.session.query(getattr(model, field_name)).filter(getattr(model, field_name) == value) - if not is_public: - query = query.filter(model.tenant_id == self.tenant_id, model.company_id == self.company_id) - - exists = query.first() is not None - self.cache[key] = exists - return exists - - -TRANSPORT_TYPE_VALUES = { - "none", - "transport", - "box", - "licence plates", - "truck", - "vessel", - "rail_barge", - "container", - "airplane", - "gondola", - "flatbed", -} - - -def normalize_public_code(value: Optional[str]) -> Optional[str]: - if value is None: - return None - text = str(value).strip().upper() - return text or None - - -def validate_public_code( - validator: ForeignKeyValidator, - model, - value: Optional[str], - line_num: int, - col_name: str, - field_name: str = "code", - required: bool = False, -) -> Optional[Dict[str, Any]]: - code = normalize_public_code(value) - if not code: - if required: - return {"line": line_num, "col": col_name, "msg": "Requerido"} - return None - if not validator.check_exists(model, code, field_name=field_name, is_public=True): - return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} - return None - - -def validate_tenant_fk_id( - validator: ForeignKeyValidator, - model, - value: Optional[int], - line_num: int, - col_name: str, - required: bool = False, -) -> Optional[Dict[str, Any]]: - if value is None: - if required: - return {"line": line_num, "col": col_name, "msg": "Requerido"} - return None - if not validator.check_exists(model, value): - return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} - return None - -@celery_app.task(bind=True) -def scan_file(self, job_id: str, model_target: str, config: str = None): - """ - Pass 1: Read CSV, Validate types, Write Errors to JSONL. - File content is loaded from Redis (written by API on upload) so worker does not need shared filesystem. - """ - logger.info(f"Starting scan for job {job_id} target {model_target}") - - # 1. Get file from Redis and write to worker local disk - file_path = _ensure_worker_has_file_from_redis(job_id) - if not file_path: - return {"status": "failed", "error": "File not found (missing or expired in queue). Please upload again."} - _ensure_worker_has_meta_from_redis(job_id, file_path) - - error_path = common_storage.error_path_for_job(JOB_TYPE, job_id) - - total_rows = 0 - error_count = 0 - processed_rows = 0 - - # 3. Count Total (Quick Pass) or just estimate - try: - with open(file_path, 'r', encoding='utf-8-sig') as f: - total_rows = sum(1 for _ in f) - 1 # Minus header - except Exception as e: - return {"status": "failed", "error": f"Cannot read file: {e}"} - - footer_config = parse_footer_config(config) - date_format = footer_config.get("dateFormat") - - # Validate and set default date_format if not provided - if not date_format: - date_format = "yyyy-mm-dd" # Default to ISO format - logger.info(f"No date_format specified in config, using default: {date_format}") - - try: - tenant_id, company_id = common_meta.require_tenant_context(file_path) - except ValueError as e: - return {"status": "failed", "error": str(e)} - - meta = common_meta.load_meta(file_path) - template_id = meta.get("template_id") or ( - "imp_temp_header" if model_target == "invoice_header" else "imp_temp_details" - ) - - inv_type_value = normalize_public_code(footer_config.get("invoice_type") or meta.get("invoice_type") or "TEM") - if not inv_type_value: - inv_type_value = "TEM" - - try: - from api.v1.modules.a76.invoices.models import InvoiceHeader - 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.currency_types.models import CurrencyType - from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode - from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( - CodePedimentoRegimen, - ) - from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento - from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType - from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection - from api.v1.modules.public.reference_data.incoterms.models import Incoterm - from api.v1.modules.a76.parts.models import Part - - models = { - "InvoiceHeader": InvoiceHeader, - "InvoiceType": InvoiceType, - "ClientProvider": ClientProvider, - "CustomsBroker": CustomsBroker, - "RegimenPedimento": RegimenPedimento, - "CodePedimentoRegimen": CodePedimentoRegimen, - "PedimentoCode": PedimentoCode, - "CurrencyType": CurrencyType, - "CustomsSection": CustomsSection, - "Incoterm": Incoterm, - "Part": Part, - } - - with CoreSessionLocal() as session, \ - open(file_path, 'r', encoding='utf-8-sig') as f_in, \ - open(error_path, 'w', encoding='utf-8') as f_err: - validator = ForeignKeyValidator(session, tenant_id, company_id) - invoice_id_cache: Dict[str, Optional[int]] = {} - - # Detect Delimiter - sample = f_in.read(2048) - f_in.seek(0) - try: - dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") - except: - dialect = 'excel' - - reader = csv.DictReader(f_in, dialect=dialect) - - for i, row in enumerate(reader, start=1): - # Check for Progress Update - if i % 1000 == 0: - self.update_state(state='PROGRESS', meta={ - 'current': i, - 'total': total_rows, - 'errors': error_count - }) - - # Solo columnas de la plantilla (respetar plantilla tal cual) - row_norm = row_from_template(row, template_id, normalize_header) - errors = validate_row_strict( - row_norm, - model_target, - i, - date_format, - validator, - inv_type_value, - invoice_id_cache, - models, - ) - - if errors: - error_count += 1 - # Write simple JSON error - f_err.write(json.dumps(errors) + "\n") - - processed_rows += 1 - - except Exception as e: - logger.error(f"Scan failed: {e}") - return {"status": "failed", "error": str(e)} - - # 4. Store error line numbers in Redis so insert_valid_rows can skip them (any worker) - error_lines_list = [] - errors_detail: List[Dict[str, Any]] = [] - try: - if os.path.exists(error_path): - with open(error_path, "r", encoding="utf-8") as f: - for line in f: - try: - err = json.loads(line) - if "line" in err: - error_lines_list.append(err["line"]) - if len(errors_detail) < 500: - errors_detail.append( - { - "line": err["line"], - "col": err.get("col", ""), - "msg": err.get("msg", ""), - } - ) - except Exception: - pass - if error_lines_list: - common_storage.store_error_lines(JOB_TYPE, job_id, error_lines_list) - except Exception as e: - logger.warning(f"Failed to store error lines in Redis: {e}") - - return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) - -def validate_row_phase_1( - row: Dict[str, Any], - target: str, - line_num: int, - date_format: Optional[str], -) -> Optional[Dict[str, Any]]: - """ - Validation: Unique IDs, Dates, and Numeric constraint checks. - Target: 'invoice_header' or 'invoice_details' - """ - def check_decimal(col_name): - val = row.get(col_name) - if val and str(val).strip(): - if parse_decimal(val) is None: - return {"line": line_num, "col": col_name, "msg": "Debe ser un número decimal válido"} - return None - - def check_int(col_name): - val = row.get(col_name) - if val and str(val).strip(): - if parse_int(val) is None: - return {"line": line_num, "col": col_name, "msg": "Debe ser un número entero válido"} - return None - - def check_date(col_name): - date_str = row.get(col_name) - if date_str and str(date_str).strip(): - if not is_valid_date(date_str, date_format): - expected = display_date_format(date_format) - return { - "line": line_num, - "col": col_name, - "msg": f"Formato de fecha inválido ({expected})", - } - return None - - def check_weight(col_name): - val = row.get(col_name) - if val and str(val).strip(): - if parse_weight_unit(val) is None: - return {"line": line_num, "col": col_name, "msg": "Unidad de peso inválida (ej. KGS, LBS)"} - return None - - def check_currency(col_name): - val = row.get(col_name) - if val and str(val).strip(): - parsed_currency = parse_currency(val, None) - val_norm = normalize_header(val) - # parse_currency returns MANUAL if unknown, so if it wasn't explicitly MANUAL, it's invalid - if parsed_currency.value == "manual" and "MANUAL" not in val_norm: - return {"line": line_num, "col": col_name, "msg": "Moneda inválida (ej. MN, ME, USD, PESOS)"} - return None - - def check_transport_type(col_name): - val = row.get(col_name) - if val and str(val).strip(): - if str(val).strip().lower() not in TRANSPORT_TYPE_VALUES: - return {"line": line_num, "col": col_name, "msg": "Tipo de transporte inválido (ej. box, truck, container)"} - return None - - # A. Invoice Header - if target == 'invoice_header': - # 1. Unique ID - if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'): - return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} - - # 2. Date Format - date_str = row.get('FECHA FACTURA') or row.get('FECHA') - if not date_str or not str(date_str).strip(): - return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"} - - err = check_date('FECHA FACTURA') or check_date('FECHA') - if err: return err - - err = check_date('FECHA EMISION') - if err: return err - - # 3. Numeric Fields - for col in ['TIPO DE CAMBIO', 'FLETES', 'VALOR SEGUROS', 'SEGUROS', 'EMBALAJES', 'OTROS INCREMENTABLES']: - err = check_decimal(col) - if err: return err - - # 4. Integer FKs - for col in ['CLAVE PROVEEDOR', 'CLAVE VENDIDO A', 'CLAVE ENVIADO A', 'AGENTE ADUANAL', 'REMESA']: - err = check_int(col) - if err: return err - - # 5. Enums - for col in ['TIPO PESO']: - err = check_weight(col) - if err: return err - - for col in ['TIPO MONEDA']: - err = check_currency(col) - if err: return err - - for col in ['TIPO TRANSPORTE']: - err = check_transport_type(col) - if err: return err - - # B. Invoice Details (Parts) - elif target == 'invoice_details': - # 1. Line Number - if not row.get('LINEA') and not row.get('RENGLON') and not row.get('PARTIDA'): - return {"line": line_num, "col": "LINEA", "msg": "Requerido"} - - # 2. Parent Link (Invoice Number) - if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')): - return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} - - # 3. Numeric Fields - for col in ['PRECIO UNITARIO', 'PRECIOUNITARIO', 'VALOR COMERCIAL', 'VALORCOMERCIAL', 'CANTIDAD']: - err = check_decimal(col) - if err: return err - - for col in ['CANTIDAD BULTOS', 'CANTIDADBULTOS', 'LINEA', 'RENGLON', 'PARTIDA']: - err = check_int(col) - if err: return err - - return None - - -def validate_row_strict( - row: Dict[str, Any], - target: str, - line_num: int, - date_format: Optional[str], - validator: ForeignKeyValidator, - inv_type_value: str, - invoice_id_cache: Dict[str, Optional[int]], - models: Dict[str, Any], -) -> Optional[Dict[str, Any]]: - err = validate_row_phase_1(row, target, line_num, date_format) - if err: - return err - - InvoiceHeader = models["InvoiceHeader"] - InvoiceType = models["InvoiceType"] - ClientProvider = models["ClientProvider"] - CustomsBroker = models["CustomsBroker"] - RegimenPedimento = models["RegimenPedimento"] - CurrencyType = models["CurrencyType"] - CustomsSection = models["CustomsSection"] - Incoterm = models["Incoterm"] - Part = models["Part"] - - if target == "invoice_header": - if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): - return {"line": line_num, "col": "TIPO FACTURA", "msg": "No existe en el catalogo"} - - provider_id = parse_int(row.get("CLAVE PROVEEDOR")) - err = validate_tenant_fk_id(validator, ClientProvider, provider_id, line_num, "CLAVE PROVEEDOR", required=True) - if err: - return err - - sold_to_id = parse_int(row.get("CLAVE VENDIDO A")) - err = validate_tenant_fk_id(validator, ClientProvider, sold_to_id, line_num, "CLAVE VENDIDO A", required=True) - if err: - return err - - shipped_to_id = parse_int(row.get("CLAVE ENVIADO A")) - err = validate_tenant_fk_id(validator, ClientProvider, shipped_to_id, line_num, "CLAVE ENVIADO A", required=True) - if err: - return err - - broker_id = parse_int(row.get("AGENTE ADUANAL")) - err = validate_tenant_fk_id(validator, CustomsBroker, broker_id, line_num, "AGENTE ADUANAL") - if err: - return err - - err = validate_public_code( - validator, - RegimenPedimento, - row.get("REGIMEN") or row.get("CLAVEDOCUMENTO"), - line_num, - "CLAVEDOCUMENTO", - ) - if err: - return err - - err = validate_public_code( - validator, - CustomsSection, - row.get("ADUANA DE CRUCE"), - line_num, - "ADUANA DE CRUCE", - field_name="customs_code", - ) - if err: - return err - - err = validate_public_code( - validator, - CurrencyType, - row.get("CLAVE MONEDA"), - line_num, - "CLAVE MONEDA", - ) - if err: - return err - - err = validate_public_code( - validator, - Incoterm, - row.get("CLAVE INCOTERM"), - line_num, - "CLAVE INCOTERM", - ) - if err: - return err - - elif target == "invoice_details": - invoice_number = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() - if not invoice_number: - return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} - - cache_key = f"{invoice_number}|{inv_type_value}" - if cache_key in invoice_id_cache: - invoice_id = invoice_id_cache[cache_key] - else: - invoice_id = ( - validator.session.query(InvoiceHeader.id) - .filter( - InvoiceHeader.tenant_id == validator.tenant_id, - InvoiceHeader.company_id == validator.company_id, - InvoiceHeader.invoice_number == invoice_number, - InvoiceHeader.invoice_type == inv_type_value, - ) - .scalar() - ) - invoice_id_cache[cache_key] = invoice_id - if not invoice_id: - return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Factura no existe"} - - part_num = (row.get("NUMPARTE") or row.get("NUMERO PARTE") or "").strip() - if not part_num: - return {"line": line_num, "col": "NUMPARTE", "msg": "Requerido"} - if not validator.check_exists(Part, part_num, field_name="part_number"): - return {"line": line_num, "col": "NUMPARTE", "msg": "No existe en el catalogo"} - - return None - -def parse_footer_config(config: Optional[str]) -> Dict[str, Any]: - if not config: - return {} - try: - if isinstance(config, str): - return json.loads(config) - if isinstance(config, dict): - return config - except Exception: - return {} - return {} - - -def display_date_format(date_format: Optional[str]) -> str: - if not date_format: - return "YYYY-MM-DD" - return date_format.upper() - - -def parse_date(date_text: Optional[str], date_format: Optional[str]) -> Optional[datetime.date]: - if not date_text: - return None - candidates = [] - fmt_map = { - "dd/mm/yyyy": "%d/%m/%Y", - "mm/dd/yyyy": "%m/%d/%Y", - "yyyy-mm-dd": "%Y-%m-%d", - } - if date_format and date_format in fmt_map: - candidates.append(fmt_map[date_format]) - candidates.extend(["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"]) - for fmt in candidates: - try: - return datetime.strptime(str(date_text).strip(), fmt).date() - except ValueError: - continue - return None - - -def is_valid_date(date_text: Optional[str], date_format: Optional[str]) -> bool: - return parse_date(date_text, date_format) is not None - - -def normalize_header(name: Optional[str]) -> str: - if not name: - return "" - name = unicodedata.normalize("NFKD", str(name)).upper() - name = "".join(ch for ch in name if not unicodedata.combining(ch)) - name = re.sub(r"[^A-Z0-9]+", " ", name) - return re.sub(r"\s+", " ", name).strip() - - -def normalize_row(row: Dict[str, Any]) -> Dict[str, Any]: - return {normalize_header(k): v for k, v in row.items()} - - -def parse_int(value: Any) -> Optional[int]: - if value is None: - return None - text = str(value).strip() - if not text: - return None - try: - return int(text) - except ValueError: - return None - - -def parse_decimal(value: Any) -> Optional[Decimal]: - if value is None: - return None - text = str(value).strip() - if not text: - return None - text = text.replace(",", "") - try: - return Decimal(text) - except Exception: - return None - - -def decimal_or_zero(value: Any) -> Decimal: - """Return parsed decimal or Decimal('0') for CSV nulls/empty (vanilla default).""" - return parse_decimal(value) or Decimal("0") - - -def int_or_zero(value: Any) -> int: - """Return parsed int or 0 for CSV nulls/empty (vanilla default).""" - return parse_int(value) if parse_int(value) is not None else 0 - - -def parse_currency(value: Optional[str], currency_type: Optional[str]): - from api.v1.modules.a76.invoices.models import Currency - if value: - normalized = normalize_header(value) - if normalized in {"MN", "M N", "NACIONAL", "LOCAL", "PESOS", "PESO"}: - return Currency.LOCAL - if normalized in {"ME", "M E", "EXTRANJERA", "EXTRANJERO", "FOREIGN", "USD", "DOLAR", "DOLARES"}: - return Currency.FOREIGN - if "MANUAL" in normalized: - return Currency.MANUAL - if currency_type and str(currency_type).strip().upper() == "MXN": - return Currency.LOCAL - if currency_type: - return Currency.FOREIGN - return Currency.MANUAL - - -def parse_weight_unit(value: Optional[str]): - from api.v1.modules.a76.invoices.models import WeightUnit - if not value: - return None - normalized = normalize_header(value) - if normalized in {"KG", "KGS", "KILOS", "KILOGRAMOS"}: - return WeightUnit.KGS - if normalized in {"LB", "LBS", "LIBRAS"}: - return WeightUnit.LBS - return None - - -def resolve_tenant_fk_id( - session: CoreSessionLocal, - model, - value: Optional[int], - tenant_id: int, - company_id: int, - cache: Dict[int, Optional[int]], -) -> Optional[int]: - if value is None: - return None - if value in cache: - return cache[value] - exists = ( - session.query(model.id) - .filter( - model.id == value, - model.tenant_id == tenant_id, - model.company_id == company_id, - ) - .scalar() - ) - cache[value] = value if exists is not None else None - return cache[value] - - -def resolve_public_code( - session: CoreSessionLocal, - model, - column, - value: Optional[str], - cache: Dict[str, Optional[str]], -) -> Optional[str]: - if not value: - return None - normalized = str(value).strip().upper() - if not normalized: - return None - if normalized in cache: - return cache[normalized] - exists = session.query(column).filter(column == normalized).scalar() - cache[normalized] = normalized if exists is not None else None - return cache[normalized] - -@celery_app.task(bind=True) -def insert_valid_rows(self, job_id: str, model_target: str): - """ - Pass 2: Re-read CSV, Skip Errors, Bulk Insert. - File and meta are loaded from Redis if present (same as scan_file), so worker does not need shared filesystem. - """ - logger.info(f"Starting Commit for {job_id} target {model_target}") - - # Ensure we have the file on this worker: prefer Redis (so any worker can run commit) - file_path = _ensure_worker_has_file_from_redis(job_id) - if not file_path: - alt_path = common_storage.file_path_for_job(JOB_TYPE, job_id) - if not os.path.exists(alt_path): - return {"status": "failed", "error": "File not found (missing or expired). Please upload and confirm again."} - file_path = alt_path - else: - _ensure_worker_has_meta_from_redis(job_id, file_path) - - try: - tenant_id, company_id = common_meta.require_tenant_context(file_path) - except ValueError as e: - return {"status": "failed", "error": str(e)} - - meta = common_meta.load_meta(file_path) - meta_path = common_meta.get_meta_path(file_path) - error_path = common_storage.error_path_for_job(JOB_TYPE, job_id) - error_lines = common_storage.get_error_lines(JOB_TYPE, job_id, error_path) - - try: - from api.v1.modules.a76.invoices.models import ( - InvoiceHeader, - InvoiceComplianceMx, - InvoiceFinancials, - InvoiceLogistics, - InvoiceSalesDetails, - OperationType, - TransportType, - WeightUnit, - ) - 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.currency_types.models import CurrencyType - from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode - from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen - from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento - from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType - from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection - from api.v1.modules.public.reference_data.incoterms.models import Incoterm - - from api.v1.modules.a76.items.models import LineItem - from api.v1.modules.a76.items.line_financials.models import LineFinancial - from api.v1.modules.a76.items.line_quantities.models import LineQuantity - from api.v1.modules.a76.items.line_customs.models import LineCustom - from api.v1.modules.a76.items.line_descriptions.models import LineDescription - from api.v1.modules.a76.parts.models import Part - - footer_config = parse_footer_config(meta.get("footer_config")) - - date_format = footer_config.get("dateFormat") - # Validate and set default date_format if not provided - if not date_format: - date_format = "yyyy-mm-dd" # Default to ISO format - logger.info(f"No date_format specified in config, using default: {date_format}") - else: - logger.info(f"Using date_format from config: {date_format}") - - # Default types from config or fallback - op_type_value = OperationType(meta.get('operation_type', 'imp').lower()) - inv_type_value = normalize_public_code(footer_config.get('invoice_type') or 'TEM') or 'TEM' - - logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}") - - headers_to_insert = [] - details_to_insert = [] - skipped_invalid = 0 - skipped_missing_invoice = 0 - skipped_missing_fk = 0 - skipped_fk_details = [] - inserted_count = 0 - response = None - - with CoreSessionLocal() as session: - invoice_id_cache = {} - cleared_invoices = set() # Track invoices where we've already cleared items in this job - provider_cache: Dict[int, Optional[int]] = {} - sold_to_cache: Dict[int, Optional[int]] = {} - shipped_to_cache: Dict[int, Optional[int]] = {} - broker_cache: Dict[int, Optional[int]] = {} - regimen_cache: Dict[str, Optional[str]] = {} - currency_type_cache: Dict[str, Optional[str]] = {} - customs_section_cache: Dict[str, Optional[str]] = {} - part_cache: Dict[str, Optional[int]] = {} - - validator = ForeignKeyValidator(session, tenant_id, company_id) - - with open(file_path, 'r', encoding='utf-8-sig') as f: - # Detect Delimiter - sample = f.read(2048) - f.seek(0) - try: - dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") - except: - dialect = 'excel' - - reader = csv.DictReader(f, dialect=dialect) - - template_id = meta.get("template_id") or ( - "imp_temp_header" if model_target == "invoice_header" else "imp_temp_details" - ) - - for i, row in enumerate(reader, start=1): - if i in error_lines: - continue - - row_norm = row_from_template(row, template_id, normalize_header) - - # Mapping Logic (solo campos que acepta el modelo de facturas) - if model_target == 'invoice_header': - invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() - invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format) - - if not invoice_number or not invoice_date: - skipped_invalid += 1 - logger.debug(f"Row {i}: Skipped - missing invoice_number or invalid invoice_date. " - f"Invoice: {invoice_number}, Date: {row_norm.get('FECHA FACTURA') or row_norm.get('FECHA')}") - continue - - # --- NEW: Foreign Key Validations --- - # 1. Invoice Type (Public) - if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): - skipped_missing_fk += 1 - reason = f"Tipo de factura '{inv_type_value}' no existe" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - provider_id = parse_int(row_norm.get('CLAVE PROVEEDOR')) - err = validate_tenant_fk_id( - validator, - ClientProvider, - provider_id, - i, - "CLAVE PROVEEDOR", - required=True, - ) - if err: - skipped_invalid += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - sold_to_id = parse_int(row_norm.get('CLAVE VENDIDO A')) - err = validate_tenant_fk_id( - validator, - ClientProvider, - sold_to_id, - i, - "CLAVE VENDIDO A", - required=True, - ) - if err: - skipped_invalid += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - shipped_to_id = parse_int(row_norm.get('CLAVE ENVIADO A')) - err = validate_tenant_fk_id( - validator, - ClientProvider, - shipped_to_id, - i, - "CLAVE ENVIADO A", - required=True, - ) - if err: - skipped_invalid += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - broker_id = parse_int(row_norm.get('AGENTE ADUANAL')) - err = validate_tenant_fk_id( - validator, - CustomsBroker, - broker_id, - i, - "AGENTE ADUANAL", - ) - if err: - skipped_missing_fk += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - err = validate_public_code( - validator, - RegimenPedimento, - row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO'), - i, - "CLAVEDOCUMENTO", - ) - if err: - skipped_missing_fk += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - err = validate_public_code( - validator, - CustomsSection, - row_norm.get('ADUANA DE CRUCE'), - i, - "ADUANA DE CRUCE", - field_name="customs_code", - ) - if err: - skipped_missing_fk += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - err = validate_public_code( - validator, - CurrencyType, - row_norm.get('CLAVE MONEDA'), - i, - "CLAVE MONEDA", - ) - if err: - skipped_missing_fk += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - err = validate_public_code( - validator, - Incoterm, - row_norm.get('CLAVE INCOTERM'), - i, - "CLAVE INCOTERM", - ) - if err: - skipped_missing_fk += 1 - reason = f"{err['col']}: {err['msg']}" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - transport_type_val = row_norm.get('TIPO TRANSPORTE') - if transport_type_val and str(transport_type_val).strip().lower() not in TRANSPORT_TYPE_VALUES: - skipped_invalid += 1 - reason = "TIPO TRANSPORTE: Tipo de transporte invalido" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - currency_val = row_norm.get('TIPO MONEDA') - if currency_val and str(currency_val).strip(): - parsed_currency = parse_currency(currency_val, None) - val_norm = normalize_header(currency_val) - if parsed_currency.value == "manual" and "MANUAL" not in val_norm: - skipped_invalid += 1 - reason = "TIPO MONEDA: Moneda invalida" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - # 2. Client/Provider and broker checks are handled above - - # --- 4. Check for Existing Invoice (Upsert Logic) --- - existing_header = None - if invoice_number: - existing_header = ( - session.query(InvoiceHeader) - .filter( - InvoiceHeader.tenant_id == tenant_id, - InvoiceHeader.company_id == company_id, - InvoiceHeader.invoice_number == invoice_number, - InvoiceHeader.invoice_type == inv_type_value - ) - .first() - ) - - if existing_header: - # UPDATE existing header - header = existing_header - header.invoice_date = invoice_date - header.operation_type = op_type_value - header.is_updated = True # Mark as updated - header.updated_date = datetime.utcnow() - header.document_type = resolve_public_code( - session, - RegimenPedimento, - RegimenPedimento.code, - (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), - regimen_cache, - ) - header.project_number = (row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None) - header.purchase_order = (row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None) - header.alternate_invoice = (row_norm.get('FACTURA ALTERNA') or None) - header.invoice_ref = (row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None) - header.emission_date = parse_date(row_norm.get('FECHA EMISION'), date_format) - header.observation_es = (row_norm.get('OBSERVACIONES E') or None) - header.observation_en = (row_norm.get('OBSERVACIONES I') or None) - - logger.info(f"Row {i}: Updating existing invoice {invoice_number}") - - # Clean up related data that will be re-inserted/updated - # Note: compliance, financials, logistics are 1-to-1 relationships and will be updated by assignment below - # but we might want to be explicit if ORM doesn't handle replace well. - # SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly. - - else: - # CREATE new header - header = InvoiceHeader( - invoice_number=invoice_number, - invoice_date=invoice_date, - operation_type=op_type_value, - is_updated=False, - system="CSV", - capture_date=datetime.utcnow(), - invoice_type=inv_type_value, - document_type=resolve_public_code( - session, - RegimenPedimento, - RegimenPedimento.code, - (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), - regimen_cache, - ), - project_number=(row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None), - purchase_order=(row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None), - alternate_invoice=(row_norm.get('FACTURA ALTERNA') or None), - invoice_ref=(row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None), - emission_date=parse_date(row_norm.get('FECHA EMISION'), date_format), - observation_es=(row_norm.get('OBSERVACIONES E') or None), - observation_en=(row_norm.get('OBSERVACIONES I') or None), - tenant_id=tenant_id, - company_id=company_id, - ) - - compliance = InvoiceComplianceMx( - remesa=parse_int(row_norm.get('REMESA')), - aduana=resolve_public_code( - session, - CustomsSection, - CustomsSection.customs_code, - row_norm.get('ADUANA DE CRUCE'), - customs_section_cache, - ), - provider_id=resolve_tenant_fk_id( - session, - ClientProvider, - parse_int(row_norm.get('CLAVE PROVEEDOR')), - tenant_id, - company_id, - provider_cache, - ), - sold_to_id=resolve_tenant_fk_id( - session, - ClientProvider, - parse_int(row_norm.get('CLAVE VENDIDO A')), - tenant_id, - company_id, - sold_to_cache, - ), - shipped_to_id=resolve_tenant_fk_id( - session, - ClientProvider, - parse_int(row_norm.get('CLAVE ENVIADO A')), - tenant_id, - company_id, - shipped_to_cache, - ), - customs_broker_id=resolve_tenant_fk_id( - session, - CustomsBroker, - parse_int(row_norm.get('AGENTE ADUANAL')), - tenant_id, - company_id, - broker_cache, - ), - edocument=(row_norm.get('E DOCUMENT') or None), - vucem_operation_num=(row_norm.get('NUM OPERACION') or None), - tenant_id=tenant_id, - company_id=company_id, - ) - - financials_currency_type = resolve_public_code( - session, - CurrencyType, - CurrencyType.code, - row_norm.get('CLAVE MONEDA'), - currency_type_cache, - ) - financials = InvoiceFinancials( - currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type), - currency_type=financials_currency_type, - exchange_rate=decimal_or_zero(row_norm.get('TIPO DE CAMBIO')), - freight=decimal_or_zero(row_norm.get('FLETES')), - insurance_value=decimal_or_zero(row_norm.get('VALOR SEGUROS')), - insurance=decimal_or_zero(row_norm.get('SEGUROS')), - packaging=decimal_or_zero(row_norm.get('EMBALAJES')), - other_increments=decimal_or_zero(row_norm.get('OTROS INCREMENTABLES')), - tenant_id=tenant_id, - company_id=company_id, - ) - - weight_type = parse_weight_unit(row_norm.get('TIPO PESO')) - logistics = None - if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'): - raw_transport = (row_norm.get('TIPO TRANSPORTE') or "none") - transport_str = str(raw_transport).strip().lower() or "none" - try: - transport_type = TransportType(transport_str) - except ValueError: - transport_type = TransportType.NONE - logistics = InvoiceLogistics( - carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None), - driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), - transport_type=transport_type, - transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), - weight_type=weight_type or WeightUnit.KGS, - seal_number=(row_norm.get('PRECINTO') or None), - incoterm=(row_norm.get('CLAVE INCOTERM') or None), - entry_exit_date=parse_date(row_norm.get('FECHA EMISION'), date_format), - tenant_id=tenant_id, - company_id=company_id, - ) - - header.compliance_mx = compliance - header.financials = financials - if logistics: - header.logistics = logistics - - headers_to_insert.append(header) - - elif model_target == 'invoice_details': - invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip() - if not invoice_number: - skipped_invalid += 1 - continue - - cache_key = f"{invoice_number}|{inv_type_value}" - if cache_key in invoice_id_cache: - invoice_id = invoice_id_cache[cache_key] - else: - invoice_id = ( - session.query(InvoiceHeader.id) - .filter( - InvoiceHeader.tenant_id == tenant_id, - InvoiceHeader.company_id == company_id, - InvoiceHeader.invoice_number == invoice_number, - InvoiceHeader.invoice_type == inv_type_value, - ) - .scalar() - ) - invoice_id_cache[cache_key] = invoice_id - - if not invoice_id: - logger.warning( - "Invoice not found for details row %s (invoice_number=%s)", - i, - invoice_number, - ) - skipped_missing_invoice += 1 - continue - - part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip() - if not part_num: - skipped_invalid += 1 - reason = "NUMPARTE: Requerido" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - if not validator.check_exists(Part, part_num, field_name="part_number"): - skipped_missing_fk += 1 - reason = f"NUMPARTE '{part_num}' no existe" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue - - # --- Prevent Duplicates: Clear existing items for this invoice (Once per job) --- - if invoice_id not in cleared_invoices: - logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates") - - # 1. Delete Items (Cascades to LineItem, LineFinancial, etc. if DB configured, check models) - # Checking Item model, we usually need to be careful. - # Assuming Cascade delete is set up on FKs or we rely on ORM cascade if using relationships. - # Here we use bulk delete. - session.query(Item).filter(Item.invoice_id == invoice_id).delete(synchronize_session=False) - - # 2. Delete InvoiceSalesDetails - session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) - - cleared_invoices.add(invoice_id) - - # --- NEW LOGIC: Expanded Anexo 76 Structure --- - - # A. Find/Cache Part - part_id = None - part_id = part_cache.get(part_num) - if part_id is None: - p = session.query(Part.id).filter( - Part.part_number == part_num, - Part.tenant_id == tenant_id, - Part.company_id == company_id - ).first() - if p: - part_id = p.id - part_cache[part_num] = part_id - - line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA')) - line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) - - # 1. Parent Item - item = Item( - invoice_id=invoice_id, - tenant_id=tenant_id, - company_id=company_id, - item_type="N", # Default to Normal - system_origin="CSV" - ) - session.add(item) - session.flush() # Need item.id - - # 2. Main Line - line = LineItem( - item_id=item.id, - line_number=line_num, - part_number=part_id, - tenant_id=tenant_id, - company_id=company_id - ) - session.add(line) - session.flush() # Need line.id - - # 3. Financial Data (vanilla: nulls from CSV -> 0) - price = parse_decimal(row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO')) - val_com = parse_decimal(row_norm.get('VALOR COMERCIAL') or row_norm.get('VALORCOMERCIAL')) - qty = parse_decimal(row_norm.get('CANTIDAD')) - commercial_total = val_com or (price * qty if price and qty else None) - - session.add(LineFinancial( - item_line_id=line.id, - unit_cost_capture=decimal_or_zero(price), - total_commercial_value=decimal_or_zero(commercial_total), - )) - - # 4. Quantities (vanilla: nulls -> 0 so we always have a quantity row) - session.add(LineQuantity( - item_line_id=line.id, - quantity=decimal_or_zero(qty), - )) - - # 5. Customs/Fraction - origin = row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') - fraction = row_norm.get('FRACCION') - if origin or fraction: - session.add(LineCustom( - item_line_id=line.id, - fraction=fraction, - origin_country=origin, - )) - - # 6. Description - desc = row_norm.get('DESCRIPCION') - if desc: - session.add(LineDescription( - item_line_id=line.id, - description_spanish=desc, - )) - - # 7. Legacy Sales Details (For specific audit/UI fields; vanilla: nulls -> 0) - detail = InvoiceSalesDetails( - invoice_id=invoice_id, - line_number=line_num, - sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), - line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), - tenant_id=tenant_id, - company_id=company_id, - ) - session.add(detail) - details_to_insert.append(item) # Use as counter/ref - - # 3. Bulk Insert (ORM Transaction) - try: - if model_target == 'invoice_header': - if headers_to_insert: - logger.info(f"Attempting to commit {len(headers_to_insert)} headers") - session.add_all(headers_to_insert) - session.commit() - inserted_count = len(headers_to_insert) - logger.info(f"Headers commit successful. Inserted: {inserted_count}") - else: - logger.warning(f"No headers to insert for job {job_id}") - else: - if details_to_insert: - logger.info(f"Attempting to commit {len(details_to_insert)} items and related data") - session.commit() # Everything was already added with session.add() - inserted_count = len(details_to_insert) - logger.info(f"Details commit successful. Inserted: {inserted_count}") - else: - logger.warning(f"No details to insert for job {job_id}") - - except Exception as db_err: - session.rollback() - logger.error(f"DB Error during {model_target} commit: {db_err}") - import traceback - logger.error(traceback.format_exc()) - return {"status": "failed", "error": str(db_err)} - - # 4. Determine final status and prepare response (inside session block to access variables) - total_skipped = skipped_invalid + skipped_missing_fk + skipped_missing_invoice - - # Log summary - logger.info(f"Job {job_id} completed. Inserted: {inserted_count}, Skipped: {total_skipped} " - f"(invalid: {skipped_invalid}, missing_fk: {skipped_missing_fk}, missing_invoice: {skipped_missing_invoice})") - - # Prepare response based on results - if inserted_count == 0: - if total_skipped > 0: - logger.warning(f"No valid records to insert for job {job_id}. All {total_skipped} records were rejected.") - response = { - "status": "warning", - "inserted": 0, - "skipped_invalid": skipped_invalid, - "skipped_missing_invoice": skipped_missing_invoice, - "skipped_missing_fk": skipped_missing_fk, - "skipped_details": skipped_fk_details, - "message": f"No se insertaron registros. {total_skipped} fueron rechazados." - } - else: - logger.error(f"No valid records found in CSV for job {job_id}") - response = { - "status": "failed", - "error": "No hay registros válidos en el archivo CSV", - "inserted": 0, - "skipped_invalid": skipped_invalid, - "skipped_missing_invoice": skipped_missing_invoice, - "skipped_missing_fk": skipped_missing_fk, - "skipped_details": skipped_fk_details - } - else: - # Success case - at least some records were inserted - response = { - "status": "finished", - "inserted": inserted_count, - "skipped_invalid": skipped_invalid, - "skipped_missing_invoice": skipped_missing_invoice, - "skipped_missing_fk": skipped_missing_fk, - "skipped_details": skipped_fk_details - } - - except Exception as e: - logger.error(f"Task failed: {e}") - import traceback - logger.error(traceback.format_exc()) - return {"status": "failed", "error": str(e)} - - # 5. Cleanup: remove temp files and Redis keys so data is not kept indefinitely - try: - common_storage.cleanup_import_job( - JOB_TYPE, job_id, - file_path=file_path, - error_path=error_path, - meta_path=meta_path, - ) - except Exception as cleanup_err: - logger.warning("Failed to cleanup temp files or Redis: %s", cleanup_err) - - # Ensure response is defined (fallback in case of unexpected errors) - if response is None: - logger.error(f"Unexpected error: response not set for job {job_id}") - response = { - "status": "failed", - "error": "Error inesperado durante el procesamiento", - "inserted": 0, - "skipped_invalid": skipped_invalid, - "skipped_missing_invoice": skipped_missing_invoice, - "skipped_missing_fk": skipped_missing_fk, - "skipped_details": skipped_fk_details - } - - return response +import os +from datetime import datetime +from decimal import Decimal +import csv +import json +import logging +import re +import unicodedata +from typing import Dict, Any, Optional, List, Set, Tuple + +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from core.paths import layout_path +from sqlalchemy import func + +from ..common import storage as common_storage +from ..common import meta as common_meta +from ..common import responses as common_responses +from .template_config import row_from_template +# Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process + +logger = logging.getLogger(__name__) + +# Job type vacío para facturas (prefijo Redis "import_" sin tipo, ver common/storage.py) +JOB_TYPE = "" + +# Redis keys and TTL for import file/meta (exportados para routes; coinciden con common_storage cuando job_type="") +IMPORT_FILE_KEY_PREFIX = "import_file:" +IMPORT_META_KEY_PREFIX = "import_meta:" +IMPORT_ERROR_LINES_KEY_PREFIX = "import_error_lines:" +IMPORT_REDIS_TTL = common_storage.IMPORT_REDIS_TTL + + +def _ensure_worker_has_file_from_redis(job_id: str) -> Optional[str]: + """Usa common storage con job_type vacío (prefijo import_).""" + return common_storage.ensure_file_from_redis(JOB_TYPE, job_id, "Invoices import") + + +def _ensure_worker_has_meta_from_redis(job_id: str, file_path: str) -> bool: + return common_storage.ensure_meta_from_redis(JOB_TYPE, job_id, file_path, "Invoices import") + + +def _delete_import_from_redis(job_id: str) -> None: + common_storage.delete_import_from_redis(JOB_TYPE, job_id) + +class ForeignKeyValidator: + def __init__(self, session, tenant_id, company_id): + self.session = session + self.tenant_id = tenant_id + self.company_id = company_id + self.cache = {} # {(model_name, value): bool} + + def check_exists(self, model, value, field_name="id", is_public=False): + if value is None: + return True # Assume optional if None, or let DB handle not-null + + key = (model.__name__, value) + if key in self.cache: + return self.cache[key] + + col = getattr(model, field_name) + if field_name == "short_name" and hasattr(model, "short_name"): + query = self.session.query(col).filter(func.upper(col) == (value.upper() if isinstance(value, str) else value)) + else: + query = self.session.query(col).filter(col == value) + if not is_public: + query = query.filter(model.tenant_id == self.tenant_id, model.company_id == self.company_id) + + exists = query.first() is not None + self.cache[key] = exists + return exists + + +TRANSPORT_TYPE_VALUES = { + "none", + "transport", + "box", + "licence plates", + "truck", + "vessel", + "rail_barge", + "container", + "airplane", + "gondola", + "flatbed", +} + + +def normalize_public_code(value: Optional[str]) -> Optional[str]: + if value is None: + return None + text = str(value).strip().upper() + return text or None + + +def validate_public_code( + validator: ForeignKeyValidator, + model, + value: Optional[str], + line_num: int, + col_name: str, + field_name: str = "code", + required: bool = False, +) -> Optional[Dict[str, Any]]: + code = normalize_public_code(value) + if not code: + if required: + return {"line": line_num, "col": col_name, "msg": "Requerido"} + return None + if not validator.check_exists(model, code, field_name=field_name, is_public=True): + return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} + return None + + +def validate_tenant_fk_id( + validator: ForeignKeyValidator, + model, + value: Optional[int], + line_num: int, + col_name: str, + required: bool = False, +) -> Optional[Dict[str, Any]]: + if value is None: + if required: + return {"line": line_num, "col": col_name, "msg": "Requerido"} + return None + if not validator.check_exists(model, value): + return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} + return None + + +def _validate_client_provider_ref( + validator: ForeignKeyValidator, + model, + raw_value: Any, + line_num: int, + col_name: str, + required: bool, +) -> Optional[Dict[str, Any]]: + """Valida CLAVE PROVEEDOR / VENDIDO A / ENVIADO A: acepta ID (entero) o short_name (texto).""" + if raw_value is None or not str(raw_value).strip(): + if required: + return {"line": line_num, "col": col_name, "msg": "Requerido"} + return None + pid = parse_int(raw_value) + if pid is not None: + return validate_tenant_fk_id(validator, model, pid, line_num, col_name, required=False) + short_norm = str(raw_value).strip().upper() + if not validator.check_exists(model, short_norm, field_name="short_name"): + return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} + return None + + +def _validate_customs_broker_ref( + validator: ForeignKeyValidator, + model, + raw_value: Any, + line_num: int, + col_name: str, + required: bool = False, +) -> Optional[Dict[str, Any]]: + """Valida AGENTE ADUANAL: acepta ID (entero) o clave broker_key (texto).""" + if raw_value is None or not str(raw_value).strip(): + if required: + return {"line": line_num, "col": col_name, "msg": "Requerido"} + return None + pid = parse_int(raw_value) + if pid is not None: + return validate_tenant_fk_id(validator, model, pid, line_num, col_name, required=False) + clave = str(raw_value).strip() + if not validator.check_exists(model, clave, field_name="broker_key"): + return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} + return None + +@celery_app.task(bind=True) +def scan_file(self, job_id: str, model_target: str, config: str = None, job_type_override: Optional[str] = None): + """Pass 1: Read CSV, Validate types, Write Errors to JSONL. Delegates to _do_scan_file.""" + return _do_scan_file(job_id, model_target, config, job_type_override) + + +def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None, job_type_override: Optional[str] = None) -> Dict[str, Any]: + """Pass 1 body: load file/meta from storage, run validations, store error lines. Uses effective_job_type for storage.""" + effective_job_type = job_type_override if job_type_override is not None else JOB_TYPE + log_prefix = "Exportación import" if effective_job_type else "Invoices import" + + logger.info(f"Starting scan for job {job_id} target {model_target}") + + # 1. Get file from Redis and write to worker local disk + file_path = common_storage.ensure_file_from_redis(effective_job_type, job_id, log_prefix) + if not file_path: + return {"status": "failed", "error": "File not found (missing or expired in queue). Please upload again."} + common_storage.ensure_meta_from_redis(effective_job_type, job_id, file_path, log_prefix) + + error_path = common_storage.error_path_for_job(effective_job_type, job_id) + + total_rows = 0 + error_count = 0 + processed_rows = 0 + + # 3. Count Total (Quick Pass) or just estimate + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + total_rows = sum(1 for _ in f) - 1 # Minus header + except Exception as e: + return {"status": "failed", "error": f"Cannot read file: {e}"} + + footer_config = parse_footer_config(config) + date_format = footer_config.get("dateFormat") + + # Validate and set default date_format if not provided + if not date_format: + date_format = "yyyy-mm-dd" # Default to ISO format + logger.info(f"No date_format specified in config, using default: {date_format}") + + try: + tenant_id, company_id = common_meta.require_tenant_context(file_path) + except ValueError as e: + return {"status": "failed", "error": str(e)} + + meta = common_meta.load_meta(file_path) + template_id = meta.get("template_id") or ( + "imp_temp_header" if model_target == "invoice_header" else + "imp_temp_details" if model_target == "invoice_details" else "imp_temp_series" + ) + # Cuando el scan viene de Exportación (job_type_override "exp"), forzar exp_def_header o exp_def_partidas + if job_type_override == "exp" and model_target == "invoice_header": + template_id = "exp_def_header" + if job_type_override == "exp" and model_target == "invoice_details": + template_id = "exp_def_partidas" + if job_type_override == "exp" and model_target == "invoice_series": + template_id = "exp_def_series" + inv_type_value = normalize_public_code(footer_config.get("invoice_type") or meta.get("invoice_type") or "TEM") + if not inv_type_value: + inv_type_value = "TEM" + if model_target == "invoice_series" and inv_type_value in ("DEF", "MATDE", "EXDEF"): + template_id = "imp_def_series" + if meta.get("operation_type") == "exp" and model_target == "invoice_details": + template_id = "exp_def_partidas" + + logger.info( + "Scan job %s template_id=%s model_target=%s job_type_override=%s", + job_id, template_id, model_target, job_type_override, + ) + + # --- Series de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_SERIES_IMPO_DEF / VALIDA_PARCIAL) --- + DEF_SERIES_TEMPLATE_OR_TYPE = ( + model_target == "invoice_series" + and ( + template_id == "imp_def_series" + or (inv_type_value in ("DEF", "MATDE", "EXDEF")) + ) + ) + if DEF_SERIES_TEMPLATE_OR_TYPE: + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from .validators.series_impo_def import ( + validate_row_series_impo_def, + ) + + DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") + actualizar = meta.get("actualizar", False) + autonumerar = meta.get("autonumerar", True) + validar_series_exception = meta.get("validar_series", False) + _fc = parse_footer_config(meta.get("footer_config")) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + elif _fc.get("mode") == "update": + actualizar = True + elif _fc.get("mode") == "replace": + actualizar = False + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + else: + as_val = _fc.get("autonumber_series", "true") + autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") + if "validar_series" in _fc: + validar_series_exception = bool(_fc["validar_series"]) + + with CoreSessionLocal() as session: + q = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + rows_inv = q.all() + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in rows_inv: + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + partida_max_series: Dict[Tuple[str, str], int] = {} + q_qty = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + LineQuantity.quantity, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .outerjoin(LineQuantity, LineQuantity.item_line_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for num, ln, qty in q_qty.all(): + if num is not None and ln is not None: + key = (str(num).strip(), str(ln).strip()) + if qty is not None: + partida_max_series[key] = int(qty) if qty else 0 + else: + partida_max_series[key] = 0 + + existing_series_keys: Set[Tuple[str, str, str]] = set() + existing_series_data: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + if actualizar and not autonumerar: + q_ser = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + Serie.row, + Serie.serial_numbers, + Serie.model, + Serie.sub_model, + Serie.number_id, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(Serie, Serie.line_item_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for num, ln, rw, sn, md, sm, nid in q_ser.all(): + if num is not None: + k = (str(num).strip(), str(ln).strip(), str(rw).strip()) + existing_series_keys.add(k) + existing_series_data.setdefault(k, { + "serial_numbers": sn or "", + "model": md or "", + "sub_model": sm or "", + "number_id": nid or "", + }) + + csv_series_count_so_far: Dict[Tuple[str, str], int] = {} + + with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + errors_detail = [] + error_lines_list: List[int] = [] + for i, row in enumerate(reader, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + row_norm = row_from_template(row, "imp_def_series", normalize_header) + warnings_list: List[Dict[str, Any]] = [] + err = validate_row_series_impo_def( + row_norm, + i, + actualizar=actualizar, + autonumerar=autonumerar, + validar_series_exception=validar_series_exception, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + partida_max_series=partida_max_series, + csv_series_count_so_far=csv_series_count_so_far, + existing_series_keys=existing_series_keys, + existing_series_data=existing_series_data, + warnings=warnings_list, + ) + if err and not err.get("warning"): + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps(err) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + else: + inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip() + line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip() + if inv_num and line_fac: + key_csv = (inv_num, line_fac) + csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 + for w in warnings_list: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Series importación definitiva scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Series de Exportación Definitiva: flujo exp_def_series (Clarion VALIDA_TODA_SERIES_EXPO / VALIDA_PARCIAL) --- + if model_target == "invoice_series" and template_id == "exp_def_series": + logger.info("Series expo scan: running validation for job %s", job_id) + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from api.v1.modules.a76.general_catalogs.company.models import Company + from .validators.series_expo import validate_row_series_expo + + _fc = parse_footer_config(meta.get("footer_config")) + autonumerar = meta.get("autonumerar", True) + actualizar = meta.get("actualizar", False) + validar_series_exception = meta.get("validar_series", False) + if _fc: + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + elif _fc.get("autonumber_series", "true") is not None: + autonumerar = str(_fc.get("autonumber_series", "true")).lower() in ("true", "1", "si", "sí", "yes") + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "validar_series" in _fc: + validar_series_exception = bool(_fc["validar_series"]) + + RFC_EXCEPTION_EGM = {"EGM0303257J1"} + + with CoreSessionLocal() as session: + company = session.query(Company).filter(Company.id == company_id).first() + company_rfc = (company.rfc or "").strip().upper() if company else "" + + q_inv_expo = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in q_inv_expo.all(): + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + partida_max_series: Dict[Tuple[str, str], int] = {} + q_qty = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + LineQuantity.quantity, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .outerjoin(LineQuantity, LineQuantity.item_line_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + for num, ln, qty in q_qty.all(): + if num is not None and ln is not None: + key = (str(num).strip(), str(ln).strip()) + if qty is not None: + partida_max_series[key] = int(qty) if qty else 0 + else: + partida_max_series[key] = 0 + + existing_series_keys: Set[Tuple[str, str, str]] = set() + existing_series_data: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + if actualizar and not autonumerar: + q_ser = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + Serie.row, + Serie.serial_numbers, + Serie.model, + Serie.sub_model, + Serie.number_id, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(Serie, Serie.line_item_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + for num, ln, rw, sn, md, sm, nid in q_ser.all(): + if num is not None: + k = (str(num).strip(), str(ln).strip(), str(rw).strip()) + existing_series_keys.add(k) + existing_series_data.setdefault(k, { + "serial_numbers": sn or "", + "model": md or "", + "sub_model": sm or "", + "number_id": nid or "", + }) + + csv_series_count_so_far: Dict[Tuple[str, str], int] = {} + + invoice_numbers_from_csv: Set[str] = set() + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + for row in reader: + row_norm = row_from_template(row, "exp_def_series", normalize_header) + inv = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA EXPO") or "").strip() + if inv: + invoice_numbers_from_csv.add(inv) + if company_rfc in RFC_EXCEPTION_EGM: + rfc_exception_updated: Set[str] = invoice_numbers_from_csv + else: + rfc_exception_updated = set() + + with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err: + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(f_in.read(2048), delimiters=",;\t") + except Exception: + dialect = "excel" + f_in.seek(0) + reader = csv.DictReader(f_in, dialect=dialect) + errors_detail = [] + error_lines_list: List[int] = [] + for i, row in enumerate(reader, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + row_norm = row_from_template(row, "exp_def_series", normalize_header) + warnings_list: List[Dict[str, Any]] = [] + err = validate_row_series_expo( + row_norm, + i, + actualizar=actualizar, + autonumerar=autonumerar, + validar_series_exception=validar_series_exception, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + rfc_exception_updated=rfc_exception_updated, + partida_max_series=partida_max_series, + csv_series_count_so_far=csv_series_count_so_far, + existing_series_keys=existing_series_keys, + existing_series_data=existing_series_data, + warnings=warnings_list, + ) + if err and not err.get("warning"): + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps(err) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + else: + inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA EXPO") or "").strip() + line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip() + if inv_num and line_fac: + key_csv = (inv_num, line_fac) + csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 + for w in warnings_list: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Series exportación definitiva scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Series Compras Mexicanas: misma lógica que Impo Def, facturas MEX --- + if model_target == "invoice_series" and template_id == "cmex_series": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from .validators.series_impo_def import validate_row_series_impo_def + + actualizar = meta.get("actualizar", False) + autonumerar = meta.get("autonumerar", True) + validar_series_exception = meta.get("validar_series", False) + _fc = parse_footer_config(meta.get("footer_config")) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + elif _fc.get("mode") == "update": + actualizar = True + elif _fc.get("mode") == "replace": + actualizar = False + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + else: + as_val = _fc.get("autonumber_series", "true") + autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") + if "validar_series" in _fc: + validar_series_exception = bool(_fc["validar_series"]) + + with CoreSessionLocal() as session: + q = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + rows_inv = q.all() + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in rows_inv: + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + partida_max_series: Dict[Tuple[str, str], int] = {} + q_qty = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + LineQuantity.quantity, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .outerjoin(LineQuantity, LineQuantity.item_line_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + for num, ln, qty in q_qty.all(): + if num is not None and ln is not None: + key = (str(num).strip(), str(ln).strip()) + if qty is not None: + partida_max_series[key] = int(qty) if qty else 0 + else: + partida_max_series[key] = 0 + + existing_series_keys: Set[Tuple[str, str, str]] = set() + existing_series_data: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + if actualizar and not autonumerar: + q_ser = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + Serie.row, + Serie.serial_numbers, + Serie.model, + Serie.sub_model, + Serie.number_id, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(Serie, Serie.line_item_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + for num, ln, rw, sn, md, sm, nid in q_ser.all(): + if num is not None: + k = (str(num).strip(), str(ln).strip(), str(rw).strip()) + existing_series_keys.add(k) + existing_series_data.setdefault(k, { + "serial_numbers": sn or "", + "model": md or "", + "sub_model": sm or "", + "number_id": nid or "", + }) + + csv_series_count_so_far: Dict[Tuple[str, str], int] = {} + + with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + errors_detail = [] + error_lines_list: List[int] = [] + for i, row in enumerate(reader, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + row_norm = row_from_template(row, "cmex_series", normalize_header) + warnings_list: List[Dict[str, Any]] = [] + err = validate_row_series_impo_def( + row_norm, + i, + actualizar=actualizar, + autonumerar=autonumerar, + validar_series_exception=validar_series_exception, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + partida_max_series=partida_max_series, + csv_series_count_so_far=csv_series_count_so_far, + existing_series_keys=existing_series_keys, + existing_series_data=existing_series_data, + warnings=warnings_list, + catalog_label="Compras Mexicanas", + ) + if err and not err.get("warning"): + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps(err) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + else: + inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip() + line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip() + if inv_num and line_fac: + key_csv = (inv_num, line_fac) + csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 + for w in warnings_list: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Series Compras Mexicanas scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Series de Importación Temporal: flujo específico (Clarion VALIDA_TODA / VALIDA_PARCIAL) --- + if model_target == "invoice_series": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from .validators.series_impo_temp import ( + validate_row_series_impo_temp, + ) + + actualizar = meta.get("actualizar", False) + autonumerar = meta.get("autonumerar", True) + validar_series_exception = meta.get("validar_series", False) + _fc = parse_footer_config(meta.get("footer_config")) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + elif _fc.get("mode") == "update": + actualizar = True + elif _fc.get("mode") == "replace": + actualizar = False + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + else: + as_val = _fc.get("autonumber_series", "true") + autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") + if "validar_series" in _fc: + validar_series_exception = bool(_fc["validar_series"]) + + with CoreSessionLocal() as session: + # Invoice lookup: imp + TEM + q = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + rows_inv = q.all() + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in rows_inv: + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + # Existing series keys: (invoice_number, linea_factura, linea_serie) + existing_series_keys: Set[Tuple[str, str, str]] = set() + existing_series_data: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + if actualizar and not autonumerar: + q_ser = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + Serie.row, + Serie.serial_numbers, + Serie.model, + Serie.sub_model, + Serie.number_id, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(Serie, Serie.line_item_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + for num, ln, rw, sn, md, sm, nid in q_ser.all(): + if num is not None: + k = (str(num).strip(), str(ln).strip(), str(rw).strip()) + existing_series_keys.add(k) + existing_series_data.setdefault(k, { + "serial_numbers": sn or "", + "model": md or "", + "sub_model": sm or "", + "number_id": nid or "", + }) + + with open(file_path, "r", encoding="utf-8-sig") as f_in, open(error_path, "w", encoding="utf-8") as f_err: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + errors_detail: List[Dict[str, Any]] = [] + error_lines_list: List[int] = [] + for i, row in enumerate(reader, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + row_norm = row_from_template(row, "imp_temp_series", normalize_header) + warnings_list: List[Dict[str, Any]] = [] + err = validate_row_series_impo_temp( + row_norm, + i, + actualizar=actualizar, + autonumerar=autonumerar, + validar_series_exception=validar_series_exception, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + existing_series_keys=existing_series_keys, + existing_series_data=existing_series_data, + warnings=warnings_list, + ) + if err and not err.get("warning"): + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps(err) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + for w in warnings_list: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Series import scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Partidas de Importación Temporal: flujo específico (Clarion VALIDA_TODA / VALIDA_PARCIAL) --- + if model_target == "invoice_details" and template_id == "imp_temp_details": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + 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.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.public.reference_data.payment_methods.models import PaymentMethod + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.a76.parts.models import Part + from .validators.partidas_impo_temp import validate_row_partidas_impo_temp + + _fc = parse_footer_config(meta.get("footer_config")) + autonumerar = meta.get("autonumerar", True) + actualizar = meta.get("actualizar", False) + levantar_subpartidas = meta.get("levantar_subpartidas", False) + calcular_costo_en_base_a_total = meta.get("calcular_costo_unitario_en_base_a_valor_total", False) + validar_decimales_pza = meta.get("validar_decimales_pza", False) + if _fc: + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + elif _fc.get("autonumber_partidas", "true") is not None: + autonumerar = str(_fc.get("autonumber_partidas", "true")).lower() in ("true", "1", "si", "sí", "yes") + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "levantar_subpartidas" in _fc: + levantar_subpartidas = bool(_fc["levantar_subpartidas"]) + if "calcular_costo_unitario_en_base_a_valor_total" in _fc: + calcular_costo_en_base_a_total = bool(_fc["calcular_costo_unitario_en_base_a_valor_total"]) + if "validar_decimales_pza" in _fc: + validar_decimales_pza = bool(_fc["validar_decimales_pza"]) + + RFC_EXCEPTION_UPDATED = {"TPI121217SF6", "TCI170502858"} + RFC_EXCEPTION_NUM_PARTE = {"CTE980130518"} + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in q_inv.all(): + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + existing_line_keys_by_invoice: Dict[str, Set[str]] = {} + q_li = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + for num, ln in q_li.all(): + if num is not None: + key = str(num).strip() + if key not in existing_line_keys_by_invoice: + existing_line_keys_by_invoice[key] = set() + existing_line_keys_by_invoice[key].add(str(ln).strip()) + + partidas_principales_bd: Set[Tuple[str, str]] = set() + try: + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + q_pp = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(FaLineItem, FaLineItem.id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + FaLineItem.is_subitem == False, + FaLineItem.contains_subitems == True, + ) + ) + for num, ln in q_pp.all(): + if num is not None: + partidas_principales_bd.add((str(num).strip(), str(ln).strip())) + except Exception: + pass + + valid_class_codes: Set[str] = set() + 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] = {} + for c in session.query(Class).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all(): + code = (c.class_code or "").strip().upper() + if code: + valid_class_codes.add(code) + class_um_by_code[code] = (c.unit_of_measure or "").strip().upper() + class_fraction_by_code[code] = (c.fraction or "").strip() + class_desc_es_by_code[code] = (c.description_es or "").strip() + class_desc_en_by_code[code] = (c.description_en or "").strip() + + valid_uom_codes: Set[str] = set() + for u in session.query(UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all(): + if u[0]: + valid_uom_codes.add((u[0] or "").strip().upper()) + + valid_bulks_codes: Set[str] = set() + for p in session.query(Package.key).filter(Package.tenant_id == tenant_id, Package.company_id == company_id).all(): + if p[0]: + valid_bulks_codes.add((p[0] or "").strip()) + + valid_country_keys: Set[str] = set() + for row in session.query(Country.m3_key, Country.ame_key).all(): + if row[0]: + valid_country_keys.add((row[0] or "").strip().upper()) + if row[1]: + valid_country_keys.add((row[1] or "").strip().upper()) + + valid_fraction_ame: Set[str] = set() + for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all(): + if row[0]: + valid_fraction_ame.add((row[0] or "").strip()) + + authorized_sectors: Set[str] = set() + for row in session.query(Sector.key).filter(Sector.authorized == True).all(): + if row[0]: + authorized_sectors.add((row[0] or "").strip().upper()) + + valid_payment_methods: Set[str] = set() + for row in session.query(PaymentMethod.key).all(): + if row[0] is not None: + valid_payment_methods.add(str(row[0]).strip()) + + valid_valuation_methods: Set[str] = set() + for row in session.query(ValuationMethod.key).all(): + if row[0]: + valid_valuation_methods.add((row[0] or "").strip()) + + company = session.query(Company).filter(Company.id == company_id).first() + company_has_prosec = bool(company.prosec) if company else False + company_rfc = (company.rfc or "").strip().upper() if company else "" + + valid_part_numbers: Set[str] = set() + for row in session.query(Part.part_number).filter(Part.tenant_id == tenant_id, Part.company_id == company_id).all(): + if row[0]: + valid_part_numbers.add((row[0] or "").strip().upper()) + + rfc_exception_updated: Set[str] = set() + rfc_exception_num_parte: Set[str] = set() + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + invoice_numbers_from_csv = set() + for row in rows_list: + inv = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() + if inv: + invoice_numbers_from_csv.add(inv) + if company_rfc in RFC_EXCEPTION_UPDATED: + rfc_exception_updated = invoice_numbers_from_csv + if company_rfc in RFC_EXCEPTION_NUM_PARTE: + rfc_exception_num_parte = invoice_numbers_from_csv + + line_counts_csv: Dict[Tuple[str, str], int] = {} + partidas_principales_csv: Set[Tuple[str, str]] = set() + + def _get_row(row_norm: Dict[str, Any], *keys: str) -> str: + for k in keys: + v = row_norm.get(k) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + for row in rows_list: + row_norm = row_from_template(row, "imp_temp_details", normalize_header) + inv = _get_row(row_norm, "NUMERO FACTURA", "NUM FACTURA", "FACTURA") + linea = _get_row(row_norm, "LINEA", "RENGLON", "PARTIDA") + if inv and linea: + key = (inv, linea) + line_counts_csv[key] = line_counts_csv.get(key, 0) + 1 + u = _get_row(row_norm, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper() + if u == "P" and inv and linea: + partidas_principales_csv.add((inv, linea)) + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "imp_temp_details", normalize_header) + err = validate_row_partidas_impo_temp( + row_norm, + i, + autonumerar=autonumerar, + actualizar=actualizar, + levantar_subpartidas=levantar_subpartidas, + calcular_costo_en_base_a_total=calcular_costo_en_base_a_total, + validar_decimales_pza=validar_decimales_pza, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + rfc_exception_updated=rfc_exception_updated, + existing_line_keys_by_invoice=existing_line_keys_by_invoice, + line_counts_csv=line_counts_csv, + partidas_principales_csv=partidas_principales_csv, + partidas_principales_bd=partidas_principales_bd, + 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, + rfc_exception_num_parte=rfc_exception_num_parte or None, + valid_part_numbers=valid_part_numbers, + warnings=None, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) + except Exception as e: + logger.exception("Partidas import scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Partidas de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_PARIMPO_DEF / VALIDA_PARCIAL) --- + # Estructura de columnas: misma que partidas TEM (imp_def_details resuelve a imp_temp_details). Facturas DEF/MATDE/EXDEF. + if model_target == "invoice_details" and template_id == "imp_def_details": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + 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.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.public.reference_data.payment_methods.models import PaymentMethod + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.a76.parts.models import Part + from .validators.partidas_impo_def import validate_row_partidas_impo_def + + DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") + _fc = parse_footer_config(meta.get("footer_config")) + autonumerar = meta.get("autonumerar", True) + actualizar = meta.get("actualizar", False) + levantar_subpartidas = meta.get("levantar_subpartidas", False) + calcular_costo_en_base_a_total = meta.get("calcular_costo_unitario_en_base_a_valor_total", False) + validar_decimales_pza = meta.get("validar_decimales_pza", False) + if _fc: + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + elif _fc.get("autonumber_partidas", "true") is not None: + autonumerar = str(_fc.get("autonumber_partidas", "true")).lower() in ("true", "1", "si", "sí", "yes") + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "levantar_subpartidas" in _fc: + levantar_subpartidas = bool(_fc["levantar_subpartidas"]) + if "calcular_costo_unitario_en_base_a_valor_total" in _fc: + calcular_costo_en_base_a_total = bool(_fc["calcular_costo_unitario_en_base_a_valor_total"]) + if "validar_decimales_pza" in _fc: + validar_decimales_pza = bool(_fc["validar_decimales_pza"]) + + RFC_EXCEPTION_UPDATED = {"TPI121217SF6", "TCI170502858"} + RFC_EXCEPTION_NUM_PARTE = {"CTE980130518"} + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in q_inv.all(): + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + existing_line_keys_by_invoice: Dict[str, Set[str]] = {} + q_li = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for num, ln in q_li.all(): + if num is not None: + key = str(num).strip() + if key not in existing_line_keys_by_invoice: + existing_line_keys_by_invoice[key] = set() + existing_line_keys_by_invoice[key].add(str(ln).strip()) + + partidas_principales_bd: Set[Tuple[str, str]] = set() + try: + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + q_pp = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(FaLineItem, FaLineItem.id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + FaLineItem.is_subitem == False, + FaLineItem.contains_subitems == True, + ) + ) + for num, ln in q_pp.all(): + if num is not None: + partidas_principales_bd.add((str(num).strip(), str(ln).strip())) + except Exception: + pass + + valid_class_codes: Set[str] = set() + 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] = {} + for c in session.query(Class).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all(): + code = (c.class_code or "").strip().upper() + if code: + valid_class_codes.add(code) + class_um_by_code[code] = (c.unit_of_measure or "").strip().upper() + class_fraction_by_code[code] = (c.fraction or "").strip() + class_desc_es_by_code[code] = (c.description_es or "").strip() + class_desc_en_by_code[code] = (c.description_en or "").strip() + + valid_uom_codes: Set[str] = set() + for u in session.query(UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all(): + if u[0]: + valid_uom_codes.add((u[0] or "").strip().upper()) + + valid_bulks_codes: Set[str] = set() + for p in session.query(Package.key).filter(Package.tenant_id == tenant_id, Package.company_id == company_id).all(): + if p[0]: + valid_bulks_codes.add((p[0] or "").strip()) + + valid_country_keys: Set[str] = set() + for row in session.query(Country.m3_key, Country.ame_key).all(): + if row[0]: + valid_country_keys.add((row[0] or "").strip().upper()) + if row[1]: + valid_country_keys.add((row[1] or "").strip().upper()) + + valid_fraction_ame: Set[str] = set() + for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all(): + if row[0]: + valid_fraction_ame.add((row[0] or "").strip()) + + authorized_sectors: Set[str] = set() + for row in session.query(Sector.key).filter(Sector.authorized == True).all(): + if row[0]: + authorized_sectors.add((row[0] or "").strip().upper()) + + valid_payment_methods: Set[str] = set() + for row in session.query(PaymentMethod.key).all(): + if row[0] is not None: + valid_payment_methods.add(str(row[0]).strip()) + + valid_valuation_methods: Set[str] = set() + for row in session.query(ValuationMethod.key).all(): + if row[0]: + valid_valuation_methods.add((row[0] or "").strip()) + + company = session.query(Company).filter(Company.id == company_id).first() + company_has_prosec = bool(company.prosec) if company else False + company_rfc = (company.rfc or "").strip().upper() if company else "" + + valid_part_numbers: Set[str] = set() + for row in session.query(Part.part_number).filter(Part.tenant_id == tenant_id, Part.company_id == company_id).all(): + if row[0]: + valid_part_numbers.add((row[0] or "").strip().upper()) + + rfc_exception_updated: Set[str] = set() + rfc_exception_num_parte: Set[str] = set() + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + invoice_numbers_from_csv = set() + for row in rows_list: + inv = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() + if inv: + invoice_numbers_from_csv.add(inv) + if company_rfc in RFC_EXCEPTION_UPDATED: + rfc_exception_updated = invoice_numbers_from_csv + if company_rfc in RFC_EXCEPTION_NUM_PARTE: + rfc_exception_num_parte = invoice_numbers_from_csv + + line_counts_csv: Dict[Tuple[str, str], int] = {} + partidas_principales_csv: Set[Tuple[str, str]] = set() + + def _get_row_def(row_norm: Dict[str, Any], *keys: str) -> str: + for k in keys: + v = row_norm.get(k) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + for row in rows_list: + row_norm = row_from_template(row, "imp_def_details", normalize_header) + inv = _get_row_def(row_norm, "NUMERO FACTURA", "NUM FACTURA", "FACTURA") + linea = _get_row_def(row_norm, "LINEA", "RENGLON", "PARTIDA") + if inv and linea: + key = (inv, linea) + line_counts_csv[key] = line_counts_csv.get(key, 0) + 1 + u = _get_row_def(row_norm, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper() + if u == "P" and inv and linea: + partidas_principales_csv.add((inv, linea)) + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "imp_def_details", normalize_header) + err = validate_row_partidas_impo_def( + row_norm, + i, + autonumerar=autonumerar, + actualizar=actualizar, + levantar_subpartidas=levantar_subpartidas, + calcular_costo_en_base_a_total=calcular_costo_en_base_a_total, + validar_decimales_pza=validar_decimales_pza, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + rfc_exception_updated=rfc_exception_updated, + existing_line_keys_by_invoice=existing_line_keys_by_invoice, + line_counts_csv=line_counts_csv, + partidas_principales_csv=partidas_principales_csv, + partidas_principales_bd=partidas_principales_bd, + 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, + rfc_exception_num_parte=rfc_exception_num_parte or None, + valid_part_numbers=valid_part_numbers, + warnings=None, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) + except Exception as e: + logger.exception("Partidas importación definitiva scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Partidas Exportación Definitiva: Clarion VALIDA_TODA_PAR_EXPO / VALIDA_PARCIAL_PAR_EXPO / VALIDACIONES_PAR_EXPO --- + if model_target == "invoice_details" and template_id == "exp_def_partidas": + logger.info("Partidas expo scan: running validation for job %s", job_id) + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + 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.public.reference_data.payment_methods.models import PaymentMethod + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.a76.parts.models import Part + from .validators.partidas_expo import validate_row_partidas_expo + + _fc = parse_footer_config(meta.get("footer_config")) + autonumerar = meta.get("autonumerar", True) + actualizar = meta.get("actualizar", False) + levantar_subpartidas = meta.get("levantar_subpartidas", False) + validar_decimales_pza = meta.get("validar_decimales_pza", False) + if _fc: + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + elif _fc.get("autonumber_partidas", "true") is not None: + autonumerar = str(_fc.get("autonumber_partidas", "true")).lower() in ("true", "1", "si", "sí", "yes") + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "levantar_subpartidas" in _fc: + levantar_subpartidas = bool(_fc["levantar_subpartidas"]) + if "validar_decimales_pza" in _fc: + validar_decimales_pza = bool(_fc["validar_decimales_pza"]) + + RFC_EXCEPTION_UPDATED = set() + RFC_EXCEPTION_EGM = {"EGM0303257J1"} + + with CoreSessionLocal() as session: + company = session.query(Company).filter(Company.id == company_id).first() + company_rfc = (company.rfc or "").strip().upper() if company else "" + if company_rfc in RFC_EXCEPTION_EGM: + rfc_exception_egm = True + else: + rfc_exception_egm = False + + q_inv_expo = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in q_inv_expo.all(): + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + existing_line_keys_by_invoice: Dict[str, Set[str]] = {} + q_li_expo = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + for num, ln in q_li_expo.all(): + if num is not None: + key = str(num).strip() + if key not in existing_line_keys_by_invoice: + existing_line_keys_by_invoice[key] = set() + existing_line_keys_by_invoice[key].add(str(ln).strip()) + + partidas_principales_bd: Set[Tuple[str, str]] = set() + try: + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + q_pp = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(FaLineItem, FaLineItem.id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + FaLineItem.is_subitem == False, + FaLineItem.contains_subitems == True, + ) + ) + for num, ln in q_pp.all(): + if num is not None: + partidas_principales_bd.add((str(num).strip(), str(ln).strip())) + except Exception: + pass + + factura_impo_tem_by_number: Dict[str, int] = {} + q_tem = session.query(InvoiceHeader.invoice_number, InvoiceHeader.id).filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + for num, iid in q_tem.all(): + if num: + factura_impo_tem_by_number[str(num).strip()] = iid + + factura_impo_def_by_number: Dict[str, int] = {} + DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") + q_def = session.query(InvoiceHeader.invoice_number, InvoiceHeader.id).filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + for num, iid in q_def.all(): + if num: + factura_impo_def_by_number[str(num).strip()] = iid + + line_exists_tem: Set[Tuple[int, str]] = set() + q_li_tem = ( + session.query(LineItem.invoice_id, LineItem.line_number) + .join(InvoiceHeader, InvoiceHeader.id == LineItem.invoice_id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + for inv_id, ln in q_li_tem.all(): + if inv_id is not None and ln is not None: + line_exists_tem.add((inv_id, str(ln).strip())) + + line_exists_def: Set[Tuple[int, str]] = set() + q_li_def = ( + session.query(LineItem.invoice_id, LineItem.line_number) + .join(InvoiceHeader, InvoiceHeader.id == LineItem.invoice_id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for inv_id, ln in q_li_def.all(): + if inv_id is not None and ln is not None: + line_exists_def.add((inv_id, str(ln).strip())) + + valid_uom_codes: Set[str] = set() + for u in session.query(UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all(): + if u[0]: + valid_uom_codes.add((u[0] or "").strip().upper()) + + valid_bulks_codes: Set[str] = set() + for p in session.query(Package.key).filter(Package.tenant_id == tenant_id, Package.company_id == company_id).all(): + if p[0]: + valid_bulks_codes.add((p[0] or "").strip()) + + valid_payment_methods: Set[str] = set() + for row in session.query(PaymentMethod.key).all(): + if row[0] is not None: + valid_payment_methods.add(str(row[0]).strip()) + + valid_fraction_ame: Set[str] = set() + for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all(): + if row[0]: + valid_fraction_ame.add((row[0] or "").strip()) + + valid_part_numbers: Set[str] = set() + for row in session.query(Part.part_number).filter(Part.tenant_id == tenant_id, Part.company_id == company_id).all(): + if row[0]: + valid_part_numbers.add((row[0] or "").strip().upper()) + + invoice_numbers_from_csv = set() + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + for row in rows_list: + row_norm = row_from_template(row, "exp_def_partidas", normalize_header) + inv = (row_norm.get("NUMERO FACTURA EXPO") or row_norm.get("NUMERO FACTURA EXPO.") or row_norm.get("FACTURA EXPO") or "").strip() + if inv: + invoice_numbers_from_csv.add(inv) + if company_rfc in RFC_EXCEPTION_EGM: + rfc_exception_updated = invoice_numbers_from_csv + else: + rfc_exception_updated = set() + + line_counts_csv: Dict[Tuple[str, str], int] = {} + partidas_principales_csv: Set[Tuple[str, str]] = set() + + def _get_row_expo(row_norm: Dict[str, Any], *keys: str) -> str: + for k in keys: + v = row_norm.get(k) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + for row in rows_list: + row_norm = row_from_template(row, "exp_def_partidas", normalize_header) + inv = _get_row_expo(row_norm, "NUMERO FACTURA EXPO", "NUMERO FACTURA EXPO.", "FACTURA EXPO") + linea = _get_row_expo(row_norm, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO") + if inv and linea: + key = (inv, linea) + line_counts_csv[key] = line_counts_csv.get(key, 0) + 1 + u = _get_row_expo(row_norm, "ES PARTIDA/SUBPARTIDA", "ESSUBPARTIDA", "ES PARTIDA O SUBPARTIDA").upper() + if u == "P" and inv and linea: + partidas_principales_csv.add((inv, linea)) + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + row_norm = row_from_template(row, "exp_def_partidas", normalize_header) + err = validate_row_partidas_expo( + row_norm, + i, + autonumerar=autonumerar, + actualizar=actualizar, + levantar_subpartidas=levantar_subpartidas, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + rfc_exception_updated=rfc_exception_updated, + existing_line_keys_by_invoice=existing_line_keys_by_invoice, + line_counts_csv=line_counts_csv, + partidas_principales_csv=partidas_principales_csv, + partidas_principales_bd=partidas_principales_bd, + 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, + rfc_exception_egm=rfc_exception_egm, + validar_decimales_pza=validar_decimales_pza, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + logger.info( + "Partidas expo scan rechazo línea %s (col %s): %s", + err["line"], + err.get("col", ""), + err.get("msg", ""), + ) + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Partidas exportación definitiva scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Partidas Compras Mexicanas: misma lógica que Impo Def, facturas MEX --- + if model_target == "invoice_details" and template_id == "cmex_details": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + 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.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.public.reference_data.payment_methods.models import PaymentMethod + from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import USTariffFraction + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.a76.parts.models import Part + from .validators.partidas_impo_def import validate_row_partidas_impo_def + + _fc = parse_footer_config(meta.get("footer_config")) + autonumerar = meta.get("autonumerar", True) + actualizar = meta.get("actualizar", False) + levantar_subpartidas = meta.get("levantar_subpartidas", False) + calcular_costo_en_base_a_total = meta.get("calcular_costo_unitario_en_base_a_valor_total", False) + validar_decimales_pza = meta.get("validar_decimales_pza", False) + if _fc: + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + elif _fc.get("autonumber_partidas", "true") is not None: + autonumerar = str(_fc.get("autonumber_partidas", "true")).lower() in ("true", "1", "si", "sí", "yes") + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "levantar_subpartidas" in _fc: + levantar_subpartidas = bool(_fc["levantar_subpartidas"]) + if "calcular_costo_unitario_en_base_a_valor_total" in _fc: + calcular_costo_en_base_a_total = bool(_fc["calcular_costo_unitario_en_base_a_valor_total"]) + if "validar_decimales_pza" in _fc: + validar_decimales_pza = bool(_fc["validar_decimales_pza"]) + + RFC_EXCEPTION_UPDATED = {"TPI121217SF6", "TCI170502858"} + RFC_EXCEPTION_NUM_PARTE = {"CTE980130518"} + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in q_inv.all(): + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + existing_line_keys_by_invoice: Dict[str, Set[str]] = {} + q_li = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + for num, ln in q_li.all(): + if num is not None: + key = str(num).strip() + if key not in existing_line_keys_by_invoice: + existing_line_keys_by_invoice[key] = set() + existing_line_keys_by_invoice[key].add(str(ln).strip()) + + partidas_principales_bd: Set[Tuple[str, str]] = set() + try: + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + q_pp = ( + session.query(InvoiceHeader.invoice_number, LineItem.line_number) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(FaLineItem, FaLineItem.id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + FaLineItem.is_subitem == False, + FaLineItem.contains_subitems == True, + ) + ) + for num, ln in q_pp.all(): + if num is not None: + partidas_principales_bd.add((str(num).strip(), str(ln).strip())) + except Exception: + pass + + valid_class_codes: Set[str] = set() + 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] = {} + for c in session.query(Class).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all(): + code = (c.class_code or "").strip().upper() + if code: + valid_class_codes.add(code) + class_um_by_code[code] = (c.unit_of_measure or "").strip().upper() + class_fraction_by_code[code] = (c.fraction or "").strip() + class_desc_es_by_code[code] = (c.description_es or "").strip() + class_desc_en_by_code[code] = (c.description_en or "").strip() + + valid_uom_codes: Set[str] = set() + for u in session.query(UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all(): + if u[0]: + valid_uom_codes.add((u[0] or "").strip().upper()) + + valid_bulks_codes: Set[str] = set() + for p in session.query(Package.key).filter(Package.tenant_id == tenant_id, Package.company_id == company_id).all(): + if p[0]: + valid_bulks_codes.add((p[0] or "").strip()) + + valid_country_keys: Set[str] = set() + for row in session.query(Country.m3_key, Country.ame_key).all(): + if row[0]: + valid_country_keys.add((row[0] or "").strip().upper()) + if row[1]: + valid_country_keys.add((row[1] or "").strip().upper()) + + valid_fraction_ame: Set[str] = set() + for row in session.query(USTariffFraction.code).filter(USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id).all(): + if row[0]: + valid_fraction_ame.add((row[0] or "").strip()) + + authorized_sectors: Set[str] = set() + for row in session.query(Sector.key).filter(Sector.authorized == True).all(): + if row[0]: + authorized_sectors.add((row[0] or "").strip().upper()) + + valid_payment_methods: Set[str] = set() + for row in session.query(PaymentMethod.key).all(): + if row[0] is not None: + valid_payment_methods.add(str(row[0]).strip()) + + valid_valuation_methods: Set[str] = set() + for row in session.query(ValuationMethod.key).all(): + if row[0]: + valid_valuation_methods.add((row[0] or "").strip()) + + company = session.query(Company).filter(Company.id == company_id).first() + company_has_prosec = bool(company.prosec) if company else False + company_rfc = (company.rfc or "").strip().upper() if company else "" + + valid_part_numbers: Set[str] = set() + for row in session.query(Part.part_number).filter(Part.tenant_id == tenant_id, Part.company_id == company_id).all(): + if row[0]: + valid_part_numbers.add((row[0] or "").strip().upper()) + + rfc_exception_updated: Set[str] = set() + rfc_exception_num_parte: Set[str] = set() + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + invoice_numbers_from_csv = set() + for row in rows_list: + inv = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() + if inv: + invoice_numbers_from_csv.add(inv) + if company_rfc in RFC_EXCEPTION_UPDATED: + rfc_exception_updated = invoice_numbers_from_csv + if company_rfc in RFC_EXCEPTION_NUM_PARTE: + rfc_exception_num_parte = invoice_numbers_from_csv + + line_counts_csv: Dict[Tuple[str, str], int] = {} + partidas_principales_csv: Set[Tuple[str, str]] = set() + + def _get_row_cmex(row_norm: Dict[str, Any], *keys: str) -> str: + for k in keys: + v = row_norm.get(k) + if v is not None and str(v).strip(): + return str(v).strip() + return "" + + for row in rows_list: + row_norm = row_from_template(row, "cmex_details", normalize_header) + inv = _get_row_cmex(row_norm, "NUMERO FACTURA", "NUM FACTURA", "FACTURA") + linea = _get_row_cmex(row_norm, "LINEA", "RENGLON", "PARTIDA") + if inv and linea: + key = (inv, linea) + line_counts_csv[key] = line_counts_csv.get(key, 0) + 1 + u = _get_row_cmex(row_norm, "ES PARTIDA O SUBPARTIDA", "ESSUBPARTIDA").upper() + if u == "P" and inv and linea: + partidas_principales_csv.add((inv, linea)) + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "cmex_details", normalize_header) + err = validate_row_partidas_impo_def( + row_norm, + i, + autonumerar=autonumerar, + actualizar=actualizar, + levantar_subpartidas=levantar_subpartidas, + calcular_costo_en_base_a_total=calcular_costo_en_base_a_total, + validar_decimales_pza=validar_decimales_pza, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + rfc_exception_updated=rfc_exception_updated, + existing_line_keys_by_invoice=existing_line_keys_by_invoice, + line_counts_csv=line_counts_csv, + partidas_principales_csv=partidas_principales_csv, + partidas_principales_bd=partidas_principales_bd, + 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, + rfc_exception_num_parte=rfc_exception_num_parte or None, + valid_part_numbers=valid_part_numbers, + warnings=None, + catalog_label="Compras Mexicanas", + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) + except Exception as e: + logger.exception("Partidas Compras Mexicanas scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Encabezados de Importación Temporal: flujo específico (Clarion VALIDA_TODA / VALIDA_PARCIAL) --- + if model_target == "invoice_header" and template_id == "imp_temp_header": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + 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.customs_sections.models import CustomsSection + 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.a76.general_catalogs.exchange_rate.models import ExchangeRate + from api.v1.modules.a76.transportation.transporters.models import Transporter + from .validators.encabezados_impo_temp import ( + validate_row_encabezados_impo_temp, + parse_pedimento_col_a, + _pedimento_key_from_parsed, + ) + + def _ped_key_from_row(ped_str: str) -> Optional[str]: + parsed = parse_pedimento_col_a(ped_str) + if not parsed: + return None + return _pedimento_key_from_parsed(parsed[0], parsed[1], parsed[2]) + + _fc = parse_footer_config(meta.get("footer_config")) + actualizar = meta.get("actualizar", False) + autonumerar_remesas = meta.get("autonumerar_remesas", False) + control_remesa = bool(_fc.get("control_remesa", False)) + remesa_inicio = _fc.get("remesa_inicio") + remesa_fin = _fc.get("remesa_fin") + if remesa_inicio is not None: + try: + remesa_inicio = int(remesa_inicio) + except (TypeError, ValueError): + remesa_inicio = None + if remesa_fin is not None: + try: + remesa_fin = int(remesa_fin) + except (TypeError, ValueError): + remesa_fin = None + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "autonumerar_remesas" in _fc: + autonumerar_remesas = bool(_fc["autonumerar_remesas"]) + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + invoice_exists_by_number: Dict[str, bool] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in q_inv.all(): + if num: + n = str(num).strip() + invoice_exists_by_number[n] = True + invoice_updated_by_number[n] = bool(is_upd) + + pedimento_data_by_key: Dict[str, List[Dict[str, Any]]] = {} + for p in ( + session.query( + Pedimentos.id, + Pedimentos.year, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + Pedimentos.operation_type, + Pedimentos.regime, + Pedimentos.pedimento_type, + ) + .filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + ) + .all() + ): + co = (p.customs_office or "").strip() + lic = (p.license or "").strip() + num = (p.pedimento_number or "").strip() + if not co or not lic or not num: + continue + key = _pedimento_key_from_parsed(co, lic, num) + entry_date = None + end_date = None + pd = ( + session.query(PedimentoDates.entry_date, PedimentoDates.end_date) + .filter(PedimentoDates.pedimento_id == p.id).first() + ) + if pd: + entry_date = pd[0] + end_date = pd[1] + info = { + "id": p.id, + "regime": (p.regime or "").strip(), + "operation_type": (p.operation_type or "").strip(), + "pedimento_type": (p.pedimento_type or "").strip(), + "entry_date": entry_date, + "end_date": end_date, + } + if key not in pedimento_data_by_key: + pedimento_data_by_key[key] = [] + pedimento_data_by_key[key].append(info) + + remesa_por_pedimento_bd: Dict[str, Set[int]] = {} + q_rem = ( + session.query( + InvoiceComplianceMx.remesa, + Pedimentos.year, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + ) + .join(Pedimentos, Pedimentos.id == InvoiceComplianceMx.pedimento_id) + .filter( + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + InvoiceComplianceMx.pedimento_id.isnot(None), + InvoiceComplianceMx.remesa.isnot(None), + ) + ) + for rem, y, co, lic, num in q_rem.all(): + if co and lic and num and rem is not None: + key = _pedimento_key_from_parsed( + (co or "").strip(), + (lic or "").strip(), + (num or "").strip(), + ) + if key not in remesa_por_pedimento_bd: + remesa_por_pedimento_bd[key] = set() + remesa_por_pedimento_bd[key].add(int(rem)) + + valid_provider_ids: Set[int] = set() + valid_sold_to_ids: Set[int] = set() + valid_shipped_to_ids: Set[int] = set() + valid_provider_short_names: Set[str] = set() + valid_sold_to_short_names: Set[str] = set() + valid_shipped_to_short_names: Set[str] = set() + for cp in session.query(ClientProvider.id, ClientProvider.short_name).filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).all(): + valid_provider_ids.add(cp[0]) + valid_sold_to_ids.add(cp[0]) + valid_shipped_to_ids.add(cp[0]) + if cp[1] and str(cp[1]).strip(): + sn_upper = str(cp[1]).strip().upper() + valid_provider_short_names.add(sn_upper) + valid_sold_to_short_names.add(sn_upper) + valid_shipped_to_short_names.add(sn_upper) + + valid_broker_ids: Set[int] = set() + valid_broker_claves: Set[str] = set() + for cb in session.query(CustomsBroker.id, CustomsBroker.broker_key).filter( + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + ).all(): + valid_broker_ids.add(cb[0]) + if cb[1] and str(cb[1]).strip(): + valid_broker_claves.add(str(cb[1]).strip()) + + valid_transporter_keys: Set[str] = set() + for t in session.query(Transporter.transporter_key).filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ).all(): + if t[0]: + valid_transporter_keys.add((t[0] or "").strip().upper()) + + valid_incoterms: Set[str] = set() + for inc in session.query(Incoterm.code).all(): + if inc[0]: + valid_incoterms.add((inc[0] or "").strip().upper()) + + valid_aduana_codes: Set[str] = set() + for cs in session.query(CustomsSection.customs_code).all(): + if cs[0]: + valid_aduana_codes.add((cs[0] or "").strip()) + + valid_currency_codes: Set[str] = set() + for ct in session.query(CurrencyType.code).all(): + if ct[0]: + valid_currency_codes.add((ct[0] or "").strip().upper()) + + exchange_rate_by_date: Dict[str, Any] = {} + for er in session.query(ExchangeRate.date, ExchangeRate.value).filter( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ).all(): + if er[0] and er[1] is not None: + dk = er[0].strftime("%Y-%m-%d") if hasattr(er[0], "strftime") else str(er[0])[:10] + exchange_rate_by_date[dk] = er[1] + + invoice_has_partidas_by_number: Dict[str, bool] = {} + existing_tipo_moneda_by_number: Dict[str, str] = {} + q_li_count = ( + session.query(InvoiceHeader.invoice_number, func.count(LineItem.id)) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + .group_by(InvoiceHeader.invoice_number) + ) + for num, cnt in q_li_count.all(): + if num: + invoice_has_partidas_by_number[str(num).strip()] = cnt > 0 + q_fin = ( + session.query(InvoiceHeader.invoice_number, InvoiceFinancials.currency) + .join(InvoiceFinancials, InvoiceFinancials.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + for num, cur in q_fin.all(): + if num and cur: + cur_str = (cur or "").strip().lower() + if cur_str == "foreign": + existing_tipo_moneda_by_number[str(num).strip()] = "ME" + elif cur_str == "local": + existing_tipo_moneda_by_number[str(num).strip()] = "MN" + else: + existing_tipo_moneda_by_number[str(num).strip()] = cur_str.upper()[:2] + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + remesa_por_pedimento_csv: Dict[str, Dict[int, str]] = {} + for row in rows_list: + row_norm = row_from_template(row, "imp_temp_header", normalize_header) + ped = (row_norm.get("PEDIMENTO") or "").strip() + rem = row_norm.get("REMESA") + factura = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA") or "").strip() + if not ped or not factura: + continue + key = _ped_key_from_row(ped) + if not key: + continue + try: + rem_int = int(rem) if rem is not None and str(rem).strip() else None + except (TypeError, ValueError): + rem_int = None + if rem_int is not None: + if key not in remesa_por_pedimento_csv: + remesa_por_pedimento_csv[key] = {} + if rem_int not in remesa_por_pedimento_csv[key]: + remesa_por_pedimento_csv[key][rem_int] = factura + + error_count = 0 + processed_rows = 0 + error_lines_list: List[int] = [] + errors_detail: List[Dict[str, Any]] = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "imp_temp_header", normalize_header) + warnings_row: List[Dict[str, Any]] = [] + err = validate_row_encabezados_impo_temp( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_updated_by_number=invoice_updated_by_number, + pedimento_data_by_key=pedimento_data_by_key, + remesa_por_pedimento_bd=remesa_por_pedimento_bd, + remesa_por_pedimento_csv=remesa_por_pedimento_csv, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + valid_broker_ids=valid_broker_ids, + valid_broker_claves=valid_broker_claves, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_aduana_codes=valid_aduana_codes, + valid_currency_codes=valid_currency_codes, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + autonumerar_remesas=autonumerar_remesas, + control_remesa=control_remesa, + remesa_inicio=remesa_inicio, + remesa_fin=remesa_fin, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + for w in warnings_row: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Encabezados importación temporal scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Encabezados de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_FACIMPO_DEF / VALIDA_PARCIAL) --- + if model_target == "invoice_header" and template_id == "imp_def_header": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + 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.customs_sections.models import CustomsSection + 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.a76.general_catalogs.exchange_rate.models import ExchangeRate + from api.v1.modules.a76.transportation.transporters.models import Transporter + from .validators.encabezados_impo_def import ( + validate_row_encabezados_impo_def, + parse_pedimento_col_a_impo_def, + ) + from .validators.encabezados_impo_temp import _pedimento_key_from_parsed + + DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") + + def _ped_key_from_row_def(ped_str: str) -> Optional[str]: + parsed = parse_pedimento_col_a_impo_def(ped_str) + if not parsed: + return None + return _pedimento_key_from_parsed(parsed[0], parsed[1], parsed[2]) + + _fc = parse_footer_config(meta.get("footer_config")) + actualizar = meta.get("actualizar", False) + autonumerar_remesas = meta.get("autonumerar_remesas", False) + control_remesa = bool(_fc.get("control_remesa", False)) + remesa_inicio = _fc.get("remesa_inicio") + remesa_fin = _fc.get("remesa_fin") + if remesa_inicio is not None: + try: + remesa_inicio = int(remesa_inicio) + except (TypeError, ValueError): + remesa_inicio = None + if remesa_fin is not None: + try: + remesa_fin = int(remesa_fin) + except (TypeError, ValueError): + remesa_fin = None + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "autonumerar_remesas" in _fc: + autonumerar_remesas = bool(_fc["autonumerar_remesas"]) + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + invoice_exists_by_number = {} + invoice_updated_by_number = {} + for num, iid, is_upd in q_inv.all(): + if num: + n = str(num).strip() + invoice_exists_by_number[n] = True + invoice_updated_by_number[n] = bool(is_upd) + + pedimento_data_by_key = {} + for p in ( + session.query( + Pedimentos.id, + Pedimentos.year, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + Pedimentos.operation_type, + Pedimentos.regime, + Pedimentos.pedimento_type, + ) + .filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + Pedimentos.operation_type == "imp", + Pedimentos.regime == "IMD", + ) + .all() + ): + co = (p.customs_office or "").strip() + lic = (p.license or "").strip() + num = (p.pedimento_number or "").strip() + if not co or not lic or not num: + continue + key = _pedimento_key_from_parsed(co, lic, num) + entry_date = None + end_date = None + pd = ( + session.query(PedimentoDates.entry_date, PedimentoDates.end_date) + .filter(PedimentoDates.pedimento_id == p.id).first() + ) + if pd: + entry_date = pd[0] + end_date = pd[1] + info = { + "id": p.id, + "regime": (p.regime or "").strip(), + "operation_type": (p.operation_type or "").strip(), + "pedimento_type": (p.pedimento_type or "").strip(), + "entry_date": entry_date, + "end_date": end_date, + } + if key not in pedimento_data_by_key: + pedimento_data_by_key[key] = [] + pedimento_data_by_key[key].append(info) + + remesa_por_pedimento_bd = {} + q_rem = ( + session.query( + InvoiceComplianceMx.remesa, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + ) + .join(InvoiceHeader, InvoiceHeader.id == InvoiceComplianceMx.invoice_id) + .join(Pedimentos, Pedimentos.id == InvoiceComplianceMx.pedimento_id) + .filter( + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + InvoiceComplianceMx.pedimento_id.isnot(None), + InvoiceComplianceMx.remesa.isnot(None), + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for rem, co, lic, num in q_rem.all(): + if co and lic and num and rem is not None: + key = _pedimento_key_from_parsed( + (co or "").strip(), + (lic or "").strip(), + (num or "").strip(), + ) + if key not in remesa_por_pedimento_bd: + remesa_por_pedimento_bd[key] = set() + remesa_por_pedimento_bd[key].add(int(rem)) + + valid_provider_ids = set() + valid_sold_to_ids = set() + valid_shipped_to_ids = set() + valid_provider_short_names = set() + valid_sold_to_short_names = set() + valid_shipped_to_short_names = set() + for cp in session.query(ClientProvider.id, ClientProvider.short_name).filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).all(): + valid_provider_ids.add(cp[0]) + valid_sold_to_ids.add(cp[0]) + valid_shipped_to_ids.add(cp[0]) + if cp[1] and str(cp[1]).strip(): + sn_upper = str(cp[1]).strip().upper() + valid_provider_short_names.add(sn_upper) + valid_sold_to_short_names.add(sn_upper) + valid_shipped_to_short_names.add(sn_upper) + + valid_broker_ids = set() + valid_broker_claves = set() + for cb in session.query(CustomsBroker.id, CustomsBroker.broker_key).filter( + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + ).all(): + valid_broker_ids.add(cb[0]) + if cb[1] and str(cb[1]).strip(): + valid_broker_claves.add(str(cb[1]).strip()) + + valid_transporter_keys = set() + for t in session.query(Transporter.transporter_key).filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ).all(): + if t[0]: + valid_transporter_keys.add((t[0] or "").strip().upper()) + + valid_incoterms = set() + for inc in session.query(Incoterm.code).all(): + if inc[0]: + valid_incoterms.add((inc[0] or "").strip().upper()) + + valid_aduana_codes = set() + for cs in session.query(CustomsSection.customs_code).all(): + if cs[0]: + valid_aduana_codes.add((cs[0] or "").strip()) + + valid_currency_codes = set() + for ct in session.query(CurrencyType.code).all(): + if ct[0]: + valid_currency_codes.add((ct[0] or "").strip().upper()) + + exchange_rate_by_date = {} + for er in session.query(ExchangeRate.date, ExchangeRate.value).filter( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ).all(): + if er[0] and er[1] is not None: + dk = er[0].strftime("%Y-%m-%d") if hasattr(er[0], "strftime") else str(er[0])[:10] + exchange_rate_by_date[dk] = er[1] + + invoice_has_partidas_by_number = {} + existing_tipo_moneda_by_number = {} + q_li_count = ( + session.query(InvoiceHeader.invoice_number, func.count(LineItem.id)) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + .group_by(InvoiceHeader.invoice_number) + ) + for num, cnt in q_li_count.all(): + if num: + invoice_has_partidas_by_number[str(num).strip()] = cnt > 0 + q_fin = ( + session.query(InvoiceHeader.invoice_number, InvoiceFinancials.currency) + .join(InvoiceFinancials, InvoiceFinancials.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(DEF_INVOICE_TYPES), + ) + ) + for num, cur in q_fin.all(): + if num and cur: + cur_str = (cur or "").strip().lower() + if cur_str == "foreign": + existing_tipo_moneda_by_number[str(num).strip()] = "ME" + elif cur_str == "local": + existing_tipo_moneda_by_number[str(num).strip()] = "MN" + else: + existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2] + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + remesa_por_pedimento_csv = {} + for row in rows_list: + row_norm = row_from_template(row, "imp_def_header", normalize_header) + ped = (row_norm.get("PEDIMENTO") or "").strip() + rem = row_norm.get("REMESA") + factura = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA") or "").strip() + if not ped or not factura: + continue + key = _ped_key_from_row_def(ped) + if not key: + continue + try: + rem_int = int(rem) if rem is not None and str(rem).strip() else None + except (TypeError, ValueError): + rem_int = None + if rem_int is not None: + if key not in remesa_por_pedimento_csv: + remesa_por_pedimento_csv[key] = {} + if rem_int not in remesa_por_pedimento_csv[key]: + remesa_por_pedimento_csv[key][rem_int] = factura + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "imp_def_header", normalize_header) + warnings_row = [] + err = validate_row_encabezados_impo_def( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_updated_by_number=invoice_updated_by_number, + pedimento_data_by_key=pedimento_data_by_key, + remesa_por_pedimento_bd=remesa_por_pedimento_bd, + remesa_por_pedimento_csv=remesa_por_pedimento_csv, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + valid_broker_ids=valid_broker_ids, + valid_broker_claves=valid_broker_claves, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_aduana_codes=valid_aduana_codes, + valid_currency_codes=valid_currency_codes, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + autonumerar_remesas=autonumerar_remesas, + control_remesa=control_remesa, + remesa_inicio=remesa_inicio, + remesa_fin=remesa_fin, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + for w in warnings_row: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Encabezados importación definitiva scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Encabezados Exportación (Expo Def) y Cambio de Régimen: flujo específico (Clarion VALIDA_TODA_FAC_EXPO / VALIDA_PARCIAL) --- + if model_target == "invoice_header" and template_id == "exp_def_header": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.a76.pedmientos.models.pedimento_dates import PedimentoDates + 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.customs_sections.models import CustomsSection + 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.a76.general_catalogs.exchange_rate.models import ExchangeRate + from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.a76.manifests.manifest.models import Manifest + from .validators.encabezados_expo import validate_row_encabezados_expo + from .validators.encabezados_impo_temp import _pedimento_key_from_parsed + from .validators.encabezados_impo_def import parse_pedimento_col_a_impo_def + + _fc = parse_footer_config(meta.get("footer_config")) + actualizar = meta.get("actualizar", False) + autonumerar_remesas = meta.get("autonumerar_remesas", False) + recalcular_fecha_pedimentos = meta.get("recalcular_fecha_pedimentos", False) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "autonumerar_remesas" in _fc: + autonumerar_remesas = bool(_fc["autonumerar_remesas"]) + if "recalcular_fecha_pedimentos" in _fc: + recalcular_fecha_pedimentos = bool(_fc["recalcular_fecha_pedimentos"]) + + cambio_regimen_raw = (meta.get("cambio_regimen") or _fc.get("cambio_regimen") or "NO").strip().upper() + cambio_regimen = cambio_regimen_raw == "SI" + tipo_factura = (meta.get("tipo_factura") or _fc.get("tipo_factura") or "AFIJO").strip().upper() + + TIPOS_FACTURA_EXPO_VALIDOS = frozenset({"NODES", "AFIJO", "DONAC", "SCRAP"}) + if tipo_factura not in TIPOS_FACTURA_EXPO_VALIDOS: + return { + "status": "failed", + "error": f"Tipo de factura '{tipo_factura}' no válido para Exportación. Debe ser uno de: NODES, AFIJO, DONAC, SCRAP.", + } + + if cambio_regimen and tipo_factura != "AFIJO": + return { + "status": "failed", + "error": "Este tipo de factura no es compatible para Cambio de Régimen, seleccionar AFIJO.", + } + + def _ped_key_from_row_expo(ped_str: str): + parsed = parse_pedimento_col_a_impo_def(ped_str) + if not parsed: + return None + return _pedimento_key_from_parsed(parsed[0], parsed[1], parsed[2]) + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated, InvoiceHeader.is_updated_rep) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + invoice_exists_by_number = {} + invoice_updated_by_number = {} + invoice_in_report_by_number = {} + for num, iid, is_upd, is_rep in q_inv.all(): + if num: + n = str(num).strip() + invoice_exists_by_number[n] = True + invoice_updated_by_number[n] = bool(is_upd) + invoice_in_report_by_number[n] = bool(is_rep) if is_rep is not None else False + + if cambio_regimen: + ped_filter_op = "imp" + ped_filter_regimes = ["IMD"] + else: + ped_filter_op = "exp" + ped_filter_regimes = ["EXD", "ETE", "ETR"] + + pedimento_data_by_key = {} + for p in ( + session.query( + Pedimentos.id, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + Pedimentos.operation_type, + Pedimentos.regime, + Pedimentos.pedimento_type, + Pedimentos.pedimento_code, + ) + .filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + Pedimentos.operation_type == ped_filter_op, + Pedimentos.regime.in_(ped_filter_regimes), + ) + .all() + ): + co = (p.customs_office or "").strip() + lic = (p.license or "").strip() + num = (p.pedimento_number or "").strip() + if not co or not lic or not num: + continue + key = _pedimento_key_from_parsed(co, lic, num) + entry_date = None + end_date = None + pd = ( + session.query(PedimentoDates.entry_date, PedimentoDates.end_date) + .filter(PedimentoDates.pedimento_id == p.id).first() + ) + if pd: + entry_date = pd[0] + end_date = pd[1] + info = { + "id": p.id, + "regime": (p.regime or "").strip(), + "operation_type": (p.operation_type or "").strip().upper()[:3], + "pedimento_type": (p.pedimento_type or "").strip(), + "pedimento_code": (p.pedimento_code or "").strip().upper(), + "entry_date": entry_date, + "end_date": end_date, + } + if key not in pedimento_data_by_key: + pedimento_data_by_key[key] = [] + pedimento_data_by_key[key].append(info) + + remesa_por_pedimento_bd = {} + q_rem = ( + session.query( + InvoiceComplianceMx.remesa, + Pedimentos.customs_office, + Pedimentos.license, + Pedimentos.pedimento_number, + ) + .join(InvoiceHeader, InvoiceHeader.id == InvoiceComplianceMx.invoice_id) + .join(Pedimentos, Pedimentos.id == InvoiceComplianceMx.pedimento_id) + .filter( + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + InvoiceComplianceMx.pedimento_id.isnot(None), + InvoiceComplianceMx.remesa.isnot(None), + InvoiceHeader.operation_type == "exp", + ) + ) + for rem, co, lic, num in q_rem.all(): + if co and lic and num and rem is not None: + key = _pedimento_key_from_parsed( + (co or "").strip()[:2], + (lic or "").strip(), + (num or "").strip(), + ) + if key not in remesa_por_pedimento_bd: + remesa_por_pedimento_bd[key] = set() + remesa_por_pedimento_bd[key].add(int(rem)) + + valid_provider_ids = set() + valid_sold_to_ids = set() + valid_shipped_to_ids = set() + valid_provider_short_names = set() + valid_sold_to_short_names = set() + valid_shipped_to_short_names = set() + for cp in session.query(ClientProvider.id, ClientProvider.short_name).filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).all(): + valid_provider_ids.add(cp[0]) + valid_sold_to_ids.add(cp[0]) + valid_shipped_to_ids.add(cp[0]) + if cp[1] and str(cp[1]).strip(): + sn_upper = str(cp[1]).strip().upper() + valid_provider_short_names.add(sn_upper) + valid_sold_to_short_names.add(sn_upper) + valid_shipped_to_short_names.add(sn_upper) + + valid_broker_ids = set() + valid_broker_claves = set() + for cb in session.query(CustomsBroker.id, CustomsBroker.broker_key).filter( + CustomsBroker.tenant_id == tenant_id, + CustomsBroker.company_id == company_id, + ).all(): + valid_broker_ids.add(cb[0]) + if cb[1] and str(cb[1]).strip(): + valid_broker_claves.add(str(cb[1]).strip()) + + valid_transporter_keys = set() + for t in session.query(Transporter.transporter_key).filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ).all(): + if t[0]: + valid_transporter_keys.add((t[0] or "").strip().upper()) + + valid_incoterms = set() + for inc in session.query(Incoterm.code).all(): + if inc[0]: + valid_incoterms.add((inc[0] or "").strip().upper()) + + valid_aduana_codes = set() + for cs in session.query(CustomsSection.customs_code).all(): + if cs[0]: + valid_aduana_codes.add((cs[0] or "").strip()) + + valid_currency_codes = set() + for ct in session.query(CurrencyType.code).all(): + if ct[0]: + valid_currency_codes.add((ct[0] or "").strip().upper()) + + valid_manifiesto_codes = set() + for m in session.query(Manifest.manifest_number).filter( + Manifest.tenant_id == tenant_id, + Manifest.company_id == company_id, + ).all(): + if m[0] and str(m[0]).strip(): + valid_manifiesto_codes.add(str(m[0]).strip()) + + exchange_rate_by_date = {} + for er in session.query(ExchangeRate.date, ExchangeRate.value).filter( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ).all(): + if er[0] and er[1] is not None: + dk = er[0].strftime("%Y-%m-%d") if hasattr(er[0], "strftime") else str(er[0])[:10] + exchange_rate_by_date[dk] = er[1] + + invoice_has_partidas_by_number = {} + existing_tipo_moneda_by_number = {} + q_li_count = ( + session.query(InvoiceHeader.invoice_number, func.count(LineItem.id)) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + .group_by(InvoiceHeader.invoice_number) + ) + for num, cnt in q_li_count.all(): + if num: + invoice_has_partidas_by_number[str(num).strip()] = cnt > 0 + q_fin = ( + session.query(InvoiceHeader.invoice_number, InvoiceFinancials.currency) + .join(InvoiceFinancials, InvoiceFinancials.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + for num, cur in q_fin.all(): + if num and cur: + cur_str = (cur or "").strip().lower() + if cur_str == "foreign": + existing_tipo_moneda_by_number[str(num).strip()] = "ME" + elif cur_str == "local": + existing_tipo_moneda_by_number[str(num).strip()] = "MN" + else: + existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2] + + date_format = _fc.get("dateFormat") or meta.get("date_format") + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + remesa_por_pedimento_csv = {} + for row in rows_list: + row_norm = row_from_template(row, "exp_def_header", normalize_header) + ped = (row_norm.get("PEDIMENTO") or "").strip() + rem = row_norm.get("REMESA") + factura = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA") or "").strip() + if not ped or not factura: + continue + key = _ped_key_from_row_expo(ped) + if not key: + continue + try: + rem_int = int(rem) if rem is not None and str(rem).strip() else None + except (TypeError, ValueError): + rem_int = None + if rem_int is not None: + if key not in remesa_por_pedimento_csv: + remesa_por_pedimento_csv[key] = {} + if rem_int not in remesa_por_pedimento_csv[key]: + remesa_por_pedimento_csv[key][rem_int] = factura + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "exp_def_header", normalize_header) + warnings_row = [] + err = validate_row_encabezados_expo( + row_norm, + i, + actualizar=actualizar, + cambio_regimen=cambio_regimen, + tipo_factura=tipo_factura, + invoice_exists_by_number=invoice_exists_by_number, + invoice_updated_by_number=invoice_updated_by_number, + invoice_in_report_by_number=invoice_in_report_by_number, + pedimento_data_by_key=pedimento_data_by_key, + remesa_por_pedimento_bd=remesa_por_pedimento_bd, + remesa_por_pedimento_csv=remesa_por_pedimento_csv, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_broker_ids=valid_broker_ids, + valid_broker_claves=valid_broker_claves, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_aduana_codes=valid_aduana_codes, + valid_currency_codes=valid_currency_codes, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + valid_manifiesto_codes=valid_manifiesto_codes, + valid_enviado_por_ids=valid_provider_ids, + valid_enviado_por_short_names=valid_provider_short_names, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + autonumerar_remesas=autonumerar_remesas, + recalcular_fecha_pedimentos=recalcular_fecha_pedimentos, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + for w in warnings_row: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Encabezados exportación scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Encabezados Compras Mexicanas: flujo específico (Clarion VALIDA_TODA_FAC_COM_MEX / VALIDA_PARCIAL) --- + if model_target == "invoice_header" and template_id == "cmex_header": + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceFinancials + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + 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.a76.general_catalogs.exchange_rate.models import ExchangeRate + from api.v1.modules.a76.transportation.transporters.models import Transporter + from .validators.encabezados_cmex import validate_row_encabezados_cmex + + _fc = parse_footer_config(meta.get("footer_config")) + actualizar = meta.get("actualizar", False) + if _fc and "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + invoice_exists_by_number = {} + invoice_updated_by_number = {} + for num, iid, is_upd in q_inv.all(): + if num: + n = str(num).strip() + invoice_exists_by_number[n] = True + invoice_updated_by_number[n] = bool(is_upd) + + valid_provider_ids = set() + valid_sold_to_ids = set() + valid_shipped_to_ids = set() + valid_provider_short_names = set() + valid_sold_to_short_names = set() + valid_shipped_to_short_names = set() + for cp in session.query(ClientProvider.id, ClientProvider.short_name).filter( + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).all(): + valid_provider_ids.add(cp[0]) + valid_sold_to_ids.add(cp[0]) + valid_shipped_to_ids.add(cp[0]) + if cp[1] and str(cp[1]).strip(): + sn_upper = str(cp[1]).strip().upper() + valid_provider_short_names.add(sn_upper) + valid_sold_to_short_names.add(sn_upper) + valid_shipped_to_short_names.add(sn_upper) + + valid_transporter_keys = set() + for t in session.query(Transporter.transporter_key).filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ).all(): + if t[0]: + valid_transporter_keys.add((t[0] or "").strip().upper()) + + valid_incoterms = set() + for inc in session.query(Incoterm.code).all(): + if inc[0]: + valid_incoterms.add((inc[0] or "").strip().upper()) + + valid_currency_codes = set() + for ct in session.query(CurrencyType.code).all(): + if ct[0]: + valid_currency_codes.add((ct[0] or "").strip().upper()) + + exchange_rate_by_date = {} + for er in session.query(ExchangeRate.date, ExchangeRate.value).filter( + ExchangeRate.tenant_id == tenant_id, + ExchangeRate.company_id == company_id, + ).all(): + if er[0] and er[1] is not None: + dk = er[0].strftime("%Y-%m-%d") if hasattr(er[0], "strftime") else str(er[0])[:10] + exchange_rate_by_date[dk] = er[1] + + invoice_has_partidas_by_number = {} + existing_tipo_moneda_by_number = {} + q_li_count = ( + session.query(InvoiceHeader.invoice_number, func.count(LineItem.id)) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + .group_by(InvoiceHeader.invoice_number) + ) + for num, cnt in q_li_count.all(): + if num: + invoice_has_partidas_by_number[str(num).strip()] = cnt > 0 + q_fin = ( + session.query(InvoiceHeader.invoice_number, InvoiceFinancials.currency) + .join(InvoiceFinancials, InvoiceFinancials.invoice_id == InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "MEX", + ) + ) + for num, cur in q_fin.all(): + if num and cur: + cur_str = (cur or "").strip().lower() + if cur_str == "foreign": + existing_tipo_moneda_by_number[str(num).strip()] = "ME" + elif cur_str == "local": + existing_tipo_moneda_by_number[str(num).strip()] = "MN" + else: + existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2] + + with open(file_path, "r", encoding="utf-8-sig") as f_in: + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f_in, dialect=dialect) + rows_list = list(reader) + + error_count = 0 + processed_rows = 0 + error_lines_list = [] + errors_detail = [] + + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + if i % 1000 == 0: + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + row_norm = row_from_template(row, "cmex_header", normalize_header) + warnings_row = [] + err = validate_row_encabezados_cmex( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_updated_by_number=invoice_updated_by_number, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_currency_codes=valid_currency_codes, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + if err: + error_count += 1 + error_lines_list.append(err["line"]) + f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n") + if len(errors_detail) < 500: + errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + for w in warnings_row: + if len(errors_detail) < 500: + errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True}) + processed_rows += 1 + + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + return common_responses.scan_result( + job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + ) + except Exception as e: + logger.exception("Encabezados Compras Mexicanas scan failed: %s", e) + return {"status": "failed", "error": str(e)} + + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + 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.currency_types.models import CurrencyType + from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode + from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( + CodePedimentoRegimen, + ) + from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType + from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + from api.v1.modules.public.reference_data.incoterms.models import Incoterm + from api.v1.modules.a76.parts.models import Part + + models = { + "InvoiceHeader": InvoiceHeader, + "InvoiceType": InvoiceType, + "ClientProvider": ClientProvider, + "CustomsBroker": CustomsBroker, + "RegimenPedimento": RegimenPedimento, + "CodePedimentoRegimen": CodePedimentoRegimen, + "PedimentoCode": PedimentoCode, + "CurrencyType": CurrencyType, + "CustomsSection": CustomsSection, + "Incoterm": Incoterm, + "Part": Part, + } + + with CoreSessionLocal() as session, \ + open(file_path, 'r', encoding='utf-8-sig') as f_in, \ + open(error_path, 'w', encoding='utf-8') as f_err: + validator = ForeignKeyValidator(session, tenant_id, company_id) + invoice_id_cache: Dict[str, Optional[int]] = {} + + # Detect Delimiter + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except: + dialect = 'excel' + + reader = csv.DictReader(f_in, dialect=dialect) + + for i, row in enumerate(reader, start=1): + # Check for Progress Update + if i % 1000 == 0: + self.update_state(state='PROGRESS', meta={ + 'current': i, + 'total': total_rows, + 'errors': error_count + }) + + # Solo columnas de la plantilla (respetar plantilla tal cual) + row_norm = row_from_template(row, template_id, normalize_header) + errors = validate_row_strict( + row_norm, + model_target, + i, + date_format, + validator, + inv_type_value, + invoice_id_cache, + models, + ) + + if errors: + error_count += 1 + # Write simple JSON error + f_err.write(json.dumps(errors) + "\n") + + processed_rows += 1 + + except Exception as e: + logger.error(f"Scan failed: {e}") + return {"status": "failed", "error": str(e)} + + # 4. Store error line numbers in Redis so insert_valid_rows can skip them (any worker) + error_lines_list = [] + errors_detail: List[Dict[str, Any]] = [] + try: + if os.path.exists(error_path): + with open(error_path, "r", encoding="utf-8") as f: + for line in f: + try: + err = json.loads(line) + if "line" in err: + error_lines_list.append(err["line"]) + if len(errors_detail) < 500: + errors_detail.append( + { + "line": err["line"], + "col": err.get("col", ""), + "msg": err.get("msg", ""), + } + ) + except Exception: + pass + common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) + except Exception as e: + logger.warning(f"Failed to store error lines in Redis: {e}") + + return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) + +def validate_row_phase_1( + row: Dict[str, Any], + target: str, + line_num: int, + date_format: Optional[str], +) -> Optional[Dict[str, Any]]: + """ + Validation: Unique IDs, Dates, and Numeric constraint checks. + Target: 'invoice_header' or 'invoice_details' + """ + def check_decimal(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if parse_decimal(val) is None: + return {"line": line_num, "col": col_name, "msg": "Debe ser un número decimal válido"} + return None + + def check_int(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if parse_int(val) is None: + return {"line": line_num, "col": col_name, "msg": "Debe ser un número entero válido"} + return None + + def check_date(col_name): + date_str = row.get(col_name) + if date_str and str(date_str).strip(): + if not is_valid_date(date_str, date_format): + expected = display_date_format(date_format) + return { + "line": line_num, + "col": col_name, + "msg": f"Formato de fecha inválido ({expected})", + } + return None + + def check_weight(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if parse_weight_unit(val) is None: + return {"line": line_num, "col": col_name, "msg": "Unidad de peso inválida (ej. KGS, LBS)"} + return None + + def check_currency(col_name): + val = row.get(col_name) + if val and str(val).strip(): + parsed_currency = parse_currency(val, None) + val_norm = normalize_header(val) + # parse_currency returns MANUAL if unknown, so if it wasn't explicitly MANUAL, it's invalid + if parsed_currency.value == "manual" and "MANUAL" not in val_norm: + return {"line": line_num, "col": col_name, "msg": "Moneda inválida (ej. MN, ME, USD, PESOS)"} + return None + + def check_transport_type(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if str(val).strip().lower() not in TRANSPORT_TYPE_VALUES: + return {"line": line_num, "col": col_name, "msg": "Tipo de transporte inválido (ej. box, truck, container)"} + return None + + # A. Invoice Header + if target == 'invoice_header': + # 1. Unique ID + if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'): + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + # 2. Date Format + date_str = row.get('FECHA FACTURA') or row.get('FECHA') + if not date_str or not str(date_str).strip(): + return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"} + + err = check_date('FECHA FACTURA') or check_date('FECHA') + if err: return err + + err = check_date('FECHA EMISION') + if err: return err + + # 3. Numeric Fields + for col in ['TIPO DE CAMBIO', 'FLETES', 'VALOR SEGUROS', 'SEGUROS', 'EMBALAJES', 'OTROS INCREMENTABLES']: + err = check_decimal(col) + if err: return err + + # 4. Integer FKs (CLAVE PROVEEDOR/VENDIDO/ENVIADO aceptan short_name; AGENTE ADUANAL acepta clave; solo REMESA exige entero) + int_fk_cols = ['CLAVE PROVEEDOR', 'CLAVE VENDIDO A', 'CLAVE ENVIADO A', 'AGENTE ADUANAL', 'REMESA'] + if target == 'invoice_header': + int_fk_cols = ['REMESA'] # proveedor/vendido/enviado por short_name; agente aduanal por clave + for col in int_fk_cols: + err = check_int(col) + if err: return err + + # 5. Enums + for col in ['TIPO PESO']: + err = check_weight(col) + if err: return err + + for col in ['TIPO MONEDA']: + err = check_currency(col) + if err: return err + + for col in ['TIPO TRANSPORTE']: + err = check_transport_type(col) + if err: return err + + # B. Invoice Details (Parts) + elif target == 'invoice_details': + # 1. Line Number + if not row.get('LINEA') and not row.get('RENGLON') and not row.get('PARTIDA'): + return {"line": line_num, "col": "LINEA", "msg": "Requerido"} + + # 2. Parent Link (Invoice Number) + if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')): + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + # 3. Numeric Fields + for col in ['PRECIO UNITARIO', 'PRECIOUNITARIO', 'VALOR COMERCIAL', 'VALORCOMERCIAL', 'CANTIDAD']: + err = check_decimal(col) + if err: return err + + for col in ['CANTIDAD BULTOS', 'CANTIDADBULTOS', 'LINEA', 'RENGLON', 'PARTIDA']: + err = check_int(col) + if err: return err + + return None + + +def validate_row_strict( + row: Dict[str, Any], + target: str, + line_num: int, + date_format: Optional[str], + validator: ForeignKeyValidator, + inv_type_value: str, + invoice_id_cache: Dict[str, Optional[int]], + models: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + err = validate_row_phase_1(row, target, line_num, date_format) + if err: + return err + + InvoiceHeader = models["InvoiceHeader"] + InvoiceType = models["InvoiceType"] + ClientProvider = models["ClientProvider"] + CustomsBroker = models["CustomsBroker"] + RegimenPedimento = models["RegimenPedimento"] + CurrencyType = models["CurrencyType"] + CustomsSection = models["CustomsSection"] + Incoterm = models["Incoterm"] + Part = models["Part"] + + if target == "invoice_header": + if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): + return {"line": line_num, "col": "TIPO FACTURA", "msg": "No existe en el catalogo"} + + err = _validate_client_provider_ref( + validator, ClientProvider, row.get("CLAVE PROVEEDOR"), line_num, "CLAVE PROVEEDOR", required=True + ) + if err: + return err + + err = _validate_client_provider_ref( + validator, ClientProvider, row.get("CLAVE VENDIDO A"), line_num, "CLAVE VENDIDO A", required=True + ) + if err: + return err + + err = _validate_client_provider_ref( + validator, ClientProvider, row.get("CLAVE ENVIADO A"), line_num, "CLAVE ENVIADO A", required=True + ) + if err: + return err + + err = _validate_customs_broker_ref( + validator, CustomsBroker, row.get("AGENTE ADUANAL"), line_num, "AGENTE ADUANAL", required=False + ) + if err: + return err + + err = validate_public_code( + validator, + RegimenPedimento, + row.get("REGIMEN") or row.get("CLAVEDOCUMENTO"), + line_num, + "CLAVEDOCUMENTO", + ) + if err: + return err + + err = validate_public_code( + validator, + CustomsSection, + row.get("ADUANA DE CRUCE"), + line_num, + "ADUANA DE CRUCE", + field_name="customs_code", + ) + if err: + return err + + err = validate_public_code( + validator, + CurrencyType, + row.get("CLAVE MONEDA"), + line_num, + "CLAVE MONEDA", + ) + if err: + return err + + err = validate_public_code( + validator, + Incoterm, + row.get("CLAVE INCOTERM"), + line_num, + "CLAVE INCOTERM", + ) + if err: + return err + + elif target == "invoice_details": + invoice_number = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() + if not invoice_number: + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + cache_key = f"{invoice_number}|{inv_type_value}" + if cache_key in invoice_id_cache: + invoice_id = invoice_id_cache[cache_key] + else: + invoice_id = ( + validator.session.query(InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == validator.tenant_id, + InvoiceHeader.company_id == validator.company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value, + ) + .scalar() + ) + invoice_id_cache[cache_key] = invoice_id + if not invoice_id: + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Factura no existe"} + + part_num = (row.get("NUMPARTE") or row.get("NUMERO PARTE") or "").strip() + if not part_num: + return {"line": line_num, "col": "NUMPARTE", "msg": "Requerido"} + if not validator.check_exists(Part, part_num, field_name="part_number"): + return {"line": line_num, "col": "NUMPARTE", "msg": "No existe en el catalogo"} + + return None + +def parse_footer_config(config: Optional[str]) -> Dict[str, Any]: + if not config: + return {} + try: + if isinstance(config, str): + return json.loads(config) + if isinstance(config, dict): + return config + except Exception: + return {} + return {} + + +def display_date_format(date_format: Optional[str]) -> str: + if not date_format: + return "YYYY-MM-DD" + return date_format.upper() + + +def parse_date(date_text: Optional[str], date_format: Optional[str]) -> Optional[datetime.date]: + if not date_text: + return None + candidates = [] + fmt_map = { + "dd/mm/yyyy": "%d/%m/%Y", + "mm/dd/yyyy": "%m/%d/%Y", + "yyyy-mm-dd": "%Y-%m-%d", + } + if date_format and date_format in fmt_map: + candidates.append(fmt_map[date_format]) + candidates.extend(["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"]) + for fmt in candidates: + try: + return datetime.strptime(str(date_text).strip(), fmt).date() + except ValueError: + continue + return None + + +def is_valid_date(date_text: Optional[str], date_format: Optional[str]) -> bool: + return parse_date(date_text, date_format) is not None + + +def normalize_header(name: Optional[str]) -> str: + if not name: + return "" + name = unicodedata.normalize("NFKD", str(name)).upper() + name = "".join(ch for ch in name if not unicodedata.combining(ch)) + name = re.sub(r"[^A-Z0-9]+", " ", name) + return re.sub(r"\s+", " ", name).strip() + + +def normalize_row(row: Dict[str, Any]) -> Dict[str, Any]: + return {normalize_header(k): v for k, v in row.items()} + + +def parse_int(value: Any) -> Optional[int]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + return int(text) + except ValueError: + pass + try: + f = float(text.replace(",", "")) + if f == int(f): + return int(f) + return None + except ValueError: + return None + + +def parse_decimal(value: Any) -> Optional[Decimal]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + text = text.replace(",", "") + try: + return Decimal(text) + except Exception: + return None + + +def decimal_or_zero(value: Any) -> Decimal: + """Return parsed decimal or Decimal('0') for CSV nulls/empty (vanilla default).""" + return parse_decimal(value) or Decimal("0") + + +def int_or_zero(value: Any) -> int: + """Return parsed int or 0 for CSV nulls/empty (vanilla default).""" + return parse_int(value) if parse_int(value) is not None else 0 + + +def parse_currency(value: Optional[str], currency_type: Optional[str]): + from api.v1.modules.a76.invoices.models import Currency + if value: + normalized = normalize_header(value) + if normalized in {"MN", "M N", "NACIONAL", "LOCAL", "PESOS", "PESO"}: + return Currency.LOCAL + if normalized in {"ME", "M E", "EXTRANJERA", "EXTRANJERO", "FOREIGN", "USD", "DOLAR", "DOLARES"}: + return Currency.FOREIGN + if "MANUAL" in normalized: + return Currency.MANUAL + if currency_type and str(currency_type).strip().upper() == "MXN": + return Currency.LOCAL + if currency_type: + return Currency.FOREIGN + return Currency.MANUAL + + +def parse_weight_unit(value: Optional[str]): + from api.v1.modules.a76.invoices.models import WeightUnit + if not value: + return None + normalized = normalize_header(value) + if normalized in {"KG", "KGS", "KILOS", "KILOGRAMOS"}: + return WeightUnit.KGS + if normalized in {"LB", "LBS", "LIBRAS"}: + return WeightUnit.LBS + return None + + +def resolve_tenant_fk_id( + session: CoreSessionLocal, + model, + value: Optional[int], + tenant_id: int, + company_id: int, + cache: Dict[int, Optional[int]], +) -> Optional[int]: + if value is None: + return None + if value in cache: + return cache[value] + exists = ( + session.query(model.id) + .filter( + model.id == value, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = value if exists is not None else None + return cache[value] + + +def resolve_client_provider_id( + session: CoreSessionLocal, + model, + value: Any, + tenant_id: int, + company_id: int, + cache: Dict[Any, Optional[int]], +) -> Optional[int]: + """Resuelve ID de ClientProvider por id (entero) o por short_name (texto). value puede ser int o str.""" + if value is None or (isinstance(value, str) and not value.strip()): + return None + if value in cache: + return cache[value] + pid = parse_int(value) + if pid is not None: + found = ( + session.query(model.id) + .filter( + model.id == pid, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = found + return found + short_norm = str(value).strip().upper() + if short_norm in cache: + return cache[short_norm] + found = ( + session.query(model.id) + .filter( + func.upper(model.short_name) == short_norm, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = found + cache[short_norm] = found + return found + + +def resolve_customs_broker_id( + session: CoreSessionLocal, + model, + value: Any, + tenant_id: int, + company_id: int, + cache: Dict[Any, Optional[int]], +) -> Optional[int]: + """Resuelve ID de CustomsBroker por id (entero) o por broker_key (clave). value puede ser int o str.""" + if value is None or (isinstance(value, str) and not value.strip()): + return None + if value in cache: + return cache[value] + pid = parse_int(value) + if pid is not None: + found = ( + session.query(model.id) + .filter( + model.id == pid, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = found + return found + clave = str(value).strip() + if clave in cache: + return cache[clave] + found = ( + session.query(model.id) + .filter( + model.broker_key == clave, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = found + cache[clave] = found + return found + + +def resolve_public_code( + session: CoreSessionLocal, + model, + column, + value: Optional[str], + cache: Dict[str, Optional[str]], +) -> Optional[str]: + if not value: + return None + normalized = str(value).strip().upper() + if not normalized: + return None + if normalized in cache: + return cache[normalized] + exists = session.query(column).filter(column == normalized).scalar() + cache[normalized] = normalized if exists is not None else None + return cache[normalized] + +@celery_app.task(bind=True) +def insert_valid_rows(self, job_id: str, model_target: str, job_type_override: Optional[str] = None): + """Pass 2: Re-read CSV, Skip Errors, Bulk Insert. Delegates to _do_insert_valid_rows.""" + return _do_insert_valid_rows(job_id, model_target, job_type_override) + + +def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Optional[str] = None) -> Dict[str, Any]: + """ + Pass 2: Re-read CSV, Skip Errors, Bulk Insert. + File and meta are loaded from Redis if present (same as scan_file), so worker does not need shared filesystem. + When job_type_override is set (e.g. "exp" for Exportación), storage keys use that prefix. + """ + effective_job_type = job_type_override if job_type_override is not None else JOB_TYPE + log_prefix = "Exportación import" if effective_job_type else "Invoices import" + + logger.info(f"Starting Commit for {job_id} target {model_target}") + + # Ensure we have the file on this worker: prefer Redis (so any worker can run commit) + file_path = common_storage.ensure_file_from_redis(effective_job_type, job_id, log_prefix) + if not file_path: + alt_path = common_storage.file_path_for_job(effective_job_type, job_id) + if not os.path.exists(alt_path): + return {"status": "failed", "error": "File not found (missing or expired). Please upload and confirm again."} + file_path = alt_path + else: + common_storage.ensure_meta_from_redis(effective_job_type, job_id, file_path, log_prefix) + + try: + tenant_id, company_id = common_meta.require_tenant_context(file_path) + except ValueError as e: + return {"status": "failed", "error": str(e)} + + meta = common_meta.load_meta(file_path) + meta_path = common_meta.get_meta_path(file_path) + error_path = common_storage.error_path_for_job(effective_job_type, job_id) + error_lines = common_storage.get_error_lines(effective_job_type, job_id, error_path) + + # Si el upload fue de series (template_id imp_temp_series o imp_def_series), usar flujo series aunque model_target venga mal + use_series_flow = ( + model_target == "invoice_series" + or meta.get("template_id") in ("imp_temp_series", "imp_def_series", "cmex_series", "exp_def_series") + ) + + _footer_for_series = parse_footer_config(meta.get("footer_config")) or {} + _inv_type_series = normalize_public_code(_footer_for_series.get("invoice_type") or meta.get("invoice_type") or "") + use_def_series_commit = ( + use_series_flow + and ( + meta.get("template_id") == "imp_def_series" + or meta.get("template_id") == "cmex_series" + or _inv_type_series in ("DEF", "MATDE", "EXDEF") + ) + ) + use_expo_series_commit = use_series_flow and meta.get("template_id") == "exp_def_series" + + # --- Series de Exportación Definitiva: commit (INSERT/UPDATE item_line_series para facturas exp) --- + if use_expo_series_commit: + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from .validators.series_expo import row_to_series_normalized_expo + + actualizar = meta.get("actualizar", False) + autonumerar = meta.get("autonumerar", True) + _fc = parse_footer_config(meta.get("footer_config")) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + else: + as_val = _fc.get("autonumber_series", "true") + autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") + + with CoreSessionLocal() as session: + q_inv = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "exp", + ) + ) + invoice_id_by_number: Dict[str, int] = {} + for num, iid in q_inv.all(): + if num: + invoice_id_by_number[str(num).strip()] = iid + + inserted_count = 0 + updated_count = 0 + skipped_invalid = 0 + skipped_details: List[Dict[str, Any]] = [] + + with open(file_path, "r", encoding="utf-8-sig") as f: + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f, dialect=dialect) + + for i, row in enumerate(reader, start=1): + if i in error_lines: + skipped_invalid += 1 + continue + row_norm = row_from_template(row, "exp_def_series", normalize_header) + data = row_to_series_normalized_expo(row_norm) + invoice_number = data["NUMERO FACTURA"] + linea_factura = data["LINEA FACTURA"] + linea_serie = data["LINEA SERIE"] + + if not invoice_number or invoice_number not in invoice_id_by_number: + skipped_invalid += 1 + skipped_details.append({ + "line": i, + "invoice": invoice_number or "(vacío)", + "reason": "Factura de exportación no encontrada.", + }) + continue + invoice_id = invoice_id_by_number[invoice_number] + line_number_val = parse_int(linea_factura) + if line_number_val is None: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA FACTURA debe ser numérico."}) + continue + line_item = ( + session.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.line_number == line_number_val, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + if not line_item: + line_numbers = [ + r[0] for r in + session.query(LineItem.line_number) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .order_by(LineItem.line_number) + .all() + ] + existing_str = ", ".join(str(n) for n in line_numbers) if line_numbers else "ninguna" + skipped_invalid += 1 + skipped_details.append({ + "line": i, + "invoice": invoice_number, + "reason": f"Partida línea {linea_factura} no existe en la factura. Partidas existentes: {existing_str}.", + }) + continue + + if autonumerar: + max_row = ( + session.query(Serie.row) + .filter(Serie.line_item_id == line_item.id) + .order_by(Serie.row.desc()) + .limit(1) + .scalar() + ) + row_num = (max_row or 0) + 1 + else: + row_num = parse_int(linea_serie) + if row_num is None: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA SERIE debe ser numérico."}) + continue + + existing_serie = ( + session.query(Serie) + .filter( + Serie.line_item_id == line_item.id, + Serie.row == row_num, + ) + .first() + ) + + if existing_serie: + if actualizar: + existing_serie.serial_numbers = data["SERIE"] or existing_serie.serial_numbers + existing_serie.model = data["MODELO"] or existing_serie.model + existing_serie.sub_model = data["SUB MODELO"] or existing_serie.sub_model + existing_serie.number_id = data["NUMERO ID"] or existing_serie.number_id + session.add(existing_serie) + updated_count += 1 + else: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "Serie ya existe (use actualizar)."}) + else: + new_serie = Serie( + tenant_id=tenant_id, + company_id=company_id, + line_item_id=line_item.id, + row=row_num, + serial_numbers=data["SERIE"] or None, + model=data["MODELO"] or None, + sub_model=data["SUB MODELO"] or None, + number_id=data["NUMERO ID"] or None, + ) + session.add(new_serie) + inserted_count += 1 + + session.commit() + + common_storage.cleanup_import_job(effective_job_type, job_id, file_path=file_path, error_path=error_path, meta_path=meta_path) + status = "finished" if (inserted_count + updated_count) > 0 else ("warning" if skipped_invalid else "failed") + out = { + "status": status, + "inserted": inserted_count, + "updated": updated_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": 0, + "skipped_duplicate": 0, + "skipped_details": skipped_details, + } + if status == "failed": + out["error"] = "No hay registros válidos en el archivo CSV." + elif status == "warning" and skipped_invalid: + out["message"] = f"No se insertaron registros. {skipped_invalid} fueron rechazados." + return out + except Exception as e: + logger.exception("Series exportación definitiva commit failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Series de Importación Definitiva: commit (INSERT/UPDATE item_line_series para facturas DEF o MEX) --- + if use_def_series_commit: + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from .validators.series_impo_def import ( + validate_row_series_impo_def, + row_to_series_normalized_def, + ) + + _series_template_id = meta.get("template_id") + is_cmex_series = _series_template_id == "cmex_series" + SERIES_INV_TYPES = ("MEX",) if is_cmex_series else ("DEF", "MATDE", "EXDEF") + series_row_template_id = "cmex_series" if is_cmex_series else "imp_def_series" + + DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") # keep for any legacy reference + actualizar = meta.get("actualizar", False) + autonumerar = meta.get("autonumerar", True) + validar_series_exception = meta.get("validar_series", False) + _fc = parse_footer_config(meta.get("footer_config")) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + elif _fc.get("mode") == "update": + actualizar = True + elif _fc.get("mode") == "replace": + actualizar = False + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + else: + as_val = _fc.get("autonumber_series", "true") + autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") + if "validar_series" in _fc: + validar_series_exception = bool(_fc["validar_series"]) + + with CoreSessionLocal() as session: + q = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(SERIES_INV_TYPES), + ) + ) + rows_inv = q.all() + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in rows_inv: + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + partida_max_series = {} + q_qty = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + LineQuantity.quantity, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .outerjoin(LineQuantity, LineQuantity.item_line_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(SERIES_INV_TYPES), + ) + ) + for num, ln, qty in q_qty.all(): + if num is not None and ln is not None: + key = (str(num).strip(), str(ln).strip()) + partida_max_series[key] = int(qty) if qty else 0 + + existing_series_keys = set() + existing_series_data: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + if actualizar and not autonumerar: + q_ser = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + Serie.row, + Serie.serial_numbers, + Serie.model, + Serie.sub_model, + Serie.number_id, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(Serie, Serie.line_item_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type.in_(SERIES_INV_TYPES), + ) + ) + for num, ln, rw, sn, md, sm, nid in q_ser.all(): + if num is not None: + k = (str(num).strip(), str(ln).strip(), str(rw).strip()) + existing_series_keys.add(k) + existing_series_data.setdefault(k, { + "serial_numbers": sn or "", + "model": md or "", + "sub_model": sm or "", + "number_id": nid or "", + }) + + csv_series_count_so_far: Dict[Tuple[str, str], int] = {} + inserted_count = 0 + updated_count = 0 + skipped_invalid = 0 + skipped_details: List[Dict[str, Any]] = [] + + with open(file_path, "r", encoding="utf-8-sig") as f: + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f, dialect=dialect) + + for i, row in enumerate(reader, start=1): + if i in error_lines: + continue + row_norm = row_from_template(row, series_row_template_id, normalize_header) + err = validate_row_series_impo_def( + row_norm, + i, + actualizar=actualizar, + autonumerar=autonumerar, + validar_series_exception=validar_series_exception, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + partida_max_series=partida_max_series, + csv_series_count_so_far=csv_series_count_so_far, + existing_series_keys=existing_series_keys, + existing_series_data=existing_series_data, + warnings=None, + catalog_label="Compras Mexicanas" if is_cmex_series else "Importación Definitiva", + ) + if err: + skipped_invalid += 1 + skipped_details.append({ + "line": i, + "invoice": (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip(), + "reason": err.get("msg", ""), + }) + continue + + data = row_to_series_normalized_def(row_norm) + invoice_number = data["NUMERO FACTURA"] + linea_factura = data["LINEA FACTURA"] + linea_serie = data["LINEA SERIE"] + + if not invoice_number or invoice_number not in invoice_id_by_number: + skipped_invalid += 1 + continue + invoice_id = invoice_id_by_number[invoice_number] + line_number_val = parse_int(linea_factura) + if line_number_val is None: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA FACTURA debe ser numérico."}) + continue + line_item = ( + session.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.line_number == line_number_val, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + if not line_item: + line_numbers = [ + r[0] for r in + session.query(LineItem.line_number) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .order_by(LineItem.line_number) + .all() + ] + existing_str = ", ".join(str(n) for n in line_numbers) if line_numbers else "ninguna" + skipped_invalid += 1 + skipped_details.append({ + "line": i, + "invoice": invoice_number, + "reason": f"Partida línea {linea_factura} no existe en la factura. Partidas existentes en la factura: {existing_str}.", + }) + continue + + if autonumerar: + max_row = ( + session.query(Serie.row) + .filter(Serie.line_item_id == line_item.id) + .order_by(Serie.row.desc()) + .limit(1) + .scalar() + ) + row_num = (max_row or 0) + 1 + else: + row_num = parse_int(linea_serie) + if row_num is None: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA SERIE debe ser numérico."}) + continue + + existing_serie = ( + session.query(Serie) + .filter( + Serie.line_item_id == line_item.id, + Serie.row == row_num, + ) + .first() + ) + + if existing_serie: + if actualizar: + existing_serie.serial_numbers = data["SERIE"] or existing_serie.serial_numbers + existing_serie.model = data["MODELO"] or existing_serie.model + existing_serie.sub_model = data["SUB MODELO"] or existing_serie.sub_model + existing_serie.number_id = data["NUMERO ID"] or existing_serie.number_id + session.add(existing_serie) + updated_count += 1 + else: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "Serie ya existe (use actualizar)."}) + else: + new_serie = Serie( + tenant_id=tenant_id, + company_id=company_id, + line_item_id=line_item.id, + row=row_num, + serial_numbers=data["SERIE"] or None, + model=data["MODELO"] or None, + sub_model=data["SUB MODELO"] or None, + number_id=data["NUMERO ID"] or None, + ) + session.add(new_serie) + inserted_count += 1 + + key_csv = (invoice_number.strip(), (linea_factura or "").strip()) + if key_csv[0] and key_csv[1]: + csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 + + session.commit() + + common_storage.cleanup_import_job(effective_job_type, job_id, file_path=file_path, error_path=error_path, meta_path=meta_path) + status = "finished" if (inserted_count + updated_count) > 0 else ("warning" if skipped_invalid else "failed") + out = { + "status": status, + "inserted": inserted_count, + "updated": updated_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": 0, + "skipped_duplicate": 0, + "skipped_details": skipped_details, + } + if status == "failed": + out["error"] = "No hay registros válidos en el archivo CSV." + elif status == "warning" and skipped_invalid: + out["message"] = f"No se insertaron registros. {skipped_invalid} fueron rechazados." + return out + except Exception as e: + logger.exception("Series importación definitiva commit failed: %s", e) + return {"status": "failed", "error": str(e)} + + # --- Series de Importación Temporal: commit (INSERT/UPDATE item_line_series) --- + if use_series_flow: + try: + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.series.models import Serie + from .validators.series_impo_temp import ( + validate_row_series_impo_temp, + row_to_series_normalized, + ) + + actualizar = meta.get("actualizar", False) + autonumerar = meta.get("autonumerar", True) + validar_series_exception = meta.get("validar_series", False) + _fc = parse_footer_config(meta.get("footer_config")) + if _fc: + if "actualizar" in _fc: + actualizar = bool(_fc["actualizar"]) + elif _fc.get("mode") == "update": + actualizar = True + elif _fc.get("mode") == "replace": + actualizar = False + if "autonumerar" in _fc: + autonumerar = bool(_fc["autonumerar"]) + else: + as_val = _fc.get("autonumber_series", "true") + autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") + if "validar_series" in _fc: + validar_series_exception = bool(_fc["validar_series"]) + + with CoreSessionLocal() as session: + q = ( + session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.is_updated) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + rows_inv = q.all() + invoice_id_by_number: Dict[str, int] = {} + invoice_updated_by_number: Dict[str, bool] = {} + for num, iid, is_upd in rows_inv: + if num: + invoice_id_by_number[str(num).strip()] = iid + invoice_updated_by_number[str(num).strip()] = bool(is_upd) + + existing_series_keys: Set[Tuple[str, str, str]] = set() + existing_series_data: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + if actualizar and not autonumerar: + q_ser = ( + session.query( + InvoiceHeader.invoice_number, + LineItem.line_number, + Serie.row, + Serie.serial_numbers, + Serie.model, + Serie.sub_model, + Serie.number_id, + ) + .join(LineItem, LineItem.invoice_id == InvoiceHeader.id) + .join(Serie, Serie.line_item_id == LineItem.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp", + InvoiceHeader.invoice_type == "TEM", + ) + ) + for num, ln, rw, sn, md, sm, nid in q_ser.all(): + if num is not None: + k = (str(num).strip(), str(ln).strip(), str(rw).strip()) + existing_series_keys.add(k) + existing_series_data.setdefault(k, { + "serial_numbers": sn or "", + "model": md or "", + "sub_model": sm or "", + "number_id": nid or "", + }) + + inserted_count = 0 + updated_count = 0 + skipped_invalid = 0 + skipped_details: List[Dict[str, Any]] = [] + + with open(file_path, "r", encoding="utf-8-sig") as f: + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = "excel" + reader = csv.DictReader(f, dialect=dialect) + + for i, row in enumerate(reader, start=1): + if i in error_lines: + continue + row_norm = row_from_template(row, "imp_temp_series", normalize_header) + err = validate_row_series_impo_temp( + row_norm, + i, + actualizar=actualizar, + autonumerar=autonumerar, + validar_series_exception=validar_series_exception, + invoice_id_by_number=invoice_id_by_number, + invoice_updated_by_number=invoice_updated_by_number, + existing_series_keys=existing_series_keys, + existing_series_data=existing_series_data, + warnings=None, + ) + if err: + skipped_invalid += 1 + skipped_details.append({ + "line": i, + "invoice": (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip(), + "reason": err.get("msg", ""), + }) + continue + + data = row_to_series_normalized(row_norm) + invoice_number = data["NUMERO FACTURA"] + linea_factura = data["LINEA FACTURA"] + linea_serie = data["LINEA SERIE"] + + if not invoice_number or invoice_number not in invoice_id_by_number: + skipped_invalid += 1 + continue + invoice_id = invoice_id_by_number[invoice_number] + line_number_val = parse_int(linea_factura) + if line_number_val is None: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA FACTURA debe ser numérico."}) + continue + line_item = ( + session.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.line_number == line_number_val, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + if not line_item: + skipped_invalid += 1 + skipped_details.append({ + "line": i, + "invoice": invoice_number, + "reason": f"Partida línea {linea_factura} no existe en la factura.", + }) + continue + + if autonumerar: + max_row = ( + session.query(Serie.row) + .filter(Serie.line_item_id == line_item.id) + .order_by(Serie.row.desc()) + .limit(1) + .scalar() + ) + row_num = (max_row or 0) + 1 + else: + row_num = parse_int(linea_serie) + if row_num is None: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA SERIE debe ser numérico."}) + continue + + existing_serie = ( + session.query(Serie) + .filter( + Serie.line_item_id == line_item.id, + Serie.row == row_num, + ) + .first() + ) + + if existing_serie: + if actualizar: + existing_serie.serial_numbers = data["SERIE"] or existing_serie.serial_numbers + existing_serie.model = data["MODELO"] or existing_serie.model + existing_serie.sub_model = data["SUB MODELO"] or existing_serie.sub_model + existing_serie.number_id = data["NUMERO ID"] or existing_serie.number_id + session.add(existing_serie) + updated_count += 1 + else: + skipped_invalid += 1 + skipped_details.append({"line": i, "invoice": invoice_number, "reason": "Serie ya existe (use actualizar)."}) + else: + new_serie = Serie( + tenant_id=tenant_id, + company_id=company_id, + line_item_id=line_item.id, + row=row_num, + serial_numbers=data["SERIE"] or None, + model=data["MODELO"] or None, + sub_model=data["SUB MODELO"] or None, + number_id=data["NUMERO ID"] or None, + ) + session.add(new_serie) + inserted_count += 1 + + session.commit() + + common_storage.cleanup_import_job(effective_job_type, job_id, file_path=file_path, error_path=error_path, meta_path=meta_path) + status = "finished" if (inserted_count + updated_count) > 0 else ("warning" if skipped_invalid else "failed") + out = { + "status": status, + "inserted": inserted_count, + "updated": updated_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": 0, + "skipped_duplicate": 0, + "skipped_details": skipped_details, + } + if status == "failed": + out["error"] = "No hay registros válidos en el archivo CSV." + elif status == "warning" and skipped_invalid: + out["message"] = f"No se insertaron registros. {skipped_invalid} fueron rechazados." + return out + except Exception as e: + logger.exception("Series import commit failed: %s", e) + return {"status": "failed", "error": str(e)} + + try: + from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, + InvoiceComplianceMx, + InvoiceFinancials, + InvoiceLogistics, + InvoiceSalesDetails, + OperationType, + TransportType, + WeightUnit, + ) + 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.pedmientos.models.pedimentos import Pedimentos + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode + from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen + from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType + from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + from api.v1.modules.public.reference_data.incoterms.models import Incoterm + from .validators.encabezados_impo_temp import ( + parse_pedimento_col_a, + _pedimento_key_from_parsed, + row_to_transport_type_clarion, + ) + from .validators.encabezados_impo_def import parse_pedimento_col_a_impo_def + + from api.v1.modules.a76.items.models import LineItem + from api.v1.modules.a76.items.line_financials.models import LineFinancial + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from api.v1.modules.a76.items.line_customs.models import LineCustom + from api.v1.modules.a76.items.line_descriptions.models import LineDescription + from api.v1.modules.a76.parts.models import Part + 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 + + footer_config = parse_footer_config(meta.get("footer_config")) + + date_format = footer_config.get("dateFormat") + # Validate and set default date_format if not provided + if not date_format: + date_format = "yyyy-mm-dd" # Default to ISO format + logger.info(f"No date_format specified in config, using default: {date_format}") + else: + logger.info(f"Using date_format from config: {date_format}") + + # Default types from config or fallback + op_type_value = OperationType(meta.get('operation_type', 'imp').lower()) + inv_type_value = normalize_public_code(footer_config.get('invoice_type') or 'TEM') or 'TEM' + _template_id_insert = meta.get("template_id") or ( + "imp_temp_header" if model_target == "invoice_header" else "imp_temp_details" + ) + if job_type_override == "exp" and model_target == "invoice_details": + _template_id_insert = "exp_def_partidas" + if job_type_override == "exp" and model_target == "invoice_series": + _template_id_insert = "exp_def_series" + if model_target == "invoice_header" and _template_id_insert == "imp_def_header": + inv_type_value = "DEF" + if model_target == "invoice_header" and _template_id_insert == "cmex_header": + inv_type_value = "MEX" + if model_target == "invoice_header" and _template_id_insert == "exp_def_header": + op_type_value = OperationType("exp") + inv_type_value = normalize_public_code( + meta.get("tipo_factura") or footer_config.get("tipo_factura") or "AFIJO" + ) or "AFIJO" + _es_cambio_regimen = None + if model_target == "invoice_header" and _template_id_insert == "exp_def_header": + cambio_regimen_raw = ( + str(meta.get("cambio_regimen") or footer_config.get("cambio_regimen") or "NO").strip().upper() + ) + _es_cambio_regimen = "S" if cambio_regimen_raw == "SI" else "N" + if model_target == "invoice_details" and _template_id_insert == "imp_def_details": + inv_type_value = "DEF" + if model_target == "invoice_details" and _template_id_insert == "cmex_details": + inv_type_value = "MEX" + if model_target == "invoice_details" and _template_id_insert == "exp_def_partidas": + op_type_value = OperationType("exp") + inv_type_value = normalize_public_code( + meta.get("tipo_factura") or footer_config.get("tipo_factura") or "AFIJO" + ) or "AFIJO" + if _template_id_insert == "cmex_series": + inv_type_value = "MEX" + + logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}") + + headers_to_insert = [] + details_to_insert = [] + skipped_invalid = 0 + skipped_missing_invoice = 0 + skipped_missing_fk = 0 + skipped_fk_details = [] + inserted_count = 0 + response = None + + with CoreSessionLocal() as session: + invoice_id_cache = {} + cleared_invoices = set() # Track invoices where we've already cleared items in this job + provider_cache: Dict[Any, Optional[int]] = {} + sold_to_cache: Dict[Any, Optional[int]] = {} + shipped_to_cache: Dict[Any, Optional[int]] = {} + broker_cache: Dict[Any, Optional[int]] = {} + regimen_cache: Dict[str, Optional[str]] = {} + currency_type_cache: Dict[str, Optional[str]] = {} + customs_section_cache: Dict[str, Optional[str]] = {} + part_cache: Dict[str, Optional[int]] = {} + pedimento_id_cache: Dict[str, Optional[int]] = {} + shipped_by_cache: Dict[Any, Optional[int]] = {} + _fc_insert = parse_footer_config(meta.get("footer_config")) + autonumerar_remesas_insert = _fc_insert.get("autonumerar_remesas", False) + class_id_by_code: Dict[str, int] = {} + uom_id_by_code: Dict[str, int] = {} + package_id_by_key: Dict[str, int] = {} + if model_target == 'invoice_details': + for c in session.query(Class.id, Class.class_code).filter(Class.tenant_id == tenant_id, Class.company_id == company_id).all(): + if c[1]: + class_id_by_code[(c[1] or "").strip().upper()] = c[0] + for u in session.query(UnitOfMeasure.id, UnitOfMeasure.code).filter(UnitOfMeasure.tenant_id == tenant_id, UnitOfMeasure.company_id == company_id).all(): + if u[1]: + uom_id_by_code[(u[1] or "").strip().upper()] = u[0] + for p in session.query(Package.id, Package.key).filter(Package.tenant_id == tenant_id, Package.company_id == company_id).all(): + if p[1]: + package_id_by_key[(p[1] or "").strip()] = p[0] + + validator = ForeignKeyValidator(session, tenant_id, company_id) + + error_msg_by_line: Dict[int, str] = {} + if error_path and os.path.exists(error_path): + try: + with open(error_path, "r", encoding="utf-8") as f_err: + for line in f_err: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + if "line" in rec and "msg" in rec: + error_msg_by_line[int(rec["line"])] = str(rec["msg"]).strip() + except (json.JSONDecodeError, ValueError, TypeError): + pass + except Exception: + pass + + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Detect Delimiter + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except: + dialect = 'excel' + + reader = csv.DictReader(f, dialect=dialect) + + template_id = _template_id_insert + + for i, row in enumerate(reader, start=1): + row_norm = row_from_template(row, template_id, normalize_header) + if i in error_lines: + skipped_invalid += 1 + if model_target == 'invoice_header': + inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() + elif _template_id_insert == "exp_def_partidas": + inv_for_detail = (row_norm.get('NUMERO FACTURA EXPO') or row_norm.get('NUMERO FACTURA EXPO.') or row_norm.get('FACTURA EXPO') or '').strip() + else: + inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() + reason = error_msg_by_line.get(i, "Línea marcada con error en el escaneo previo (revisar reporte de validación).") + skipped_fk_details.append({ + "line": i, + "invoice": inv_for_detail or "(vacío)", + "reason": reason, + }) + continue + + # Mapping Logic (solo campos que acepta el modelo de facturas) + if model_target == 'invoice_header': + invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() + invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format) + + if not invoice_number or not invoice_date: + skipped_invalid += 1 + reason = "Número de factura o fecha faltante/inválida" + skipped_fk_details.append({"line": i, "invoice": invoice_number or "(vacío)", "reason": reason}) + logger.debug(f"Row {i}: Skipped - missing invoice_number or invalid invoice_date. " + f"Invoice: {invoice_number}, Date: {row_norm.get('FECHA FACTURA') or row_norm.get('FECHA')}") + continue + + # --- NEW: Foreign Key Validations --- + # 1. Invoice Type (Public) + if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): + skipped_missing_fk += 1 + reason = f"Tipo de factura '{inv_type_value}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = _validate_client_provider_ref( + validator, + ClientProvider, + row_norm.get('CLAVE PROVEEDOR'), + i, + "CLAVE PROVEEDOR", + required=True, + ) + if err: + skipped_invalid += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = _validate_client_provider_ref( + validator, + ClientProvider, + row_norm.get('CLAVE VENDIDO A'), + i, + "CLAVE VENDIDO A", + required=True, + ) + if err: + skipped_invalid += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = _validate_client_provider_ref( + validator, + ClientProvider, + row_norm.get('CLAVE ENVIADO A'), + i, + "CLAVE ENVIADO A", + required=True, + ) + if err: + skipped_invalid += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + if inv_type_value != "MEX": + err = _validate_customs_broker_ref( + validator, + CustomsBroker, + row_norm.get('AGENTE ADUANAL'), + i, + "AGENTE ADUANAL", + required=False, + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + RegimenPedimento, + row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO'), + i, + "CLAVEDOCUMENTO", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + CustomsSection, + row_norm.get('ADUANA DE CRUCE'), + i, + "ADUANA DE CRUCE", + field_name="customs_code", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + if inv_type_value == "MEX": + err = validate_public_code( + validator, + CurrencyType, + row_norm.get('CLAVE MONEDA'), + i, + "CLAVE MONEDA", + ) + if err and (row_norm.get('TIPO MONEDA') or '').strip().upper() == 'MC': + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + else: + err = validate_public_code( + validator, + CurrencyType, + row_norm.get('CLAVE MONEDA'), + i, + "CLAVE MONEDA", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + Incoterm, + row_norm.get('CLAVE INCOTERM'), + i, + "CLAVE INCOTERM", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + transport_type_val = row_norm.get('TIPO TRANSPORTE') + transport_str_normalized = (row_to_transport_type_clarion(transport_type_val) or str(transport_type_val or "").strip().lower() or "none") + if transport_type_val and transport_str_normalized not in TRANSPORT_TYPE_VALUES: + skipped_invalid += 1 + reason = "TIPO TRANSPORTE: Tipo de transporte invalido" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + currency_val = row_norm.get('TIPO MONEDA') + if currency_val and str(currency_val).strip(): + parsed_currency = parse_currency(currency_val, None) + val_norm = normalize_header(currency_val) + if parsed_currency.value == "manual" and "MANUAL" not in val_norm: + skipped_invalid += 1 + reason = "TIPO MONEDA: Moneda invalida" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # 2. Client/Provider and broker checks are handled above + + # --- 4. Check for Existing Invoice (Upsert Logic) --- + existing_header = None + if invoice_number: + existing_header = ( + session.query(InvoiceHeader) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value, + InvoiceHeader.operation_type == op_type_value, + ) + .first() + ) + + # --- Resolve PEDIMENTO (Col A) to pedimento_id and REMESA (Col B) --- + pedimento_id = None + remesa_val = parse_int(row_norm.get('REMESA')) if inv_type_value != "MEX" else None + ped_str = (row_norm.get('PEDIMENTO') or '').strip() if inv_type_value != "MEX" else '' + if ped_str: + if template_id == "exp_def_header": + parsed = parse_pedimento_col_a_impo_def(ped_str) + else: + parsed = parse_pedimento_col_a(ped_str) + if parsed: + customs_office_p, license_p, num_p = (x.strip() if x else "" for x in parsed) + key_p = _pedimento_key_from_parsed(customs_office_p, license_p, num_p) + if key_p not in pedimento_id_cache: + co_prefix = (customs_office_p or "").strip()[:2].zfill(2) + ped_query = ( + session.query(Pedimentos.id) + .filter( + Pedimentos.tenant_id == tenant_id, + Pedimentos.company_id == company_id, + Pedimentos.customs_office.startswith(co_prefix), + Pedimentos.license == license_p, + Pedimentos.pedimento_number == num_p, + ) + ) + if template_id == "exp_def_header": + if _es_cambio_regimen == "S": + ped_query = ped_query.filter( + func.lower(Pedimentos.operation_type) == "imp", + func.upper(Pedimentos.regime) == "IMD", + ) + else: + ped_query = ped_query.filter( + func.lower(Pedimentos.operation_type) == "exp", + func.upper(Pedimentos.regime).in_(["EXD", "ETE", "ETR"]), + ) + ped_row = ped_query.first() + pedimento_id_cache[key_p] = ped_row[0] if ped_row else None + pedimento_id = pedimento_id_cache[key_p] + if pedimento_id is not None and remesa_val is None and autonumerar_remesas_insert: + max_rem = ( + session.query(func.max(InvoiceComplianceMx.remesa)) + .filter(InvoiceComplianceMx.pedimento_id == pedimento_id) + .scalar() + ) + remesa_val = (max_rem or 0) + 1 + + if existing_header: + # UPDATE existing header + header = existing_header + header.invoice_date = invoice_date + header.operation_type = op_type_value + header.is_updated = True # Mark as updated + header.updated_date = datetime.utcnow() + header.document_type = ( + None if inv_type_value == "MEX" else + resolve_public_code( + session, + RegimenPedimento, + RegimenPedimento.code, + (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), + regimen_cache, + ) + ) + header.project_number = (row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None) + header.purchase_order = (row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None) + header.alternate_invoice = (row_norm.get('FACTURA ALTERNA') or None) + header.invoice_ref = (row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None) + header.emission_date = parse_date(row_norm.get('FECHA EMISION'), date_format) + header.observation_es = (row_norm.get('OBSERVACIONES E') or None) + header.observation_en = (row_norm.get('OBSERVACIONES I') or None) + logger.info(f"Row {i}: Updating existing invoice {invoice_number}") + + # Clean up related data that will be re-inserted/updated + # Note: compliance, financials, logistics are 1-to-1 relationships and will be updated by assignment below + # but we might want to be explicit if ORM doesn't handle replace well. + # SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly. + + else: + # CREATE new header + header = InvoiceHeader( + invoice_number=invoice_number, + invoice_date=invoice_date, + operation_type=op_type_value, + is_updated=False, + system="CSV", + capture_date=datetime.utcnow(), + invoice_type=inv_type_value, + document_type=( + None if inv_type_value == "MEX" else + resolve_public_code( + session, + RegimenPedimento, + RegimenPedimento.code, + (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), + regimen_cache, + ) + ), + project_number=(row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None), + purchase_order=(row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None), + alternate_invoice=(row_norm.get('FACTURA ALTERNA') or None), + invoice_ref=(row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None), + emission_date=parse_date(row_norm.get('FECHA EMISION'), date_format), + observation_es=(row_norm.get('OBSERVACIONES E') or None), + observation_en=(row_norm.get('OBSERVACIONES I') or None), + tenant_id=tenant_id, + company_id=company_id, + ) + + compliance = InvoiceComplianceMx( + pedimento_id=pedimento_id if inv_type_value != "MEX" else None, + remesa=remesa_val if inv_type_value != "MEX" else None, + aduana=( + None if inv_type_value == "MEX" else + resolve_public_code( + session, + CustomsSection, + CustomsSection.customs_code, + row_norm.get('ADUANA DE CRUCE'), + customs_section_cache, + ) + ), + provider_id=resolve_client_provider_id( + session, + ClientProvider, + row_norm.get('CLAVE PROVEEDOR'), + tenant_id, + company_id, + provider_cache, + ), + sold_to_id=resolve_client_provider_id( + session, + ClientProvider, + row_norm.get('CLAVE VENDIDO A'), + tenant_id, + company_id, + sold_to_cache, + ), + shipped_to_id=resolve_client_provider_id( + session, + ClientProvider, + row_norm.get('CLAVE ENVIADO A'), + tenant_id, + company_id, + shipped_to_cache, + ), + customs_broker_id=( + None if inv_type_value == "MEX" else + resolve_customs_broker_id( + session, + CustomsBroker, + row_norm.get('AGENTE ADUANAL'), + tenant_id, + company_id, + broker_cache, + ) + ), + edocument=(row_norm.get('E DOCUMENT') or None), + vucem_operation_num=(row_norm.get('NUM OPERACION') or None), + manifest_number=(row_norm.get('MANIFIESTO') or None), + shipped_by_id=resolve_client_provider_id( + session, + ClientProvider, + row_norm.get('ENVIADO POR'), + tenant_id, + company_id, + shipped_by_cache, + ) if row_norm.get('ENVIADO POR') else None, + tenant_id=tenant_id, + company_id=company_id, + ) + + financials_currency_type = resolve_public_code( + session, + CurrencyType, + CurrencyType.code, + row_norm.get('CLAVE MONEDA'), + currency_type_cache, + ) + financials = InvoiceFinancials( + currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type), + currency_type=financials_currency_type, + exchange_rate=decimal_or_zero(row_norm.get('TIPO DE CAMBIO')), + freight=decimal_or_zero(row_norm.get('FLETES')), + insurance_value=decimal_or_zero(row_norm.get('VALOR SEGUROS')), + insurance=decimal_or_zero(row_norm.get('SEGUROS')), + packaging=decimal_or_zero(row_norm.get('EMBALAJES')), + other_increments=decimal_or_zero(row_norm.get('OTROS INCREMENTABLES')), + tenant_id=tenant_id, + company_id=company_id, + ) + + weight_type = parse_weight_unit(row_norm.get('TIPO PESO')) + logistics = None + if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'): + raw_transport = row_norm.get('TIPO TRANSPORTE') + transport_str = (row_to_transport_type_clarion(raw_transport) or str(raw_transport or "").strip().lower() or "none") + try: + transport_type = TransportType(transport_str) + except ValueError: + transport_type = TransportType.NONE + logistics = InvoiceLogistics( + carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None), + driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), + transport_type=transport_type, + transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), + weight_type=weight_type or WeightUnit.KGS, + seal_number=(row_norm.get('PRECINTO') or None), + incoterm=(row_norm.get('CLAVE INCOTERM') or None), + entry_exit_date=parse_date(row_norm.get('FECHA EMISION'), date_format), + tenant_id=tenant_id, + company_id=company_id, + ) + + header.compliance_mx = compliance + header.financials = financials + if logistics: + header.logistics = logistics + + headers_to_insert.append(header) + + elif model_target == 'invoice_details': + if _template_id_insert == "exp_def_partidas": + invoice_number = ( + row_norm.get('NUMERO FACTURA EXPO') or row_norm.get('NUMERO FACTURA EXPO.') or row_norm.get('FACTURA EXPO') or '' + ).strip() + else: + invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip() + if not invoice_number: + skipped_invalid += 1 + continue + + if _template_id_insert == "exp_def_partidas": + # Expo: lookup by invoice_number + operation_type only (no invoice_type filter, matching scan behavior) + cache_key = f"{invoice_number}|exp" + if cache_key in invoice_id_cache: + invoice_id = invoice_id_cache[cache_key] + else: + invoice_id = ( + session.query(InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.operation_type == "exp", + ) + .scalar() + ) + invoice_id_cache[cache_key] = invoice_id + else: + cache_key = f"{invoice_number}|{inv_type_value}|{op_type_value.value}" + if cache_key in invoice_id_cache: + invoice_id = invoice_id_cache[cache_key] + else: + invoice_id = ( + session.query(InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value, + InvoiceHeader.operation_type == op_type_value, + ) + .scalar() + ) + invoice_id_cache[cache_key] = invoice_id + + if not invoice_id: + logger.warning( + "Invoice not found for details row %s (invoice_number=%s)", + i, + invoice_number, + ) + skipped_missing_invoice += 1 + continue + + # --- Partidas Exportación Definitiva: inserción real --- + if _template_id_insert == "exp_def_partidas": + part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip() + part_id = part_cache.get(part_num) if part_num else None + if part_id is None and part_num: + p = session.query(Part.id).filter(Part.part_number == part_num, Part.tenant_id == tenant_id, Part.company_id == company_id).first() + if p: + part_id = p.id + part_cache[part_num] = part_id + + line_num_val = (row_norm.get('LINEA EXPO') or row_norm.get('LINEA EXPO.') or row_norm.get('RENGLON EXPO')) + line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) + + uom_code = (row_norm.get('UNIDAD DE MEDIDA') or row_norm.get('U.M.') or row_norm.get('UNIDAD MEDIDA') or '').strip().upper() + uom_id = uom_id_by_code.get(uom_code) if uom_code else None + bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip() + package_id = package_id_by_key.get(bulk_key) if bulk_key else None + + descarga_val = (row_norm.get('GENERA DESCARGA') or row_norm.get('GENERA DESCARGA?') or row_norm.get('DESCARGA') or 'SI').strip().upper() + tipo_impo = (row_norm.get('TIPO DE IMPO') or row_norm.get('TIPO DE IMPO.') or row_norm.get('TIPO IMPO') or row_norm.get('PROCEDENCIA') or '').strip().upper() + factura_impo = (row_norm.get('FACTURA IMPO') or row_norm.get('FACTURA IMPO.') or row_norm.get('FACTURA IMPORTACION') or '').strip() + linea_impo_val = (row_norm.get('LINEA IMPO') or row_norm.get('LINEA IMPO.') or row_norm.get('LINEA IMPORTACION') or '').strip() + + se_pago = (row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SE PAGO IMPUESTO? (SI o NO)') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper() + forma_pago = (row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or '').strip() or None + + es_sub_raw = (row_norm.get('ES PARTIDA/SUBPARTIDA') or row_norm.get('ESSUBPARTIDA') or row_norm.get('ES PARTIDA O SUBPARTIDA') or '').strip().upper() + linea_principal_val = (row_norm.get('LINEA PRINCIPAL') or row_norm.get('LINEAPRINCIPAL') or row_norm.get('PARTIDA PRINCIPAL') or '').strip() + is_subitem = (es_sub_raw == 'S') + contains_subitems = (es_sub_raw == 'P') + + # Clear existing line items once per invoice + if invoice_id not in cleared_invoices: + logger.info(f"Clearing existing details for Expo Invoice {invoice_number} (ID: {invoice_id})") + session.query(LineItem).filter(LineItem.invoice_id == invoice_id).delete(synchronize_session=False) + session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) + cleared_invoices.add(invoice_id) + + line = LineItem( + invoice_id=invoice_id, + line_number=line_num, + tenant_id=tenant_id, + company_id=company_id, + part_number_id=part_id, + unit_of_measure=uom_id, + order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None), + tax_payment=(se_pago == 'SI'), + payment_method=forma_pago, + ) + session.add(line) + session.flush() + + # FaLineItem (a24 extension: subpartidas, descarga, factura impo ref) + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + fa_line = FaLineItem( + id=line.id, + tenant_id=tenant_id, + company_id=company_id, + search_invoice=factura_impo or None, + search_line=parse_int(linea_impo_val), + search_type=tipo_impo or None, + download=(descarga_val == 'SI'), + is_subitem=is_subitem, + contains_subitems=contains_subitems, + subitem_number=parse_int(linea_principal_val) if is_subitem else None, + ) + session.add(fa_line) + + price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('COSTOUNITARIO')) + qty = parse_decimal(row_norm.get('CANTIDAD EXPORTADA/DESCARGAR') or row_norm.get('CANTIDAD EXPORTADA') or row_norm.get('CANTIDAD')) + commercial_total = (price * qty) if price and qty else None + + session.add(LineFinancial( + item_line_id=line.id, + unit_cost_capture=decimal_or_zero(price), + total_commercial_value=decimal_or_zero(commercial_total), + )) + + net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO')) + gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO')) + session.add(LineQuantity( + item_line_id=line.id, + quantity=decimal_or_zero(qty), + net_weight=decimal_or_zero(net_w), + gross_weight=decimal_or_zero(gross_w), + package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + package_id=package_id, + )) + + origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip() + fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCIONARANCELARIA') or '').strip() + american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip() + session.add(LineCustom( + item_line_id=line.id, + origin_country=origin or None, + fraction=fraction or None, + american_fraction=american_fraction or None, + )) + + extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip() + additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip() + lot = (row_norm.get('LOTE') or '').strip() + entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUM ENTRADA') or '').strip() + session.add(LineDescription( + item_line_id=line.id, + extra_description=extra_desc or None, + additional_info_spanish=additional_info or None, + lot=lot or None, + entry_number=entry_number or None, + )) + + session.add(InvoiceSalesDetails( + invoice_id=invoice_id, + line_number=line_num, + sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None), + line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + tenant_id=tenant_id, + company_id=company_id, + )) + details_to_insert.append(line) + continue + + part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip() + if not part_num: + skipped_invalid += 1 + reason = "NUMPARTE: Requerido" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + if not validator.check_exists(Part, part_num, field_name="part_number"): + skipped_missing_fk += 1 + reason = f"NUMPARTE '{part_num}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # --- Prevent Duplicates: Clear existing line items for this invoice (Once per job) --- + if invoice_id not in cleared_invoices: + logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates") + session.query(LineItem).filter(LineItem.invoice_id == invoice_id).delete(synchronize_session=False) + session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) + cleared_invoices.add(invoice_id) + + # --- Partidas: LineItem with invoice_id (no Item parent) + full CSV mapping --- + part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip() + part_id = part_cache.get(part_num) if part_num else None + if part_id is None and part_num: + p = session.query(Part.id).filter(Part.part_number == part_num, Part.tenant_id == tenant_id, Part.company_id == company_id).first() + if p: + part_id = p.id + part_cache[part_num] = part_id + + line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA')) + line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) + + class_code = (row_norm.get('CLASE') or '').strip().upper() + class_id = class_id_by_code.get(class_code) if class_code else None + uom_code = (row_norm.get('UNIDAD DE MEDIDA') or row_norm.get('UNIDAD MEDIDA') or '').strip().upper() + uom_id = uom_id_by_code.get(uom_code) if uom_code else None + bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip() + package_id = package_id_by_key.get(bulk_key) if bulk_key else None + + line = LineItem( + invoice_id=invoice_id, + line_number=line_num, + tenant_id=tenant_id, + company_id=company_id, + part_number_id=part_id, + class_id=class_id, + unit_of_measure=uom_id, + order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), + material_type=(row_norm.get('ID TYPE') or row_norm.get('IDTYPE') or None), + tax_payment=(str(row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper() == 'SI'), + payment_method=(row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or None), + valuation_method=(row_norm.get('METODO DE VALORACION') or row_norm.get('METODODEVALORACION') or row_norm.get('METODO VALORACION') or None), + ) + session.add(line) + session.flush() + + price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO')) + if price is None: + total_val = parse_decimal(row_norm.get('TOTAL')) + qty = parse_decimal(row_norm.get('CANTIDAD IMPORTADA') or row_norm.get('CANTIDAD')) + price = (total_val / qty) if (total_val and qty and qty != 0) else None + qty = parse_decimal(row_norm.get('CANTIDAD IMPORTADA') or row_norm.get('CANTIDAD')) + commercial_total = (price * qty) if price and qty else parse_decimal(row_norm.get('TOTAL')) + + session.add(LineFinancial( + item_line_id=line.id, + unit_cost_capture=decimal_or_zero(price), + total_commercial_value=decimal_or_zero(commercial_total), + )) + + net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO')) + gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO')) + session.add(LineQuantity( + item_line_id=line.id, + quantity=decimal_or_zero(qty), + net_weight=decimal_or_zero(net_w), + gross_weight=decimal_or_zero(gross_w), + package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + package_id=package_id, + )) + + origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip() + fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCION') or row_norm.get('FRACCIONARANCELARIA') or '').strip() + fraction_type = (row_norm.get('PREFERENCIA ARANCELARIA') or row_norm.get('PREFERENCIA') or '').strip() + sector = (row_norm.get('SECTOR') or '').strip() + american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip() + session.add(LineCustom( + item_line_id=line.id, + origin_country=origin or None, + fraction=fraction or None, + fraction_type=fraction_type or None, + sector=sector or None, + american_fraction=american_fraction or None, + )) + + desc_es = (row_norm.get('DESCRIPCION ESPAÑOL') or row_norm.get('DESCRIPCIONE') or row_norm.get('DESCRIPCION') or '').strip() + desc_en = (row_norm.get('DESCRIPCION INGLES') or row_norm.get('DESCRIPCIONI') or '').strip() + brand = (row_norm.get('MARCA') or '').strip() + model = (row_norm.get('MODELO') or '').strip() + extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip() + additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip() + lot = (row_norm.get('LOTE') or '').strip() + entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUMEROENTRADA') or row_norm.get('NUM ENTRADA') or '').strip() + session.add(LineDescription( + item_line_id=line.id, + description_spanish=desc_es or None, + description_english=desc_en or None, + brand=brand or None, + model=model or None, + extra_description=extra_desc or None, + additional_info_spanish=additional_info or None, + lot=lot or None, + entry_number=entry_number or None, + )) + + session.add(InvoiceSalesDetails( + invoice_id=invoice_id, + line_number=line_num, + sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), + line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + tenant_id=tenant_id, + company_id=company_id, + )) + details_to_insert.append(line) + + # 3. Bulk Insert (ORM Transaction) + try: + if model_target == 'invoice_header': + if headers_to_insert: + logger.info(f"Attempting to commit {len(headers_to_insert)} headers") + session.add_all(headers_to_insert) + session.commit() + inserted_count = len(headers_to_insert) + logger.info(f"Headers commit successful. Inserted: {inserted_count}") + else: + logger.warning(f"No headers to insert for job {job_id}") + else: + if details_to_insert: + logger.info(f"Attempting to commit {len(details_to_insert)} items and related data") + session.commit() # Everything was already added with session.add() + inserted_count = len(details_to_insert) + logger.info(f"Details commit successful. Inserted: {inserted_count}") + else: + logger.warning(f"No details to insert for job {job_id}") + + except Exception as db_err: + session.rollback() + logger.error(f"DB Error during {model_target} commit: {db_err}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(db_err)} + + # 4. Determine final status and prepare response (inside session block to access variables) + total_skipped = skipped_invalid + skipped_missing_fk + skipped_missing_invoice + + # Log summary + logger.info(f"Job {job_id} completed. Inserted: {inserted_count}, Skipped: {total_skipped} " + f"(invalid: {skipped_invalid}, missing_fk: {skipped_missing_fk}, missing_invoice: {skipped_missing_invoice})") + + # Prepare response based on results + if inserted_count == 0: + if total_skipped > 0: + logger.warning(f"No valid records to insert for job {job_id}. All {total_skipped} records were rejected.") + response = { + "status": "warning", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details, + "message": f"No se insertaron registros. {total_skipped} fueron rechazados. Revisa el detalle por línea a continuación.", + } + else: + logger.error(f"No valid records found in CSV for job {job_id}") + response = { + "status": "failed", + "error": "No hay registros válidos en el archivo CSV", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + else: + # Success case - at least some records were inserted + response = { + "status": "finished", + "inserted": inserted_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + + except Exception as e: + logger.error(f"Task failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(e)} + + # 5. Cleanup: remove temp files and Redis keys so data is not kept indefinitely + try: + common_storage.cleanup_import_job( + effective_job_type, job_id, + file_path=file_path, + error_path=error_path, + meta_path=meta_path, + ) + except Exception as cleanup_err: + logger.warning("Failed to cleanup temp files or Redis: %s", cleanup_err) + + # Ensure response is defined (fallback in case of unexpected errors) + if response is None: + logger.error(f"Unexpected error: response not set for job {job_id}") + response = { + "status": "failed", + "error": "Error inesperado durante el procesamiento", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + + return response diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py index 1a3fc274..711f4653 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py new file mode 100644 index 00000000..2c363064 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py @@ -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", +] diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py new file mode 100644 index 00000000..bdff4a3f --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py new file mode 100644 index 00000000..f9c698be --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py new file mode 100644 index 00000000..fdb969b1 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py new file mode 100644 index 00000000..eb841583 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py @@ -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") diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py new file mode 100644 index 00000000..a32c6d6e --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py @@ -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, + ) diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py new file mode 100644 index 00000000..06f086a8 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py new file mode 100644 index 00000000..72b03ca7 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py new file mode 100644 index 00000000..9d143635 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py @@ -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"]), + } diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py new file mode 100644 index 00000000..d00d228f --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py @@ -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"]), + } diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py new file mode 100644 index 00000000..44668900 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py @@ -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"]), + } diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py index 5a5e9da7..2993f180 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py index ffea72b9..b69bd5c2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py index e1311509..a8a06874 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py index 31c35f12..ee2ea29e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py index d2bf4418..41d80821 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py index 8a34a31e..76106678 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py index e0c20c6e..4734240b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py @@ -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, diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py index 0f67ef69..248d35e8 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py @@ -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 diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py index 8bc55b07..7b92de23 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -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 ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index fe40fc97..5d94ebdd 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -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 diff --git a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py index b912cf39..890247f3 100644 --- a/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/routes/pedimentos.py @@ -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 diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index 92f4d00f..e95c99da 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -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"): diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak deleted file mode 100644 index f74e0da8..00000000 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py.bak +++ /dev/null @@ -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 - """ diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/__init__.py b/backend/api/v1/modules/a76/reports/movements/saldos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py new file mode 100644 index 00000000..ddc10f80 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/csv_utils.py @@ -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() diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/routes.py b/backend/api/v1/modules/a76/reports/movements/saldos/routes.py new file mode 100644 index 00000000..2d7341cd --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/routes.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/schemas.py b/backend/api/v1/modules/a76/reports/movements/saldos/schemas.py new file mode 100644 index 00000000..5eaeda47 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/reports/movements/saldos/tasks.py b/backend/api/v1/modules/a76/reports/movements/saldos/tasks.py new file mode 100644 index 00000000..070543e1 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/movements/saldos/tasks.py @@ -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 diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 9d8781d8..796bf862 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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", diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index d103647d..16eedac7 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -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] diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 949855b8..fbb9a69a 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -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 (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) diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 952b64e5..6a69f29a 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -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 diff --git a/backend/api/v1/modules/public/reference_data/material_types/routes.py b/backend/api/v1/modules/public/reference_data/material_types/routes.py index a90f7b06..159778bf 100644 --- a/backend/api/v1/modules/public/reference_data/material_types/routes.py +++ b/backend/api/v1/modules/public/reference_data/material_types/routes.py @@ -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), diff --git a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py index d7d69e4e..93b188f6 100644 --- a/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py +++ b/backend/api/v1/modules/public/reference_data/pedimento_codes/routes.py @@ -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), ): diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 806c5b84..4e739e77 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -31,6 +31,7 @@ celery_app.conf.update( "api.v1.modules.a76.reports.importacion.packing_list.task", "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", "api.v1.modules.a76.reports.movements.invoices.tasks", + "api.v1.modules.a76.reports.movements.saldos.tasks", "api.v1.modules.a76.reports.exportacion.descargo.task", "api.v1.modules.a76.layouts_csv.facturas.tasks", "api.v1.modules.a76.layouts_csv.exportacion.tasks", diff --git a/backend/core/email.py b/backend/core/email.py index 41b81a67..c0880988 100644 --- a/backend/core/email.py +++ b/backend/core/email.py @@ -71,9 +71,10 @@ class EmailService: """ msg.attach(MIMEText(html_body, 'html')) - # CSV attachment + # CSV attachment with UTF-8 BOM for Excel compatibility attachment = MIMEBase('text', 'csv') - attachment.set_payload(csv_content.encode('utf-8')) + csv_bytes = b'\xef\xbb\xbf' + csv_content.encode('utf-8') + attachment.set_payload(csv_bytes) encoders.encode_base64(attachment) attachment.add_header( 'Content-Disposition', diff --git a/frontend/package.json b/frontend/package.json index e7cd1887..068aeeab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,6 +59,7 @@ "dependencies": { "@types/dompurify": "^3.2.0", "@types/marked": "^6.0.0", + "chart.js": "^4.5.1", "dompurify": "^3.0.9", "keycloak-js": "^26.2.1", "lucide-svelte": "^0.553.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4933f90b..aca29481 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@types/marked': specifier: ^6.0.0 version: 6.0.0 + chart.js: + specifier: ^4.5.1 + version: 4.5.1 dompurify: specifier: ^3.0.9 version: 3.3.1 @@ -415,6 +418,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lix-js/sdk@0.4.7': resolution: {integrity: sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==} engines: {node: '>=18'} @@ -993,6 +999,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -2289,6 +2299,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@lix-js/sdk@0.4.7': dependencies: '@lix-js/server-protocol-schema': 0.1.1 @@ -2852,6 +2864,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.1: {} chokidar@4.0.3: diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 28e77fcd..d833b4b5 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -88,6 +88,18 @@ export interface LineReferences { serie_id?: number; } +export interface Serie { + id?: number; + line_item_id?: number; + row?: number; + serial_numbers?: string; + model?: string; + sub_model?: string; + brand?: string; + expo_brad?: string; + number_id?: string; +} + export interface FaLineItem { id?: number; tenant_id?: number; @@ -183,7 +195,8 @@ export interface Item { quantity?: LineQuantities; description?: LineDescriptions; reference?: LineReferences; - fa_data?: FaLineItem; // Fixed Asset specific data + fa_data?: FaLineItem; // Fixed Asset specific data + series?: Serie[]; // Series data (multiple per line) } export interface ItemListResponse { diff --git a/frontend/src/lib/api/dashboard/a76/saldos-report.ts b/frontend/src/lib/api/dashboard/a76/saldos-report.ts new file mode 100644 index 00000000..fb3629cd --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/saldos-report.ts @@ -0,0 +1,59 @@ +/** + * API Client for Saldos Temporales Report + */ +import { api } from '$lib/api'; + +// ===== TYPES ===== + +export interface SaldosFilter { + // Range / Period + range_type: string; // pedimento | payment_date | invoice_date | parts | classes + start_date?: string | null; // For pedimento/part/class identifiers + end_date?: string | null; // For pedimento/part/class identifiers + date_start?: string | null; // Explicit start date filter + date_end?: string | null; // Explicit end date filter + + // Column 2 – Filtros e Identificadores + currency: string; // foreign | national + report_type: string; // normal | detailed | with_download + exchange_rate: string; // invoice_date | payment_date + omit_low_balance: boolean; + balance_type: string; // normal | repair | both + level: string; // partida | subpartida | all + + // Column 3 – Configuración Final + end_date_as_cutoff: boolean; + show_pending_series: boolean; + include_asset_tag_images: boolean; + use_large_asset_tag_icons: boolean; + send_email: boolean; + julian_date: boolean; + include_regla_octava: boolean; + shelter: boolean; + + // Print options + print_part: boolean; + print_class: boolean; + + // Optional identifiers populated from catalog selectors + provider?: string | null; + buyer?: string | null; + asset_class?: string | null; + asset_type?: string | null; + location?: string | null; + pedimento_code?: string | null; +} + +// ===== API METHODS ===== + +export const saldosReportApi = { + /** Trigger async CSV generation; returns task_id */ + generateReportAsync: (filters: SaldosFilter, companyId: number) => + api.post<{ task_id: string }>(`/v1/a76/reports/movements/saldos/generate?company_id=${companyId}`, filters), + + /** Poll task status */ + getTaskStatus: (taskId: string) => + api.get<{ task_id: string; status: string; result?: any; meta?: any }>( + `/v1/a76/reports/movements/saldos/task/${taskId}` + ) +}; diff --git a/frontend/src/lib/components/dashboard/activity-feed.svelte b/frontend/src/lib/components/dashboard/activity-feed.svelte index fde3b9b4..9f501382 100644 --- a/frontend/src/lib/components/dashboard/activity-feed.svelte +++ b/frontend/src/lib/components/dashboard/activity-feed.svelte @@ -14,9 +14,10 @@ interface Props { activities: ActivityItem[]; + class?: string; } - let { activities }: Props = $props(); + let { activities, class: cls = '' }: Props = $props(); const icons = { invoice: FileText, @@ -39,14 +40,24 @@ } function formatDate(dateStr: string): string { - const date = new Date(dateStr); + // Normalizar: si el string no tiene info de zona horaria, asumir UTC agregando 'Z' + const normalized = /[Z+\-]\d*$/.test(dateStr.trim()) ? dateStr : dateStr + 'Z'; + const date = new Date(normalized); const now = new Date(); const diffMs = now.getTime() - date.getTime(); + + // Si la fecha es futura (diff negativo) o muy reciente, mostrar 'Ahora mismo' + if (diffMs < 0) { + return 'Ahora mismo'; + } + const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); - if (diffMins < 60) { + if (diffMins < 1) { + return 'Ahora mismo'; + } else if (diffMins < 60) { return `Hace ${diffMins} min`; } else if (diffHours < 24) { return `Hace ${diffHours}h`; @@ -58,48 +69,55 @@ } - - - Actividad Reciente - Últimas operaciones registradas en el sistema + + +
+
+ + + Actividad Reciente + + Últimas operaciones registradas en el sistema +
+ {#if activities.length > 0} + {activities.length} registros + {/if} +
{#if activities.length === 0} -
- -

No hay actividad reciente

+
+ +

No hay actividad reciente

{:else} -
- {#each activities as activity} - {@const Icon = getIcon(activity.type)} -
-
-
- +
+ +
+ +
+ {#each activities as activity, idx} + {@const Icon = getIcon(activity.type)} +
+
+
+ +
+
+
+
+

{activity.title}

+ + {formatDate(activity.timestamp)} + +
+ {#if activity.description} +

{activity.description}

+ {/if}
-
-
-

{activity.title}

- - {formatDate(activity.timestamp)} - -
- {#if activity.description} -

{activity.description}

- {/if} - {#if activity.status} - - {activity.status} - - {/if} -
-
- {/each} + {/each} +
{/if} diff --git a/frontend/src/lib/components/dashboard/chart-card.svelte b/frontend/src/lib/components/dashboard/chart-card.svelte index 529f10bb..7080bb2f 100644 --- a/frontend/src/lib/components/dashboard/chart-card.svelte +++ b/frontend/src/lib/components/dashboard/chart-card.svelte @@ -1,41 +1,62 @@ - - - {title} + + + {title} {#if description} {description} {/if} {#if data.length === 0} -
No hay datos disponibles
+
+

No hay datos disponibles

+
{:else if type === 'bar'}
- {#each data as item} -
-
- {item.label} - {item.value.toLocaleString()} + {#each data as item, i} +
+
+ {i + 1} + {item.label} + {item.value.toLocaleString()}
-
+
@@ -43,10 +64,10 @@ {/each}
{:else if type === 'pie'} -
- {#each data as item} +
+ {#each data as item, i}
-
+
{item.label}
{item.value.toLocaleString()}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 0e2a0b4e..f6cbbd8d 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -9,6 +9,7 @@ FileText, UploadCloud } from 'lucide-svelte'; + import { tick } from 'svelte'; let { open = $bindable(false), @@ -53,6 +54,16 @@ } open = newOpen; } + + // When modal shows finished state with rejection details, scroll the detail table into view + $effect(() => { + if (open && isFinished && commitResults?.skipped_details?.length > 0) { + tick().then(() => { + const el = document.getElementById('detalle-errores-import'); + el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + } + }); @@ -234,12 +245,17 @@ >
{totalSkipped} + {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0} +

+ Revisa el detalle por línea en la tabla inferior. +

+ {/if}
{#if commitResults.skipped_details && commitResults.skipped_details.length > 0} -
+
Detalle de Errores diff --git a/frontend/src/lib/components/dashboard/donut-chart.svelte b/frontend/src/lib/components/dashboard/donut-chart.svelte index 64f6c17f..2635f565 100644 --- a/frontend/src/lib/components/dashboard/donut-chart.svelte +++ b/frontend/src/lib/components/dashboard/donut-chart.svelte @@ -1,103 +1,135 @@ - - - {title} + + +
+
+ + + {title} + + Desglose por tipo de operación +
+ {#if total > 0} + {total.toLocaleString()} ops + {/if} +
- + + {#if data.length === 0} -
No hay datos disponibles
+
+
+ +
+
+

Sin datos disponibles

+

Las operaciones aparecerán aquí una vez registradas

+
+
{:else} -
- -
- - {#each donutSegments() as segment, i} +
+ + +
+ + + {#each donutSlices() as s} {/each} - -
-
-
{total.toLocaleString()}
-
Total
-
+
+ {total.toLocaleString()} + total
- -
- {#each segments as segment, i} -
-
-
- {segment.label} + +
+ {#each segments as s} +
+
+
+ + {s.label} +
+
+ {s.value.toLocaleString()} + {s.pct.toFixed(1)}% +
-
- - {segment.value.toLocaleString()} - - - ({segment.percentage.toFixed(1)}%) - +
+
{/each}
+
{/if} diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index d5578a0c..3fb918c1 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -109,6 +109,10 @@ formData.import_tariff_type = snap.import_tariff_type ?? ''; formData.export_tariff_code = snap.export_tariff_code ?? ''; formData.export_tariff_type = snap.export_tariff_type ?? ''; + + // Reiniciar banderas de error al cargar datos + showErrors = false; + validationErrors = {}; } else { // Reset form when initialData is null (new class) formData.class_code = ''; @@ -128,12 +132,17 @@ formData.import_tariff_type = ''; formData.export_tariff_code = ''; formData.export_tariff_type = ''; + + // Reiniciar banderas de error al limpiar formulario + showErrors = false; + validationErrors = {}; } }); // Estado de validación let validationErrors = $state>({}); let showErrors = $state(false); + let isSubmitting = $state(false); // Estado de diálogos let showMaterialDialog = $state(false); @@ -262,6 +271,37 @@ searchMaterial = ''; } + // Fix 1: Buscar descripción de Tipo de Activo Fijo al perder el foco + async function handleMaterialBlur() { + validateField('material_key'); + const code = formData.material_key?.trim().toUpperCase(); + if (!code) { + formData.material_description = ''; + return; + } + // Primero buscar en la caché local (si ya se cargó el catálogo) + if (materialTypes.length > 0) { + const found = materialTypes.find((m) => m.key.toUpperCase() === code); + if (found) { + formData.material_key = found.key; + formData.material_description = found.description; + return; + } + } + // Si no está en caché, consultar el API + try { + const response = await materialTypesApi.get(code); + if (response.data) { + formData.material_key = response.data.key; + formData.material_description = response.data.description; + } else { + formData.material_description = '(Código no encontrado)'; + } + } catch { + formData.material_description = '(Código no encontrado)'; + } + } + async function openUnitOfMeasureSearch() { showUnitDialog = true; searchUnit = ''; @@ -276,6 +316,39 @@ searchUnit = ''; } + // Fix 1: Buscar descripción de U.M. Comercial al perder el foco + async function handleUnitBlur() { + validateField('unit_of_measure'); + const code = formData.unit_of_measure?.trim().toUpperCase(); + if (!code) { + formData.unit_of_measure_description = ''; + return; + } + // Primero buscar en la caché local (si ya se cargó el catálogo) + await loadUnitsOfMeasure(); + const found = unitsOfMeasureData.find((u) => u.code.toUpperCase() === code); + if (found) { + formData.unit_of_measure = found.code; + formData.unit_of_measure_description = found.description; + formData.unit_measure_key = found.claveMexicana; + } else { + formData.unit_of_measure_description = '(Código no encontrado)'; + } + } + + // Fix 2: Máscara para Fracción Americana (formato 0000.00.00.00 = 13 chars) + function formatUSFraction(event: Event) { + const input = event.target as HTMLInputElement; + // Extraer solo dígitos + const digits = input.value.replace(/\D/g, '').slice(0, 10); + // Construir la máscara insertando puntos en las posiciones correctas + let masked = digits; + if (digits.length > 4) masked = digits.slice(0, 4) + '.' + digits.slice(4); + if (digits.length > 6) masked = digits.slice(0, 4) + '.' + digits.slice(4, 6) + '.' + digits.slice(6); + if (digits.length > 8) masked = digits.slice(0, 4) + '.' + digits.slice(4, 6) + '.' + digits.slice(6, 8) + '.' + digits.slice(8); + formData.us_fraction = masked; + } + async function openUSFractionSearch() { showUSFractionDialog = true; searchUSFraction = ''; @@ -451,7 +524,8 @@ // Validar campo individual (para validación en blur) function validateField(fieldName: string) { - if (!showErrors) return; // Solo validar si ya se intentó guardar + // Bloquear validación repetida si: a) no se ha intentado guardar o b) está guardando + if (!showErrors || isSubmitting) return; const errors = { ...validationErrors }; @@ -496,22 +570,28 @@ validationErrors = errors; } - function handleSave() { - // Activar visualización de errores - showErrors = true; - + async function handleSave() { // Validar formulario if (!validateForm()) { + showErrors = true; toast.error('Por favor, complete todos los campos obligatorios'); return; } + // Activar bandera the submitting para que no se validen inputs al azar en el blur + isSubmitting = true; + // Tomamos una copia muerta de los datos actuales const dataToSave = $state.snapshot(formData); - // Ejecutamos el onSave pasándole la copia - if (onSave) { - onSave(dataToSave); + try { + // Ejecutamos el onSave pasándole la copia. Await en caso de que devuelva promesa. + if (onSave) { + await onSave(dataToSave); + } + } finally { + // Liberar el estado de subida solo después de que acabe todo el flujo + isSubmitting = false; } } @@ -522,9 +602,17 @@ } // Escuchar el evento de guardado del padre + import { onDestroy } from 'svelte'; + if (typeof document !== 'undefined') { document.addEventListener('save-form', handleSave); } + + onDestroy(() => { + if (typeof document !== 'undefined') { + document.removeEventListener('save-form', handleSave); + } + });
@@ -569,7 +657,7 @@ ? 'border-red-500 focus-visible:ring-red-500' : ''}" maxlength={10} - onblur={() => validateField('material_key')} + onblur={handleMaterialBlur} />
+ +
+ + + + Línea + Serie + Modelo + Sub modelo + Núm. ID + Acciones + + + + {#if seriesList.length === 0} + + + No hay series registradas. Usa "Nueva serie" para agregar una. + + + {:else} + {#each seriesList as serie, i (i)} + hasSerial && selectForEdit(i)} + > + {serie.row ?? i + 1} + + {serie.serial_numbers || '-'} + + + {serie.model || '-'} + + + {serie.sub_model || '-'} + + {serie.number_id || '-'} + + + + + {/each} + {/if} + + +
+ + + {#if currentSerie && selectedSeriesIndex !== null} +
+
+ + {selectedSeriesIndex >= seriesList.length - 1 && !currentSerie?.id + ? 'Nueva serie' + : `Editar serie (línea ${currentSerie.row ?? selectedSeriesIndex + 1})`} + + +
+
+
+ +
+ {invoiceNumber || '-'} +
+
+
+ +
+ {invoiceLine || '-'} +
+
+
+ + +
+
+ + handleSerieInput(e, 'serial_numbers')} + /> +
+
+ + handleSerieInput(e, 'model')} + /> +
+
+ +
+ {partNumber || '-'} +
+
+
+ + handleSerieInput(e, 'sub_model')} + /> +
+
+ + handleSerieInput(e, 'number_id')} + /> +
+
+
+ {/if} +
- You can enter multiple serial numbers, one per line + {#if !hasSerial} + Los datos capturados se conservan, pero la edición queda deshabilitada mientras "Lleva serie" esté apagado. + {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 8bc9b75a..5c50968d 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -315,7 +315,8 @@ }, reference: { serie_id: undefined - } + }, + series: [] }; } @@ -544,6 +545,8 @@ selectedItem = lineData.full_item; // Deep clone and normalize numeric values editingItem = normalizeItemData(JSON.parse(JSON.stringify(lineData.full_item))); + if (!editingItem.series) editingItem.series = []; + else if (!Array.isArray(editingItem.series)) editingItem.series = [editingItem.series]; // Guardar una copia del estado original para restaurar al cancelar originalItemData = JSON.parse(JSON.stringify(editingItem)); // Enrich with descriptive data @@ -1016,7 +1019,7 @@
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte index 30be961a..a10d6ef6 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/observations-tab-form.svelte @@ -96,7 +96,7 @@ } -
+
@@ -249,7 +249,7 @@ {#if operationType === 1 || invoiceType === 'CR'} -
+
Factura Alterna & Flags
-
+
@@ -520,8 +520,8 @@ {#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR'} -
-
+
+
{#if invoiceType !== 'MEX'} -
+
@@ -152,7 +152,7 @@ (formData.is_mixed = v === 'true')} - class="flex gap-4" + class="flex flex-wrap gap-4" >
@@ -248,7 +248,7 @@
-
+
diff --git a/frontend/src/lib/components/dashboard/kpi-card.svelte b/frontend/src/lib/components/dashboard/kpi-card.svelte index 0c0db870..ae0780a5 100644 --- a/frontend/src/lib/components/dashboard/kpi-card.svelte +++ b/frontend/src/lib/components/dashboard/kpi-card.svelte @@ -1,5 +1,4 @@ - - - - {metric.label} - - {#if Icon} +
+ {#if Icon} +
- {/if} - - -
- {metric.value.toLocaleString()} - {#if metric.unit} - {metric.unit} - {/if}
- {#if metric.percentage_change !== undefined && TrendIcon} -
- - + {/if} + +
+

{metric.label}

+
+ + {metric.value.toLocaleString()}{#if metric.unit}{metric.unit}{/if} + + {#if metric.percentage_change !== undefined && TrendIcon && metric.trend} + + {Math.abs(metric.percentage_change).toFixed(1)}% - vs mes anterior -
- {/if} - - + {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/trend-chart.svelte b/frontend/src/lib/components/dashboard/trend-chart.svelte index e9dd978e..288acd4c 100644 --- a/frontend/src/lib/components/dashboard/trend-chart.svelte +++ b/frontend/src/lib/components/dashboard/trend-chart.svelte @@ -1,71 +1,216 @@ - - - Tendencia de Operaciones - Evolución mensual de facturas y pedimentos - - - {#if monthlyData.length === 0} -
-

No hay datos disponibles

-
- {:else} - -
- {#each monthlyData as point, i} -
- -
-
- -
- {point.value.toLocaleString()} -
-
-
- - {point.label} -
- {/each} + + +
+
+ + + Tendencia de Operaciones + + Evolución mensual de operaciones
- -
-
-
- {monthlyData.reduce((sum, d) => sum + d.value, 0).toLocaleString()} -
-
Total
+ {#if monthlyData.length >= 2} + {@const pct = trendPct()} +
+ {#if pct > 0} + +{pct}% + {:else if pct < 0} + {pct}% + {:else} + 0% + {/if} + vs mes ant. +
+ {/if} +
+ + + + {#if monthlyData.length === 0} +
+
+
-
- {Math.round( - monthlyData.reduce((sum, d) => sum + d.value, 0) / monthlyData.length - ).toLocaleString()} -
-
Promedio
+

Sin datos disponibles

+

Los datos aparecerán aquí una vez registrados

+
+ {:else if monthlyData.length === 1} +
-
{maxValue.toLocaleString()}
-
Máximo
+
{monthlyData[0].value.toLocaleString()}
+
+ operaciones en {monthlyData[0].label} +
+
+
+ + La gráfica aparecerá con más de un mes de datos +
+
+ {:else} + +
+ +
+ + +
+
+
{total.toLocaleString()}
+
Total
+
+
+
{average.toLocaleString()}
+
Promedio / mes
+
+
+
{maxValue.toLocaleString()}
+
Máximo
{/if} diff --git a/frontend/src/lib/components/login-form.svelte b/frontend/src/lib/components/login-form.svelte index ebb3726a..80d0493e 100644 --- a/frontend/src/lib/components/login-form.svelte +++ b/frontend/src/lib/components/login-form.svelte @@ -5,17 +5,16 @@ Field, FieldLabel, FieldDescription, - FieldSeparator, } from "$lib/components/ui/field/index.js"; import { Input } from "$lib/components/ui/input/index.js"; import { Button } from "$lib/components/ui/button/index.js"; import { cn } from "$lib/utils.js"; - import { FileText, ShieldCheck } from 'lucide-svelte'; + import faviconUrl from '$lib/assets/favicon.svg'; import type { HTMLAttributes } from "svelte/elements"; import { page } from '$app/state'; import { enhance } from '$app/forms'; import { loginWithProvider } from '$lib/sso'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; let { class: className, ...restProps }: HTMLAttributes = $props(); @@ -23,14 +22,20 @@ let username = $state('demo'); let password = $state('demo123'); - let tenantSlug = $state('aduanasoft'); + let tenantSlug = $state(''); let loading = $state(false); - - // Obtener el error del servidor si existe + // step 1 = credenciales, step 2 = selección de organización + let step = $state<1 | 2>(1); + let readyToSubmit = $state(false); + let formEl: HTMLFormElement | undefined = $state(); + + // Descubrimiento de tenants + type TenantInfo = { id: number; name: string; slug: string }; + let tenants = $state([]); + const error = $derived(page.form?.error || ''); // Limpiar todo el localStorage y cookies al montar el componente de login - // Esto asegura que no queden datos del tenant anterior onMount(() => { clearAllData(); }); @@ -46,59 +51,100 @@ } } - // Función para limpiar todo el localStorage y cookies function clearAllData() { if (typeof localStorage !== 'undefined') { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); localStorage.removeItem('activeCompanyId'); } - clearClientCookies(); } + + async function fetchTenants(): Promise { + try { + const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); + // Llama a /login SIN tenant_slug: el backend verifica credenciales primero, + // luego devuelve las orgs. Sin contraseña válida no se revela nada. + const res = await fetch(`${apiBase}/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (res.ok) { + const data = await res.json(); + // { status: "choose_tenant", tenants: [...] } + if (data.tenants) return data.tenants; + } + } catch { + // ignore — el server action mostrará el error de autenticación + } + return []; + } + + // Llama al backend real desde el paso 2 + function confirmTenant() { + if (!tenantSlug) return; + readyToSubmit = true; + formEl?.requestSubmit(); + } + + function goBack() { + step = 1; + tenantSlug = ''; + tenants = []; + readyToSubmit = false; + } function handleMicrosoftLogin() { - // Limpiar datos antes de iniciar SSO clearAllData(); - - // Guardar el tenant_slug en localStorage para recuperarlo después del callback - if (tenantSlug) { - localStorage.setItem('pending_tenant_slug', tenantSlug); - } + if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug); loginWithProvider('microsoft'); } function handleGoogleLogin() { - // Limpiar datos antes de iniciar SSO clearAllData(); - - // Guardar el tenant_slug en localStorage para recuperarlo después del callback - if (tenantSlug) { - localStorage.setItem('pending_tenant_slug', tenantSlug); - } + if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug); loginWithProvider('google'); } - - function handleAppleLogin() { - // Apple no está configurado por defecto en Keycloak, - // pero puedes agregarlo siguiendo el mismo patrón - alert('Apple SSO no está configurado aún'); - }
- + +
{ + use:enhance={async ({ cancel }) => { + // Interceptar solo en el paso 1 antes de hacer el submit real + if (!readyToSubmit) { + cancel(); + loading = true; + tenants = await fetchTenants(); + loading = false; + if (tenants.length === 1) { + // 1 sola org: login directo + tenantSlug = tenants[0].slug; + readyToSubmit = true; + await tick(); // esperar a que el DOM refleje tenantSlug antes de enviar + formEl?.requestSubmit(); + } else if (tenants.length > 1) { + // Varias orgs: mostrar selector + step = 2; + } else { + // 0 orgs: enviar igual, el backend rechazará + readyToSubmit = true; + await tick(); + formEl?.requestSubmit(); + } + return; + } loading = true; return async ({ update, result }) => { await update(); loading = false; - - // Si hay un error, limpiar cookies del cliente + readyToSubmit = false; if (result.type === 'failure') { clearClientCookies(); } @@ -106,131 +152,218 @@ }} > -
-

Anexo 76

-

- Sistema de Cumplimiento Fiscal y Aduanal -

+ +
+ Anexo 76 +
+

Anexo 76

+

+ Sistema de Cumplimiento Fiscal y Aduanal +

+
- + {#if error} -
+
+ + + {error}
{/if} - - - Tenant - - - - - Usuario - - - -
- Contraseña - - ¿Olvidaste tu contraseña? - + + + + {#if step === 2} + + + {/if} + + {#if step === 1} + + + Usuario + + + + + + + + + + + + +
+
+ o continúa con +
- - - - - - - O continua con - - - + + + +

+ ¿No tienes cuenta?{' '} + + Regístrate + +

+ {:else} + +
+

Selecciona tu organización

+

Tu cuenta tiene acceso a varias organizaciones

+
+ +
+ {#each tenants as t} + + {/each} +
+ + + + + + - - - - - ¿No tienes una cuenta? Regístrate - + Volver al inicio de sesión + + {/if} -