feature/validaciones-invoices-transortes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)."
|
||||
),
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
130
backend/tests/unit/layouts_csv/test_fk_loader_state_mex_key.py
Normal file
130
backend/tests/unit/layouts_csv/test_fk_loader_state_mex_key.py
Normal 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"
|
||||
141
docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md
Normal file
141
docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md
Normal 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`
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || ''
|
||||
};
|
||||
|
||||
@@ -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: ''
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user