diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py index 4e2f7f32..ad60c9b2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py @@ -3,6 +3,7 @@ Carga de conjuntos FK para validación de import CSV de transportistas. Clarion: GTransportista (ClaveTrans), GPaises (Pais_Ame), GEstados (Descripcion), relación Estado-País. """ from typing import Set, Tuple, Optional +from sqlalchemy.orm import Session import logging from core.database import CoreSessionLocal @@ -13,6 +14,7 @@ logger = logging.getLogger(__name__) def load_transportistas_fk_sets( tenant_id: Optional[int] = None, company_id: Optional[int] = None, + db: Optional[Session] = None, ) -> Tuple[ Set[str], Set[str], @@ -32,50 +34,56 @@ def load_transportistas_fk_sets( state_descriptions_upper: Set[str] = set() state_country_set: Set[Tuple[str, str]] = set() - try: - with CoreSessionLocal() as session: - from api.v1.modules.a76.transportation.transporters.models import Transporter - from api.v1.modules.public.reference_data.countries.models import Country - from api.v1.modules.public.reference_data.states.models import State + def _load(session: Session): + from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.public.reference_data.countries.models import Country + from api.v1.modules.public.reference_data.states.models import State - if tenant_id is not None and company_id is not None: - for row in ( - session.query(Transporter.transporter_key) - .filter( - Transporter.tenant_id == tenant_id, - Transporter.company_id == company_id, - ) - .all() - ): - if row[0] and (row[0] or "").strip(): - existing_transporter_keys.add((row[0] or "").strip().upper()) - - for row in session.query(Country.ame_key).all(): - if row[0]: - valid_country_ame.add((row[0] or "").strip().upper()) - - for state in session.query(State).all(): - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() + if tenant_id is not None and company_id is not None: + for row in ( + session.query(Transporter.transporter_key) + .filter( + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, ) - ame = None - if country and (country.ame_key or "").strip(): - ame = (country.ame_key or "").strip().upper() + .all() + ): + if row[0] and (row[0] or "").strip(): + existing_transporter_keys.add((row[0] or "").strip().upper()) - desc = (state.description or "").strip() - if desc: - state_descriptions_upper.add(desc.upper()) - if ame: - state_country_set.add((ame, desc.upper())) + for row in session.query(Country.ame_key).all(): + if row[0]: + valid_country_ame.add((row[0] or "").strip().upper()) - mex_key = (state.mex_key or "").strip() - if mex_key: - mk = mex_key.upper() - state_descriptions_upper.add(mk) - if ame: - state_country_set.add((ame, mk)) + for state in session.query(State).all(): + country = ( + session.query(Country) + .filter(Country.m3_key == state.m3_key) + .first() + ) + ame = None + if country and (country.ame_key or "").strip(): + ame = (country.ame_key or "").strip().upper() + + desc = (state.description or "").strip() + if desc: + state_descriptions_upper.add(desc.upper()) + if ame: + state_country_set.add((ame, desc.upper())) + + mex_key = (state.mex_key or "").strip() + if mex_key: + mk = mex_key.upper() + state_descriptions_upper.add(mk) + if ame: + state_country_set.add((ame, mk)) + + try: + if db: + _load(db) + else: + with CoreSessionLocal() as session: + _load(session) except Exception as e: logger.warning("Transportistas import: could not load FK sets: %s", e) diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py index d3daa7e8..7dd6b16e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py @@ -37,24 +37,31 @@ def validate_row_transporter( clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper() use_partial = actualizar and bool(clave and clave in existing) + if not use_partial and clave and clave in existing: + errors.append({ + "line": line_num, + "col": "CLAVE TRANSPORTISTA", + "msg": f"La clave '{row.get('CLAVE TRANSPORTISTA')}' ya existe en el catálogo." + }) + if use_partial: errors.extend( valida_parcial_transportistas( - row, - line_num, - valid_country_ame=valid_country_ame, - state_descriptions_upper=state_descriptions_upper, - state_country_set=state_country_set, + row, + line_num, + valid_country_ame=valid_country_ame, + state_descriptions_upper=state_descriptions_upper, + state_country_set=state_country_set, ) ) else: errors.extend( valida_toda_transportistas( - row, - line_num, - valid_country_ame=valid_country_ame, - state_descriptions_upper=state_descriptions_upper, - state_country_set=state_country_set, + row, + line_num, + valid_country_ame=valid_country_ame, + state_descriptions_upper=state_descriptions_upper, + state_country_set=state_country_set, ) ) return errors diff --git a/backend/api/v1/modules/a76/transportation/catalog_parity.py b/backend/api/v1/modules/a76/transportation/catalog_parity.py index 6f097144..700977ec 100644 --- a/backend/api/v1/modules/a76/transportation/catalog_parity.py +++ b/backend/api/v1/modules/a76/transportation/catalog_parity.py @@ -277,6 +277,7 @@ def transporter_model_to_row(t) -> Dict[str, Any]: def validate_transporter_row_for_api( + db: Session, tenant_id: int, company_id: int, row: Dict[str, Any], @@ -292,7 +293,7 @@ def validate_transporter_row_for_api( valid_country_ame, state_descriptions_upper, state_country_set, - ) = load_transportistas_fk_sets(tenant_id, company_id) + ) = load_transportistas_fk_sets(tenant_id, company_id, db=db) clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper() # existing set from loader is uppercased keys for this company diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py index 8f40888d..a5494e8f 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/services.py +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -104,6 +104,7 @@ class TransporterService: """Create a new transporter""" data = transporter_data.model_dump() validate_transporter_row_for_api( + db, tenant_id, company_id, transporter_fields_to_csv_row(data), @@ -163,6 +164,7 @@ class TransporterService: } merged.update(update_data) validate_transporter_row_for_api( + db, tenant_id, company_id, transporter_fields_to_csv_row(merged), diff --git a/backend/core/database.py b/backend/core/database.py index 95e937d3..b1889310 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -150,8 +150,13 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]: yield db finally: db.close() - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) + try: + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) + except ValueError: + # Ignorar ValueError de contextvars en dependencias síncronas + # debido a que AnyIO puede ejecutar el teardown en un contexto diferente. + pass async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]: @@ -168,8 +173,11 @@ async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSess finally: await session.close() finally: - rls_tenant_var.reset(token_t) - rls_company_var.reset(token_c) + try: + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) + except ValueError: + pass @contextmanager diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d34ecb93..ff387ee0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -331,18 +331,48 @@ async function fetchApi( // Manejo especial para errores 422 (validation error) if (response.status === 422) { // HTTPException(detail={ message, errors }) — catálogo / CSV parity - const det = data.detail; - const validationErrors = (errors: unknown[]) => errors as NonNullable; + const det = data.detail || (typeof data.message === 'object' ? data.message : null); if ( det && - typeof det === 'object' && + typeof det === "object" && !Array.isArray(det) && Array.isArray((det as { errors?: unknown }).errors) ) { - const d = det as { message?: string; errors: unknown[] }; + const d = det as { + message?: string; + errors: Array<{ col?: string; msg?: string; field?: string; message?: string }>; + }; + + // Mapping for catalog column names to DTO field names + const colToField: Record = { + "CLAVE TRANSPORTISTA": "transporter_key", + NOMBRE: "name", + "NOMBRE CORTO": "short_name", + RESPONSABLE: "responsible", + RFC: "rfc", + CALLES: "streets", + "CODIGO POSTAL": "postal_code", + CIUDAD: "city", + ESTADO: "state", + PAIS: "country", + "CODIGO CARGADOR": "loader_code", + "CODIGO CAAT": "caat_code", + "CODIGO TRANS": "transport_code", + "TIPO INTERFASE TRANS": "transport_interface_type", + "SERVIDOR FTP": "ftp_server", + "USUARIO FTP": "ftp_user", + "CLAVE ACCESO FTP": "ftp_password", + "DIRECTORIO FTP": "ftp_directory" + }; + + const normalizedErrors = d.errors.map((err) => ({ + field: err.field || (err.col ? colToField[err.col] || err.col : ""), + message: err.message || err.msg || "Error de validación" + })); + return { - error: d.message || 'Error de validación', - validationErrors: validationErrors(d.errors), + error: d.message || (typeof data.message === 'string' ? data.message : 'Error de validación'), + validationErrors: normalizedErrors, status: response.status }; } @@ -350,21 +380,31 @@ async function fetchApi( if (data.errors && Array.isArray(data.errors)) { return { error: data.message || 'Error de validación', - validationErrors: validationErrors(data.errors), + validationErrors: data.errors as NonNullable, status: response.status }; } // Errores de validación de FastAPI (con detail) else if (data.detail) { let errorMessage = 'Error de validación: '; + const vErrors: NonNullable = []; // FastAPI devuelve errores de validación en data.detail como array if (Array.isArray(data.detail)) { - const errors = data.detail.map((err: any) => { + data.detail.forEach((err: any) => { + const fieldPath = err.loc ? err.loc.filter((l: any) => l !== 'body').join('.') : 'campo'; + const msg = humanizeValidationMessage(err.msg || 'error de validación'); + + vErrors.push({ + field: err.loc ? String(err.loc[err.loc.length - 1]) : 'campo', + message: msg + }); + }); + + errorMessage += data.detail.map((err: any) => { const field = err.loc ? err.loc.join('.') : 'campo desconocido'; return `${field}: ${err.msg}`; }).join(', '); - errorMessage += errors; } else if (typeof data.detail === 'string') { errorMessage = data.detail; } else { @@ -373,6 +413,7 @@ async function fetchApi( return { error: errorMessage, + validationErrors: vErrors.length ? vErrors : undefined, status: response.status }; } diff --git a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte index ca480f5e..ef3cea88 100644 --- a/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte @@ -288,7 +288,7 @@ - + e.preventDefault()}> {title} diff --git a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte index 0bbb617d..939cd3d0 100644 --- a/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/trailers/create-edit-dialog.svelte @@ -168,7 +168,7 @@ - + e.preventDefault()}> {title} diff --git a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte index 6d05d2ba..2446ea29 100644 --- a/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/transporters/create-edit-dialog.svelte @@ -52,6 +52,7 @@ let loading = $state(false); let error = $state(null); + let fieldErrors = $state>({}); let countries = $state([]); let states = $state([]); let refsLoading = $state(false); @@ -109,6 +110,7 @@ $effect(() => { if (!open) { error = null; + fieldErrors = {}; loading = false; return; } @@ -120,9 +122,89 @@ void loadReferenceData(); }); + function validateForm(): boolean { + const errors: Record = {}; + + // Clave del transportista + if (!formData.transporter_key?.trim()) { + errors.transporter_key = 'La clave es obligatoria'; + } else if (/\s/.test(formData.transporter_key)) { + errors.transporter_key = 'La clave no puede contener espacios'; + } else if (!/^[A-Za-z0-9_-]+$/.test(formData.transporter_key)) { + errors.transporter_key = 'La clave solo permite letras, números, guiones y guiones bajos'; + } + + // Nombre / Razón Social + if (!formData.name?.trim()) { + errors.name = 'El nombre o razón social es obligatorio'; + } + + // RFC (Opcional, pero si se pone debe ser válido si es MX) + if (formData.rfc?.trim()) { + const rfcRegex = + /^([A-ZÑ&]{3,4}) ?(?:- ?)?(\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])) ?(?:- ?)?([A-Z\d]{2})([A-Z\d])$/i; + if (formData.country === 'MEX' && !rfcRegex.test(formData.rfc.trim())) { + errors.rfc = 'Formato de RFC inválido para México'; + } + } + + // Código Postal + if (formData.postal_code?.trim()) { + if (formData.country === 'MEX' && !/^\d{5}$/.test(formData.postal_code.trim())) { + errors.postal_code = 'El código postal en México debe ser de 5 dígitos'; + } else if (!/^\d+$/.test(formData.postal_code.trim())) { + errors.postal_code = 'El código postal debe ser numérico'; + } + } + + // Códigos de transporte + if (formData.caat_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.caat_code)) { + errors.caat_code = 'El código CAAT debe ser alfanumérico'; + } + if (formData.transport_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.transport_code)) { + errors.transport_code = 'El código de transporte debe ser alfanumérico'; + } + if (formData.loader_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.loader_code)) { + errors.loader_code = 'El código de cargador debe ser alfanumérico'; + } + if (formData.filler_code?.trim() && !/^[A-Za-z0-9]+$/.test(formData.filler_code)) { + errors.filler_code = 'El código de relleno debe ser alfanumérico'; + } + + // Configuración FTP + if (formData.ftp_server?.trim()) { + const hostRegex = + /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; + const ipRegex = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (!hostRegex.test(formData.ftp_server) && !ipRegex.test(formData.ftp_server)) { + errors.ftp_server = 'Servidor FTP inválido (debe ser un host o IP)'; + } + } + if (formData.ftp_user?.trim() && /\s/.test(formData.ftp_user)) { + errors.ftp_user = 'El usuario FTP no puede contener espacios'; + } + + fieldErrors = errors; + return Object.keys(errors).length === 0; + } + + function clearFieldError(field: string) { + if (fieldErrors[field]) { + fieldErrors[field] = ''; + } + } + async function handleSubmit() { if (loading) return; error = null; + fieldErrors = {}; + + if (!validateForm()) { + error = 'Por favor, corrige los errores en el formulario'; + return; + } + loading = true; try { @@ -131,11 +213,6 @@ throw new Error('No hay una compañía seleccionada'); } - // Validación básica - if (!formData.transporter_key.trim()) { - throw new Error('La clave es requerida'); - } - let response; if (isEdit && item) { response = await transportersApi.update(item.transporter_key, formData, companyId); @@ -144,9 +221,17 @@ } if (response.error) { - const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors; + const ve = response.validationErrors; if (ve?.length) { - throw new Error(ve.map((e) => e.msg).join(' · ')); + // Mapear errores de validación del backend si están disponibles + const backendErrors: Record = {}; + ve.forEach((err) => { + if (err.field) { + backendErrors[err.field] = err.message || 'Error de validación'; + } + }); + fieldErrors = backendErrors; + throw new Error('Errores de validación en el servidor'); } throw new Error(response.error); } @@ -180,7 +265,7 @@ - + e.preventDefault()}> {title} @@ -214,14 +299,28 @@ id="transporter_key" bind:value={formData.transporter_key} disabled={isEdit} + aria-invalid={!!fieldErrors.transporter_key} + oninput={() => clearFieldError('transporter_key')} required maxlength={30} /> + {#if fieldErrors.transporter_key} +

{fieldErrors.transporter_key}

+ {/if}
- - + + clearFieldError('name')} + maxlength={256} + /> + {#if fieldErrors.name} +

{fieldErrors.name}

+ {/if}
@@ -229,19 +328,42 @@ clearFieldError('short_name')} maxlength={10} placeholder="Máx. 10 car." /> + {#if fieldErrors.short_name} +

{fieldErrors.short_name}

+ {/if}
- + clearFieldError('rfc')} + maxlength={30} + /> + {#if fieldErrors.rfc} +

{fieldErrors.rfc}

+ {/if}
- + clearFieldError('responsible')} + maxlength={100} + /> + {#if fieldErrors.responsible} +

{fieldErrors.responsible}

+ {/if}
@@ -251,7 +373,16 @@
- + clearFieldError('caat_code')} + maxlength={49} + /> + {#if fieldErrors.caat_code} +

{fieldErrors.caat_code}

+ {/if}
@@ -259,9 +390,14 @@ clearFieldError('transport_code')} maxlength={8} placeholder="Máx. 8 car." /> + {#if fieldErrors.transport_code} +

{fieldErrors.transport_code}

+ {/if}
@@ -269,9 +405,14 @@ clearFieldError('loader_code')} maxlength={9} placeholder="Máx. 9 car." /> + {#if fieldErrors.loader_code} +

{fieldErrors.loader_code}

+ {/if}
@@ -279,13 +420,27 @@ clearFieldError('transport_interface_type')} maxlength={20} /> + {#if fieldErrors.transport_interface_type} +

{fieldErrors.transport_interface_type}

+ {/if}
- + clearFieldError('filler_code')} + maxlength={20} + /> + {#if fieldErrors.filler_code} +

{fieldErrors.filler_code}

+ {/if}
@@ -300,18 +455,44 @@
- + clearFieldError('streets')} + maxlength={100} + /> + {#if fieldErrors.streets} +

{fieldErrors.streets}

+ {/if}
- + clearFieldError('city')} + maxlength={30} + /> + {#if fieldErrors.city} +

{fieldErrors.city}

+ {/if}
- - + clearFieldError('country')} + > + {refsLoading ? '...' : formData.country @@ -327,14 +508,25 @@ {/each} + {#if fieldErrors.country} +

{fieldErrors.country}

+ {/if}
- - + clearFieldError('state')} + > + {refsLoading ? '...' : formData.state || @@ -349,10 +541,22 @@ {/each} + {#if fieldErrors.state} +

{fieldErrors.state}

+ {/if}
- + clearFieldError('postal_code')} + maxlength={15} + /> + {#if fieldErrors.postal_code} +

{fieldErrors.postal_code}

+ {/if}
@@ -363,23 +567,60 @@
- + clearFieldError('ftp_server')} + maxlength={200} + /> + {#if fieldErrors.ftp_server} +

{fieldErrors.ftp_server}

+ {/if}
- + clearFieldError('ftp_user')} + maxlength={200} + /> + {#if fieldErrors.ftp_user} +

{fieldErrors.ftp_user}

+ {/if}
- + clearFieldError('ftp_password')} + maxlength={100} + /> + {#if fieldErrors.ftp_password} +

{fieldErrors.ftp_password}

+ {/if}
- + clearFieldError('ftp_directory')} + maxlength={1000} + /> + {#if fieldErrors.ftp_directory} +

{fieldErrors.ftp_directory}

+ {/if}
diff --git a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte index b3b57717..c0c45e1e 100644 --- a/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte +++ b/frontend/src/lib/components/dashboard/transportation/vehicles/create-edit-dialog.svelte @@ -215,7 +215,7 @@ - + e.preventDefault()}> {title}