From 7a029307a89bd384e854a7f6999dfe743b71d2bb Mon Sep 17 00:00:00 2001 From: hreyes Date: Fri, 27 Mar 2026 12:56:47 -0600 Subject: [PATCH] feature/validaciones-invoices-transortes --- .../a76/invoices/common/common_validators.py | 244 +++++++++++++++--- .../api/v1/modules/a76/invoices/services.py | 121 ++++++--- .../modules/a76/layouts_csv/facturas/tasks.py | 210 ++++++++++----- .../layouts_csv/facturas/template_config.py | 8 + .../facturas/validators/encabezados_cmex.py | 12 - .../validators/encabezados_impo_temp.py | 12 - .../facturas/validators/transport_catalog.py | 228 ++++++++++++++++ .../layouts_csv/trailers/common/fk_loader.py | 40 ++- .../transportistas/common/fk_loader.py | 31 ++- .../layouts_csv/vehicles/common/fk_loader.py | 33 ++- .../a76/transportation/drivers/services.py | 13 +- .../a76/transportation/trailers/services.py | 13 +- .../transportation/transporters/services.py | 14 +- .../a76/transportation/vehicles/services.py | 13 +- .../test_fk_loader_state_mex_key.py | 130 ++++++++++ ...gracion_csv_transporte_sin_validaciones.md | 141 ++++++++++ .../invoices/edit/general-tab-form.svelte | 88 ++++++- .../dashboard/invoices/edit/save-invoice.ts | 35 ++- .../dashboard/invoices/edit/[id]/+page.svelte | 9 +- .../dashboard/invoices/settings/+page.svelte | 4 +- 20 files changed, 1172 insertions(+), 227 deletions(-) create mode 100644 backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py create mode 100644 backend/tests/unit/layouts_csv/test_fk_loader_state_mex_key.py create mode 100644 docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md 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 558f6940..f4c0db4c 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -13,12 +13,19 @@ from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRat 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.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver 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 +def _logistics_str_nonempty(value) -> bool: + return value is not None and str(value).strip() != "" + + def _normalize_invoice_currency_value(value) -> str: """Lowercase currency code (foreign/local/manual) for comparisons.""" if value is None or value == "": @@ -344,25 +351,40 @@ def validate_common( code="INVALID_VALUE", value=invoice.compliance_mx.remesa, ) + else: + # Misma remesa puede repetirse en distintos pedimentos; solo debe ser única + # por (pedimento_id, empresa, tenant). Creación: existing_invoice es None. + current_invoice_id = None + if existing_invoice is not None: + current_invoice_id = existing_invoice.id + else: + current_invoice_id = getattr(invoice, "id", None) - 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, + duplicated_remesa = ( + db.query(InvoiceComplianceMx) + .filter( + InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa, + InvoiceComplianceMx.pedimento_id + == invoice.compliance_mx.pedimento_id, + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + ) + .first() ) + if duplicated_remesa and ( + current_invoice_id is None + or duplicated_remesa.invoice_id != current_invoice_id + ): + errors.add_error( + field="compliance_mx.remesa", + message=( + "El valor de Remesa ya está registrado para este mismo Pedimento " + "en otra factura." + ), + solution=["Usa otra remesa o revisa la factura que ya la tiene capturada"], + code="DUPLICATE_VALUE", + value=invoice.compliance_mx.remesa, + ) # Financials checks (if provided) if invoice.financials: @@ -511,6 +533,7 @@ def validate_common( ) if invoice.logistics: + carrier_exists = None if invoice.logistics.carrier_id: # `carrier_id` is the frontend/export key (string). Prefer validating by the internal int # when present, otherwise validate by `transporter_key`. @@ -542,8 +565,36 @@ def validate_common( solution=["Verifica el ID del Transportista", "Revisa el catálogo"], code="NOT_FOUND", value=invoice.logistics.carrier_id, - ) - + ) + + if carrier_exists and _logistics_str_nonempty( + getattr(invoice.logistics, "driver_name", None) + ): + driver_row = ( + db.query(Driver) + .filter( + Driver.transporter_key == str(invoice.logistics.carrier_id).strip(), + Driver.driver_name + == str(invoice.logistics.driver_name).strip(), + Driver.tenant_id == tenant_id, + Driver.company_id == company_id, + ) + .first() + ) + if not driver_row: + errors.add_error( + field="logistics.driver_name", + message=( + "El conductor no existe en el catálogo de conductores " + "para el transportista indicado." + ), + solution=[ + "Registra el conductor en el catálogo o elige uno de la lista" + ], + code="NOT_FOUND", + value=invoice.logistics.driver_name, + ) + if invoice.logistics.transport_type not in [t.value for t in TransportType]: errors.add_error( field="logistics.transport_type", @@ -555,31 +606,138 @@ def validate_common( 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, + # Vehículo: solo catálogo si viene informado (cualquier tipo de transporte). + tid = getattr(invoice.logistics, "transport_int_id", None) + tcode = invoice.logistics.transport_id + if tid is not None: + v_by_int = ( + db.query(Vehicle) + .filter( + Vehicle.vehicle_id == tid, + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, ) + .first() + ) + if not v_by_int: + errors.add_error( + field="logistics.transport_int_id", + message="El Vehículo (transporte) no existe en el catálogo.", + solution=[ + "Verifica el ID interno del vehículo o usa la clave de transporte" + ], + code="NOT_FOUND", + value=tid, + ) + elif _logistics_str_nonempty(tcode): + v_by_key = ( + db.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) + == str(tcode).strip().upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if not v_by_key: + errors.add_error( + field="logistics.transport_id", + message="El Vehículo (clave transporte) no existe en el catálogo.", + solution=["Verifica la clave en el catálogo de vehículos"], + code="NOT_FOUND", + value=tcode, + ) + + # Remolque: obligatorio si tipo != 'none'; con 'none' solo se valida catálogo si hay dato. + tr_int = getattr(invoice.logistics, "trailer_int_id", None) + tr_num = invoice.logistics.trailer_num + if invoice.logistics.transport_type != "none": + if tr_int is not None: + tr = ( + db.query(Trailer) + .filter( + Trailer.trailer_id == tr_int, + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_int_id", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el ID interno del remolque"], + code="NOT_FOUND", + value=tr_int, + ) + elif not _logistics_str_nonempty(tr_num): + errors.add_error( + field="logistics.trailer_num", + message=( + "El Remolque es obligatorio cuando el Tipo de Transporte es distinto de 'none'." + ), + solution=["Selecciona un remolque del catálogo"], + code="REQUIRED_FIELD", + value=tr_num, + ) + else: + tr = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(tr_num).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_num", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el número de remolque"], + code="NOT_FOUND", + value=tr_num, + ) + else: + if tr_int is not None: + tr = ( + db.query(Trailer) + .filter( + Trailer.trailer_id == tr_int, + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_int_id", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el ID interno del remolque"], + code="NOT_FOUND", + value=tr_int, + ) + elif _logistics_str_nonempty(tr_num): + tr = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(tr_num).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_num", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el número de remolque"], + code="NOT_FOUND", + value=tr_num, + ) if invoice.financials: submitted = invoice.financials.currency diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 5bcd6fd0..676a57d3 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -11,7 +11,6 @@ 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 api.v1.modules.a76.items.models import LineItem - from . import models, schemas @@ -22,10 +21,11 @@ def _autofill_transport_int_ids( company_id: int, ) -> None: """ - Ensures invoice logistics internal int IDs are populated when their string codes exist. + Ensures invoice logistics internal int IDs are populated when catalog rows exist. - The frontend and exports/imports keep using the string keys/codes (carrier_id, transport_id, trailer_num), - but internally we also persist integer surrogate IDs for fast/consistent joins. + String keys (carrier_id, transport_id, trailer_num) are matched to existing catalog + rows for the tenant/company; no placeholder rows are created. Invalid references must + be rejected by validate_common before persist. """ # Local imports to avoid circular dependencies. @@ -49,51 +49,102 @@ def _autofill_transport_int_ids( if carrier_code and carrier_int_id is None: transporter_obj = ( db.query(Transporter) - .filter(Transporter.transporter_key == carrier_code) + .filter( + Transporter.transporter_key == carrier_code, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) .first() ) - if not transporter_obj: - # Minimal insert so mapping doesn't end up NULL. - transporter_obj = Transporter( - transporter_key=carrier_code, - tenant_id=tenant_id, - company_id=company_id, - ) - db.add(transporter_obj) - db.flush() - _set("carrier_int_id", transporter_obj.transporter_id) + if transporter_obj: + _set("carrier_int_id", transporter_obj.transporter_id) transport_code = _get("transport_id") transport_int_id = _get("transport_int_id") if transport_code and transport_int_id is None: vehicle_obj = ( - db.query(Vehicle).filter(Vehicle.vehicle_key == transport_code).first() - ) - if not vehicle_obj: - vehicle_obj = Vehicle( - vehicle_key=transport_code, - tenant_id=tenant_id, - company_id=company_id, + db.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) == str(transport_code).strip().upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, ) - db.add(vehicle_obj) - db.flush() - _set("transport_int_id", vehicle_obj.vehicle_id) + .first() + ) + if vehicle_obj: + _set("transport_int_id", vehicle_obj.vehicle_id) trailer_code = _get("trailer_num") trailer_int_id = _get("trailer_int_id") if trailer_code and trailer_int_id is None: trailer_obj = ( - db.query(Trailer).filter(Trailer.trailer_number == trailer_code).first() - ) - if not trailer_obj: - trailer_obj = Trailer( - trailer_number=trailer_code, - tenant_id=tenant_id, - company_id=company_id, + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(trailer_code).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, ) - db.add(trailer_obj) - db.flush() - _set("trailer_int_id", trailer_obj.trailer_id) + .first() + ) + if trailer_obj: + _set("trailer_int_id", trailer_obj.trailer_id) + + # Placa: si no vino valor, tomar del vehículo y si no del remolque (catálogo) + lp = _get("license_plate") + if lp is None or str(lp).strip() == "": + veh = None + tv_id = _get("transport_int_id") + tv_code = _get("transport_id") + if tv_id is not None: + veh = ( + db.query(Vehicle) + .filter( + Vehicle.vehicle_id == tv_id, + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + elif tv_code: + veh = ( + db.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) == str(tv_code).strip().upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if veh and getattr(veh, "plate_number", None): + _set("license_plate", veh.plate_number) + else: + tr = None + tr_id = _get("trailer_int_id") + tr_code = _get("trailer_num") + if tr_id is not None: + tr = ( + db.query(Trailer) + .filter( + Trailer.trailer_id == tr_id, + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + elif tr_code: + tr = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(tr_code).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if tr and getattr(tr, "plate_number", None): + _set("license_plate", tr.plate_number) def _get_current_username() -> 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 7717a874..4169392b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -2453,6 +2453,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_pedimento_col_a, _pedimento_key_from_parsed, ) + from .validators.transport_catalog import logistics_scan_row_errors def _ped_key_from_row(ped_str: str) -> Optional[str]: parsed = parse_pedimento_col_a(ped_str) @@ -2849,6 +2850,9 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_date_fn=parse_date, warnings=warnings_row, ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: error_count += 1 @@ -2909,6 +2913,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_pedimento_col_a_impo_def, ) from .validators.encabezados_impo_temp import _pedimento_key_from_parsed + from .validators.transport_catalog import logistics_scan_row_errors DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") @@ -3205,6 +3210,9 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_date_fn=parse_date, warnings=warnings_row, ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: error_count += 1 @@ -3260,6 +3268,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = 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 + from .validators.transport_catalog import logistics_scan_row_errors _fc = parse_footer_config(meta.get("footer_config")) actualizar = meta.get("actualizar", False) @@ -3582,6 +3591,9 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_date_fn=parse_date, warnings=warnings_row, ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: error_count += 1 @@ -3630,6 +3642,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = 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 + from .validators.transport_catalog import logistics_scan_row_errors _fc = parse_footer_config(meta.get("footer_config")) actualizar = meta.get("actualizar", False) @@ -3751,62 +3764,69 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = 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 = [] - row_errors = validate_row_encabezados_cmex( - row_norm, - i, - actualizar=actualizar, - invoice_exists_by_number=invoice_exists_by_number, - invoice_processed_by_number=invoice_processed_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, - ) - blocking = [e for e in row_errors if not e.get("warning")] - if blocking: - error_count += 1 - error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( + with CoreSessionLocal() as session: + 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 = [] + row_errors = validate_row_encabezados_cmex( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_processed_by_number=invoice_processed_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, + ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) + blocking = [e for e in row_errors if not e.get("warning")] + if blocking: + error_count += 1 + error_lines_list.append(i) + for e in blocking: + f_err.write( + json.dumps( + { + "line": e["line"], + "col": e.get("col", ""), + "msg": e.get("msg", ""), + "solution": e.get("solution", ""), + } + ) + + "\n" + ) + for e in row_errors + warnings_row: + if len(errors_detail) < 5000: + errors_detail.append( { "line": e["line"], "col": e.get("col", ""), "msg": e.get("msg", ""), "solution": e.get("solution", ""), + "warning": bool(e.get("warning", False)), } ) - + "\n" - ) - for e in row_errors + warnings_row: - if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) - processed_rows += 1 + processed_rows += 1 common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( @@ -4223,6 +4243,18 @@ def validate_row_strict( if err: return err + from .validators.transport_catalog import logistics_scan_row_errors + + log_errs = logistics_scan_row_errors( + row, + validator.session, + validator.tenant_id, + validator.company_id, + line_num, + ) + if log_errs: + return log_errs[0] + 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: @@ -5370,6 +5402,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt row_to_transport_type_clarion, _patente_from_agente_aduanal, ) + from .validators.transport_catalog import ( + normalize_transport_cell, + resolve_logistics_transport_fields_for_commit, + resolve_transport_csv_fields, + validate_csv_invoice_logistics_transport, + ) from .validators.encabezados_impo_def import parse_pedimento_col_a_impo_def from .validators.pedimento_resolution import resolve_pedimento_candidates @@ -6064,40 +6102,84 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt 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'): + transport_key_csv, trailer_num_csv, transport_num_csv = resolve_transport_csv_fields(row_norm) + has_logistics_data = bool( + weight_type + or row_norm.get('TIPO TRANSPORTE') + or row_norm.get('NUMERO TRANSPORTE') + or transport_key_csv + or trailer_num_csv + or row_norm.get('CLAVE TRANSPORTISTA') + or row_norm.get('NOMBRE CONDUCTOR') + ) + if has_logistics_data: 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 + err_transport = validate_csv_invoice_logistics_transport( + row_norm=row_norm, + session=session, + tenant_id=tenant_id, + company_id=company_id, + transport_type=transport_type, + ) + if err_transport: + skipped_invalid += 1 + reason = err_transport + skipped_fk_details.append( + {"line": i, "invoice": invoice_number, "reason": reason} + ) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue - carrier_code = row_norm.get('CLAVE TRANSPORTISTA') or None + transport_num_effective = (transport_num_csv or None) + if transport_type == TransportType.NONE: + transport_num_effective = None + + carrier_code = normalize_transport_cell(row_norm.get('CLAVE TRANSPORTISTA')) or None carrier_int_id = None if carrier_code: - # Internal int mapping for carrier_code -> transporter_id - from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.a76.transportation.transporters.models import ( + Transporter, + ) carrier_obj = ( session.query(Transporter) - .filter(Transporter.transporter_key == carrier_code) + .filter( + Transporter.transporter_key == carrier_code, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) .first() ) - if not carrier_obj: - carrier_obj = Transporter( - transporter_key=carrier_code, - tenant_id=tenant_id, - company_id=company_id, - ) - session.add(carrier_obj) - session.flush() - carrier_int_id = carrier_obj.transporter_id + if carrier_obj: + carrier_int_id = carrier_obj.transporter_id + + ( + transport_id_value, + trailer_num_value, + transport_int_id, + trailer_int_id, + ) = resolve_logistics_transport_fields_for_commit( + session=session, + tenant_id=tenant_id, + company_id=company_id, + clave_transporte_raw=transport_key_csv, + numero_caja_raw=trailer_num_csv, + ) logistics = InvoiceLogistics( carrier_id=carrier_code, carrier_int_id=carrier_int_id, driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), transport_type=transport_type, - transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), + transport_num=transport_num_effective, + transport_id=transport_id_value, + trailer_num=trailer_num_value, + transport_int_id=transport_int_id, + trailer_int_id=trailer_int_id, weight_type=weight_type or WeightUnit.KGS, seal_number=(row_norm.get('PRECINTO') or None), incoterm=(row_norm.get('CLAVE INCOTERM') or None), 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 711f4653..e972528c 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 @@ -29,6 +29,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, @@ -66,6 +68,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, @@ -102,6 +106,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, @@ -135,6 +141,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, 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 index 320924d3..f46b639b 100644 --- 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 @@ -51,18 +51,6 @@ def _validaciones_transporte_cmex(row: Dict[str, Any], line_num: int) -> Optiona "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 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 index c3213dad..d79df42e 100644 --- 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 @@ -344,18 +344,6 @@ def _validaciones_transporte(row: Dict[str, Any], line_num: int) -> Optional[Dic "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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py new file mode 100644 index 00000000..4597e479 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py @@ -0,0 +1,228 @@ +"""Mapeo y validación de transporte para CSV (paridad con facturas manuales / validate_common).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import TransportType + + +def normalize_transport_cell(raw: Optional[str]) -> str: + if raw is None: + return "" + return str(raw).replace("\u00a0", " ").strip() + + +def resolve_transport_csv_fields(row: dict) -> Tuple[str, str, str]: + """ + Prioridad de columnas: + 1) CLAVE TRANSPORTE / NUMERO CAJA + 2) NUMERO TRANSPORTE como fallback legacy. + """ + legacy = normalize_transport_cell(row.get("NUMERO TRANSPORTE")) + clave_transporte = normalize_transport_cell(row.get("CLAVE TRANSPORTE")) or legacy + numero_caja = normalize_transport_cell(row.get("NUMERO CAJA")) or legacy + numero_transporte = legacy + return clave_transporte, numero_caja, numero_transporte + + +def resolve_logistics_transport_fields_for_commit( + session: Session, + tenant_id: int, + company_id: int, + clave_transporte_raw: str, + numero_caja_raw: str, +) -> Tuple[Optional[str], Optional[str], Optional[int], Optional[int]]: + """ + Resuelve claves y IDs internos para logística: + - transport_id / transport_int_id a partir de Vehicle.vehicle_key + - trailer_num / trailer_int_id a partir de Trailer.trailer_number + """ + from api.v1.modules.a76.transportation.vehicles.models import Vehicle + from api.v1.modules.a76.transportation.trailers.models import Trailer + + clave_transporte = normalize_transport_cell(clave_transporte_raw) + numero_caja = normalize_transport_cell(numero_caja_raw) + + transport_id: Optional[str] = clave_transporte or None + trailer_num: Optional[str] = numero_caja or None + transport_int_id: Optional[int] = None + trailer_int_id: Optional[int] = None + + if transport_id: + vehicle = ( + session.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) == transport_id.upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if vehicle is not None: + transport_int_id = vehicle.vehicle_id + + if trailer_num: + trailer = ( + session.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) == trailer_num.upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if trailer is not None: + trailer_int_id = trailer.trailer_id + + return transport_id, trailer_num, transport_int_id, trailer_int_id + + +def validate_csv_invoice_logistics_transport( + row_norm: dict, + session: Session, + tenant_id: int, + company_id: int, + transport_type: TransportType, +) -> Optional[str]: + """ + Paridad con validate_common (factura manual): + - Transportista y conductor: solo catálogo si vienen informados. + - Vehículo: solo catálogo si viene CLAVE TRANSPORTE explícita y no resuelve. + - Remolque: obligatorio (y catálogo) si tipo ≠ none; con none, solo catálogo si NUMERO CAJA explícito. + """ + from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.a76.transportation.drivers.models import Driver + + carrier_code = normalize_transport_cell(row_norm.get("CLAVE TRANSPORTISTA")) + carrier_ok = False + if carrier_code: + tr_obj = ( + session.query(Transporter) + .filter( + Transporter.transporter_key == carrier_code, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() + ) + if not tr_obj: + return "CLAVE TRANSPORTISTA no existe en el catálogo" + carrier_ok = True + + driver_raw = normalize_transport_cell(row_norm.get("NOMBRE CONDUCTOR")) + if carrier_ok and driver_raw: + dr = ( + session.query(Driver) + .filter( + Driver.transporter_key == carrier_code, + Driver.driver_name == driver_raw, + Driver.tenant_id == tenant_id, + Driver.company_id == company_id, + ) + .first() + ) + if not dr: + return ( + "NOMBRE CONDUCTOR: no existe en el catálogo de conductores " + "para el transportista indicado" + ) + + transport_key_csv, trailer_num_csv, _ = resolve_transport_csv_fields(row_norm) + ( + _transport_id_value, + trailer_num_resolved, + transport_int_id, + trailer_int_id, + ) = resolve_logistics_transport_fields_for_commit( + session=session, + tenant_id=tenant_id, + company_id=company_id, + clave_transporte_raw=transport_key_csv, + numero_caja_raw=trailer_num_csv, + ) + + explicit_clave = normalize_transport_cell(row_norm.get("CLAVE TRANSPORTE")) + if explicit_clave and transport_int_id is None: + return "CLAVE TRANSPORTE: vehículo no existe en el catálogo" + + explicit_caja = normalize_transport_cell(row_norm.get("NUMERO CAJA")) + if transport_type == TransportType.NONE: + if explicit_caja and trailer_int_id is None: + return "NUMERO CAJA: remolque no existe en el catálogo" + return None + + if trailer_int_id is None: + if not (trailer_num_resolved and str(trailer_num_resolved).strip()): + return ( + "NUMERO CAJA (remolque) obligatorio cuando TIPO TRANSPORTE es distinto de NINGUNO" + ) + return "NUMERO CAJA: remolque no existe en el catálogo" + + return None + + +def logistics_scan_row_errors( + row_norm: dict, + session: Session, + tenant_id: int, + company_id: int, + line_num: int, +) -> List[Dict[str, Any]]: + """ + Errores de scan (misma lógica que commit: validate_csv_invoice_logistics_transport). + Solo corre si la fila trae algún dato de logística de transporte. + """ + from .encabezados_impo_temp import row_to_transport_type_clarion + + transport_key_csv, trailer_num_csv, _ = resolve_transport_csv_fields(row_norm) + + def _cell(key: str) -> str: + return normalize_transport_cell(row_norm.get(key)) + + has_data = bool( + _cell("TIPO TRANSPORTE") + or _cell("NUMERO TRANSPORTE") + or _cell("CLAVE TRANSPORTE") + or transport_key_csv + or trailer_num_csv + or _cell("CLAVE TRANSPORTISTA") + or _cell("NOMBRE CONDUCTOR") + ) + if not has_data: + return [] + + 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: + tt = TransportType(transport_str) + except ValueError: + tt = TransportType.NONE + + msg = validate_csv_invoice_logistics_transport( + row_norm=row_norm, + session=session, + tenant_id=tenant_id, + company_id=company_id, + transport_type=tt, + ) + if not msg: + return [] + return [ + { + "line": line_num, + "col": "LOGISTICA TRANSPORTE", + "msg": msg, + "solution": ( + "Verifica CLAVE TRANSPORTISTA, NOMBRE CONDUCTOR, CLAVE TRANSPORTE, " + "NUMERO CAJA y TIPO TRANSPORTE (remolque obligatorio si no es NINGUNO)." + ), + } + ] diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py index c87adcaa..f9e2c3cf 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py @@ -25,8 +25,8 @@ def load_trailers_fk_sets( Devuelve: - valid_trailer_type_keys: códigos de trailer_type (GTipoTrailer) - valid_country_ame: claves americana de países (GPaises.Pais_Ame), mayúsculas - - state_descriptions_upper: descripciones de estados en mayúsculas (GEstados) - - state_country_set: set de (ame_key_pais, description_estado_upper) para validar "estado pertenece a país" + - state_descriptions_upper: descripciones y mex_key en mayúsculas (GEstados) + - state_country_set: (ame_key_pais, token_upper) para description o mex_key - state_ame_to_description: dict clave_ame_upper -> description_upper (opcional; vacío si State no tiene ame_key) """ valid_trailer_type_keys: Set[str] = set() @@ -50,21 +50,35 @@ def load_trailers_fk_sets( 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() + ) + ame_country = None + if country and (country.ame_key or "").strip(): + ame_country = (country.ame_key or "").strip().upper() + desc = (state.description or "").strip() if desc: state_descriptions_upper.add(desc.upper()) - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() + if ame_country: + state_country_set.add((ame_country, desc.upper())) + + mex_key = (state.mex_key or "").strip() + if mex_key: + mk = mex_key.upper() + state_descriptions_upper.add(mk) + if ame_country: + state_country_set.add((ame_country, mk)) + if desc: + state_ame_to_description[mk] = desc.upper() + + ame_state = getattr(state, "ame_key", None) + if ame_state and (ame_state or "").strip(): + state_ame_to_description[(ame_state or "").strip().upper()] = ( + desc.upper() if desc else (ame_state or "").strip().upper() ) - if country and (country.ame_key or "").strip(): - state_country_set.add( - ((country.ame_key or "").strip().upper(), desc.upper()) - ) - ame = getattr(state, "ame_key", None) - if ame and (ame or "").strip(): - state_ame_to_description[(ame or "").strip().upper()] = desc.upper() if desc else (ame or "").strip().upper() except Exception as e: logger.warning("Trailers import: could not load FK sets: %s", e) 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 86ad4740..4e2f7f32 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 @@ -24,8 +24,8 @@ def load_transportistas_fk_sets( Devuelve: - existing_transporter_keys: claves de transportistas existentes (tenant/company) en mayúsculas - valid_country_ame: claves americana de países (GPaises.Pais_Ame), mayúsculas - - state_descriptions_upper: descripciones de estados en mayúsculas (GEstados) - - state_country_set: set de (ame_key_pais, description_estado_upper) para validar estado pertenece a país + - state_descriptions_upper: descripciones y claves cortas (mex_key) de estados en mayúsculas (GEstados) + - state_country_set: set de (ame_key_pais, token_upper) para description o mex_key """ existing_transporter_keys: Set[str] = set() valid_country_ame: Set[str] = set() @@ -55,18 +55,27 @@ def load_transportistas_fk_sets( 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() + ) + 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()) - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() - ) - if country and (country.ame_key or "").strip(): - state_country_set.add( - ((country.ame_key or "").strip().upper(), 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)) 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/vehicles/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py index f76aa77d..1cf2b300 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py @@ -24,8 +24,8 @@ def load_vehicles_fk_sets( Devuelve: - valid_transport_codes: códigos de transport_types (GTipoTransportes) - valid_country_ame: claves americana de países (GPaises.Pais_Ame), mayúsculas - - state_descriptions_upper: descripciones de estados en mayúsculas (GEstados), para "estado existe" - - state_country_set: set de (ame_key_pais, description_estado_upper) para validar "estado pertenece a país" + - state_descriptions_upper: descripciones y mex_key en mayúsculas (GEstados) + - state_country_set: (ame_key_pais, token_upper) para description o mex_key """ valid_transport_codes: Set[str] = set() valid_country_ame: Set[str] = set() @@ -46,21 +46,28 @@ def load_vehicles_fk_sets( if row[0]: valid_country_ame.add((row[0] or "").strip().upper()) - # States: description (GEstados.Descripcion); State.m3_key = Country.m3_key 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()) - # País para este estado vía m3_key - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() - ) - if country and (country.ame_key or "").strip(): - state_country_set.add( - ((country.ame_key or "").strip().upper(), 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)) except Exception as e: logger.warning("Vehicles import: could not load FK sets: %s", e) diff --git a/backend/api/v1/modules/a76/transportation/drivers/services.py b/backend/api/v1/modules/a76/transportation/drivers/services.py index d9d9d261..66cc1976 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/services.py +++ b/backend/api/v1/modules/a76/transportation/drivers/services.py @@ -1,9 +1,17 @@ from typing import List, Optional +from sqlalchemy import text from sqlalchemy.orm import Session from . import dto, models +DRIVER_ID_SEQ = "a76.driver_driver_id_seq" + + +def allocate_driver_id(db: Session) -> int: + """Next surrogate driver_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{DRIVER_ID_SEQ}')")).scalar() + class DriverService: @staticmethod @@ -34,7 +42,10 @@ class DriverService: @staticmethod def create_driver(db: Session, driver_data: dto.DriverCreateDTO): - new_driver = models.Driver(**driver_data.dict()) + data = driver_data.model_dump() + if data.get("driver_id") is None: + data["driver_id"] = allocate_driver_id(db) + new_driver = models.Driver(**data) db.add(new_driver) db.commit() db.refresh(new_driver) diff --git a/backend/api/v1/modules/a76/transportation/trailers/services.py b/backend/api/v1/modules/a76/transportation/trailers/services.py index 4ffb7c30..d82caa87 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/services.py +++ b/backend/api/v1/modules/a76/transportation/trailers/services.py @@ -1,9 +1,17 @@ from typing import Optional, Tuple, List, Dict, Any from sqlalchemy.orm import Session +from sqlalchemy import text from . import dto, models +TRAILER_ID_SEQ = "a76.trailer_trailer_id_seq" + + +def allocate_trailer_id(db: Session) -> int: + """Next surrogate trailer_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{TRAILER_ID_SEQ}')")).scalar() + class TrailerService: """Service for Trailer CRUD operations with tenant support""" @@ -66,8 +74,11 @@ class TrailerService: company_id: int, ) -> models.Trailer: """Create a new trailer""" + data = trailer_data.model_dump() + if data.get("trailer_id") is None: + data["trailer_id"] = allocate_trailer_id(db) new_trailer = models.Trailer( - **trailer_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **data, tenant_id=tenant_id, company_id=company_id ) db.add(new_trailer) db.commit() diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py index d92880a9..e71a4c68 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/services.py +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -2,12 +2,19 @@ from typing import Optional, Tuple, List, Dict, Any import logging from sqlalchemy.orm import Session -from sqlalchemy import func +from sqlalchemy import func, text from . import dto, models logger = logging.getLogger(__name__) +TRANSPORTER_ID_SEQ = "a76.transporter_transporter_id_seq" + + +def allocate_transporter_id(db: Session) -> int: + """Next surrogate transporter_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{TRANSPORTER_ID_SEQ}')")).scalar() + class TransporterService: """Service for Transporter CRUD operations with tenant support""" @@ -91,8 +98,11 @@ class TransporterService: company_id: int, ) -> models.Transporter: """Create a new transporter""" + data = transporter_data.model_dump() + if data.get("transporter_id") is None: + data["transporter_id"] = allocate_transporter_id(db) new_transporter = models.Transporter( - **transporter_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **data, tenant_id=tenant_id, company_id=company_id ) db.add(new_transporter) db.commit() diff --git a/backend/api/v1/modules/a76/transportation/vehicles/services.py b/backend/api/v1/modules/a76/transportation/vehicles/services.py index 2b9801ac..fd953f27 100644 --- a/backend/api/v1/modules/a76/transportation/vehicles/services.py +++ b/backend/api/v1/modules/a76/transportation/vehicles/services.py @@ -1,9 +1,17 @@ from typing import Optional, Tuple, List, Dict, Any from sqlalchemy.orm import Session +from sqlalchemy import text from . import dto, models +VEHICLE_ID_SEQ = "a76.vehicle_vehicle_id_seq" + + +def allocate_vehicle_id(db: Session) -> int: + """Next surrogate vehicle_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{VEHICLE_ID_SEQ}')")).scalar() + class VehicleService: """Service for Vehicle CRUD operations with tenant support""" @@ -66,8 +74,11 @@ class VehicleService: company_id: int, ) -> models.Vehicle: """Create a new vehicle""" + data = vehicle_data.model_dump() + if data.get("vehicle_id") is None: + data["vehicle_id"] = allocate_vehicle_id(db) new_vehicle = models.Vehicle( - **vehicle_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **data, tenant_id=tenant_id, company_id=company_id ) db.add(new_vehicle) db.commit() diff --git a/backend/tests/unit/layouts_csv/test_fk_loader_state_mex_key.py b/backend/tests/unit/layouts_csv/test_fk_loader_state_mex_key.py new file mode 100644 index 00000000..e28b42e5 --- /dev/null +++ b/backend/tests/unit/layouts_csv/test_fk_loader_state_mex_key.py @@ -0,0 +1,130 @@ +"""Smoke: loaders CSV incluyen State.mex_key en validación de ESTADO/PAÍS.""" +from unittest.mock import MagicMock, patch + + +def _fake_state(m3_key="MEX", description="Chihuahua", mex_key="CHH"): + s = MagicMock() + s.m3_key = m3_key + s.description = description + s.mex_key = mex_key + return s + + +def _fake_country(m3_key="MEX", ame_key="MX"): + c = MagicMock() + c.m3_key = m3_key + c.ame_key = ame_key + return c + + +def _session_with_state_queries(fake_state, fake_country): + """Simula session.query en el orden de transportistas/vehicles/trailers loaders.""" + n = [0] + + def query_side_effect(*_args, **_kwargs): + n[0] += 1 + m = MagicMock() + if n[0] == 1: + m.filter.return_value.all.return_value = [] + elif n[0] == 2: + m.all.return_value = [("MX",)] + elif n[0] == 3: + m.all.return_value = [fake_state] + elif n[0] == 4: + m.filter.return_value.first.return_value = fake_country + return m + + session = MagicMock() + session.query.side_effect = query_side_effect + return session + + +@patch("api.v1.modules.a76.layouts_csv.transportistas.common.fk_loader.CoreSessionLocal") +def test_transportistas_fk_sets_includes_mex_key(mock_session_local): + from api.v1.modules.a76.layouts_csv.transportistas.common.fk_loader import ( + load_transportistas_fk_sets, + ) + + st = _fake_state() + mock_session_local.return_value.__enter__.return_value = _session_with_state_queries( + st, _fake_country() + ) + + _, _, state_desc, state_cc = load_transportistas_fk_sets(1, 1) + + assert "CHH" in state_desc + assert "CHIHUAHUA" in state_desc + assert ("MX", "CHH") in state_cc + assert ("MX", "CHIHUAHUA") in state_cc + + +def _session_vehicles(fake_state, fake_country): + n = [0] + + def query_side_effect(*_args, **_kwargs): + n[0] += 1 + m = MagicMock() + if n[0] == 1: + m.all.return_value = [("VH",)] + elif n[0] == 2: + m.all.return_value = [("MX",)] + elif n[0] == 3: + m.all.return_value = [fake_state] + elif n[0] == 4: + m.filter.return_value.first.return_value = fake_country + return m + + session = MagicMock() + session.query.side_effect = query_side_effect + return session + + +@patch("api.v1.modules.a76.layouts_csv.vehicles.common.fk_loader.CoreSessionLocal") +def test_vehicles_fk_sets_includes_mex_key(mock_session_local): + from api.v1.modules.a76.layouts_csv.vehicles.common.fk_loader import load_vehicles_fk_sets + + mock_session_local.return_value.__enter__.return_value = _session_vehicles( + _fake_state(), _fake_country() + ) + + _, _, state_desc, state_cc = load_vehicles_fk_sets(1, 1) + + assert "CHH" in state_desc + assert ("MX", "CHH") in state_cc + + +def _session_trailers(fake_state, fake_country): + n = [0] + + def query_side_effect(*_args, **_kwargs): + n[0] += 1 + m = MagicMock() + if n[0] == 1: + m.all.return_value = [("BX",)] + elif n[0] == 2: + m.all.return_value = [("MX",)] + elif n[0] == 3: + fake_state.ame_key = None + m.all.return_value = [fake_state] + elif n[0] == 4: + m.filter.return_value.first.return_value = fake_country + return m + + session = MagicMock() + session.query.side_effect = query_side_effect + return session + + +@patch("api.v1.modules.a76.layouts_csv.trailers.common.fk_loader.CoreSessionLocal") +def test_trailers_fk_sets_includes_mex_key(mock_session_local): + from api.v1.modules.a76.layouts_csv.trailers.common.fk_loader import load_trailers_fk_sets + + mock_session_local.return_value.__enter__.return_value = _session_trailers( + _fake_state(), _fake_country() + ) + + _, _, state_desc, state_cc, ame_map = load_trailers_fk_sets(1, 1) + + assert "CHH" in state_desc + assert ("MX", "CHH") in state_cc + assert ame_map.get("CHH") == "CHIHUAHUA" diff --git a/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md b/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md new file mode 100644 index 00000000..730c66ae --- /dev/null +++ b/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md @@ -0,0 +1,141 @@ +# Analisis de flujo de migracion CSV de transporte (sin validaciones) + +## Objetivo + +Definir como migrar el flujo de importacion CSV de facturas hacia columnas nuevas de transporte, priorizando continuidad operativa y consistencia de IDs en `invoice_logistics`, sin depender de reglas de validacion funcional. + +## Estado actual de la rama + +- El pipeline actual ya separa `scan` y `commit` en `tasks.py`. +- El mapeo de plantillas (`template_config.py`) aun esta centrado en `NUMERO TRANSPORTE`. +- En `commit`, la persistencia de logistica usa principalmente: + - `carrier_id` / `carrier_int_id` desde `CLAVE TRANSPORTISTA`. + - `transport_type` desde `TIPO TRANSPORTE`. + - `transport_num` desde `NUMERO TRANSPORTE`. +- No existe aun una ruta establecida para columnas nuevas `CLAVE TRANSPORTE` y `NUMERO CAJA`. + +## Intencion funcional identificada en los commits analizados + +Sin copiar su logica de validacion, la idea de flujo que se quiso introducir es: + +1. Separar los datos de transporte en dos entradas semanticas: + - clave de vehiculo (`CLAVE TRANSPORTE`) + - numero de caja/remolque (`NUMERO CAJA`) +2. Mantener compatibilidad con archivos legacy: + - usar `NUMERO TRANSPORTE` como fallback cuando no existan columnas nuevas. +3. Resolver IDs internos en commit contra catalogos: + - `vehicle_key` -> `vehicle_id` + - `trailer_number` -> `trailer_id` +4. Persistir tanto la clave legible (string) como su ID interno (int) en `invoice_logistics`. +5. Mantener paridad de flujo entre scan y commit en cuanto a normalizacion y resolucion de campos, pero sin convertir scan en un bloqueo por validaciones de negocio. + +## Flujo propuesto (sin validaciones de negocio) + +### 1) Scan (preprocesamiento y trazabilidad) + +Objetivo: preparar datos y metadatos, no rechazar por reglas funcionales. + +- Leer CSV con `row_from_template`. +- Normalizar celdas relevantes de transporte: + - trim + - reemplazo de NBSP por espacio + - `None`/vacio a cadena vacia +- Construir una estructura por renglon con campos de transporte efectivos: + - `effective_transport_type` + - `effective_vehicle_key` + - `effective_trailer_number` + - `effective_legacy_transport_num` (solo trazabilidad) +- Guardar errores tecnicos (parseo CSV, formato bruto no interpretable), pero no bloquear por reglas de catalogo/negocio. + +### 2) Commit (resolucion y persistencia de IDs) + +Objetivo: persistir de forma consistente los IDs nuevos de migracion. + +- Determinar campos efectivos por precedencia (ver tabla siguiente). +- Resolver `carrier_int_id` con `CLAVE TRANSPORTISTA` (flujo ya existente). +- Resolver `transport_int_id` cuando exista `effective_vehicle_key`: + - lookup en `vehicle.vehicle_key` +- Resolver `trailer_int_id` cuando exista `effective_trailer_number`: + - lookup en `trailer.trailer_number` +- Persistir en `InvoiceLogistics`: + - string keys: `carrier_id`, `transport_id`, `trailer_num`, `transport_num` + - int refs: `carrier_int_id`, `transport_int_id`, `trailer_int_id` + +Importante: para migracion, si hay string pero no hay match de ID, **no romper flujo**; persistir string y dejar int en `NULL` (el FK ya permite `SET NULL`). + +## Matriz de mapeo CSV -> `invoice_logistics` + +| Entrada CSV | Rol | Campo destino (string) | Campo destino (int) | Catalogo/lookup | +|---|---|---|---|---| +| `CLAVE TRANSPORTISTA` | Transportista | `carrier_id` | `carrier_int_id` | `transporter.transporter_key -> transporter_id` | +| `CLAVE TRANSPORTE` | Vehiculo | `transport_id` | `transport_int_id` | `vehicle.vehicle_key -> vehicle_id` | +| `NUMERO CAJA` | Caja/Remolque | `trailer_num` | `trailer_int_id` | `trailer.trailer_number -> trailer_id` | +| `NUMERO TRANSPORTE` (legacy) | Fallback | `transport_num` (siempre trazable) y apoyo para resolver vehicle/trailer segun precedencia | opcional | depende de reglas de precedencia | +| `TIPO TRANSPORTE` | Tipo logistica | `transport_type` | n/a | enum interno | + +## Reglas de precedencia de datos (nuevas vs legacy) + +Definicion para no generar ambiguedad: + +1. Si viene `CLAVE TRANSPORTE`, usarla para `transport_id`. +2. Si viene `NUMERO CAJA`, usarla para `trailer_num`. +3. Si faltan columnas nuevas y viene `NUMERO TRANSPORTE`: + - usarlo como `transport_num` (trazabilidad legacy) + - y usarlo como fallback de resolucion para `transport_id`/`trailer_num` solo cuando el campo nuevo correspondiente este vacio. +4. Nunca sobreescribir un dato nuevo con legacy si el nuevo viene poblado. + +Sugerencia para parciales (`actualizar=true`): +- aplicar merge campo a campo: solo actualizar datos de transporte que lleguen informados en CSV; conservar los demas en la fila existente. + +## Relacion con la migracion de IDs + +La migracion `ca7d3c4e8b2a` ya formaliza: + +- `carrier_int_id` -> FK a `transporter.transporter_id` +- `transport_int_id` -> FK a `vehicle.vehicle_id` +- `trailer_int_id` -> FK a `trailer.trailer_id` + +Por lo tanto, el flujo CSV debe priorizar: + +- resolver claves string de catalogo de forma determinista +- poblar IDs internos cuando haya match +- mantener string keys para trazabilidad y backfill futuro + +## Impacto de implementacion por archivo (sin validaciones) + +### `backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py` + +- Agregar columnas canonicas nuevas en encabezados: + - `CLAVE TRANSPORTE` + - `NUMERO CAJA` +- Mantener `NUMERO TRANSPORTE` como compatibilidad legacy (no eliminar de inmediato). +- Ajustar aliases para tolerar variantes de cabecera. + +### `backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py` + +- Incorporar helper de resolucion de campos efectivos de transporte: + - prioridad nuevas columnas + - fallback legacy +- En `scan`, registrar estructura normalizada (sin rechazo por reglas de catalogo). +- En `commit`, poblar `InvoiceLogistics` con: + - `transport_id`/`transport_int_id` + - `trailer_num`/`trailer_int_id` + - `transport_num` como legado/trazabilidad +- Mantener comportamiento de no falla por ausencia de match de IDs. + +### Nuevo helper recomendado: `backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py` + +- Aunque no se usen validaciones de negocio, centralizar funciones de: + - normalizacion de celdas + - resolucion de keys efectivas + - lookups de IDs de vehiculo/remolque +- Evita duplicar logica entre scan y commit. + +## Criterios de aceptacion de esta migracion de flujo + +- Queda definida una sola fuente de verdad para precedencia de columnas nuevas/legacy. +- `commit` persiste consistentemente claves string e IDs int de logistica. +- El flujo funciona aun cuando no haya match de catalogo (sin bloqueo por validacion). +- No hay ambiguedad entre: + - `transport_id` vs `transport_num` + - `transport_int_id` vs `trailer_int_id` diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index 6edc6d3d..9cfe1c10 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -95,7 +95,7 @@ transport_id: invoice.logistics?.transport_id || '', driver_name: invoice.logistics?.driver_name || '', transport_type: invoice.logistics?.transport_type || '', - transport_num: invoice.logistics?.vehicle_num || '', + trailer_num: invoice.logistics?.trailer_num || '', aduana: invoice.compliance_mx?.aduana || '', document_type: invoice.document_type || '', manifest_number: invoice.compliance_mx?.manifest_number || '', @@ -127,7 +127,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '', manifest_number: '', @@ -161,6 +161,10 @@ if (formData.electronic_signature === undefined) { formData.electronic_signature = ''; } + if (formData.trailer_num === undefined && (formData as any).transport_num) { + formData.trailer_num = (formData as any).transport_num; + delete (formData as any).transport_num; + } } // Opciones de tipo de peso @@ -213,6 +217,52 @@ ] ); + /** Conductores del transportista elegido (evita opciones inválidas ante validate_common). */ + const driversForCarrier = $derived.by(() => { + if (invoiceType === 'MEX') { + return drivers; + } + const cid = + formData.carrier_id !== null && formData.carrier_id !== undefined + ? String(formData.carrier_id).trim() + : ''; + if (!cid) { + return []; + } + return drivers.filter((d) => String(d.transporter_key) === cid); + }); + + $effect(() => { + if (invoiceType === 'MEX') { + return; + } + const cid = + formData.carrier_id !== null && formData.carrier_id !== undefined + ? String(formData.carrier_id).trim() + : ''; + if (!cid) { + if (formData.driver_name) { + formData.driver_name = ''; + } + if (formData.trailer_num) { + formData.trailer_num = ''; + } + return; + } + if (!drivers.length) { + return; + } + if (formData.driver_name) { + const ok = drivers.some( + (d) => + String(d.transporter_key) === cid && d.driver_name === formData.driver_name + ); + if (!ok) { + formData.driver_name = ''; + } + } + }); + const shippedByHeaderOptions = $derived( operationType === 1 || invoiceType === 'CR' ? [ @@ -683,6 +733,8 @@ value={formData.carrier_id ? String(formData.carrier_id) : ''} onValueChange={(v) => { formData.carrier_id = v || null; + formData.driver_name = ''; + formData.trailer_num = ''; }} > @@ -747,6 +799,8 @@ { formData.driver_name = v ?? ''; }} @@ -755,7 +809,11 @@ {#if formData.driver_name} {formData.driver_name} - {:else if drivers.length > 0} + {:else if invoiceType !== 'MEX' && !formData.carrier_id} + Primero elige transportista... + {:else if invoiceType !== 'MEX' && driversForCarrier.length === 0} + Sin conductores para este transportista + {:else if driversForCarrier.length > 0} Selecciona conductor... {:else} Sin datos @@ -763,8 +821,8 @@ - {#each drivers as driver} - + {#each driversForCarrier as driver} + {driver.driver_name} {/each} @@ -799,19 +857,25 @@
- + { - formData.transport_num = v ?? ''; + formData.trailer_num = v ?? ''; }} > - + - {#if formData.transport_num} - {trailers.find((t) => t.trailer_number === formData.transport_num) - ?.plate_number || formData.transport_num} + {#if formData.trailer_num} + {trailers.find((t) => t.trailer_number === formData.trailer_num) + ?.plate_number || formData.trailer_num} + {:else if invoiceType !== 'MEX' && !formData.carrier_id} + Primero elige transportista... {:else if trailers.length > 0} Selecciona remolque... {:else} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 8cd4db12..bb555672 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -1,5 +1,5 @@ import { goto } from '$app/navigation'; -import { invoicesApi, type CreateInvoiceData, type UpdateInvoiceData, type OperationType } from '$lib/api/dashboard/a76/invoices'; +import { invoicesApi, type CreateInvoiceData, type UpdateInvoiceData } from '$lib/api/dashboard/a76/invoices'; interface FormDataSet { InvoiceTopFieldsFormData: any; @@ -30,6 +30,28 @@ interface SaveInvoiceResult { }>; } +function _logisticsStrNonempty(value: unknown): boolean { + return value != null && String(value).trim() !== ''; +} + +/** Remolque obligatorio si tipo != none (mismo criterio que validate_common). */ +function validateClientLogistics(formData: FormDataSet): SaveInvoiceResult['validationErrors'] { + const g = formData.generalFormData; + const transportType = _logisticsStrNonempty(g?.transport_type) ? String(g.transport_type).trim() : 'none'; + if (transportType === 'none' || _logisticsStrNonempty(g?.trailer_num)) { + return undefined; + } + return [ + { + field: 'logistics.trailer_num', + message: + 'El Remolque es obligatorio cuando el Tipo de Transporte es distinto de Ninguno.', + code: 'REQUIRED_FIELD', + value: g?.trailer_num + } + ]; +} + export async function saveInvoice(options: SaveInvoiceOptions): Promise { const { invoiceId, isCreate, companyId, formData } = options; const { InvoiceTopFieldsFormData, generalFormData, observationFormData, itemsFormData, othersFormData, continuationFormData } = formData; @@ -66,6 +88,13 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise value !== undefined) ); + // Compat: remolque vivía en transport_num en layouts antiguos + if (filtered.trailer_num === undefined && filtered.transport_num !== undefined) { + filtered.trailer_num = filtered.transport_num; + delete filtered.transport_num; + } return { ...skeleton, ...filtered }; } @@ -274,7 +279,7 @@ transport_id: invoice.logistics?.transport_id || '', driver_name: invoice.logistics?.driver_name || '', transport_type: invoice.logistics?.transport_type || '', - transport_num: invoice.logistics?.vehicle_num || '', + trailer_num: invoice.logistics?.trailer_num || '', aduana: invoice.compliance_mx?.aduana || '', document_type: invoice.document_type || '' }; diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.svelte b/frontend/src/routes/dashboard/invoices/settings/+page.svelte index f8970eb7..df10a010 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/settings/+page.svelte @@ -163,7 +163,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '', ...(settings.generalFormData || {}) @@ -266,7 +266,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '' };