feature/validaciones-invoices-transortes

This commit is contained in:
2026-03-27 12:56:47 -06:00
parent b477233245
commit 7a029307a8
20 changed files with 1172 additions and 227 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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),

View File

@@ -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"},

View File

@@ -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

View File

@@ -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

View File

@@ -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)."
),
}
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"

View File

@@ -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`

View File

@@ -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 = '';
}}
>
<Select.Trigger id="carrier_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
@@ -747,6 +799,8 @@
<Select.Root
type="single"
value={formData.driver_name || ''}
disabled={invoiceType !== 'MEX' &&
(!formData.carrier_id || driversForCarrier.length === 0)}
onValueChange={(v) => {
formData.driver_name = v ?? '';
}}
@@ -755,7 +809,11 @@
<span class="truncate">
{#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 @@
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each drivers as driver}
<Select.Item value={driver.driver_name}>
{#each driversForCarrier as driver}
<Select.Item value={driver.driver_name ?? ''}>
{driver.driver_name}
</Select.Item>
{/each}
@@ -799,19 +857,25 @@
</div>
<div class="col-span-1 sm:col-span-3 space-y-1.5">
<Label for="transport_num" class="text-xs">Placas:</Label>
<Label for="trailer_num" class="text-xs">
Remolque:{#if (formData.transport_type || 'none') !== 'none'}
<span class="text-red-500"> *</span>
{/if}
</Label>
<Select.Root
type="single"
value={formData.transport_num || ''}
value={formData.trailer_num || ''}
onValueChange={(v) => {
formData.transport_num = v ?? '';
formData.trailer_num = v ?? '';
}}
>
<Select.Trigger id="transport_num" class="h-7 w-full text-xs">
<Select.Trigger id="trailer_num" class="h-7 w-full text-xs">
<span class="truncate">
{#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}

View File

@@ -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<SaveInvoiceResult> {
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<SaveInvo
}
}
const logisticsErrors = validateClientLogistics(formData);
if (logisticsErrors?.length) {
const err: any = new Error('Revisa los datos de transporte y logística');
err.validationErrors = logisticsErrors;
throw err;
}
// Construir el payload unificado
const payload = buildInvoicePayload(formData);
@@ -260,10 +289,10 @@ function buildLogisticsData(generalFormData: any, observationFormData: any, othe
transport_id: generalFormData?.transport_id || null,
transport_type: generalFormData?.transport_type || 'none',
driver_name: generalFormData?.driver_name || null,
license_plate: generalFormData?.transport_num || null,
trailer_num: generalFormData?.trailer_num || null,
vehicle_num: continuationFormData?.numero_tipo_transporte || null,
incoterm: observationFormData?.incoterm || null,
// Campos de continuación mapeados a logistics
// Número de transporte (anexo) desde continuación; no mezclar con remolque/placa
transport_num: continuationFormData?.numero_tipo_transporte || null,
// Identificadores y Complementos
identifier_1: observationFormData?.identifier_1 || null,

View File

@@ -132,7 +132,7 @@
transport_id: '',
driver_name: '',
transport_type: '',
transport_num: '',
trailer_num: '',
aduana: '',
document_type: ''
};
@@ -223,6 +223,11 @@
const filtered = Object.fromEntries(
Object.entries(partial).filter(([_, value]) => 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 || ''
};

View File

@@ -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: ''
};