feature/csv-trailers

This commit is contained in:
hreyes
2026-03-03 07:51:11 -07:00
parent b94fd34bb2
commit c7fb49a573
8 changed files with 1297 additions and 456 deletions

View File

@@ -0,0 +1 @@
# Trailers CSV import: upload -> scan -> status -> commit

View File

@@ -0,0 +1,188 @@
"""
Rutas de importación CSV para Trailers y Cajas.
Flujo: upload -> scan -> status (polling) -> commit.
"""
import base64
import json
import logging
import os
import threading
from uuid import uuid4
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends
from sqlalchemy.orm import Session
from typing import Dict, Any
from core.celery_app import celery_app
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .schemas import ImportJobResponse
from .tasks import (
scan_file,
run_scan_sync,
run_commit_sync,
TRL_IMPORT_FILE_PREFIX,
TRL_IMPORT_META_PREFIX,
TRL_IMPORT_STATUS_PREFIX,
TRL_IMPORT_REDIS_TTL,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def _get_redis():
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
@router.post("/upload", response_model=ImportJobResponse)
async def upload_import_file(
file: UploadFile = File(...),
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
try:
tenant_id = validate_access_to_resource(db, company_id, current_user)
except Exception as e:
logger.error(f"Trailers import: access validation failed: {e}")
raise HTTPException(status_code=403, detail="Invalid company access")
if not file.filename or not file.filename.lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="Solo se permiten archivos .csv")
job_id = str(uuid4())
contents = await file.read()
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"template_id": "trailers",
}
try:
r = _get_redis()
r.set(
f"{TRL_IMPORT_FILE_PREFIX}{job_id}",
base64.b64encode(contents),
ex=TRL_IMPORT_REDIS_TTL,
)
r.set(
f"{TRL_IMPORT_META_PREFIX}{job_id}",
json.dumps(meta_data).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.error(f"Trailers import: Redis store error: {e}")
raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.")
try:
upload_dir = os.path.join(os.getcwd(), "uploads", "temp")
os.makedirs(upload_dir, exist_ok=True)
with open(os.path.join(upload_dir, f"trl_{job_id}.csv"), "wb") as f:
f.write(contents)
with open(os.path.join(upload_dir, f"trl_{job_id}.meta.json"), "w") as f:
json.dump(meta_data, f)
except Exception as e:
logger.warning(f"Trailers import: local file save failed: {e}")
scan_file.apply_async(args=[job_id], task_id=job_id)
def run_scan_background():
try:
run_scan_sync(job_id)
except Exception as e:
logger.exception(f"Trailers import: background scan failed: {e}")
threading.Thread(target=run_scan_background, daemon=True).start()
return ImportJobResponse(
job_id=job_id,
status="queued",
message="Archivo subido. Escaneo iniciado.",
)
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
try:
r = _get_redis()
raw = r.get(f"{TRL_IMPORT_STATUS_PREFIX}{job_id}")
if raw:
data = json.loads(raw.decode("utf-8"))
return data
except Exception as e:
logger.debug(f"Trailers import: could not read status from Redis: {e}")
task_result = celery_app.AsyncResult(job_id)
if task_result.state == "PENDING":
return {"status": "processing", "progress": 0}
if task_result.state == "PROGRESS":
info = (task_result.info or {})
return {
"status": "processing",
"progress": info.get("current", 0),
"total": info.get("total", 0),
}
if task_result.state == "SUCCESS":
result = task_result.result
if isinstance(result, dict) and "status" in result:
return result
return {"status": "finished", "result": result}
result = getattr(task_result, "result", None)
if isinstance(result, dict) and result.get("status") in ("finished", "warning"):
return result
logger.warning("Trailers import task %s failed: state=%s", job_id, task_result.state)
err_msg = None
tb = getattr(task_result, "traceback", None)
if tb and isinstance(tb, str):
lines = [l.strip() for l in tb.strip().split("\n") if l.strip()]
if lines:
err_msg = lines[-1]
if not err_msg:
try:
exc = task_result.get(propagate=False)
if exc is not None:
err_msg = str(exc)
except Exception:
pass
if not err_msg and result is not None:
if not isinstance(result, dict):
err_msg = str(result)
elif result.get("error") or result.get("message"):
err_msg = result.get("error") or result.get("message")
return {"status": "failed", "error": err_msg or "Task failed"}
@router.post("/{job_id}/commit")
async def commit_import_job(job_id: str):
try:
r = _get_redis()
r.set(
f"{TRL_IMPORT_STATUS_PREFIX}{job_id}",
json.dumps({"status": "processing", "message": "Insertando..."}).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.debug(f"Trailers import: could not write processing status: {e}")
def run_commit_background():
try:
run_commit_sync(job_id)
except Exception as e:
logger.exception(f"Trailers import: background commit failed: {e}")
threading.Thread(target=run_commit_background, daemon=True).start()
return {
"status": "committing",
"message": "Inserción iniciada.",
"commit_job_id": job_id,
}

View File

@@ -0,0 +1,23 @@
from pydantic import BaseModel
from typing import Optional
class ImportJobResponse(BaseModel):
job_id: str
status: str
message: str
class ImportJobStatus(BaseModel):
status: str
job_id: str
total_rows: Optional[int] = 0
error_count: Optional[int] = 0
valid_rows: Optional[int] = 0
error: Optional[str] = None
inserted: Optional[int] = 0
updated: Optional[int] = 0
skipped_invalid: Optional[int] = 0
skipped_missing_fk: Optional[int] = 0
skipped_duplicate: Optional[int] = 0
skipped_details: Optional[list] = None

View File

@@ -0,0 +1,535 @@
"""
Tareas Celery para importación CSV de Trailers y Cajas.
Flujo: scan_file (validación) → insert_valid_rows (commit).
Upsert por trailer_number usando TrailerService.
"""
import os
import base64
import csv
import json
import logging
import re
import unicodedata
from typing import Dict, Any, Optional, List
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from .template_config import row_from_template
logger = logging.getLogger(__name__)
TRL_IMPORT_FILE_PREFIX = "trl_import_file:"
TRL_IMPORT_META_PREFIX = "trl_import_meta:"
TRL_IMPORT_ERROR_LINES_PREFIX = "trl_import_error_lines:"
TRL_IMPORT_STATUS_PREFIX = "trl_import_status:"
TRL_IMPORT_REDIS_TTL = 3600
def _get_redis():
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
def _worker_upload_dir() -> str:
return os.path.join(os.getcwd(), "uploads", "temp")
def _ensure_worker_has_file_from_redis(job_id: str) -> Optional[str]:
r = _get_redis()
data = r.get(f"{TRL_IMPORT_FILE_PREFIX}{job_id}")
if not data:
return None
try:
raw = base64.b64decode(data)
except Exception as e:
logger.warning(f"Trailers import: failed to decode file from Redis: {e}")
return None
upload_dir = _worker_upload_dir()
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"trl_{job_id}.csv")
with open(file_path, "wb") as f:
f.write(raw)
return file_path
def _ensure_worker_has_meta_from_redis(job_id: str, file_path: str) -> bool:
r = _get_redis()
data = r.get(f"{TRL_IMPORT_META_PREFIX}{job_id}")
if not data:
return False
try:
meta = json.loads(data.decode("utf-8"))
except Exception as e:
logger.warning(f"Trailers import: failed to decode meta from Redis: {e}")
return False
meta_path = file_path.replace(".csv", ".meta.json")
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f)
return True
def _delete_import_from_redis(job_id: str) -> None:
try:
r = _get_redis()
r.delete(
f"{TRL_IMPORT_FILE_PREFIX}{job_id}",
f"{TRL_IMPORT_META_PREFIX}{job_id}",
f"{TRL_IMPORT_ERROR_LINES_PREFIX}{job_id}",
f"{TRL_IMPORT_STATUS_PREFIX}{job_id}",
)
except Exception as e:
logger.warning(f"Trailers import: failed to delete Redis keys: {e}")
def normalize_header(name: Optional[str]) -> str:
if not name:
return ""
name = unicodedata.normalize("NFKD", str(name)).upper()
name = "".join(ch for ch in name if not unicodedata.combining(ch))
name = re.sub(r"[^A-Z0-9]+", " ", name)
return re.sub(r"\s+", " ", name).strip()
# Max lengths from Trailer model (a76.trailer)
_MAX = {
"trailer_number": 20,
"ace_trailer_number": 10,
"trailer_type_key": 2,
"seal": 15,
"entity_code": 1,
"plate_number": 17,
"state": 30,
"country": 3,
"container_key": 3,
}
def _dedupe_headers(headers: List[str]) -> List[str]:
counts: Dict[str, int] = {}
unique: List[str] = []
for header in headers:
name = str(header or "").strip() or "COL"
count = counts.get(name, 0) + 1
counts[name] = count
if count == 1:
unique.append(name)
else:
unique.append(f"{name} {count}")
return unique
def _validate_row_trailer(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
"""Valida una fila para Trailer. Retorna error dict o None."""
trailer_number = (row.get("NUMERO TRAILER") or "").strip()
if not trailer_number:
return {"line": line_num, "col": "NUMERO TRAILER", "msg": "Requerido"}
if len(trailer_number) > _MAX["trailer_number"]:
return {"line": line_num, "col": "NUMERO TRAILER", "msg": f"Máximo {_MAX['trailer_number']} caracteres"}
for col, max_len in [
("CLAVE ACE", _MAX["ace_trailer_number"]),
("TIPO TRAILER", _MAX["trailer_type_key"]),
("PRECINTO", _MAX["seal"]),
("CODIGO ENTIDAD", _MAX["entity_code"]),
("PLACAS", _MAX["plate_number"]),
("ESTADO", _MAX["state"]),
("PAIS", _MAX["country"]),
("CLAVE CONTENEDOR", _MAX["container_key"]),
]:
val = (row.get(col) or "").strip()
if val and len(val) > max_len:
return {"line": line_num, "col": col, "msg": f"Máximo {max_len} caracteres"}
return None
def _str_or_none(val: Any, max_len: Optional[int] = None) -> Optional[str]:
if val is None:
return None
s = str(val).strip()
if not s:
return None
if max_len and len(s) > max_len:
return s[:max_len]
return s
def _row_to_trailer_dto(row: Dict[str, Any], tenant_id: int, company_id: int) -> Dict[str, Any]:
"""Build dict for TrailerCreateDTO / TrailerUpdateDTO from normalized row."""
trailer_number = _str_or_none(row.get("NUMERO TRAILER"), _MAX["trailer_number"])
if not trailer_number:
return {}
return {
"trailer_number": trailer_number,
"ace_trailer_number": _str_or_none(row.get("CLAVE ACE"), _MAX["ace_trailer_number"]),
"trailer_type_key": _str_or_none(row.get("TIPO TRAILER"), _MAX["trailer_type_key"]),
"seal": _str_or_none(row.get("PRECINTO"), _MAX["seal"]),
"entity_code": _str_or_none(row.get("CODIGO ENTIDAD"), _MAX["entity_code"]),
"plate_number": _str_or_none(row.get("PLACAS"), _MAX["plate_number"]),
"state": _str_or_none(row.get("ESTADO"), _MAX["state"]),
"country": _str_or_none(row.get("PAIS"), _MAX["country"]),
"container_key": _str_or_none(row.get("CLAVE CONTENEDOR"), _MAX["container_key"]),
}
def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, Any]:
file_path = _ensure_worker_has_file_from_redis(job_id)
if not file_path:
return {"status": "failed", "error": "Archivo no encontrado (expirado o no subido). Sube de nuevo."}
_ensure_worker_has_meta_from_redis(job_id, file_path)
error_dir = os.path.join(os.path.dirname(file_path).replace("temp", "errors"), "")
os.makedirs(error_dir, exist_ok=True)
error_path = os.path.join(error_dir, f"trl_{job_id}.jsonl")
total_rows = 0
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
total_rows = sum(1 for _ in f) - 1
except Exception as e:
return {"status": "failed", "error": str(e)}
meta_path = file_path.replace(".csv", ".meta.json")
meta = {}
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f) or {}
except Exception as e:
logger.warning(f"Trailers import: failed to read meta: {e}")
tenant_id = meta.get("tenant_id")
company_id = meta.get("company_id")
if not tenant_id or not company_id:
return {"status": "failed", "error": "Falta contexto (tenant/company)"}
error_count = 0
processed_rows = 0
errors_detail: List[Dict[str, Any]] = []
try:
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(
error_path, "w", encoding="utf-8"
) as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.reader(f_in, dialect=dialect)
try:
headers = next(reader)
except StopIteration:
headers = []
headers = _dedupe_headers(headers)
dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect)
for i, row in enumerate(dict_reader, start=1):
if progress_callback and i % 500 == 0:
progress_callback(i, total_rows, error_count)
row_norm = row_from_template(row, normalize_header)
err = _validate_row_trailer(row_norm, i)
if err:
error_count += 1
f_err.write(json.dumps(err) + "\n")
if len(errors_detail) < 500:
errors_detail.append(
{"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}
)
processed_rows += 1
except Exception as e:
logger.error(f"Trailers import scan failed: {e}")
return {"status": "failed", "error": str(e)}
error_lines_list = []
try:
if os.path.exists(error_path):
with open(error_path, "r", encoding="utf-8") as f:
for line in f:
try:
err = json.loads(line)
if "line" in err:
error_lines_list.append(err["line"])
except Exception:
pass
if error_lines_list:
r = _get_redis()
r.set(
f"{TRL_IMPORT_ERROR_LINES_PREFIX}{job_id}",
json.dumps(error_lines_list).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.warning(f"Trailers import: failed to store error lines: {e}")
result = {
"status": "waiting_confirmation",
"job_id": job_id,
"total_rows": processed_rows,
"error_count": error_count,
"valid_rows": processed_rows - error_count,
"errors": errors_detail,
}
return result
def run_scan_sync(job_id: str) -> Dict[str, Any]:
result = _do_scan(job_id, progress_callback=None)
try:
r = _get_redis()
r.set(
f"{TRL_IMPORT_STATUS_PREFIX}{job_id}",
json.dumps(result).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.warning(f"Trailers import: failed to store scan status in Redis: {e}")
return result
@celery_app.task(bind=True)
def scan_file(self, job_id: str, config: str = None):
logger.info(f"Trailers import: starting scan for job {job_id}")
def on_progress(current: int, total: int, errors: int) -> None:
self.update_state(
state="PROGRESS",
meta={"current": current, "total": total, "errors": errors},
)
result = _do_scan(job_id, progress_callback=on_progress)
try:
r = _get_redis()
r.set(
f"{TRL_IMPORT_STATUS_PREFIX}{job_id}",
json.dumps(result).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.warning(f"Trailers import: failed to store scan status in Redis: {e}")
return result
def _do_commit(job_id: str) -> Dict[str, Any]:
file_path = _ensure_worker_has_file_from_redis(job_id)
if not file_path:
alt_path = os.path.join(_worker_upload_dir(), f"trl_{job_id}.csv")
if not os.path.exists(alt_path):
return {
"status": "failed",
"error": "Archivo no encontrado (expirado). Sube y confirma de nuevo.",
}
file_path = alt_path
else:
_ensure_worker_has_meta_from_redis(job_id, file_path)
base_dir = os.path.dirname(file_path)
error_dir = base_dir.replace("temp", "errors")
error_path = os.path.join(error_dir, f"trl_{job_id}.jsonl")
error_lines = set()
try:
r = _get_redis()
raw = r.get(f"{TRL_IMPORT_ERROR_LINES_PREFIX}{job_id}")
if raw:
error_lines = set(json.loads(raw.decode("utf-8")))
except Exception as e:
logger.debug(f"Trailers import: could not load error lines from Redis: {e}")
if not error_lines and os.path.exists(error_path):
with open(error_path, "r", encoding="utf-8") as f:
for line in f:
try:
err = json.loads(line)
error_lines.add(err["line"])
except Exception:
pass
meta_path = file_path.replace(".csv", ".meta.json")
tenant_id = None
company_id = None
meta = {}
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f) or {}
tenant_id = meta.get("tenant_id")
company_id = meta.get("company_id")
except Exception:
pass
if not tenant_id or not company_id:
return {"status": "failed", "error": "Falta contexto (tenant/company)"}
from api.v1.modules.a76.transportation.trailers.services import TrailerService
from api.v1.modules.a76.transportation.trailers.dto import TrailerCreateDTO, TrailerUpdateDTO
inserted_count = 0
updated_count = 0
skipped_invalid = 0
skipped_duplicate = 0
skipped_details: List[Dict[str, Any]] = []
seen_keys_in_file: Dict[str, int] = {}
try:
with CoreSessionLocal() as session:
with open(file_path, "r", encoding="utf-8-sig") as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.reader(f, dialect=dialect)
try:
headers = next(reader)
except StopIteration:
headers = []
headers = _dedupe_headers(headers)
dict_reader = csv.DictReader(f, fieldnames=headers, dialect=dialect)
for i, row in enumerate(dict_reader, start=1):
if i in error_lines:
continue
row_norm = row_from_template(row, normalize_header)
err = _validate_row_trailer(row_norm, i)
if err:
skipped_invalid += 1
skipped_details.append(
{
"line": i,
"trailer_number": (row_norm.get("NUMERO TRAILER") or "").strip()[:20] or "-",
"invoice": (row_norm.get("NUMERO TRAILER") or "").strip()[:20] or "-",
"reason": f"{err.get('col', '')}: {err.get('msg', '')}",
}
)
continue
data = _row_to_trailer_dto(row_norm, tenant_id, company_id)
if not data or not data.get("trailer_number"):
skipped_invalid += 1
continue
tn = data["trailer_number"]
if tn in seen_keys_in_file:
skipped_duplicate += 1
skipped_details.append(
{
"line": i,
"trailer_number": tn,
"invoice": tn,
"reason": "Clave duplicada en el archivo (se usa la primera)",
}
)
continue
seen_keys_in_file[tn] = i
existing = TrailerService.get_by_id(session, tn, tenant_id, company_id)
try:
if existing:
update_data = TrailerUpdateDTO(**{k: v for k, v in data.items() if k != "trailer_number"})
TrailerService.update(session, tn, tenant_id, update_data, company_id)
updated_count += 1
else:
create_data = TrailerCreateDTO(**data)
TrailerService.create(session, create_data, tenant_id, company_id)
inserted_count += 1
except Exception as db_err:
session.rollback()
skipped_invalid += 1
skipped_details.append(
{"line": i, "trailer_number": tn, "invoice": tn, "reason": str(db_err)}
)
continue
try:
session.commit()
except Exception as db_err:
session.rollback()
logger.error(f"Trailers import DB error: {db_err}")
return {"status": "failed", "error": str(db_err)}
except Exception as e:
logger.error(f"Trailers import task failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"status": "failed", "error": str(e)}
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
if os.path.exists(error_path):
os.remove(error_path)
if os.path.exists(meta_path):
os.remove(meta_path)
_delete_import_from_redis(job_id)
except Exception as cleanup_err:
logger.warning(f"Trailers import cleanup failed: {cleanup_err}")
total_ok = inserted_count + updated_count
if total_ok == 0 and (skipped_invalid + skipped_duplicate) > 0:
return {
"status": "warning",
"inserted": inserted_count,
"updated": updated_count,
"skipped_invalid": skipped_invalid,
"skipped_duplicate": skipped_duplicate,
"skipped_missing_fk": 0,
"skipped_details": skipped_details,
"message": f"No se insertaron registros. {skipped_invalid + skipped_duplicate} rechazados.",
}
if total_ok == 0:
return {
"status": "failed",
"error": "No hay registros válidos en el archivo CSV",
"inserted": 0,
"updated": 0,
"skipped_invalid": skipped_invalid,
"skipped_duplicate": skipped_duplicate,
"skipped_missing_fk": 0,
"skipped_details": skipped_details,
}
return {
"status": "finished",
"inserted": inserted_count,
"updated": updated_count,
"skipped_invalid": skipped_invalid,
"skipped_duplicate": skipped_duplicate,
"skipped_missing_fk": 0,
"skipped_details": skipped_details,
}
def run_commit_sync(job_id: str) -> Dict[str, Any]:
result = _do_commit(job_id)
try:
r = _get_redis()
r.set(
f"{TRL_IMPORT_STATUS_PREFIX}{job_id}",
json.dumps(result).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.warning(f"Trailers import: failed to store commit status in Redis: {e}")
return result
@celery_app.task(bind=True)
def insert_valid_rows(self, job_id: str):
logger.info(f"Trailers import: starting commit for job {job_id}")
result = _do_commit(job_id)
try:
r = _get_redis()
r.set(
f"{TRL_IMPORT_STATUS_PREFIX}{job_id}",
json.dumps(result).encode("utf-8"),
ex=TRL_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.warning(f"Trailers import: failed to store commit status in Redis: {e}")
return result

View File

@@ -0,0 +1,45 @@
"""
Configuración de plantilla CSV para Trailers / Cajas (EstructuraCatTrailers.xls).
Mapeo: NUMERO TRAILER → trailer_number, CLAVE ACE → ace_trailer_number, etc.
"""
from typing import Dict, List, Any
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
"trailers": [
{"canonical": "NUMERO TRAILER", "aliases": ["CLAVE TRAILER", "TRAILER NUMBER", "TRAILER", "NUMERO"]},
{"canonical": "CLAVE ACE", "aliases": ["ACE", "NUMERO ACE", "ACE TRAILER"]},
{"canonical": "TIPO TRAILER", "aliases": ["TIPO", "TRAILER TYPE", "TIPO CAJA"]},
{"canonical": "PRECINTO", "aliases": ["SEAL"]},
{"canonical": "CODIGO ENTIDAD", "aliases": ["ENTIDAD", "ENTITY CODE", "CODIGO DE ENTIDAD"]},
{"canonical": "PLACAS", "aliases": ["PLACA", "PLATE NUMBER", "PLATE"]},
{"canonical": "ESTADO", "aliases": ["STATE"]},
{"canonical": "PAIS", "aliases": ["COUNTRY", "Pais"]},
{"canonical": "CLAVE CONTENEDOR", "aliases": ["CONTAINER", "CONTAINER KEY", "CONTENEDOR"]},
],
}
def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]:
cols = TEMPLATE_COLUMNS.get("trailers")
if not cols:
return {}
lookup: Dict[str, str] = {}
for item in cols:
canonical = item["canonical"]
lookup[normalize_header_fn(canonical)] = canonical
for alias in item.get("aliases") or []:
lookup[normalize_header_fn(alias)] = canonical
return lookup
def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]:
lookup = build_normalized_lookup(normalize_header_fn)
if not lookup:
return {normalize_header_fn(k): v for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
return out

View File

@@ -1,11 +1,19 @@
from fastapi import APIRouter
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .dto import TrailerCreateDTO, TrailerResponseDTO, TrailerUpdateDTO
from .services import TrailerService
from .imports.routes import router as imports_router
# Create router using TenantCRUDRoutes factory
# Note: trailer_number is a string (not int) and is used as the primary key
router = TenantCRUDRoutes(
# Main router: trailers CRUD + CSV imports
router = APIRouter()
# CSV import (upload → scan → status → commit)
router.include_router(imports_router, prefix="/trailers/imports", tags=["a76 / trailers / csv_import"])
# CRUD routes
crud_router = TenantCRUDRoutes(
service=TrailerService,
create_schema=TrailerCreateDTO,
update_schema=TrailerUpdateDTO,
@@ -13,10 +21,11 @@ router = TenantCRUDRoutes(
prefix="/trailers",
tags=[],
resource_name="Trailer",
id_name="trailer_number", # Using trailer_number instead of numeric ID
id_type=str, # Specify that the ID is a string
enable_list=True, # Enable GET /trailers with pagination
enable_filters=True, # Enable filtering by plate_number and trailer_type_key
id_name="trailer_number",
id_type=str,
enable_list=True,
enable_filters=True,
default_page_size=50,
max_page_size=100,
).router
router.include_router(crud_router)