diff --git a/backend/api/v1/modules/a76/audit_log/events.py b/backend/api/v1/modules/a76/audit_log/events.py index 7a79bedb..893d6569 100644 --- a/backend/api/v1/modules/a76/audit_log/events.py +++ b/backend/api/v1/modules/a76/audit_log/events.py @@ -2,12 +2,20 @@ Audit Log Events """ +import logging + from sqlalchemy import event, inspect from sqlalchemy.orm import Session + +from api.v1.modules.a76.general_catalogs.company.models import Company +from core.database import rls_company_var, rls_tenant_var + from .services.service import AuditService from .utils.serialization import serialize_for_json from core.context import get_user_context +logger = logging.getLogger(__name__) + def register_audit_listeners(models_to_audit): """ @@ -30,11 +38,37 @@ def _get_current_username(): or context.get("sub") or "System" ) - except: + except Exception: pass return "System" +def _resolve_audit_company_tenant(session: Session, target) -> tuple: + """ + company_id / tenant_id desde la fila ORM, ContextVars RLS (petición HTTP), + o ``Company.tenant_id`` por ``company_id``. + """ + company_id = getattr(target, "company_id", None) + if company_id is None and getattr(target, "__tablename__", None) == "company": + company_id = getattr(target, "id", None) + if company_id is None: + company_id = rls_company_var.get() + + tenant_id = getattr(target, "tenant_id", None) + if tenant_id is None: + tenant_id = rls_tenant_var.get() + if tenant_id is None and company_id is not None: + row = ( + session.query(Company.tenant_id) + .filter(Company.id == company_id, Company.deleted_at.is_(None)) + .first() + ) + if row: + tenant_id = int(row[0]) + + return company_id, tenant_id + + def after_insert_listener(mapper, connection, target): """ Listener for INSERT operations @@ -42,12 +76,19 @@ def after_insert_listener(mapper, connection, target): table_name = target.__tablename__ record_data = {c.name: getattr(target, c.name) for c in mapper.columns} username = _get_current_username() - company_id = getattr(target, "company_id", None) or getattr(target, "id", None) - tenant_id = getattr(target, "tenant_id", None) - # Create a session bound to the connection session = Session(bind=connection) try: + company_id, tenant_id = _resolve_audit_company_tenant(session, target) + if company_id is None or tenant_id is None: + logger.debug( + "Audit skip INSERT %s: missing company_id=%s tenant_id=%s", + table_name, + company_id, + tenant_id, + ) + return + AuditService.log_crud_operation( db=session, table_name=table_name, @@ -59,7 +100,7 @@ def after_insert_listener(mapper, connection, target): tenant_id=tenant_id, ) except Exception as e: - print(f"Error logging insert: {e}") + logger.warning("Error logging insert for %s: %s", table_name, e) finally: session.close() @@ -87,11 +128,19 @@ def after_update_listener(mapper, connection, target): record_data = {c.name: getattr(target, c.name) for c in mapper.columns} username = _get_current_username() - company_id = getattr(target, "company_id", None) or getattr(target, "id", None) - tenant_id = getattr(target, "tenant_id", None) session = Session(bind=connection) try: + company_id, tenant_id = _resolve_audit_company_tenant(session, target) + if company_id is None or tenant_id is None: + logger.debug( + "Audit skip UPDATE %s: missing company_id=%s tenant_id=%s", + table_name, + company_id, + tenant_id, + ) + return + AuditService.log_crud_operation( db=session, table_name=table_name, @@ -105,7 +154,7 @@ def after_update_listener(mapper, connection, target): tenant_id=tenant_id, ) except Exception as e: - print(f"Error logging update: {e}") + logger.warning("Error logging update for %s: %s", table_name, e) finally: session.close() @@ -117,11 +166,19 @@ def after_delete_listener(mapper, connection, target): table_name = target.__tablename__ record_data = {c.name: getattr(target, c.name) for c in mapper.columns} username = _get_current_username() - company_id = getattr(target, "company_id", None) or getattr(target, "id", None) - tenant_id = getattr(target, "tenant_id", None) session = Session(bind=connection) try: + company_id, tenant_id = _resolve_audit_company_tenant(session, target) + if company_id is None or tenant_id is None: + logger.debug( + "Audit skip DELETE %s: missing company_id=%s tenant_id=%s", + table_name, + company_id, + tenant_id, + ) + return + AuditService.log_crud_operation( db=session, table_name=table_name, @@ -133,6 +190,6 @@ def after_delete_listener(mapper, connection, target): tenant_id=tenant_id, ) except Exception as e: - print(f"Error logging delete: {e}") + logger.warning("Error logging delete for %s: %s", table_name, e) finally: session.close() diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py index eff2f188..11f1caed 100644 --- a/backend/api/v1/modules/a76/audit_log/router.py +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -25,6 +25,19 @@ from api.v1.modules.a76.general_catalogs.company.models import Company router = APIRouter() + +def _audit_scope_tenant_id(db: Session, company_id: int) -> int: + """Tenant_id de la fila ``Company`` para filtrar ``audit_logs`` (alineado con lo persistido).""" + row = ( + db.query(Company.tenant_id) + .filter(Company.id == company_id, Company.deleted_at.is_(None)) + .first() + ) + if not row: + raise HTTPException(status_code=404, detail="Company not found") + return int(row[0]) + + _SEGMENT_LABELS = { "tenants": "Espacio", "companies": "Companias", @@ -189,16 +202,17 @@ async def get_bitacora( """ Bitácora por compañía. Requiere permiso ``audit_logs.view``. """ - tenant_id = validate_access_to_resource( + validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) + scope_tenant_id = _audit_scope_tenant_id(db, company_id) query = db.query(AuditLog).filter( AuditLog.company_id == company_id, - AuditLog.tenant_id == tenant_id, + AuditLog.tenant_id == scope_tenant_id, ) # Filters @@ -250,18 +264,19 @@ async def get_procedures( """ Lista de procedimientos para filtros (alcance compañía). Requiere ``audit_logs.view``. """ - tenant_id = validate_access_to_resource( + validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) + scope_tenant_id = _audit_scope_tenant_id(db, company_id) results = ( db.query(AuditLog.procedure) .filter( AuditLog.company_id == company_id, - AuditLog.tenant_id == tenant_id, + AuditLog.tenant_id == scope_tenant_id, ) .distinct() .order_by(AuditLog.procedure) @@ -279,19 +294,20 @@ async def get_audit_detail( """ Detalle de un registro de bitácora. Requiere ``audit_logs.view``. """ - tenant_id = validate_access_to_resource( + validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) + scope_tenant_id = _audit_scope_tenant_id(db, company_id) log = ( db.query(AuditLog) .filter( AuditLog.spec_id == spec_id, AuditLog.company_id == company_id, - AuditLog.tenant_id == tenant_id, + AuditLog.tenant_id == scope_tenant_id, ) .first() ) @@ -316,13 +332,14 @@ async def list_tenant_files( if not should_ensure_s3_bucket(): raise HTTPException(status_code=400, detail="S3 storage is disabled") - tenant_id = validate_access_to_resource( + validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) - tenant_prefix = _tenant_prefix(tenant_id) + scope_tenant_id = _audit_scope_tenant_id(db, company_id) + tenant_prefix = _tenant_prefix(scope_tenant_id) rel_path = _normalize_relative_path(path) list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix @@ -342,7 +359,7 @@ async def list_tenant_files( continuation_token=continuation_token, ) - company_names = _companies_map(db, tenant_id) + company_names = _companies_map(db, scope_tenant_id) folders: List[AuditFileFolderItem] = [] for prefix in data.get("prefixes", []): rel = _relative_from_tenant_prefix(prefix, tenant_prefix) @@ -410,13 +427,14 @@ async def download_tenant_file( if not should_ensure_s3_bucket(): raise HTTPException(status_code=400, detail="S3 storage is disabled") - tenant_id = validate_access_to_resource( + validate_access_to_resource( db, company_id, current_user, required_permissions=["audit_logs.view"], ) - tenant_prefix = _tenant_prefix(tenant_id) + scope_tenant_id = _audit_scope_tenant_id(db, company_id) + tenant_prefix = _tenant_prefix(scope_tenant_id) rel_path = _normalize_relative_path(path) if not rel_path or rel_path.endswith("/"): raise HTTPException(status_code=400, detail="A file path is required") diff --git a/backend/api/v1/modules/a76/audit_log/services/service.py b/backend/api/v1/modules/a76/audit_log/services/service.py index 368e3fed..c390e0fb 100644 --- a/backend/api/v1/modules/a76/audit_log/services/service.py +++ b/backend/api/v1/modules/a76/audit_log/services/service.py @@ -1,6 +1,7 @@ """ Audit Log Service """ +import logging from datetime import datetime, timedelta, date, time from decimal import Decimal import uuid @@ -9,8 +10,8 @@ from typing import Optional, List, Dict, Any from sqlalchemy.orm import Session from ..models import AuditLog from .core import AuditMapper, ReferenceGenerator -from core.security import verify_token # keep if needed or simpler just remove if unused -# We don't need security import here anymore as context is passed explicitly or handled by events + +logger = logging.getLogger(__name__) def _make_json_safe(obj: Any) -> Any: @@ -188,49 +189,62 @@ class AuditService: ) @staticmethod - def log_login(db: Session, username: str, ip_address: str = None, user_agent: str = None): - - # Prevent duplicate login logs (debounce 5 seconds) - # This handles cases where frontend might submit twice or redirects trigger re-auth - try: - # Timezone handling: Use UTC for consistency - now = datetime.now(pytz.UTC) + def log_login( + db: Session, + username: str, + ip_address: str = None, + user_agent: str = None, + company_id: Optional[int] = None, + tenant_id: Optional[int] = None, + ): + """ + ``audit_logs`` exige ``tenant_id`` y ``company_id``. El login vía Hub no + define compañía activa; sin ambos argumentos no se inserta fila (antes fallaba NOT NULL). + """ + if company_id is None or tenant_id is None: + return None + try: + now = datetime.now(pytz.UTC) five_seconds_ago = now - timedelta(seconds=5) - - # Check for recent login from same user existing = db.query(AuditLog).filter( AuditLog.username == username, AuditLog.operation_type == "LOGIN", - # Compare against timestamp (timezone aware) - AuditLog.timestamp >= five_seconds_ago + AuditLog.company_id == company_id, + AuditLog.tenant_id == tenant_id, + AuditLog.timestamp >= five_seconds_ago, ).first() - if existing: return existing - except Exception as e: - import traceback - traceback.print_exc() - + logger.warning("Login audit debounce query failed: %s", e) + return AuditService.create_audit_log( - db=db, - reference="LOGIN", - procedure="SYSTEM SCAF", - movement="SYSTEM LOGIN", - username=username, - operation_type="LOGIN", - ip_address=ip_address, - user_agent=user_agent + db=db, + reference="LOGIN", + procedure="SYSTEM SCAF", + movement="SYSTEM LOGIN", + username=username, + operation_type="LOGIN", + ip_address=ip_address, + user_agent=user_agent, + company_id=company_id, + tenant_id=tenant_id, ) @staticmethod - def log_logout(db: Session, username: str, ip_address: str = None, user_agent: str = None): - """ - Registra un evento de cierre de sesión - """ + def log_logout( + db: Session, + username: str, + ip_address: str = None, + user_agent: str = None, + company_id: Optional[int] = None, + tenant_id: Optional[int] = None, + ): + """Misma condición que ``log_login``: sin alcance compañía/tenant no se escribe.""" + if company_id is None or tenant_id is None: + return None try: - # Reutilizamos create_audit_log para mantener consistencia AuditService.create_audit_log( db=db, reference="LOGOUT", @@ -239,8 +253,9 @@ class AuditService: username=username, operation_type="LOGOUT", ip_address=ip_address, - user_agent=user_agent + user_agent=user_agent, + company_id=company_id, + tenant_id=tenant_id, ) except Exception as e: - # No re-lanzamos la excepción para no interrumpir el flujo de logout - pass + logger.warning("Logout audit insert failed: %s", e) diff --git a/backend/api/v1/modules/a76/csv_templates/registry.py b/backend/api/v1/modules/a76/csv_templates/registry.py index b17906b3..42224cc6 100644 --- a/backend/api/v1/modules/a76/csv_templates/registry.py +++ b/backend/api/v1/modules/a76/csv_templates/registry.py @@ -4,11 +4,10 @@ Construido a partir de los TEMPLATE_COLUMNS de cada módulo de imports. """ import csv import io -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set # Importar configs de cada módulo from api.v1.modules.a76.layouts_csv.facturas.template_config import ( - TEMPLATE_COLUMNS as IMPORTS_TEMPLATE_COLUMNS, _resolve_template_columns as resolve_imports_template, ) from api.v1.modules.a76.layouts_csv.parts.template_config import ( @@ -140,6 +139,7 @@ ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE: Dict[str, set[str]] = { "imp_temp_series": {"NUMERO FACTURA", "LINEA FACTURA"}, "imp_def_series": {"NUMERO FACTURA", "LINEA FACTURA"}, "cmex_series": {"NUMERO FACTURA", "LINEA FACTURA"}, + "exp_def_series": {"NUMERO FACTURA", "LINEA FACTURA"}, # Catálogos y transportes "customs_brokers": {"TIPO", "CLAVE", "NOMBRE"}, "clients_providers": {"PROCEDENCIA", "SHORT_NAME", "NOMBRE", "RFC"}, @@ -191,23 +191,35 @@ ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE: Dict[str, set[str]] = { } -def _apply_required_prefix(template_id: str, headers: List[str]) -> List[str]: - """Prefija '* ' a cabeceras siempre obligatorias para la plantilla.""" - required_headers = ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE.get(template_id) - if not required_headers: - return headers +def _compute_required_indices_map(base_rows: Dict[str, List[str]]) -> Dict[str, Set[int]]: + """Índices de columnas obligatorias (según plantilla ES/base), para marcar * en cualquier idioma.""" + out: Dict[str, Set[int]] = {} + for tid, headers in base_rows.items(): + req = ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE.get(tid) + if not req: + continue + req_norm = {_normalize_header_for_match(h) for h in req} + idx_set: Set[int] = set() + for i, h in enumerate(headers): + if not h: + continue + if _normalize_header_for_match(h) in req_norm: + idx_set.add(i) + out[tid] = idx_set + return out - required_norm = {_normalize_header_for_match(h) for h in required_headers} + +def _apply_required_prefix_indices(template_id: str, headers: List[str]) -> List[str]: + """Prefija '* ' usando índices fijos de la plantilla base (independiente del idioma de cabecera).""" + idx_set = _REQUIRED_INDICES.get(template_id) + if not idx_set: + return headers out: List[str] = [] - for header in headers: - norm_header = _normalize_header_for_match(header) - if norm_header and norm_header in required_norm: - if header.startswith("* "): - out.append(header) - else: - out.append(f"* {header}") + for i, h in enumerate(headers): + if i in idx_set and h and not str(h).startswith("* "): + out.append(f"* {h}") else: - out.append(header) + out.append(h) return out @@ -231,6 +243,7 @@ def _build_registry() -> Dict[str, List[str]]: "imp_def_series", "exp_def_header", "exp_def_details", + "exp_def_series", "cmex_header", "cmex_details", "cmex_series", @@ -292,7 +305,9 @@ def _build_registry() -> Dict[str, List[str]]: return registry -_TEMPLATE_HEADERS: Dict[str, List[str]] = _build_registry() +_REGISTRY_BASE_ES: Dict[str, List[str]] = _build_registry() +_REQUIRED_INDICES: Dict[str, Set[int]] = _compute_required_indices_map(_REGISTRY_BASE_ES) +_TEMPLATE_HEADERS: Dict[str, List[str]] = _REGISTRY_BASE_ES # Nombre de archivo sugerido para descarga (sin path) TEMPLATE_FILENAMES: Dict[str, str] = { @@ -320,15 +335,65 @@ TEMPLATE_FILENAMES: Dict[str, str] = { "cmex_series": "EstructuraSeriesFacComprasMex.csv", "exp_def_header": "EstructuraEncFacExpoCamReg.csv", "exp_def_details": "EstructuraParExpoCamReg.csv", + "exp_def_series": "EstructuraSeriesFacExpoCamReg.csv", } +def get_download_headers(template_id: str, locale: Optional[str] = "es") -> Optional[List[str]]: + """ + Cabeceras de descarga para la plantilla (sin prefijo *). + locale: es | en (default es). + """ + from api.v1.modules.a76.layouts_csv.common.template_locale import ( + download_header_cell, + normalize_locale, + ) + + loc = normalize_locale(locale) + + cols = resolve_imports_template(template_id) + if cols is not None: + return [download_header_cell(c, loc) for c in cols] + + if template_id in ("part_numbers", "items"): + from api.v1.modules.a76.layouts_csv.parts import template_config as parts_tc + + return parts_tc.download_headers_for_locale(loc) + + if template_id == "material_classes": + from api.v1.modules.a76.layouts_csv.classes import template_config as classes_tc + + return classes_tc.download_headers_for_locale(loc) + + # template_id del registry → (module_columns, clave interna del dict de columnas) + catalog_resolvers: list[tuple[str, dict, str]] = [ + ("boms", BOMS_TEMPLATE_COLUMNS, "boms"), + ("customs_brokers", CUSTOMS_BROKERS_TEMPLATE_COLUMNS, "customs_brokers"), + ("clients_providers", CLIENTS_PROVIDERS_TEMPLATE_COLUMNS, "client_providers"), + ("exchange_rates", EXCHANGE_RATE_TEMPLATE_COLUMNS, "exchange_rates"), + ("american_fractions", US_TARIFF_FRACTIONS_TEMPLATE_COLUMNS, "us_tariff_fractions"), + ("pedimentos", PEDIMENTOS_TEMPLATE_COLUMNS, "pedimentos"), + ("transports", VEHICLES_TEMPLATE_COLUMNS, "vehicles"), + ("drivers", DRIVERS_TEMPLATE_COLUMNS, "drivers"), + ("trailers", TRAILERS_TEMPLATE_COLUMNS, "trailers"), + ("transporters", TRANSPORTERS_TEMPLATE_COLUMNS, "transporters"), + ] + for tid, mapping, inner_key in catalog_resolvers: + if tid != template_id: + continue + cols = mapping.get(inner_key) + if cols: + return [download_header_cell(col, loc) for col in cols] + + return None + + def get_template_headers(template_id: str) -> Optional[List[str]]: - """Devuelve la lista de cabeceras canónicas para el template_id, o None si no existe.""" - headers = _TEMPLATE_HEADERS.get(template_id) - if headers is None: + """Cabeceras como en descarga ES histórica, con prefijo * en obligatorias.""" + raw = get_download_headers(template_id, "es") + if raw is None: return None - return _apply_required_prefix(template_id, headers) + return _apply_required_prefix_indices(template_id, raw) def get_template_filename(template_id: str) -> str: @@ -336,14 +401,19 @@ def get_template_filename(template_id: str) -> str: return TEMPLATE_FILENAMES.get(template_id, f"plantilla_{template_id}.csv") -def generate_csv_content(template_id: str, include_bom: bool = True) -> Optional[bytes]: +def generate_csv_content( + template_id: str, + locale: str = "es", + include_bom: bool = True, +) -> Optional[bytes]: """ Genera el contenido CSV (solo fila de cabeceras) para el template_id. UTF-8, opcionalmente con BOM para Excel. """ - headers = get_template_headers(template_id) + headers = get_download_headers(template_id, locale) if not headers: return None + headers = _apply_required_prefix_indices(template_id, headers) buf = io.StringIO() writer = csv.writer(buf, lineterminator="\n") writer.writerow(headers) diff --git a/backend/api/v1/modules/a76/csv_templates/routes.py b/backend/api/v1/modules/a76/csv_templates/routes.py index 26f1e264..78e56983 100644 --- a/backend/api/v1/modules/a76/csv_templates/routes.py +++ b/backend/api/v1/modules/a76/csv_templates/routes.py @@ -1,9 +1,9 @@ """ Rutas para descargar plantillas CSV generadas desde código (sin archivos XLS/XLSX). """ -from typing import Any, Dict +from typing import Any, Dict, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from core.security import get_current_user @@ -12,17 +12,23 @@ from .registry import generate_csv_content, get_template_filename router = APIRouter() +_LOCALE_ALLOWED = frozenset({"es", "en"}) + @router.get("/{template_id}", response_class=Response) async def download_csv_template( template_id: str, + locale: Optional[str] = Query("es", description="es | en: idioma de las cabeceras del CSV"), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Devuelve un CSV con solo la fila de cabeceras para la plantilla indicada. - Las cabeceras son los nombres canónicos definidos en cada template_config. + Query `locale`: es (defecto) o en — cabeceras localizadas cuando estén definidas. """ - content = generate_csv_content(template_id, include_bom=True) + loc = (locale or "es").lower().strip() + if loc not in _LOCALE_ALLOWED: + raise HTTPException(status_code=400, detail="locale must be 'es' or 'en'") + content = generate_csv_content(template_id, locale=loc, include_bom=True) if content is None: raise HTTPException(status_code=404, detail=f"Plantilla desconocida: {template_id}") filename = get_template_filename(template_id) @@ -31,5 +37,7 @@ async def download_csv_template( media_type="text/csv; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, no-store, max-age=0, must-revalidate", + "Pragma": "no-cache", }, ) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 10c1d5c6..00191e97 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -19,6 +19,8 @@ from core.database import get_core_db from core.s3_keys import company_certificate_key, company_logo_key from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes from core.security import ( + collect_company_ids_from_app_membership, + collect_user_role_names, get_current_user, get_tenant_from_token, resolve_effective_tenant_id_from_user, @@ -37,6 +39,48 @@ MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB logger = logging.getLogger(__name__) +def _user_is_admin(current_user: dict) -> bool: + return "admin" in collect_user_role_names(current_user) + + +def _assert_permission_any_company( + db: Session, + current_user: dict, + permission_code: str, +) -> None: + """ + Enforces a permission when endpoint has no explicit company_id param. + """ + if _user_is_admin(current_user): + return + + user_id = current_user.get("sub") or current_user.get("id") + if not user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + company_ids = collect_company_ids_from_app_membership(db, str(user_id)) + if not company_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + from api.v1.modules.core.permissions.service import PermissionService + + permission_service = PermissionService(db) + for company_id in company_ids: + if permission_service.has_any_permission(str(user_id), int(company_id), [permission_code]): + return + + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + +def _assert_permission_for_company( + db: Session, + company_id: int, + current_user: dict, + permission_code: str, +) -> int: + return validate_access_to_resource(db, company_id, current_user, [permission_code]) + + def _resolve_tenant_id_int(current_user: dict) -> int: """Misma lógica que validate_access_to_resource: entero estable para BD y claves S3.""" tid = get_tenant_from_token(current_user) @@ -87,6 +131,7 @@ async def create_company( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): + _assert_permission_any_company(db, current_user, "cat_company.create") tenant_id = current_user.get("tenant_id") if not tenant_id: @@ -114,6 +159,8 @@ async def list_companies( current_user: dict = Depends(get_current_user), ): """Get paginated list of companies for current tenant with optional filters""" + _assert_permission_any_company(db, current_user, "cat_company.view") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -142,7 +189,7 @@ async def list_companies( return { "items": [ - CompanyResponseDTO.model_validate(service.flatten_company_dto(item)) + CompanyResponseDTO.model_validate(service.flatten_company_dto(item)) for item in items ], "total": total, @@ -167,6 +214,8 @@ async def get_my_companies( Un usuario solo con roles de app y sin ``tenant_id`` en /auth/me sigue pudiendo listar sus compañías asignadas. """ + _assert_permission_any_company(db, current_user, "cat_company.view") + user_id = current_user.get("sub") or current_user.get("id") tenant_id = resolve_effective_tenant_id_from_user(current_user) @@ -194,6 +243,8 @@ async def get_company( current_user: dict = Depends(get_current_user), ): """Get a specific company by ID""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.view") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -224,6 +275,8 @@ async def update_company( current_user: dict = Depends(get_current_user), ): """Update a company""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.edit") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -299,6 +352,8 @@ async def delete_company( current_user: dict = Depends(get_current_user), ): """Delete a company""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.delete") + tenant_id = current_user.get("tenant_id") if not tenant_id: raise HTTPException( @@ -328,6 +383,8 @@ async def upload_company_logo( current_user: dict = Depends(get_current_user), ): """Upload a logo for a company""" + _assert_permission_for_company(db, company_id, current_user, "cat_company.edit") + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe @@ -409,6 +466,8 @@ async def upload_company_certificate( Upload a certificate for a company certificate_type: fiel_cer, fiel_key, cfdi_cert_cer, cfdi_cert_key, cancel_cer, cancel_key """ + _assert_permission_for_company(db, company_id, current_user, "cat_company.edit") + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe diff --git a/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py b/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py index 20f2c9a9..56f5086a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/depreciation_catalog/routes.py @@ -30,6 +30,10 @@ base_router = TenantCRUDRoutes( enable_filters=False, default_page_size=100, max_page_size=1000, + get_permissions=["cat_depreciation_catalog.view"], + create_permissions=["cat_depreciation_catalog.create"], + update_permissions=["cat_depreciation_catalog.edit"], + delete_permissions=["cat_depreciation_catalog.delete"], ) router = APIRouter(prefix="/depreciation-catalog", tags=["a76 / general catalogs / depreciation catalog"]) @@ -49,7 +53,9 @@ async def list_depreciation_catalog( db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_depreciation_catalog.view"] + ) items, total = DepreciationCatalogService.get_all( db, tenant_id, company_id, page, page_size, search diff --git a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py index 05b18223..bec0c7c5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/doda/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/doda/routes.py @@ -254,7 +254,9 @@ async def print_doda_pdf( Genera o reutiliza el PDF almacenado en S3 cuando el contenido no ha cambiado (huella SHA-256 de DODA + hijos). """ - tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + tenant_id = int( + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) + ) doda = DodaService.get_by_id(db, doda_id, tenant_id, company_id) if not doda: raise HTTPException( @@ -327,9 +329,12 @@ async def print_doda_pdf( ) async def get_doda_containers( doda_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all containers for a specific DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) containers = DodaService.get_containers(db, doda_id) return [DodaContainerResponseDTO.model_validate(c) for c in containers] @@ -343,9 +348,12 @@ async def get_doda_containers( async def add_container( doda_id: int, container_data: DodaContainerCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Add a new container to a DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) container = DodaService.add_container(db, doda_id, container_data) if not container: raise HTTPException( @@ -364,9 +372,12 @@ async def update_container( doda_id: int, container_line: int, container_data: DodaContainerUpdateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Update a container""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) container = DodaService.update_container( db, doda_id, container_line, container_data ) @@ -386,12 +397,15 @@ async def update_container( async def delete_container( doda_id: int, container_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """ Elimina un contenedor del DODA. Devuelve 409 si el contenedor tiene precintos (candados) asignados. """ + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_container(db, doda_id, container_line) return None @@ -415,8 +429,11 @@ def _seal_to_response(seal) -> DodaContainerSealResponseDTO: async def get_container_seals( doda_id: int, container_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) seals = DodaService.get_seals_for_container(db, doda_id, container_line) return [_seal_to_response(s) for s in seals] @@ -431,8 +448,11 @@ async def add_container_seal( doda_id: int, container_line: int, seal_data: DodaContainerSealCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) seal = DodaService.add_seal(db, doda_id, container_line, seal_data) return _seal_to_response(seal) @@ -446,8 +466,11 @@ async def delete_container_seal( doda_id: int, container_line: int, seal_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_seal(db, doda_id, container_line, seal_line) return None @@ -460,9 +483,12 @@ async def delete_container_seal( ) async def get_american_pedimentos( doda_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all American pedimentos for a specific DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) pedimentos = DodaService.get_american_pedimentos(db, doda_id) return [DodaAmericanPedimentoResponseDTO.model_validate(p) for p in pedimentos] @@ -476,9 +502,12 @@ async def get_american_pedimentos( async def add_american_pedimento( doda_id: int, pedimento_data: DodaAmericanPedimentoCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Add a new American pedimento to a DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) pedimento = DodaService.add_american_pedimento(db, doda_id, pedimento_data) if not pedimento: raise HTTPException( @@ -497,9 +526,12 @@ async def add_american_pedimento( async def delete_american_pedimento( doda_id: int, pedimento_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Elimina un pedimento americano del DODA.""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_american_pedimento(db, doda_id, pedimento_line) return None @@ -512,9 +544,12 @@ async def delete_american_pedimento( ) async def get_doda_pedimentos( doda_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all pedimentos for a specific DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) pedimentos = DodaService.get_pedimentos(db, doda_id) return [DodaPedimentoResponseDTO.model_validate(p) for p in pedimentos] @@ -528,9 +563,12 @@ async def get_doda_pedimentos( async def add_pedimento( doda_id: int, pedimento_data: DodaPedimentoCreateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Add a new pedimento to a DODA""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) pedimento = DodaService.add_pedimento(db, doda_id, pedimento_data) if not pedimento: raise HTTPException( @@ -548,9 +586,12 @@ async def add_pedimento( async def delete_pedimento( doda_id: int, pedimento_line: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Elimina un pedimento del DODA.""" + validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) DodaService.delete_pedimento(db, doda_id, pedimento_line) return None @@ -575,7 +616,7 @@ async def get_doda_elegibilidad( Porta las validaciones del sistema legacy (campos requeridos, max 4 contenedores, gafete si DODA, patente vs agente, certificados DODA en VU). """ - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -614,7 +655,7 @@ async def post_doda_alta( Verifica elegibilidad, construye el payload desde los datos del DODA y su VU, y envía el alta al servicio externo. Retorna {task_id, status, message} para polling. """ - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -694,11 +735,14 @@ async def post_doda_alta( ) async def get_doda_alta_status( task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Any: """ Proxy transparente al servicio externo para consultar el estado de una tarea de alta DODA. """ + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) try: ext = DodaExternalService() return ext.get_status(task_id) @@ -727,7 +771,7 @@ async def post_doda_consulta( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Dict[str, Any]: - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -788,8 +832,11 @@ async def post_doda_consulta( ) async def get_doda_consulta_status( task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Any: + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) try: ext = DodaExternalService() return ext.get_consulta_status(task_id) @@ -818,7 +865,9 @@ async def post_doda_consulta_apply( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Dict[str, Any]: - tenant_id = int(validate_access_to_resource(db, company_id, current_user)) + tenant_id = int( + validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) + ) doda_record = DodaService.get_by_id(db, doda_id, tenant_id, company_id) if not doda_record: raise HTTPException(status_code=404, detail="DODA no encontrado.") @@ -880,7 +929,7 @@ async def post_doda_eliminar( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Dict[str, Any]: - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) user_email = ( current_user.get("email") or current_user.get("preferred_username") @@ -960,8 +1009,11 @@ async def post_doda_eliminar( ) async def get_doda_eliminar_status( task_id: str, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ) -> Any: + validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) try: ext = DodaExternalService() return ext.get_eliminar_status(task_id) @@ -996,7 +1048,7 @@ async def list_doda_alta_logs( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) return DodaAltaLogService.list( db, company_id, int(tenant_id), page, page_size, doda_id, search ) @@ -1014,7 +1066,7 @@ async def get_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.view"]) record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id)) if not record: raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.") @@ -1034,7 +1086,7 @@ async def create_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.create"]) record = DodaAltaLogService.create(db, dto, company_id, int(tenant_id)) return DodaAltaLogResponseDTO.model_validate(record) @@ -1052,7 +1104,7 @@ async def update_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.edit"]) record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id)) if not record: raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.") @@ -1072,7 +1124,7 @@ async def delete_doda_alta_log( db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_doda.delete"]) record = DodaAltaLogService.get(db, log_id, company_id, int(tenant_id)) if not record: raise HTTPException(status_code=404, detail="Registro de alta DODA no encontrado.") diff --git a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py index 2feb6f7e..6af9fea3 100644 --- a/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/error_catalogs/routes.py @@ -4,12 +4,12 @@ Rutas para gestión de catálogos de errores from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user -from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource from .dto import ( ErrorClassificationCreateDTO, ErrorClassificationResponseDTO, @@ -35,6 +35,11 @@ classification_crud = TenantCRUDRoutes( tags=["error-classifications"], resource_name="ErrorClassification", enable_list=True, + list_permissions=["cat_errors.view"], + get_permissions=["cat_errors.view"], + create_permissions=["cat_errors.create"], + update_permissions=["cat_errors.edit"], + delete_permissions=["cat_errors.delete"], ) # Add custom endpoints for classifications @@ -47,15 +52,17 @@ classification_crud = TenantCRUDRoutes( ) async def get_classification_by_code( code: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error classification by its code with all related errors""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) classification = ErrorClassificationService.get_by_code( db, code, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not classification: raise HTTPException( @@ -74,15 +81,17 @@ async def get_classification_by_code( ) async def get_classification( id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error classification by its ID with all related errors""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) classification = ErrorClassificationService.get_by_id( db, id, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not classification: raise HTTPException( @@ -104,6 +113,11 @@ catalog_crud = TenantCRUDRoutes( tags=["error-catalogs"], resource_name="ErrorCatalog", enable_list=True, + list_permissions=["cat_errors.view"], + get_permissions=["cat_errors.view"], + create_permissions=["cat_errors.create"], + update_permissions=["cat_errors.edit"], + delete_permissions=["cat_errors.delete"], ) # Add custom endpoints for catalogs @@ -116,15 +130,17 @@ catalog_crud = TenantCRUDRoutes( ) async def get_error_by_code( code: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error by its code with classification details""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) error = ErrorCatalogService.get_by_code( db, code, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not error: raise HTTPException( @@ -141,15 +157,17 @@ async def get_error_by_code( ) async def get_errors_by_classification( classification_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get all errors for a specific classification""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) errors = ErrorCatalogService.get_by_classification( db, classification_id, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) return [ErrorCatalogResponseDTO.model_validate(error) for error in errors] @@ -163,15 +181,17 @@ async def get_errors_by_classification( ) async def get_error( id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get an error by its ID with classification details""" + tenant_id = validate_access_to_resource(db, company_id, current_user, ["cat_errors.view"]) error = ErrorCatalogService.get_by_id( db, id, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not error: raise HTTPException( diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py index ae872c3b..757e0bec 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/canadian_tariff_fractions/routes.py @@ -25,9 +25,9 @@ def list_canadian_fractions( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.view"] + ) skip = (page - 1) * page_size service = CanadianTariffFractionService(db) items, total = service.get_multi( @@ -52,9 +52,9 @@ def get_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.view"] + ) service = CanadianTariffFractionService(db) item = service.get(id, tenant_id, company_id) if not item: @@ -68,9 +68,9 @@ def create_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.create"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.create"] + ) service = CanadianTariffFractionService(db) return service.create(item_in, tenant_id, company_id) @@ -82,9 +82,9 @@ def update_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.edit"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.edit"] + ) service = CanadianTariffFractionService(db) item = service.get(id, tenant_id, company_id) if not item: @@ -98,9 +98,9 @@ def delete_canadian_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_canadian.delete"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_canadian.delete"] + ) service = CanadianTariffFractionService(db) item = service.get(id, tenant_id, company_id) if not item: diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py index 648b98a2..5dca0eb2 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py @@ -20,9 +20,9 @@ def get_historical_fractions( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.view"] + ) skip = (page - 1) * page_size service = HistoricalTariffFractionService(db) items, total = service.get_multi(tenant_id, company_id, skip=skip, limit=page_size, historical_fraction=historical_fraction) @@ -46,9 +46,9 @@ async def get_rate( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.view"] + ) # Parse date try: @@ -80,9 +80,9 @@ def get_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.view"] + ) service = HistoricalTariffFractionService(db) fraction = service.get(id, tenant_id, company_id) if not fraction: @@ -96,9 +96,9 @@ def create_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.create"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.create"] + ) service = HistoricalTariffFractionService(db) return service.create(fraction_in, tenant_id, company_id) @@ -110,9 +110,9 @@ def update_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.edit"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.edit"] + ) service = HistoricalTariffFractionService(db) fraction = service.get(id, tenant_id, company_id) if not fraction: @@ -126,9 +126,9 @@ def delete_historical_fraction( db: Session = Depends(get_core_db), current_user = Depends(get_current_user) ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_historical.delete"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_historical.delete"] + ) service = HistoricalTariffFractionService(db) fraction = service.get(id, tenant_id, company_id) if not fraction: diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py index 006250d0..52c5e05b 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py @@ -40,8 +40,10 @@ async def list_tariff_fractions( current_user: Dict[str, Any] = Depends(get_current_user), ): # Validar permisos según el catálogo solicitado - if catalog in ["mex", "usa"]: + if catalog == "mex": validate_access_to_resource(db, company_id, current_user, ["frac_sitar.view"]) + elif catalog == "usa": + validate_access_to_resource(db, company_id, current_user, ["frac_sitar_us.view"]) elif catalog == "american": validate_access_to_resource(db, company_id, current_user, ["frac_american.view"]) @@ -83,12 +85,19 @@ async def list_tariff_fractions( ) async def get_tariff_fraction( tariff_fraction_id: int, + catalog: str = Query("mex", description="Catalog source: 'mex', 'usa', or 'american'"), company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): - # Por defecto asumimos vista de SITAR para este endpoint de consulta por ID general - validate_access_to_resource(db, company_id, current_user, ["frac_sitar.view"]) + if catalog == "mex": + validate_access_to_resource(db, company_id, current_user, ["frac_sitar.view"]) + elif catalog == "usa": + validate_access_to_resource(db, company_id, current_user, ["frac_sitar_us.view"]) + elif catalog == "american": + validate_access_to_resource(db, company_id, current_user, ["frac_american.view"]) + else: + raise HTTPException(status_code=400, detail=f"Unsupported catalog '{catalog}'") item = TariffFractionService.get_by_id(db, tariff_fraction_id) if not item: from fastapi import HTTPException diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py index a1cee9f5..acda6661 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py @@ -77,11 +77,9 @@ async def list_us_tariff_fractions( db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): - from core.security import validate_access_to_resource as validate_perm - validate_perm(db, company_id, current_user, ["frac_american.view"]) - tenant_id = validate_access_to_resource(db, company_id, current_user) - - validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["frac_sitar_us.view"] + ) skip = (page - 1) * page_size try: diff --git a/backend/api/v1/modules/a76/general_catalogs/location/routes.py b/backend/api/v1/modules/a76/general_catalogs/location/routes.py index bd9949a5..529ad1bf 100644 --- a/backend/api/v1/modules/a76/general_catalogs/location/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/location/routes.py @@ -17,4 +17,9 @@ router = TenantCRUDRoutes( enable_list=True, enable_filters=True, max_page_size=1000, + list_permissions=["cat_locations.view"], + get_permissions=["cat_locations.view"], + create_permissions=["cat_locations.create"], + update_permissions=["cat_locations.edit"], + delete_permissions=["cat_locations.delete"], ).router diff --git a/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py b/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py index 71ee0120..539a2d6e 100644 --- a/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/prevalidators/routes.py @@ -4,12 +4,12 @@ Rutas para gestión de prevalidadores from typing import List -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user -from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource from .dto import ( PrevalidatorCreateDTO, PrevalidatorResponseDTO, @@ -29,6 +29,11 @@ prevalidator_crud = TenantCRUDRoutes( tags=["Prevalidators"], resource_name="Prevalidator", enable_list=True, + list_permissions=["cat_prevalidators.view"], + get_permissions=["cat_prevalidators.view"], + create_permissions=["cat_prevalidators.create"], + update_permissions=["cat_prevalidators.edit"], + delete_permissions=["cat_prevalidators.delete"], ) # Add custom endpoints @@ -41,15 +46,19 @@ prevalidator_crud = TenantCRUDRoutes( ) async def get_prevalidator_by_code( code: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), current_user: dict = Depends(get_current_user), ): """Get a prevalidator by its code""" + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_prevalidators.view"] + ) prevalidator = PrevalidatorService.get_by_code( db, code, - tenant_id=current_user["tenant_id"], - company_id=current_user["company_id"] + tenant_id=tenant_id, + company_id=company_id, ) if not prevalidator: raise HTTPException( @@ -78,9 +87,12 @@ async def create_prevalidator( async def update_prevalidator( prevalidator_id: int, prevalidator_data: PrevalidatorUpdateDTO, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Update a prevalidator""" + validate_access_to_resource(db, company_id, current_user, ["cat_prevalidators.edit"]) prevalidator = PrevalidatorService.update( db, prevalidator_id, prevalidator_data) if not prevalidator: @@ -98,9 +110,12 @@ async def update_prevalidator( ) async def delete_prevalidator( prevalidator_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Delete a prevalidator""" + validate_access_to_resource(db, company_id, current_user, ["cat_prevalidators.delete"]) success = PrevalidatorService.delete(db, prevalidator_id) if not success: raise HTTPException( @@ -117,10 +132,15 @@ async def delete_prevalidator( ) async def get_prevalidators_by_customs( customs: str, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), ): """Get all prevalidators for a specific customs""" - prevalidators = PrevalidatorService.get_by_customs(db, customs) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_prevalidators.view"] + ) + prevalidators = PrevalidatorService.get_by_customs(db, customs, tenant_id, company_id) return [ PrevalidatorResponseDTO.model_validate(prevalidator) for prevalidator in prevalidators diff --git a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py index f7c390a5..0e228d57 100644 --- a/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/signatures/routes.py @@ -27,6 +27,11 @@ signature_crud = TenantCRUDRoutes( enable_filters=True, # Enable filtering by code default_page_size=50, max_page_size=100, + list_permissions=["cat_signatures.view"], + get_permissions=["cat_signatures.view"], + create_permissions=["cat_signatures.create"], + update_permissions=["cat_signatures.edit"], + delete_permissions=["cat_signatures.delete"], ) router = signature_crud.router @@ -44,7 +49,9 @@ async def get_signature_by_code( current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), ): """Get a signature by its code""" - tenant_id = validate_access_to_resource(db, company_id, current_user) + tenant_id = validate_access_to_resource( + db, company_id, current_user, ["cat_signatures.view"] + ) signature = SignatureService.get_by_code(db, code, tenant_id, company_id) if not signature: raise HTTPException( @@ -61,9 +68,12 @@ async def get_signature_by_code( ) async def delete_signature( signature_id: int, + company_id: int = Query(..., description="Company ID"), db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency), ): """Delete a signature""" + validate_access_to_resource(db, company_id, current_user, ["cat_signatures.delete"]) success = SignatureService.delete(db, signature_id) if not success: raise HTTPException( diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py new file mode 100644 index 00000000..bc9ce2c4 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py @@ -0,0 +1,11 @@ +"""Límites alineados a boms/validators/common.py y common/mappers.""" +from typing import Dict + +BOMS_CSV_MAX_CHARS: Dict[str, int] = { + "NUMPARTE_PADRE": 70, + "NUMPARTE_COMPONENTE": 70, + "CANTIDAD": 30, + "UNIMED": 10, + "VERSION_BOM": 20, + "VERSION_BILL": 20, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py b/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py index eb50d89f..ecf15118 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py @@ -6,6 +6,10 @@ Placeholders hasta tener el XLS definitivo; ajustar canónicos y aliases según from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import BOMS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "boms": [ @@ -18,6 +22,17 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +# UNIMED también en parts/catálogo ("commercial UOM"); en BOM es unidad de cantidad genérica. +_BOM_LABEL_EN_OVERRIDES = {"UNIMED": "Unit of measure"} +for col in TEMPLATE_COLUMNS.get("boms") or []: + canon = col.get("canonical") + if canon in _BOM_LABEL_EN_OVERRIDES: + col.setdefault("labels", {})["en"] = _BOM_LABEL_EN_OVERRIDES[canon] + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("boms"), BOMS_CSV_MAX_CHARS) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("boms") @@ -29,6 +44,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py index b780736c..40a833c7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py @@ -6,6 +6,8 @@ Por ahora misma estructura que encabezado/partidas de exportación; luego se aju from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup # Cambio de régimen: cam_reg_header, cam_reg_details # Regularización: regulariz_header, regulariz_details @@ -108,6 +110,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: return TEMPLATE_COLUMNS.get(template_id) @@ -124,6 +128,8 @@ def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py new file mode 100644 index 00000000..c5521949 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py @@ -0,0 +1,16 @@ +"""Máximos alineados a classes/validators/common.py.""" +from typing import Dict + +MATERIAL_CLASSES_CSV_MAX_CHARS: Dict[str, int] = { + "CLASE": 30, + "DESCRIPCIONE": 500, + "DESCRIPCIONI": 500, + "CLAVEMAT": 10, + "UNIMED": 5, + "FRACCION": 20, + "FRACCIONAME": 16, + "TASADEPRECIA": 24, + "CLAVESUB": 5, + "REVFISICA": 10, + "FRACCIONEXENTAIVA": 4, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py index bd10a176..3325be7d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py @@ -8,10 +8,19 @@ from typing import Dict, List, Any, Optional, Tuple from ..common.cell_value import cell_to_str from ..common import csv_reader as common_csv_reader +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import ( + download_header_cell, + merge_download_header_into_lookup, + merge_labels_into_lookup, + normalize_locale, +) +from .csv_max_chars_for_headers import MATERIAL_CLASSES_CSV_MAX_CHARS # Valores que indican que la primera fila es cabecera (primera columna normalizada) -FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE") +FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE", "CLASS CODE") def detect_headers_or_data( file_path: str, @@ -71,6 +80,19 @@ TEMPLATE_DOWNLOAD_HEADERS: List[str] = [ "CODIGO DE PRODUCTO/SERVICIO CP", ] +TEMPLATE_DOWNLOAD_HEADERS_EN: List[str] = [ + "CLASS CODE", + "DESCRIPTION (SPANISH)", + "DESCRIPTION (ENGLISH)", + "MATERIAL TYPE", + "COMMERCIAL UOM", + "MX FRACTION", + "US FRACTION", + "DEPRECIATION RATE", + "PHYSICAL REVIEW (1/0)", + "PRODUCT/SERVICE CP CODE", +] + TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "material_classes": [ {"canonical": "CLASE", "aliases": ["CLAVE CLASE", "CLASS", "CODIGO", "CLASE CODIGO"]}, @@ -87,6 +109,48 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +# Mismos canónicos que parts (CLASE, FRACCION, …); restaurar etiquetas de catálogo de clases. +# Mismo texto que TEMPLATE_DOWNLOAD_HEADERS_EN (plantilla Clarion EN). +_MATERIAL_CLASS_LABEL_EN_OVERRIDES = { + "CLASE": "CLASS CODE", + "UNIMED": "COMMERCIAL UOM", + "FRACCION": "MX FRACTION", + "FRACCIONAME": "US FRACTION", +} +for col in TEMPLATE_COLUMNS.get("material_classes") or []: + canon = col.get("canonical") + if canon in _MATERIAL_CLASS_LABEL_EN_OVERRIDES: + col.setdefault("labels", {})["en"] = _MATERIAL_CLASS_LABEL_EN_OVERRIDES[canon] + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("material_classes"), MATERIAL_CLASSES_CSV_MAX_CHARS +) +for _mc in TEMPLATE_COLUMNS.get("material_classes") or []: + if _mc.get("canonical") == "REVFISICA": + merge_enum_hint_before_max(_mc, ["1", "0"]) + +_MC_DL_ORDER = [ + "CLASE", + "DESCRIPCIONE", + "DESCRIPCIONI", + "CLAVEMAT", + "UNIMED", + "FRACCION", + "FRACCIONAME", + "TASADEPRECIA", + "REVFISICA", + "FRACCIONEXENTAIVA", +] +_MC_BY_CANON = {c["canonical"]: c for c in (TEMPLATE_COLUMNS.get("material_classes") or [])} +_MATERIAL_CLASSES_DOWNLOAD_DEFS = [dict(_MC_BY_CANON[k]) for k in _MC_DL_ORDER] + + +def download_headers_for_locale(locale: str) -> List[str]: + loc = normalize_locale(locale) + return [download_header_cell(d, loc) for d in _MATERIAL_CLASSES_DOWNLOAD_DEFS] + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("material_classes") @@ -98,6 +162,15 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) + for es_h, en_h in zip(TEMPLATE_DOWNLOAD_HEADERS, TEMPLATE_DOWNLOAD_HEADERS_EN): + if not es_h or not en_h: + continue + es_key = normalize_header_fn(es_h) + canon = lookup.get(es_key) + if canon: + lookup[normalize_header_fn(en_h)] = canon return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py new file mode 100644 index 00000000..e04ddf65 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py @@ -0,0 +1,41 @@ +""" +Máximos de caracteres por columna canonical (plantilla client_providers) para sufijos en cabecera CSV. + +Debe mantenerse alineado con `common/mappers.py` → MAX_LEN y columnas String(n) en +`clients_and_providers.models`. Sin imports de SQLAlchemy ni Pydantic para poder cargar la plantilla sin arrancar la app. +""" +from typing import Dict + +CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS: Dict[str, int] = { + "SHORT_NAME": 10, + "NOMBRE": 256, + "RFC": 30, + "DIRECCION": 100, + "NUM_EXT": 20, + "CODIGO POSTAL": 15, + "COLONIA": 40, + "CIUDAD": 30, + "ESTADO": 30, + "PAIS": 3, + "TELEFONO": 30, + "FAX": 30, + "EMAIL": 100, + "CURP": 19, + "TIPO_PROGRAMA_SECON": 7, + "NUM_PROGRAMA_SECON": 40, + "NUM_AUT_PROSEC": 20, + "REGISTRO_EMPRESA_CERT": 40, + "INFORMACION_EXTRA": 399, + "CONTACTO": 50, + "MANUFACTURER_ID": 25, + "TAX_ID_PROGRAMS": 30, + "BROKER_EXPO": 6, + "BROKER_IMPO": 6, + "CLAVE_TRANSFER": 8, + "CLAVE_WEB": 40, + "RESPONSABLE": 80, + "POSICION": 30, + "INCOTERM": 19, + "VINCULACION": 1, + "TRANSFORMA_SUBMAQ": 1, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py index c06c1e2a..e70c6fea 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py @@ -13,13 +13,27 @@ AH=COL_EXTRA (desfase). from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import ( + merge_download_header_into_lookup, + merge_labels_into_lookup, +) +from .common.csv_max_chars_for_headers import CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "client_providers": [ - # Col A - Procedencia (E=Extranjero, N=Nacional) - {"canonical": "PROCEDENCIA", "aliases": ["TIPO PROCEDENCIA", "EXTranjero/Nacional", "E/N"]}, - # Col B - Tipo Cliente (C/P/A) - {"canonical": "TIPO", "aliases": ["CLIENT_OR_PROVIDER", "TIPO ENTIDAD", "CLIENTE O PROVEEDOR"]}, + # Col A - Procedencia (E=Extranjero, N=Nacional). Manual: select N/E (≤3). Ver CSV_HEADER_HINTS.md + { + "canonical": "PROCEDENCIA", + "aliases": ["TIPO PROCEDENCIA", "EXTranjero/Nacional", "E/N"], + "csv_hint": {"kind": "enum_codes", "codes": ["N", "E"]}, + }, + # Col B - Tipo Cliente (C/P/A). Manual: client/provider/both (≤3). Ver CSV_HEADER_HINTS.md + { + "canonical": "TIPO", + "aliases": ["CLIENT_OR_PROVIDER", "TIPO ENTIDAD", "CLIENTE O PROVEEDOR"], + "csv_hint": {"kind": "enum_codes", "codes": ["C", "P", "A"]}, + }, # Col C - Clave cliente/proveedor (máx 8 Clarion) {"canonical": "SHORT_NAME", "aliases": ["CLAVE", "CLAVE CORTA", "NOMBRE CORTO", "SIGLAS"]}, # Col D - Nombre @@ -92,6 +106,34 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + + +def _inject_max_char_hints_client_providers(columns: Optional[List[Dict[str, Any]]]) -> None: + """Añade csv_hint max_chars desde CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS (paridad modelos/MAX_LEN).""" + if not columns: + return + for item in columns: + if not isinstance(item, dict): + continue + canon = item.get("canonical") + if not canon: + continue + n = CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS.get(str(canon).strip()) + if n is None: + continue + mc: Dict[str, Any] = {"kind": "max_chars", "n": n} + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = mc + elif isinstance(ch, dict): + item["csv_hint"] = [ch, mc] + elif isinstance(ch, list): + item["csv_hint"] = [*ch, mc] + + +_inject_max_char_hints_client_providers(TEMPLATE_COLUMNS.get("client_providers")) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: """normalized_header -> canonical_name para plantilla client_providers.""" @@ -104,6 +146,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py b/backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py new file mode 100644 index 00000000..38052179 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py @@ -0,0 +1,408 @@ +""" +Traducciones EN de cabeceras CSV por nombre canónico (clave interna). +Usado para plantillas descargadas locale=en y para aliases de carga. +""" + + +def _facturas_headers() -> dict[str, str]: + return { + # Encabezados / series / partidas — alineado con messages/en.json (pedimento, exchange rate, incrementables, discharge). + "PEDIMENTO": "Pedimento", + "REMESA": "Consignment", + "NUMERO FACTURA": "Invoice number", + "FECHA FACTURA": "Invoice date", + "TIPO DE CAMBIO": "Exchange rate", + "REGIMEN": "Regime", + "CLAVE PROVEEDOR": "Supplier code", + "CLAVE VENDIDO A": "Sold-to code", + "CLAVE ENVIADO A": "Ship-to code", + "AGENTE ADUANAL": "Customs broker", + "CLAVE TRANSPORTISTA": "Carrier code", + "NOMBRE CONDUCTOR": "Driver name", + "TIPO TRANSPORTE": "Transport type", + "CLAVE TRANSPORTE": "Transport code", + "NUMERO CAJA": "Trailer/box number", + "NUMERO TRANSPORTE": "Transport number", + "TIPO MONEDA": "Currency type", + "CLAVE MONEDA": "Currency code", + "FLETES": "Freight", + "VALOR SEGUROS": "Insurance value", + "SEGUROS": "Insurance", + "EMBALAJES": "Packaging", + "OTROS INCREMENTABLES": "Incrementables (other)", + "CLAVE INCOTERM": "Incoterm code", + "PRECINTO": "Seal", + "FECHA EMISION": "Issue date", + "TIPO PESO": "Weight type", + "E DOCUMENT": "E-document", + "NUM OPERACION": "Operation number", + "ADUANA DE CRUCE": "Customs office (crossing)", + "OBSERVACIONES E": "Export remarks", + "OBSERVACIONES I": "Import remarks", + "FACTURA ALTERNA": "Alternate invoice", + "NUM PROYECTO": "Project number", + "ORDEN COMPRA": "Purchase order", + "FACTURA EXPO REF": "Export invoice reference", + "MANIFIESTO": "Manifest", + "ENVIADO POR": "Shipped by", + # Partidas impo temp / compartidas + "NUMERO FACTURA EXPO": "Export invoice number", + "LINEA": "Line", + "LINEA EXPO": "Export line", + "LINEA IMPO": "Import line", + "LINEA FACTURA": "Invoice line", + "LINEA SERIE": "Serial line", + "TIPO DE IMPO": "Import type", + "FACTURA IMPO": "Import invoice", + "GENERA DESCARGA": "Generate discharge", + "CANTIDAD EXPORTADA/DESCARGAR": "Exported qty / qty to discharge", + "CLASE": "Class", + "CANTIDAD IMPORTADA": "Imported quantity", + "UNIDAD DE MEDIDA": "Unit of measure", + "COSTO UNITARIO": "Unit cost", + "PRECIO UNITARIO": "Unit price", + "VALOR COMERCIAL": "Commercial value", + "PESO NETO": "Net weight", + "PESO BRUTO": "Gross weight", + "CANTIDAD BULTOS": "Package count", + "CLAVE BULTOS": "Package code", + "PAIS ORIGEN": "Country of origin", + "FRACCION ARANCELARIA": "Tariff fraction", + "FRACCION": "Fraction", + "PREFERENCIA ARANCELARIA": "Tariff preference", + "SECTOR": "Sector", + "FRACCION AMERICANA": "US tariff fraction", + "ORDEN DE COMPRA": "Purchase order", + "DESCRIPCION ESPAÑOL": "Description (Spanish)", + "DESCRIPCION INGLES": "Description (English)", + "DESCRIPCION": "Description", + "MARCA": "Brand", + "MODELO": "Model", + "ES PARTIDA O SUBPARTIDA": "Line or sub-line", + "ES PARTIDA/SUBPARTIDA": "Line or sub-line", + "LINEA PRINCIPAL": "Main line", + "NUM. PARTE": "Part number", + "NUMPARTE": "Part number", + "SE PAGO IMPUESTO": "Tax paid (Y/N)", + "FORMA DE PAGO": "Payment method", + "METODO DE VALORACION": "Valuation method", + "DESCRIPCION EXTRA": "Extra description", + "INFORMACION ADICIONAL": "Additional information", + "AGREGAR/SUSTITUIR": "Add/replace", + "AGREGAR(A)/SUSTITUIR(S)": "Add/replace", + "TOTAL": "Total", + "NUMERO ENTRADA": "Entry number", + "LOTE": "Lot", + "ID TYPE": "ID type", + "SERIE": "Serial", + "SUB MODELO": "Sub-model", + "NUM PARTE": "Part number", + "NUMERO ID": "ID number", + "COL_EXTRA": "Extra column", + } + + +def _series_merge() -> dict[str, str]: + """Series share columns; ensure overlap with facturas_headers.""" + return { + "NUMERO FACTURA": "Invoice number", + "LINEA FACTURA": "Invoice line", + "LINEA SERIE": "Serial line", + "SERIE": "Serial", + "MODELO": "Model", + "NUM PARTE": "Part number", + "SUB MODELO": "Sub-model", + "NUMERO ID": "ID number", + "COL_EXTRA": "Extra column", + } + + +def _customs_exchange_us_fractions_boms() -> dict[str, str]: + return { + # customs_brokers + "TIPO": "Type", + "CLAVE": "Code", + "LICENCIA": "License", + "NOMBRE": "Name", + "RFC": "Tax ID", + "DIRECCION": "Address", + "CODIGO POSTAL": "Postal code", + "CIUDAD": "City", + "ESTADO": "State", + "PAIS": "Country", + "TELEFONO": "Phone", + "FAX": "Fax", + "EMAIL": "Email", + "PERSONAL_ID": "Personal ID / CURP", + "COL_EXTRA": "Extra column", + "POSICION": "Position", + "EMPRESA": "Company", + "CONTACTO": "Contact", + # exchange_rates + "FECHA": "Date", + "VALOR": "Exchange rate", + "MONEDA_LOCAL": "Local currency", + "MONEDA_EXTRANJERA": "Foreign currency", + # us_tariff_fractions + "FRACCION_ARANCELARIA": "Tariff fraction", + "PREFIJO": "Prefix", + "UNIDAD_DE_MEDIDA": "Unit of measure", + "DESCRIPCION": "Description", + "TIPO_DE_ADVALOREM": "Ad valorem type", + "ADVALOREM_PCT": "Ad valorem %", + "ADVALOREM_DLLS": "Ad valorem (USD)", + # boms + "NUMPARTE_PADRE": "Parent part number", + "NUMPARTE_COMPONENTE": "Component part number", + "CANTIDAD": "Quantity", + "UNIMED": "Unit of measure", + "VERSION_BOM": "BOM version", + "VERSION_BILL": "Bill version", + } + + +def _clients_providers_headers() -> dict[str, str]: + return { + "PROCEDENCIA": "Origin (foreign/domestic)", + "TIPO": "Entity type", + "SHORT_NAME": "Short code", + "NOMBRE": "Name", + "RFC": "Tax ID", + "DIRECCION": "Address", + "NUM_EXT": "Exterior number", + "CODIGO POSTAL": "Postal code", + "COLONIA": "District", + "CIUDAD": "City", + "ESTADO": "State", + "PAIS": "Country", + "TELEFONO": "Phone", + "FAX": "Fax", + "EMAIL": "Email", + "CURP": "CURP", + "TIPO_PROGRAMA_SECON": "SECON program type", + "NUM_PROGRAMA_SECON": "SECON program number", + "FECHA_AUT_SECON": "SECON authorization date", + "ES_PROSEC": "Is PROSEC", + "NUM_AUT_PROSEC": "PROSEC authorization number", + "VINCULACION": "Linkage", + "ES_EMPRESA_CERTIFICADA": "Certified company", + "REGISTRO_EMPRESA_CERT": "Certified company registry", + "INFORMACION_EXTRA": "Extra information", + "CONTACTO": "Contact", + "MANUFACTURER_ID": "Manufacturer ID", + "TAX_ID_PROGRAMS": "Tax ID programs", + "BROKER_EXPO": "Export broker", + "BROKER_IMPO": "Import broker", + "CLAVE_TRANSFER": "Transfer code", + "TRANSFORMA_SUBMAQ": "Transformer/sub-maquila", + "CLAVE_WEB": "Web code", + "COL_EXTRA": "Extra column", + "RESPONSABLE": "Responsible", + "POSICION": "Position", + "INCOTERM": "Incoterm", + "ACTIVO": "Active", + } + + +def _pedimentos_headers() -> dict[str, str]: + return { + "AÑO": "Year", + "PATENTE": "Patent", + "NUMERO": "Number", + "PEDIMENTO": "Pedimento", + "TIPO_OPERACION": "Operation type", + "TIPO_PEDIMENTO": "Pediment type", + "CLAVE_PEDIMENTO": "Pediment code", + "REGIMEN": "Regime", + "FECHA_INICIO": "Start date", + "FECHA_FINAL": "End date", + "FECHA_PAGO": "Payment date", + "FECHA_ENTRADA_RECINTO": "Compound entry date", + "FECHA_EXTRACCION_RECINTO": "Compound exit date", + "FECHA_RECIBIDO": "Received date", + "FECHA_AUTORIZACION": "Authorization date", + "FECHA_CIERRE": "Closing date", + "FECHA_REVISION": "Review date", + "ADUANA_SECCION_CRUCE": "Customs office / crossing section", + "ACUSE_ELECTRONICO": "Electronic acknowledgment", + "INDIVIDUAL_CONSOLIDADO": "Individual/consolidated", + "MET_TRANSP_ENTRADA": "Inbound transport method", + "MET_TRANSP_ARRIVO": "Arrival transport method", + "MET_TRANSP_SALIDA": "Outbound transport method", + "IEPS": "IEPS", + "IEPS_2": "IEPS (2)", + "DTA": "DTA", + "DTA_2": "DTA (2)", + "CNT": "CNT", + "CNT_2": "CNT (2)", + "PREVALIDACION": "Pre-validation", + "MONTO_TIGIE": "TIGIE amount", + "PAGO_IMPUESTO": "Tax paid (Y/N)", + "ES_MIXTO": "Mixed (yes/no)", + "OBS_RECTIFICA": "Rectification remarks", + "OPCION_DESTINO": "Destination option", + "VALOR_IVA": "VAT value", + "VALOR_ME": "Foreign currency value", + "VALOR_ADUANAS": "Customs value", + "VALOR_USD": "USD value", + "VALOR_SEGUROS": "Insurance value", + "FLETE": "Freight", + "SEGUROS": "Insurance", + "EMBALAJES": "Packaging", + "OTROS_INCREMENTABLES": "Incrementables (other)", + "ESTATUS": "Status", + "PERSONA_REV": "Reviewer", + "OBSERVACIONES": "Remarks", + "TIPO_CAMBIO": "Exchange rate", + "REPRESENTANTE_AA": "Customs broker representative", + "CLIENTE_SHORT_NAME": "Client short code", + "CLAVE_DEST_ORIGEN": "Destination origin code", + "IDENTIFICADORES": "Identifiers", + "CUOTAS_COMPENSATORIAS": "Compensatory quotas", + "ERRORES": "Errors", + "MULTAS": "Fines", + "RECARGOS": "Surcharges", + "PRECIO_PAGADO": "Price paid", + "PESO_BRUTO": "Gross weight", + "IVA_DE_PREV": "VAT from pre-validation", + "IVA_2": "VAT (2)", + "IGI_2": "IGI (2)", + "FORMA_PAGO_IVA": "VAT payment method", + "FORMA_PAGO_IVA_2": "VAT payment method (2)", + "FORMA_PAGO_IGI": "IGI payment method", + "FORMA_PAGO_IGI_2": "IGI payment method (2)", + "FORMA_PAGO_IEPS_2": "IEPS payment method (2)", + "FORMA_PAGO_DTA": "DTA payment method", + "FORMA_PAGO_DTA_2": "DTA payment method (2)", + "FORMA_PAGO_CNT_2": "CNT payment method (2)", + "FORMA_PAGO_PREVAL": "Pre-validation payment method", + "FORMA_PAGO_PREVALIDACION_2": "Pre-validation payment method (2)", + } + + +def _vehicles_drivers_trailers_transporters() -> dict[str, str]: + return { + # vehicles / transports template "vehicles" + "CLAVE": "Code", + "CODIGO DE ENTIDAD": "Entity code", + "TIPO TRANSPORTE": "Transport type", + "CLAVE TRANSPORTE": "Transport code", + "CLAVE ACE": "ACE code", + "PLACAS": "Plates", + "PRECINTO": "Seal", + "NUMERO DOT": "DOT number", + "TRANSPONDEDOR": "Transponder", + "VIN": "VIN", + "EMPRESA ASEGURADORA": "Insurance company", + "NUM. ASEGURADORA": "Insurance policy number", + "FECHA DE ASEGURADORA": "Insurance date", + "MONTO ASEGURADO": "Insured amount", + "COL_EXTRA": "Extra column", + # drivers + "TRANSPORTISTA": "Carrier", + "LINEA": "Line", + "CLAVE CONDUCTOR": "Driver code", + "NOMBRE(S)": "Given name(s)", + "APELLIDO PATERNO": "Last name (paternal)", + "SEXO": "Gender", + "FECHA NACIMIENTO": "Birth date", + "PAIS NACIMIENTO": "Birth country", + "LICENCIA": "License", + "FORMA IDENTIFICACION 1": "ID type 1", + "NUM. IDENTIFICACION 1": "ID number 1", + "FORMA IDENTIFICACION 2": "ID type 2", + "NUM. IDENTIFICACION 2": "ID number 2", + "IDENTIFICACION ACE": "ACE identification", + "PAIS": "Country", + "PAIS 2": "Country 2", + "ESTADO": "State", + "ESTADO 2": "State 2", + "PERMISO LINEA EXPRESS": "Express line permit", + "PERMISO MAT. PELIGROSO": "Hazmat permit", + "TRANSPORTA MAT. PELIGROSO?": "Transports hazardous material?", + # trailers + "NUMERO TRAILER": "Trailer number", + "TIPO TRAILER": "Trailer type", + "CODIGO ENTIDAD": "Entity code", + "CLAVE CONTENEDOR": "Container code", + # transporters + "CLAVE TRANSPORTISTA": "Carrier code", + "NOMBRE CORTO": "Short name", + "RESPONSABLE": "Responsible", + "RFC": "Tax ID", + "CALLES": "Street address", + "CODIGO CAAT": "CAAT code", + "CODIGO CARGADOR": "Loader code", + "CODIGO TRANS": "Transport code", + "TIPO INTERFASE TRANS": "Carrier interface type", + "SERVIDOR FTP": "FTP server", + "USUARIO FTP": "FTP user", + "CLAVE ACCESO FTP": "FTP password", + "DIRECTORIO FTP": "FTP directory", + } + + +def _material_classes_headers() -> dict[str, str]: + # Coincide con classes/template_config.TEMPLATE_DOWNLOAD_HEADERS_EN (salvo columnas solo en template extendido). + return { + "CLASE": "CLASS CODE", + "DESCRIPCIONE": "DESCRIPTION (SPANISH)", + "DESCRIPCIONI": "DESCRIPTION (ENGLISH)", + "CLAVEMAT": "MATERIAL TYPE", + "UNIMED": "COMMERCIAL UOM", + "FRACCION": "MX FRACTION", + "FRACCIONAME": "US FRACTION", + "TASADEPRECIA": "DEPRECIATION RATE", + "CLAVESUB": "SUB KEY", + "REVFISICA": "PHYSICAL REVIEW (1/0)", + "FRACCIONEXENTAIVA": "PRODUCT/SERVICE CP CODE", + } + + +def _parts_headers() -> dict[str, str]: + # Alineado con parts/template_config.TEMPLATE_DOWNLOAD_HEADERS_EN. + return { + "NUMPARTE": "PART NUMBER", + "NUMPARTECOM": "COMMERCIAL PART NUMBER", + "DESCRIPCIONE": "DESCRIPTION (SPANISH)", + "DESCRIPCIONI": "DESCRIPTION (ENGLISH)", + "CLASE": "CLASS", + "UNIMED": "COMMERCIAL UNIT OF MEASURE", + "COSTOUNIT": "UNIT COST", + "TIPOMONEDA": "COST CURRENCY TYPE", + "CLAVEMONEDA": "CURRENCY CODE", + "PESOUNIT": "UNIT WEIGHT", + "TIPOPESO": "WEIGHT TYPE", + "FRACCION": "FRACTION", + "FRACCIONAME": "US FRACTION", + "PAIS": "COUNTRY", + "PREFERENCIA": "PREFERENCE", + "SECTOR": "SECTOR", + "RUTAIMAGEN": "IMAGE PATH", + "FDAKEY": "FDA key", + "FCCKEY": "FCC key", + "LICENCIA": "License code", + "ECCN": "ECCN", + "EXPORTCODE": "Export code", + "EXCLUSION": "Exclusion", + "ACTIVO": "Active", + } + + +def _merge_all() -> dict[str, str]: + merged: dict[str, str] = {} + for part in ( + _facturas_headers(), + _series_merge(), + _customs_exchange_us_fractions_boms(), + _clients_providers_headers(), + _pedimentos_headers(), + _vehicles_drivers_trailers_transporters(), + _material_classes_headers(), + _parts_headers(), + ): + merged.update(part) + return merged + + +HEADER_LABEL_EN: dict[str, str] = _merge_all() diff --git a/backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py b/backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py new file mode 100644 index 00000000..09b86d41 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py @@ -0,0 +1,66 @@ +"""Helpers to merge csv_hint max_chars into column definitions after inject_en_labels.""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def inject_max_char_hints_into_columns( + columns: Optional[List[Dict[str, Any]]], + max_by_canonical: Dict[str, int], +) -> None: + """Attach or append ``{\"kind\": \"max_chars\", \"n\": N}`` per canonical (aligned with validators/models).""" + if not columns or not max_by_canonical: + return + for item in columns: + if not isinstance(item, dict): + continue + canon = item.get("canonical") + if not canon: + continue + n = max_by_canonical.get(str(canon).strip()) + if n is None: + continue + mc: Dict[str, Any] = {"kind": "max_chars", "n": n} + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = mc + elif isinstance(ch, dict): + item["csv_hint"] = [ch, mc] + elif isinstance(ch, list): + item["csv_hint"] = [*ch, mc] + + +def append_csv_hint(item: Dict[str, Any], extra: Dict[str, Any]) -> None: + """Añade un hint (digits, enum adicional, etc.) tras los existentes.""" + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = extra + return + if isinstance(ch, dict): + item["csv_hint"] = [ch, extra] + return + if isinstance(ch, list): + item["csv_hint"] = [*ch, extra] + + +def merge_enum_hint_before_max( + item: Dict[str, Any], + enum_codes: List[str], +) -> None: + """Prepend enum_codes hint if not already present (for columns with fixed ≤3 values).""" + if len(enum_codes) == 0 or len(enum_codes) > 3: + return + enum_hint: Dict[str, Any] = {"kind": "enum_codes", "codes": list(enum_codes)} + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = enum_hint + return + if isinstance(ch, dict): + if ch.get("kind") == "enum_codes": + return + item["csv_hint"] = [enum_hint, ch] + return + if isinstance(ch, list): + if any(isinstance(x, dict) and x.get("kind") == "enum_codes" for x in ch): + return + item["csv_hint"] = [enum_hint, *ch] diff --git a/backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py b/backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py new file mode 100644 index 00000000..e59613da --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py @@ -0,0 +1,30 @@ +""" +Inyecta labels.en en definiciones de columnas TEMPLATE_COLUMNS tras cargar el dict. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from .csv_headers_en import HEADER_LABEL_EN + + +def inject_en_labels_into_columns(columns: Optional[List[Dict[str, Any]]]) -> None: + """Añade item['labels']['en'] cuando existe traducción para canonical.""" + if not columns: + return + for item in columns: + if not isinstance(item, dict): + continue + c = item.get("canonical") + if not c: + continue + en = HEADER_LABEL_EN.get(str(c).strip()) + if en: + lab = item.setdefault("labels", {}) + lab["en"] = en + + +def inject_en_labels_into_template_columns(template_columns: Dict[str, Optional[List[Dict[str, Any]]]]) -> None: + """Recorre todas las plantillas de un TEMPLATE_COLUMNS.""" + for cols in template_columns.values(): + inject_en_labels_into_columns(cols) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/template_locale.py b/backend/api/v1/modules/a76/layouts_csv/common/template_locale.py new file mode 100644 index 00000000..4c8df10d --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/template_locale.py @@ -0,0 +1,160 @@ +""" +Helpers for localized CSV template header labels (ES/EN) and alias registration. + +csv_hint (opcional en cada columna TEMPLATE_COLUMNS): un dict o lista de dicts (orden importa). +- kind "enum_codes" + codes: lista corta (≤3) mostrada como "Base (a, b, c)" en descarga. +- kind "digits" + n: sufijo numérico, ES "(n dígitos)" / EN "(n digits)". +- kind "max_chars" + n: máximo caracteres (modelo / validadores CSV). +- kind "literal" + es / en: sufijo libre entre paréntesis por idioma. + +cabeceras extendidas se registran en lookup de importación vía merge_download_header_into_lookup. +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Iterator, Optional + +CsvHintDict = Dict[str, Any] + + +def normalize_locale(locale: Optional[str]) -> str: + if not locale: + return "es" + l = str(locale).lower().strip() + if l.startswith("en"): + return "en" + return "es" + + +def display_header_for_locale(column: Dict[str, Any], locale: str) -> str: + """ + Pick the CSV header cell for one column definition. + Falls back to canonical when labels are absent. + For ES: no usar labels.en antes que canonical — si solo existe labels.en (inyectado), + sin labels.es el CSV en español debe seguir usando el nombre canónico (Clarion), no el EN. + """ + loc = normalize_locale(locale) + labels = column.get("labels") + if isinstance(labels, dict): + if loc == "en": + return str(labels.get("en") or labels.get("es") or column.get("canonical") or "") + return str(labels.get("es") or column.get("canonical") or labels.get("en") or "") + return str(column.get("canonical") or "") + + +def _csv_hints_iter(column: Dict[str, Any]) -> Iterator[CsvHintDict]: + """Siempre es generador (yield from ()) para filas sin csv_hint.""" + h = column.get("csv_hint") + if h is None: + yield from () + return + if isinstance(h, list): + for item in h: + if isinstance(item, dict): + yield item + elif isinstance(h, dict): + yield h + + +def _append_suffix(text: str, suffix_body: str) -> str: + inner = suffix_body.strip() + if not inner: + return text + return f"{text} ({inner})" if text else f"({inner})" + + +def _max_chars_suffix(n: int, locale: str) -> str: + loc = normalize_locale(locale) + if loc == "en": + if n == 1: + return "max. 1 character" + return f"max. {n} characters" + if n == 1: + return "máx. 1 carácter" + return f"máx. {n} caracteres" + + +def _apply_one_csv_hint(text: str, hint: CsvHintDict, locale: str) -> str: + """Aplica un sufijo a la cabecera acumulada (display o ya con hints previos).""" + loc = normalize_locale(locale) + kind = hint.get("kind") + if kind == "enum_codes": + codes = hint.get("codes") + if not isinstance(codes, list): + return text + if len(codes) == 0 or len(codes) > 3: + return text + inner = ", ".join(str(c) for c in codes) + return _append_suffix(text, inner) + if kind == "digits": + n = hint.get("n") + if n is None: + return text + try: + ni = int(n) + except (TypeError, ValueError): + return text + suf = f"{ni} digits" if loc == "en" else f"{ni} dígitos" + return _append_suffix(text, suf) + if kind == "max_chars": + n = hint.get("n") + if n is None: + return text + try: + ni = int(n) + except (TypeError, ValueError): + return text + if ni < 0: + return text + return _append_suffix(text, _max_chars_suffix(ni, loc)) + if kind == "literal": + lit = hint.get("en") if loc == "en" else hint.get("es") + if not lit and isinstance(hint.get("es"), str): + lit = hint.get("es") + if not lit and isinstance(hint.get("en"), str): + lit = hint.get("en") + if lit: + return _append_suffix(text, str(lit)) + return text + return text + + +def download_header_cell(column: Dict[str, Any], locale: Optional[str] = None) -> str: + """ + Texto final de la celda de cabecera en plantilla descargada (incl. sufijos de ayuda). + csv_hint puede ser un dict o lista de dicts (orden: p. ej. enum_codes luego max_chars). + enum_codes solo si len(codes) ≤ 3. + """ + loc = normalize_locale(locale) + text = display_header_for_locale(column, loc) + for hint in _csv_hints_iter(column): + text = _apply_one_csv_hint(text, hint, loc) + return text + + +def merge_download_header_into_lookup( + item: Dict[str, Any], + canonical: str, + normalize_header_fn: Callable[[str], str], + lookup: Dict[str, str], +) -> None: + """Registra cabeceras de descarga ES/EN (con sufijos csv_hint) como alias del canonical.""" + for loc in ("es", "en"): + cell = download_header_cell(item, loc) + if cell: + lookup[normalize_header_fn(cell)] = canonical + + +def merge_labels_into_lookup( + item: Dict[str, Any], + canonical: str, + normalize_header_fn: Callable[[str], str], + lookup: Dict[str, str], +) -> None: + """Register labels.es / labels.en as extra normalized aliases for canonical.""" + labels = item.get("labels") + if not isinstance(labels, dict): + return + for key in ("es", "en"): + lab = labels.get(key) + if lab: + lookup[normalize_header_fn(str(lab))] = canonical diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py new file mode 100644 index 00000000..6e3a50fc --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py @@ -0,0 +1,23 @@ +"""Máximos y enums para cabeceras CSV agentes aduanales (validadores / mappers).""" +from typing import Dict + +from .common.mappers import MAX_LEN + +CUSTOMS_BROKERS_CSV_MAX_CHARS: Dict[str, int] = { + "CLAVE": MAX_LEN["broker_key"], + "LICENCIA": MAX_LEN["license"], + "NOMBRE": MAX_LEN["name"], + "RFC": MAX_LEN["tax_id"], + "DIRECCION": MAX_LEN["address"], + "CODIGO POSTAL": MAX_LEN["postal_code"], + "CIUDAD": MAX_LEN["city"], + "ESTADO": MAX_LEN["state"], + "PAIS": MAX_LEN["country"], + "TELEFONO": MAX_LEN["phone"], + "FAX": MAX_LEN["fax"], + "EMAIL": MAX_LEN["email"], + "PERSONAL_ID": MAX_LEN["personal_id"], + "POSICION": MAX_LEN["position"], + "EMPRESA": MAX_LEN["company"], + "CONTACTO": MAX_LEN["contact"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py index b1a3afa6..d91c411f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py @@ -9,6 +9,10 @@ Solo se leen columnas definidas aquí; el resto se ignora. from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import CUSTOMS_BROKERS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "customs_brokers": [ @@ -49,6 +53,15 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("customs_brokers"), CUSTOMS_BROKERS_CSV_MAX_CHARS +) +for _col in TEMPLATE_COLUMNS.get("customs_brokers") or []: + if _col.get("canonical") == "TIPO": + merge_enum_hint_before_max(_col, ["MEX", "AME"]) + # Orden oficial de columnas (para CSV sin encabezado o detección) CUSTOMS_BROKERS_FIELDNAMES_ORDER = [ "TIPO", @@ -84,6 +97,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) for idx, name in enumerate(CUSTOMS_BROKERS_FIELDNAMES_ORDER): lookup[normalize_header_fn(f"_COL_{idx}_")] = name return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py b/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py index 695ad37a..748b1a0f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py @@ -1,4 +1 @@ -# common validators, mappers, fk_loader for drivers CSV import -from .fk_loader import load_drivers_fk_sets - -__all__ = ["load_drivers_fk_sets"] +"""Drivers CSV helpers (validators, FK loader). Import submodules explicitly to avoid heavy deps at package init.""" diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py new file mode 100644 index 00000000..8dda83cb --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py @@ -0,0 +1,28 @@ +"""Límites alineados a drivers/common/common_validators.MAX_LEN (canonical CSV).""" +from typing import Dict + +from .common.common_validators import MAX_LEN as D_MAX + +DRIVERS_CSV_MAX_CHARS: Dict[str, int] = { + "TRANSPORTISTA": D_MAX["transporter_key"], + "LINEA": 30, + "CLAVE CONDUCTOR": D_MAX["driver_name"], + "LICENCIA": D_MAX["license_number"], + "PERMISO LINEA EXPRESS": D_MAX["express_line_id"], + "IDENTIFICACION ACE": D_MAX["ace_id"], + "FECHA NACIMIENTO": 30, + "SEXO": D_MAX["gender"], + "PAIS NACIMIENTO": D_MAX["birth_country"], + "TRANSPORTA MAT. PELIGROSO?": D_MAX["hazardous_material_auth"], + "PERMISO MAT. PELIGROSO": D_MAX["hazardous_material_state"], + "NOMBRE(S)": D_MAX["first_name"], + "APELLIDO PATERNO": D_MAX["last_name"], + "FORMA IDENTIFICACION 1": D_MAX["id_key1"], + "NUM. IDENTIFICACION 1": D_MAX["id_number1"], + "ESTADO": D_MAX["id_state1"], + "PAIS": D_MAX["id_country1"], + "FORMA IDENTIFICACION 2": D_MAX["id_key2"], + "NUM. IDENTIFICACION 2": D_MAX["id_number2"], + "ESTADO 2": D_MAX["id_state2"], + "PAIS 2": D_MAX["id_country2"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py index 6c4be901..803e8c3f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py @@ -5,6 +5,10 @@ Configuracion de plantilla CSV para Conductores (EstructuraCatConductor.xls). from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import DRIVERS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "drivers": [ @@ -33,6 +37,16 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("drivers"), DRIVERS_CSV_MAX_CHARS) +for _dc in TEMPLATE_COLUMNS.get("drivers") or []: + c = _dc.get("canonical") + if c == "SEXO": + merge_enum_hint_before_max(_dc, ["M", "F"]) + elif c == "TRANSPORTA MAT. PELIGROSO?": + merge_enum_hint_before_max(_dc, ["SI", "NO"]) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("drivers") @@ -44,6 +58,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py new file mode 100644 index 00000000..e456f8d1 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py @@ -0,0 +1,11 @@ +"""Límites cabecera plantilla tipos de cambio.""" +from typing import Dict + +from .common.common_validators import CURRENCY_MAX + +EXCHANGE_RATE_CSV_MAX_CHARS: Dict[str, int] = { + "FECHA": 30, + "VALOR": 24, + "MONEDA_LOCAL": CURRENCY_MAX, + "MONEDA_EXTRANJERA": CURRENCY_MAX, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py index 9f32c526..c8d5471f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py @@ -6,6 +6,10 @@ Solo se leen columnas definidas aquí; el resto se ignora. from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import EXCHANGE_RATE_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "exchange_rates": [ @@ -16,6 +20,12 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("exchange_rates"), EXCHANGE_RATE_CSV_MAX_CHARS +) + def build_normalized_lookup(normalize_header_fn, template_id: str = "exchange_rates") -> Dict[str, str]: """normalized_header -> canonical_name para plantilla exchange_rates.""" @@ -28,6 +38,8 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "exchange_ra lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py index e7b6124e..a8599d19 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py @@ -6,6 +6,9 @@ Misma estructura que facturas exp_def_header / exp_def_details; módulo autocont from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from ..facturas.invoice_csv_column_hints import apply_invoice_csv_hints # Columnas para encabezado y partidas de exportación (EstructuraEncFacExpoCamReg / EstructuraParExpoCamReg) TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { @@ -101,6 +104,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +apply_invoice_csv_hints(TEMPLATE_COLUMNS) + def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: return TEMPLATE_COLUMNS.get(template_id) @@ -117,6 +124,8 @@ def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py b/backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py new file mode 100644 index 00000000..e22b5574 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py @@ -0,0 +1,116 @@ +""" +Hints de cabecera para plantillas de factura (longitudes conservadoras + enums ≤3). + +Valores orientativos respecto a validación en tasks.py y modelos de factura; afinar si cambian ORM. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max + +# Unión de canónicos frecuentes en encabezados y partidas (multiplantilla) +INVOICE_MAX_CHARS_BY_CANONICAL: Dict[str, int] = { + "NUMERO FACTURA": 100, + "FECHA FACTURA": 30, + "FECHA EMISION": 30, + "TIPO DE CAMBIO": 24, + "REGIMEN": 10, + "PEDIMENTO": 30, + "REMESA": 12, + "CLAVE PROVEEDOR": 40, + "CLAVE VENDIDO A": 40, + "CLAVE ENVIADO A": 40, + "AGENTE ADUANAL": 20, + "CLAVE TRANSPORTISTA": 30, + "NOMBRE CONDUCTOR": 80, + "TIPO TRANSPORTE": 10, + "CLAVE TRANSPORTE": 30, + "NUMERO CAJA": 20, + "NUMERO TRANSPORTE": 30, + "TIPO MONEDA": 10, + "CLAVE MONEDA": 10, + "FLETES": 24, + "VALOR SEGUROS": 24, + "SEGUROS": 24, + "EMBALAJES": 24, + "OTROS INCREMENTABLES": 24, + "CLAVE INCOTERM": 10, + "PRECINTO": 49, + "TIPO PESO": 10, + "E DOCUMENT": 5, + "NUM OPERACION": 30, + "ADUANA DE CRUCE": 80, + "OBSERVACIONES E": 500, + "OBSERVACIONES I": 500, + "FACTURA ALTERNA": 99, + "NUM PROYECTO": 14, + "ORDEN COMPRA": 50, + "FACTURA EXPO REF": 19, + "MANIFIESTO": 50, + "ENVIADO POR": 80, + "LINEA": 10, + "CLASE": 20, + "CANTIDAD IMPORTADA": 24, + "UNIDAD DE MEDIDA": 10, + "COSTO UNITARIO": 24, + "PESO NETO": 24, + "PESO BRUTO": 24, + "CANTIDAD BULTOS": 24, + "CLAVE BULTOS": 20, + "PAIS ORIGEN": 3, + "FRACCION ARANCELARIA": 20, + "PREFERENCIA ARANCELARIA": 10, + "SECTOR": 20, + "FRACCION AMERICANA": 20, + "ORDEN DE COMPRA": 50, + "DESCRIPCION ESPAÑOL": 256, + "DESCRIPCION INGLES": 256, + "MARCA": 40, + "MODELO": 40, + "NUM. PARTE": 70, + "SE PAGO IMPUESTO": 10, + "FORMA DE PAGO": 30, + "METODO DE VALORACION": 30, + "DESCRIPCION EXTRA": 256, + "INFORMACION ADICIONAL": 500, + "AGREGAR/SUSTITUIR": 10, + "TOTAL": 24, + "NUMERO ENTRADA": 30, + "LOTE": 40, + "ID TYPE": 20, + "NUMERO FACTURA EXPO": 100, + "LINEA EXPO": 10, + "TIPO DE IMPO": 10, + "FACTURA IMPO": 100, + "LINEA IMPO": 10, + "GENERA DESCARGA": 5, + "CANTIDAD EXPORTADA/DESCARGAR": 24, + "AGREGAR(A)/SUSTITUIR(S)": 12, + "ES PARTIDA O SUBPARTIDA": 10, + "ES PARTIDA/SUBPARTIDA": 10, + "LINEA FACTURA": 10, + "LINEA SERIE": 10, + "SERIE": 40, + "NUM PARTE": 70, + "SUB MODELO": 40, + "NUMERO ID": 40, +} + + +def apply_invoice_csv_hints(template_columns: Dict[str, Optional[List[Dict[str, Any]]]]) -> None: + """Inyecta max_chars y enums en todas las listas de columnas no nulas.""" + for _tid, cols in template_columns.items(): + if not cols: + continue + inject_max_char_hints_into_columns(cols, INVOICE_MAX_CHARS_BY_CANONICAL) + for item in cols: + c = item.get("canonical") + if c == "E DOCUMENT": + merge_enum_hint_before_max(item, ["S", "N"]) + elif c in ("SE PAGO IMPUESTO",): + merge_enum_hint_before_max(item, ["SI", "NO"]) + elif c == "GENERA DESCARGA": + merge_enum_hint_before_max(item, ["SI", "NO"]) + elif c == "TIPO DE IMPO": + merge_enum_hint_before_max(item, ["I", "E"]) diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py index e972528c..d878f58a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py @@ -7,6 +7,9 @@ Solo se escribe en BD lo que los modelos de facturas aceptan (respetando models) from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .invoice_csv_column_hints import apply_invoice_csv_hints # Cada plantilla define sus columnas canónicas y alias (otros nombres que aceptamos en el CSV). # canonical = nombre estándar con el que trabajamos internamente; debe coincidir con lo que @@ -266,6 +269,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +apply_invoice_csv_hints(TEMPLATE_COLUMNS) + def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: cols = TEMPLATE_COLUMNS.get(template_id) @@ -300,6 +307,8 @@ def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py new file mode 100644 index 00000000..e60fb6a7 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py @@ -0,0 +1,29 @@ +"""Máximos alineados a parts/validators/common.py (canonical interno).""" +from typing import Dict + +PART_NUMBERS_CSV_MAX_CHARS: Dict[str, int] = { + "NUMPARTE": 70, + "NUMPARTECOM": 70, + "DESCRIPCIONE": 256, + "DESCRIPCIONI": 256, + "CLASE": 20, + "UNIMED": 5, + "COSTOUNIT": 24, + "TIPOMONEDA": 3, + "CLAVEMONEDA": 3, + "PESOUNIT": 24, + "TIPOPESO": 6, + "FRACCION": 10, + "FRACCIONAME": 10, + "PAIS": 3, + "PREFERENCIA": 7, + "SECTOR": 5, + "RUTAIMAGEN": 255, + "FDAKEY": 30, + "FCCKEY": 30, + "LICENCIA": 30, + "ECCN": 20, + "EXPORTCODE": 20, + "EXCLUSION": 20, + "ACTIVO": 5, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py index 55910691..d59c3694 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py @@ -8,10 +8,19 @@ from typing import Dict, List, Any, Optional, Tuple from ..common.cell_value import cell_to_str from ..common import csv_reader as common_csv_reader +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import ( + download_header_cell, + merge_download_header_into_lookup, + merge_labels_into_lookup, + normalize_locale, +) +from .csv_max_chars_for_headers import PART_NUMBERS_CSV_MAX_CHARS # Valores que indican que la primera fila es cabecera (primera columna normalizada) -FIRST_COLUMN_HEADER_VALUES = ("NUMERO DE PARTE", "NUMPARTE") +FIRST_COLUMN_HEADER_VALUES = ("NUMERO DE PARTE", "NUMPARTE", "PART NUMBER") def detect_headers_or_data( @@ -76,6 +85,27 @@ TEMPLATE_DOWNLOAD_HEADERS: List[str] = [ "RUTA DE LA IMAGEN", ] +# Misma longitud que TEMPLATE_DOWNLOAD_HEADERS; columnas I/J vacías. +TEMPLATE_DOWNLOAD_HEADERS_EN: List[str] = [ + "PART NUMBER", + "DESCRIPTION (SPANISH)", + "DESCRIPTION (ENGLISH)", + "CLASS", + "COMMERCIAL UNIT OF MEASURE", + "UNIT COST", + "COST CURRENCY TYPE", + "CURRENCY CODE", + "", + "", + "UNIT WEIGHT", + "WEIGHT TYPE", + "FRACTION", + "COUNTRY", + "PREFERENCE", + "SECTOR", + "IMAGE PATH", +] + # Para lectura cuando la primera fila es dato (sin cabecera): nombres únicos para columnas I y J TEMPLATE_FIELDNAMES_FOR_READING: List[str] = [ "NUMERO DE PARTE", @@ -126,6 +156,53 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("part_numbers"), PART_NUMBERS_CSV_MAX_CHARS +) + +_PART_ORDER_DOWNLOAD = [ + "NUMPARTE", + "DESCRIPCIONE", + "DESCRIPCIONI", + "CLASE", + "UNIMED", + "COSTOUNIT", + "TIPOMONEDA", + "CLAVEMONEDA", + None, + None, + "PESOUNIT", + "TIPOPESO", + "FRACCION", + "PAIS", + "PREFERENCIA", + "SECTOR", + "RUTAIMAGEN", +] + +_PN_BY_CANON = {c["canonical"]: c for c in (TEMPLATE_COLUMNS.get("part_numbers") or [])} + + +def _parts_download_column_defs() -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for key in _PART_ORDER_DOWNLOAD: + if key is None: + out.append({"canonical": ""}) + else: + base = _PN_BY_CANON.get(key) + out.append(dict(base) if base else {"canonical": key}) + return out + + +_PARTS_DOWNLOAD_DEFS = _parts_download_column_defs() + + +def download_headers_for_locale(locale: str) -> List[str]: + loc = normalize_locale(locale) + return [download_header_cell(d, loc) for d in _PARTS_DOWNLOAD_DEFS] + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("part_numbers") @@ -137,6 +214,16 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) + # Plantilla EN: mismas posiciones que TEMPLATE_DOWNLOAD_HEADERS + for es_h, en_h in zip(TEMPLATE_DOWNLOAD_HEADERS, TEMPLATE_DOWNLOAD_HEADERS_EN): + if not es_h or not en_h: + continue + es_key = normalize_header_fn(es_h) + canon = lookup.get(es_key) + if canon: + lookup[normalize_header_fn(en_h)] = canon return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py new file mode 100644 index 00000000..01df9256 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py @@ -0,0 +1,58 @@ +"""Hints cabecera CSV pedimentos (dígitos col Clarion, enums acotados, longitudes típicas).""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ..common.csv_hint_inject import ( + append_csv_hint, + inject_max_char_hints_into_columns, + merge_enum_hint_before_max, +) + +# Cols 1–3: solo dígitos con longitud fija +_PEDIMENTOS_DIGITS: Dict[str, int] = { + "AÑO": 2, + "PATENTE": 4, + "NUMERO": 7, +} + +# Resto de campos con tope razonable (validadores/modelos variados) +_PEDIMENTOS_MAX_CHARS: Dict[str, int] = { + "TIPO_OPERACION": 8, + "CLAVE_PEDIMENTO": 30, + "REGIMEN": 10, + "FECHA_INICIO": 30, + "FECHA_FINAL": 30, + "FECHA_PAGO": 30, + "ADUANA_SECCION_CRUCE": 120, + "ACUSE_ELECTRONICO": 80, + "INDIVIDUAL_CONSOLIDADO": 10, + "MET_TRANSP_ENTRADA": 30, + "MET_TRANSP_ARRIVO": 30, + "MET_TRANSP_SALIDA": 30, + "PEDIMENTO": 25, + "CLIENTE_SHORT_NAME": 30, + "TIPO_PEDIMENTO": 20, + "OBSERVACIONES": 500, +} + + +def apply_pedimentos_csv_hints(columns: Optional[List[Dict[str, Any]]]) -> None: + if not columns: + return + inject_max_char_hints_into_columns(columns, _PEDIMENTOS_MAX_CHARS) + for item in columns: + canon = item.get("canonical") + if not canon: + continue + nd = _PEDIMENTOS_DIGITS.get(str(canon).strip()) + if nd is not None: + append_csv_hint(item, {"kind": "digits", "n": nd}) + if canon == "TIPO_OPERACION": + merge_enum_hint_before_max(item, ["I", "E"]) + elif canon == "PAGO_IMPUESTO": + merge_enum_hint_before_max(item, ["S", "N"]) + elif canon == "ES_MIXTO": + merge_enum_hint_before_max(item, ["SI", "NO"]) + elif canon == "INDIVIDUAL_CONSOLIDADO": + merge_enum_hint_before_max(item, ["IND", "CON"]) diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py index e18541ef..7c654b00 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py @@ -10,6 +10,9 @@ from typing import Dict, List, Any, Optional, Tuple # Convierte valor de celda a str; si es lista (p. ej. CSV con columnas duplicadas), toma el primer elemento. # Re-exportado desde common para uso en validators; ver layouts_csv.common.cell_value. from ..common.cell_value import cell_to_str as _cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .pedimentos_csv_hints import apply_pedimentos_csv_hints from ..common import csv_reader as common_csv_reader @@ -116,6 +119,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +apply_pedimentos_csv_hints(TEMPLATE_COLUMNS.get("pedimentos")) + # Orden de columnas para CSV sin cabecera (primera fila = datos). Usado por detect_headers_or_data. PEDIMENTOS_TEMPLATE_ORDER: List[str] = [ item["canonical"] for item in TEMPLATE_COLUMNS["pedimentos"] @@ -190,6 +197,8 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "pedimentos" lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py new file mode 100644 index 00000000..717e8996 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py @@ -0,0 +1,16 @@ +"""Límites alineados a trailers/common/common_validators.MAX_LEN.""" +from typing import Dict + +from .common.common_validators import MAX_LEN as T_MAX + +TRAILERS_CSV_MAX_CHARS: Dict[str, int] = { + "NUMERO TRAILER": T_MAX["trailer_number"], + "CLAVE ACE": T_MAX["ace_trailer_number"], + "TIPO TRAILER": T_MAX["trailer_type_key"], + "PRECINTO": T_MAX["seal"], + "CODIGO ENTIDAD": T_MAX["entity_code"], + "PLACAS": T_MAX["plate_number"], + "ESTADO": T_MAX["state"], + "PAIS": T_MAX["country"], + "CLAVE CONTENEDOR": T_MAX["container_key"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py index ee2ea29e..91bd4cd9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py @@ -6,6 +6,10 @@ Mapeo: NUMERO TRAILER → trailer_number, CLAVE ACE → ace_trailer_number, etc. from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import TRAILERS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "trailers": [ @@ -22,6 +26,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("trailers"), TRAILERS_CSV_MAX_CHARS) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("trailers") @@ -33,6 +41,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py new file mode 100644 index 00000000..9c51441d --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py @@ -0,0 +1,25 @@ +"""Límites alineados a transportistas/common/common_validators.MAX_LEN.""" +from typing import Dict + +from .common.common_validators import MAX_LEN as TR_MAX + +TRANSPORTISTAS_CSV_MAX_CHARS: Dict[str, int] = { + "CLAVE TRANSPORTISTA": TR_MAX["transporter_key"], + "NOMBRE": TR_MAX["name"], + "NOMBRE CORTO": TR_MAX["short_name"], + "RESPONSABLE": TR_MAX["responsible"], + "RFC": TR_MAX["rfc"], + "CALLES": TR_MAX["streets"], + "CODIGO POSTAL": TR_MAX["postal_code"], + "CIUDAD": TR_MAX["city"], + "ESTADO": TR_MAX["state"], + "PAIS": TR_MAX["country"], + "CODIGO CARGADOR": TR_MAX["loader_code"], + "CODIGO CAAT": TR_MAX["caat_code"], + "CODIGO TRANS": TR_MAX["transport_code"], + "TIPO INTERFASE TRANS": TR_MAX["transport_interface_type"], + "SERVIDOR FTP": TR_MAX["ftp_server"], + "USUARIO FTP": TR_MAX["ftp_user"], + "CLAVE ACCESO FTP": TR_MAX["ftp_password"], + "DIRECTORIO FTP": TR_MAX["ftp_directory"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py index 41d80821..7ba75abe 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py @@ -6,6 +6,10 @@ Mapeo Clarion: Col A = CLAVE TRANSPORTISTA, B = NOMBRE, ... R = DIRECTORIO FTP, from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import TRANSPORTISTAS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "transporters": [ @@ -31,6 +35,12 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("transporters"), TRANSPORTISTAS_CSV_MAX_CHARS +) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("transporters") @@ -42,6 +52,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py new file mode 100644 index 00000000..a68c2243 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py @@ -0,0 +1,14 @@ +"""Fracción americana: common_validators CODE_MAX, etc.""" +from typing import Dict + +from .common.common_validators import CODE_MAX, PREFIX_MAX, UNIT_MAX, TYPE_MAX + +US_TARIFF_FRACTIONS_CSV_MAX_CHARS: Dict[str, int] = { + "FRACCION_ARANCELARIA": CODE_MAX, + "PREFIJO": PREFIX_MAX, + "UNIDAD_DE_MEDIDA": UNIT_MAX, + "DESCRIPCION": 500, + "TIPO_DE_ADVALOREM": TYPE_MAX, + "ADVALOREM_PCT": 24, + "ADVALOREM_DLLS": 24, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py index 76106678..4af8022d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py @@ -6,6 +6,10 @@ Solo se leen columnas definidas aquí; el resto se ignora. from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str as _cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import US_TARIFF_FRACTIONS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "us_tariff_fractions": [ @@ -20,6 +24,15 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("us_tariff_fractions"), US_TARIFF_FRACTIONS_CSV_MAX_CHARS +) +for _uc in TEMPLATE_COLUMNS.get("us_tariff_fractions") or []: + if _uc.get("canonical") == "TIPO_DE_ADVALOREM": + merge_enum_hint_before_max(_uc, ["PO", "ME"]) + # Orden A..H para detectar desfase en 8ª columna (raw_row.values()[7]) TEMPLATE_ORDER = [item["canonical"] for item in TEMPLATE_COLUMNS["us_tariff_fractions"]] DESFASE_COLUMN_INDEX = 7 @@ -36,6 +49,8 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "us_tariff_f lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py new file mode 100644 index 00000000..36f9b06f --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py @@ -0,0 +1,24 @@ +"""Límites de cabecera alineados a vehicles/common/common_validators.MAX_LEN.""" +from typing import Dict + +from .common.common_validators import MAX_LEN as V_MAX + +VEHICLES_CSV_MAX_CHARS: Dict[str, int] = { + "CLAVE": V_MAX["vehicle_key"], + "CLAVE ACE": V_MAX["ace_vehicle_key"], + "CLAVE TRANSPORTE": V_MAX["transporter_key"], + "VIN": V_MAX["transport_identifier"], + "TIPO TRANSPORTE": V_MAX["transport_type"], + "CODIGO DE ENTIDAD": V_MAX["entity_code"], + "TRANSPONDEDOR": V_MAX["transponder_number"], + "NUMERO DOT": V_MAX["dot_number"], + "PLACAS": V_MAX["plate_number"], + "CIUDAD": V_MAX["city"], + "ESTADO": V_MAX["state"], + "PAIS": V_MAX["country"], + "PRECINTO": V_MAX["seal"], + "EMPRESA ASEGURADORA": V_MAX["insurance_company_name"], + "NUM. ASEGURADORA": V_MAX["insurance_number"], + "MONTO ASEGURADO": 24, + "FECHA DE ASEGURADORA": 30, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py index 248d35e8..459d7617 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py @@ -6,6 +6,10 @@ Mapeo: CLAVE → vehicle_key, CLAVE ACE → ace_vehicle_key, etc. from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import VEHICLES_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "vehicles": [ @@ -30,6 +34,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("vehicles"), VEHICLES_CSV_MAX_CHARS) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("vehicles") @@ -41,6 +49,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/core/permissions/seed_v2.py b/backend/api/v1/modules/core/permissions/seed_v2.py index cda18719..00e92e10 100644 --- a/backend/api/v1/modules/core/permissions/seed_v2.py +++ b/backend/api/v1/modules/core/permissions/seed_v2.py @@ -77,12 +77,9 @@ permissions_general = ( gen_crud("cat_broker_concepts", "Conceptos de Agente Aduanal", "general_catalogs") + gen_crud("cat_classification", "Clasificación de Conceptos", "general_catalogs") + gen_crud("cat_identifiers", "Identificadores", "general_catalogs") + - gen_crud("cat_incoterms", "Incoterms (Catálogo)", "general_catalogs") + gen_crud("cat_inpc", "INPC", "general_catalogs") + gen_crud("cat_legends", "Leyendas Fijas", "general_catalogs") + gen_crud("cat_seals", "Sellos", "general_catalogs") + - gen_crud("cat_valuation", "Métodos de Valoración", "general_catalogs") + - gen_crud("cat_countries", "Países", "general_catalogs") + gen_crud("cat_ports", "Puertos", "general_catalogs") + gen_crud("cat_um_general", "UdM Generales", "general_catalogs") + gen_crud("cat_um_customs", "UdM Aduana MEX", "general_catalogs") + @@ -92,17 +89,15 @@ permissions_general = ( gen_crud("cat_unit_conversions", "Conversiones", "general_catalogs") + gen_crud("cat_equivalencies", "Equivalencias", "general_catalogs") + gen_crud("cat_exchange_rates", "Tipos de Cambio", "general_catalogs") + - gen_crud("cat_currency", "Tipos de Moneda", "general_catalogs") + gen_crud("cat_multi_currency_types", "Tipos de Moneda (Múltiples)", "general_catalogs") + - gen_crud("cat_inv_types", "Tipos de Factura", "general_catalogs") + gen_crud("cat_signatures", "Firmas Electrónicas", "general_catalogs") + gen_crud("cat_errors", "Catálogo de Errores", "general_catalogs") + gen_crud("cat_doda", "DODA", "general_catalogs") + gen_crud("cat_prevalidators", "Prevalidadores", "general_catalogs") + gen_crud("cat_notices", "Avisos Electrónicos", "general_catalogs") + - gen_crud("cat_crossing", "Avisos de Cruce", "general_catalogs") + - gen_crud("cat_warehouses", "Recintos", "general_catalogs") + - gen_crud("cat_sectors", "Sectores", "general_catalogs") + gen_crud("cat_sectors", "Sectores", "general_catalogs") + + gen_crud("cat_locations", "Ubicaciones", "general_catalogs") + + gen_crud("cat_depreciation_catalog", "Catálogo de Depreciación", "general_catalogs") ) # ============================================================================ diff --git a/backend/core/database.py b/backend/core/database.py index 4f02f241..95e937d3 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -136,8 +136,13 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]: FastAPI inyecta ``Request`` automáticamente; los llamadores existentes que escriben ``db: Session = Depends(get_core_db)`` siguen funcionando sin cambios porque ``Request`` se resuelve en la capa de dependencia. + + Replica el mismo ``(tenant_id, company_id)`` en ContextVars para código que + comparte la transacción sin la misma instancia de sesión (p. ej. listeners). """ tenant_id, company_id = _extract_rls_context(request) + token_t = rls_tenant_var.set(tenant_id) + token_c = rls_company_var.set(company_id) db = CoreSessionLocal() db.info[RLS_TENANT_KEY] = tenant_id db.info[RLS_COMPANY_KEY] = company_id @@ -145,18 +150,26 @@ def get_core_db(request: Request = None) -> Generator[Session, None, None]: yield db finally: db.close() + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) async def get_async_core_db(request: Request = None) -> AsyncGenerator[AsyncSession, None]: """Dependency async para obtener sesión con contexto RLS.""" tenant_id, company_id = _extract_rls_context(request) - async with AsyncCoreSessionLocal() as session: - session.info[RLS_TENANT_KEY] = tenant_id - session.info[RLS_COMPANY_KEY] = company_id - try: - yield session - finally: - await session.close() + token_t = rls_tenant_var.set(tenant_id) + token_c = rls_company_var.set(company_id) + try: + async with AsyncCoreSessionLocal() as session: + session.info[RLS_TENANT_KEY] = tenant_id + session.info[RLS_COMPANY_KEY] = company_id + try: + yield session + finally: + await session.close() + finally: + rls_tenant_var.reset(token_t) + rls_company_var.reset(token_c) @contextmanager diff --git a/backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py b/backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py new file mode 100644 index 00000000..14a5b007 --- /dev/null +++ b/backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py @@ -0,0 +1,143 @@ +"""Tests for CSV template header hints (inline) and import lookup aliases (stdlib unittest).""" + +import unittest + +from api.v1.modules.a76.csv_templates.registry import ( + _normalize_header_for_match, + _TEMPLATE_HEADERS, + get_download_headers, +) +from api.v1.modules.a76.layouts_csv.common.template_locale import ( + download_header_cell, + display_header_for_locale, +) +from api.v1.modules.a76.layouts_csv.clients_and_providers import template_config as cp_tc + + +class TestRegistryCsvDownloadSmoke(unittest.TestCase): + """Smoke: cada template_id del registry tiene fila de descarga misma longitud que columnas base.""" + + def test_download_headers_length_matches_registry_for_all_templates(self): + for tid in sorted(_TEMPLATE_HEADERS.keys()): + row = get_download_headers(tid, "es") + self.assertIsNotNone(row, tid) + self.assertEqual(len(row), len(_TEMPLATE_HEADERS[tid]), tid) + + def test_customs_brokers_lookup_resolves_decorated_tip_header(self): + from api.v1.modules.a76.layouts_csv.customs_brokers import template_config as cb_tc + + lookup = cb_tc.build_normalized_lookup(_normalize_header_for_match) + hdr0 = get_download_headers("customs_brokers", "es")[0] + self.assertEqual(lookup[_normalize_header_for_match(hdr0)], "TIPO") + + def test_imp_temp_header_lookup_resolves_decorated_e_document(self): + from api.v1.modules.a76.layouts_csv.facturas import template_config as ft_tc + + lookup = ft_tc.build_normalized_lookup("imp_temp_header", _normalize_header_for_match) + for cell in get_download_headers("imp_temp_header", "es"): + if lookup.get(_normalize_header_for_match(cell)) == "E DOCUMENT": + self.assertIn("(S, N)", cell) + return + self.fail("E DOCUMENT column missing from imp_temp_header download row") + + def test_pedimentos_lookup_resolves_tipo_operacion_decorated(self): + from api.v1.modules.a76.layouts_csv.pedmientos import template_config as ped_tc + + lookup = ped_tc.build_normalized_lookup(_normalize_header_for_match, "pedimentos") + for cell in get_download_headers("pedimentos", "es"): + if lookup.get(_normalize_header_for_match(cell)) == "TIPO_OPERACION": + self.assertIn("(I, E)", cell) + return + self.fail("TIPO_OPERACION column missing from pedimentos download row") + + def test_material_classes_lookup_resolves_revfisica_enum_header(self): + from api.v1.modules.a76.layouts_csv.classes import template_config as cl_tc + + lookup = cl_tc.build_normalized_lookup(_normalize_header_for_match) + for cell in get_download_headers("material_classes", "es"): + if lookup.get(_normalize_header_for_match(cell)) == "REVFISICA": + self.assertIn("(1, 0)", cell) + return + self.fail("REVFISICA column missing from material_classes download row") + + +class TestCsvHeaderHints(unittest.TestCase): + def test_download_header_cell_enum_within_limit(self): + col = { + "canonical": "TIPO", + "csv_hint": {"kind": "enum_codes", "codes": ["C", "P", "A"]}, + } + self.assertEqual(download_header_cell(col, "es"), "TIPO (C, P, A)") + col_en = {**col, "labels": {"en": "Entity type"}} + self.assertEqual(download_header_cell(col_en, "en"), "Entity type (C, P, A)") + + def test_download_header_cell_enum_over_limit_ignored(self): + col = { + "canonical": "X", + "csv_hint": {"kind": "enum_codes", "codes": ["a", "b", "c", "d"]}, + } + self.assertEqual(download_header_cell(col, "es"), "X") + + def test_download_header_cell_digits_es_en(self): + col = {"canonical": "NUMERO", "csv_hint": {"kind": "digits", "n": 7}} + self.assertEqual(download_header_cell(col, "es"), "NUMERO (7 dígitos)") + self.assertEqual(download_header_cell(col, "en"), "NUMERO (7 digits)") + + def test_clients_providers_lookup_accepts_extended_headers(self): + lookup = cp_tc.build_normalized_lookup(_normalize_header_for_match) + self.assertEqual(lookup[_normalize_header_for_match("PROCEDENCIA")], "PROCEDENCIA") + self.assertEqual( + lookup[_normalize_header_for_match("PROCEDENCIA (N, E)")], + "PROCEDENCIA", + ) + self.assertEqual(lookup[_normalize_header_for_match("TIPO")], "TIPO") + self.assertEqual(lookup[_normalize_header_for_match("TIPO (C, P, A)")], "TIPO") + self.assertEqual( + lookup[_normalize_header_for_match("Origin (foreign/domestic) (N, E)")], + "PROCEDENCIA", + ) + self.assertEqual( + lookup[_normalize_header_for_match("Entity type (C, P, A)")], + "TIPO", + ) + self.assertEqual( + lookup[_normalize_header_for_match("RFC (máx. 30 caracteres)")], + "RFC", + ) + self.assertEqual( + lookup[_normalize_header_for_match("SHORT_NAME (máx. 10 caracteres)")], + "SHORT_NAME", + ) + self.assertEqual( + lookup[_normalize_header_for_match("Short code (max. 10 characters)")], + "SHORT_NAME", + ) + + def test_download_header_cell_max_chars(self): + col = {"canonical": "RFC", "csv_hint": {"kind": "max_chars", "n": 30}} + self.assertEqual(download_header_cell(col, "es"), "RFC (máx. 30 caracteres)") + self.assertEqual(download_header_cell(col, "en"), "RFC (max. 30 characters)") + col_one = {"canonical": "X", "csv_hint": {"kind": "max_chars", "n": 1}} + self.assertEqual(download_header_cell(col_one, "es"), "X (máx. 1 carácter)") + self.assertEqual(download_header_cell(col_one, "en"), "X (max. 1 character)") + + def test_download_header_cell_enum_then_max_chars(self): + col = { + "canonical": "TIPO", + "csv_hint": [ + {"kind": "enum_codes", "codes": ["C", "P", "A"]}, + {"kind": "max_chars", "n": 1}, + ], + } + self.assertEqual(download_header_cell(col, "es"), "TIPO (C, P, A) (máx. 1 carácter)") + + def test_display_header_unchanged_without_hint(self): + col = {"canonical": "ZZZ_NO_SCHEMA_LEN"} + self.assertEqual( + download_header_cell(col, "es"), + display_header_for_locale(col, "es"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/unit/general_catalogs/doda/test_doda_print.py b/backend/tests/unit/general_catalogs/doda/test_doda_print.py index a8098ef5..f635e74d 100644 --- a/backend/tests/unit/general_catalogs/doda/test_doda_print.py +++ b/backend/tests/unit/general_catalogs/doda/test_doda_print.py @@ -53,7 +53,9 @@ def _print_client(db_session: Session, test_tenant) -> TestClient: @patch.object( doda_routes, "validate_access_to_resource", - lambda db, company_id, current_user: int(current_user["tenant_id"]), + lambda db, company_id, current_user, required_permissions=None, require_all=True: int( + current_user["tenant_id"] + ), ) def test_print_uses_s3_cache_when_fingerprint_matches( db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch @@ -98,7 +100,9 @@ def test_print_uses_s3_cache_when_fingerprint_matches( @patch.object( doda_routes, "validate_access_to_resource", - lambda db, company_id, current_user: int(current_user["tenant_id"]), + lambda db, company_id, current_user, required_permissions=None, require_all=True: int( + current_user["tenant_id"] + ), ) def test_print_422_without_digital_seal( db_session: Session, test_tenant, monkeypatch: pytest.MonkeyPatch diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 5dd317c5..c4751712 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -104,7 +104,12 @@ }, "sidebar": { "dashboard": "Dashboard", - "help_center": "System Manuals", + "help_center": "Manuales del Sistema", + "management_label": "Gestión", + "bulk_upload": { + "title": "Cargas masivas", + "entry": "Importación CSV" + }, "reference_data": { "title": "Fixed Catalogs", "codes_pedimento_regimen": "Pedimento and Regime Codes", @@ -1603,5 +1608,198 @@ "cancel": "Cancel", "accept": "Accept" } + }, + "csv_upload": { + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest" + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header" + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change" + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)" + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + "n_inserted": "{n} inserted", + "n_updated": "{n} updated", + "err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.", + "err_unknown": "Unknown error", + "err_processing_fallback": "Processing error. Check the modal or details." + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations" + } + }, + "config_empty": "No module-specific settings.", + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + "errors": "Errors", + "errors_heading": "Scan errors (fix in your CSV)", + "errors_badge": "{shown} of {total} error(s)", + "errors_truncated": "Download the CSV to see all errors.", + "errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.", + "scan_ok_title": "File validated successfully", + "scan_ok_body": "All rows look correct and ready to import.", + "scan_problems_title": "Problems found in the file", + "scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).", + "inserted": "Inserted", + "updated": "Updated", + "rejected": "Rejected", + "rejected_hint": "See line-by-line detail in the table below.", + "ref_gaps_title": "Reference gaps (FK / catalogs)", + "ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.", + "ref_state_title": "Reference state", + "ref_state_ok": "References ready to operate (no critical gaps reported).", + "ref_state_other": "No numeric gaps; review the server message if applicable.", + "skipped_reasons_heading": "Rejection reasons summary", + "commit_errors_heading": "Error detail", + "rows_badge": "{n} rows", + "importing_records": "Importing records…", + "cancel_operation": "Cancel", + "processing": "Processing…", + "confirm_load": "Confirm import", + "close": "Close", + "th_line": "Line", + "th_column": "Column", + "th_message": "Message", + "th_solution": "Solution", + "th_reference": "Reference", + "th_reason": "Reason", + "download_csv": "Download CSV" + } } } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index b6bbd48e..708f548a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -105,6 +105,11 @@ "sidebar": { "dashboard": "Dashboard", "help_center": "Manuales del Sistema", + "management_label": "Gestión", + "bulk_upload": { + "title": "Cargas masivas", + "entry": "Importación CSV" + }, "reference_data": { "title": "Catálogos Fijos", "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", @@ -1603,5 +1608,198 @@ "cancel": "Cancelar", "accept": "Aceptar" } + }, + "csv_upload": { + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto" + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado" + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen" + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)" + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + "n_inserted": "{n} insertados", + "n_updated": "{n} actualizados", + "err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.", + "err_unknown": "Error desconocido", + "err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles." + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)" + } + }, + "config_empty": "No hay configuraciones específicas para este módulo.", + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + "errors": "Errores", + "errors_heading": "Detalle de errores (para corregir en el CSV)", + "errors_badge": "{shown} de {total} error(es)", + "errors_truncated": "Para consultar el resto de errores, descargue el CSV.", + "errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.", + "scan_ok_title": "Archivo validado correctamente", + "scan_ok_body": "Todos los registros parecen correctos y listos para importar.", + "scan_problems_title": "Se detectaron problemas en el archivo", + "scan_problems_body": "Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para importar solo las filas válidas (las erróneas se omitirán).", + "inserted": "Insertados", + "updated": "Actualizados", + "rejected": "Rechazados", + "rejected_hint": "Revisa el detalle por línea en la tabla inferior.", + "ref_gaps_title": "Brechas de referencia (FK / catálogos)", + "ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.", + "ref_state_title": "Estado de referencias", + "ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).", + "ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.", + "skipped_reasons_heading": "Resumen de motivos de rechazo", + "commit_errors_heading": "Detalle de errores", + "rows_badge": "{n} filas", + "importing_records": "Importando registros…", + "cancel_operation": "Cancelar", + "processing": "Procesando…", + "confirm_load": "Confirmar carga", + "close": "Cerrar", + "th_line": "Línea", + "th_column": "Columna", + "th_message": "Mensaje", + "th_solution": "Solución", + "th_reference": "Referencia", + "th_reason": "Motivo", + "download_csv": "Descargar CSV" + } } } diff --git a/frontend/scripts/merge_csv_upload_i18n.py b/frontend/scripts/merge_csv_upload_i18n.py new file mode 100644 index 00000000..be3d33d0 --- /dev/null +++ b/frontend/scripts/merge_csv_upload_i18n.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Fusiona bloques csv_upload en messages/en.json y messages/es.json y vuelca a src/lib/i18n/csv-upload-messages.*.json. + +La fuente de verdad del copy CSV es `messages/{en,es}.json` (alineado con sidebar, dashboard, facturas). +Si editas solo esos JSON, sincroniza con: + node -e "const fs=require('fs'),p=require('path'),r='.../frontend';for(const l of['en','es']){const j=JSON.parse(fs.readFileSync(p.join(r,'messages',l+'.json'),'utf8'));fs.writeFileSync(p.join(r,'src/lib/i18n','csv-upload-messages.'+l+'.json'),JSON.stringify(j.csv_upload,null,'\\t')+'\\n')}" + +Ejecutar desde frontend/: python scripts/merge_csv_upload_i18n.py +""" +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MESSAGES = ROOT / "messages" + +EN_CSV = { + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest", + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header", + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change", + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL", + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)", + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations", + }, + }, + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + }, +} + +ES_CSV = { + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto", + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado", + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen", + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL", + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)", + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)", + }, + }, + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + }, +} + + +def merge_locale(filename: str, csv_obj: dict) -> None: + path = MESSAGES / filename + data = json.loads(path.read_text(encoding="utf-8")) + data["csv_upload"] = csv_obj + path.write_text(json.dumps(data, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + + +def extract_csv_upload_to_lib() -> None: + """Copia `csv_upload` a src/lib/i18n/csv-upload-messages.*.json (lo que importa csv-msg.ts).""" + dest_dir = ROOT / "src/lib/i18n" + for filename, suffix in (("en.json", "en"), ("es.json", "es")): + path = MESSAGES / filename + data = json.loads(path.read_text(encoding="utf-8")) + cu = data.get("csv_upload") + if cu is None: + raise SystemExit(f"merge_csv_upload_i18n: falta csv_upload en {filename}") + out = dest_dir / f"csv-upload-messages.{suffix}.json" + out.write_text(json.dumps(cu, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + print(f"Wrote {out.relative_to(ROOT)}") + + +def main() -> None: + merge_locale("en.json", EN_CSV) + merge_locale("es.json", ES_CSV) + print("Merged csv_upload into en.json and es.json") + extract_csv_upload_to_lib() + + +if __name__ == "__main__": + main() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cda4698a..d34ecb93 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -682,16 +682,23 @@ export const api = { * Returns blob and suggested filename for the browser download. */ async getCsvTemplateDownload( - templateId: string + templateId: string, + locale?: string ): Promise<{ blob: Blob; filename: string }> { const token = getToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(`${API_BASE_URL}/v1/a76/csv-templates/${templateId}`, { - method: 'GET', - headers, - credentials: 'include' - }); + const loc = locale === 'en' ? 'en' : 'es'; + const qs = new URLSearchParams({ locale: loc }); + const response = await fetch( + `${API_BASE_URL}/v1/a76/csv-templates/${templateId}?${qs.toString()}`, + { + method: 'GET', + headers, + credentials: 'include', + cache: 'no-store' + } + ); if (!response.ok) { const msg = response.status === 404 ? 'Plantilla no encontrada' : `Error ${response.status}`; throw new Error(msg); diff --git a/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte index 1adb87aa..bf1c468b 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte @@ -5,6 +5,14 @@ import * as RadioGroup from '$lib/components/ui/radio-group/index.js'; import { Settings2 } from 'lucide-svelte'; import { tabSettings } from '$lib/config/csv-upload'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + const TAB_LABEL: Record = { + catalogos: 'tab_catalogos', + transportes: 'tab_transportes', + importacion: 'tab_importacion', + exportacion: 'tab_exportacion' + }; let { activeTab, @@ -38,7 +46,9 @@
- Configuración: {activeTab} + {csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)}
{#if currentFields.length > 0} @@ -47,7 +57,7 @@
{#if field.type !== 'boolean'} {csvMsg(field.labelKey)} {/if} @@ -59,7 +69,7 @@ {csvMsg(field.labelKey)}
{:else if field.type === 'select' && field.options} @@ -68,7 +78,7 @@ bind:value={settings[field.name]} > {#each field.options as opt} - + {/each} {:else if field.type === 'radio' && field.options} @@ -79,7 +89,7 @@ {#each field.options as opt}
- +
{/each} @@ -89,7 +99,7 @@
{:else}
- No hay configuraciones específicas para este módulo. + {csvMsg('config_empty')}
{/if} diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte index 38278510..8629bc97 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte @@ -3,6 +3,14 @@ import { Settings2 } from 'lucide-svelte'; import { globalCsvParams, tabSettings, type CsvUploadField } from '$lib/config/csv-upload'; import { cn } from '$lib/utils'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + const TAB_LABEL: Record = { + catalogos: 'tab_catalogos', + transportes: 'tab_transportes', + importacion: 'tab_importacion', + exportacion: 'tab_exportacion' + }; let { globalSettings = $bindable(), @@ -45,13 +53,13 @@
Parámetros globales{csvMsg('params_header')}
{#each globalCsvParams as param}
{csvMsg(param.labelKey)}
@@ -73,13 +81,13 @@ {#if currentTabFields.length > 0}
Configuración: {activeTab}{csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)} {#each currentTabFields as field}
{#if field.type !== 'boolean'} {csvMsg(field.labelKey)} {/if} {#if field.type === 'select' && field.options} @@ -92,7 +100,7 @@ }} > {#each field.options as opt} - + {/each} {:else if field.type === 'radio' && field.options} @@ -108,7 +116,7 @@ if (tabSettingsValues) tabSettingsValues[field.name] = opt.value; }} /> - {opt.label} + {csvMsg(opt.labelKey)} {/each}
@@ -121,7 +129,7 @@ if (tabSettingsValues) tabSettingsValues[field.name] = e.currentTarget.checked; }} /> - {field.label} + {csvMsg(field.labelKey)} {/if}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte index c9d03b91..1344f2cd 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte @@ -14,6 +14,7 @@ } from '$lib/csv-import-pending'; import { fetchCsvImportStatus, isWaitingConfirmationPayload } from '$lib/csv-import-status-api'; import { Loader2, RefreshCw } from 'lucide-svelte'; + import { csvMsg } from '$lib/i18n/csv-msg'; type ValidatedRow = CsvImportPendingEntry & { checking?: boolean }; @@ -36,22 +37,9 @@ } function profileLabel(p: CsvImportPendingEntry['profile']): string { - const map: Record = { - customs_brokers: 'Agentes aduanales', - clients_providers: 'Clientes / proveedores', - exchange_rates: 'Tipos de cambio', - pedimentos: 'Pedimentos', - material_classes: 'Clases de material', - vehicles: 'Vehículos', - drivers: 'Conductores', - trailers: 'Remolques', - transporters: 'Transportistas', - part_numbers: 'Números de parte', - boms: 'BOMs', - exportacion: 'Exportación (operaciones)', - imports: 'Importación (operaciones)' - }; - return map[p] ?? p; + const key = `pending.profiles.${p}` as const; + const t = csvMsg(key); + return t === key ? p : t; } function isStaleJob(status: number, err: string | undefined): boolean { @@ -134,7 +122,7 @@
{#if rows.length === 0 && !refreshing} -

No hay importaciones pendientes para esta empresa.

+

{csvMsg('pending.empty')}

{:else if rows.length === 0 && refreshing} -

Comprobando con el servidor…

+

{csvMsg('pending.checking')}

{:else}
    {#each rows as row (row.jobId)} @@ -177,10 +164,10 @@ {#if row.totalRows != null || row.validRows != null}
    {#if row.totalRows != null} - Total filas: {row.totalRows} + {csvMsg('pending.total_rows')}: {row.totalRows} {/if} {#if row.validRows != null} - Válidas: {row.validRows} + {csvMsg('pending.valid_rows')}: {row.validRows} {/if}
    {/if} @@ -201,9 +188,9 @@ } }} > - Reanudar + {csvMsg('pending.resume')} - +
{/each} diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index b15acaeb..85a364f4 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -16,6 +16,7 @@ criticalReferenceGaps, referenceStateReady } from '$lib/csv-import-commit-metrics'; + import { csvFmt, csvMsg } from '$lib/i18n/csv-msg'; let { open = $bindable(false), @@ -249,16 +250,16 @@
{#if isPending} - Validación de Importación + {csvMsg('modal.title_pending')} {:else if isFinished} - {hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'} + {hasErrors ? csvMsg('modal.title_warning') : csvMsg('modal.title_success')} {/if} {#if isPending} - Revise el análisis preliminar antes de confirmar la carga de datos. + {csvMsg('modal.desc_pending')} {:else if isFinished} - El proceso de importación ha finalizado. + {csvMsg('modal.desc_done')} {/if}
@@ -274,7 +275,7 @@ class="bg-card p-4 rounded-lg border flex flex-col items-center justify-center text-center shadow-sm" > Total Filas{csvMsg('modal.total_rows')} {scanResults.total_rows || 0} @@ -285,7 +286,7 @@ > Válidos{csvMsg('modal.valid_rows')} {scanResults.valid_rows || 0} Errores{csvMsg('modal.errors')} {scanResults.error_count || 0} @@ -327,10 +328,9 @@ >
-

Se detectaron problemas en el archivo

+

{csvMsg('modal.scan_problems_title')}

- Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para - importar solo las filas válidas (las erróneas se omitirán). + {csvMsg('modal.scan_problems_body')}

@@ -338,13 +338,13 @@
- Detalle de errores (para corregir en el CSV) + {csvMsg('modal.errors_heading')}
- {scanErrorsShown} de {scanErrorsTotal} error(es) + {csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })}
@@ -362,10 +362,10 @@ class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm" > - Línea - Columna - Mensaje - Solución + {csvMsg('modal.th_line')} + {csvMsg('modal.th_column')} + {csvMsg('modal.th_message')} + {csvMsg('modal.th_solution')} @@ -382,21 +382,21 @@
{#if scanErrorsTruncated}

- Para consultar el resto de errores, descargue el CSV. + {csvMsg('modal.errors_truncated')}

{/if} {:else}

- Se detectaron {scanResults.error_count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo. + {csvFmt('modal.errors_missing_detail', { count: scanResults.error_count })}

{/if} {:else}
-

Archivo validado correctamente

-

Todos los registros parecen correctos y listos para importar.

+

{csvMsg('modal.scan_ok_title')}

+

{csvMsg('modal.scan_ok_body')}

{/if} @@ -413,7 +413,7 @@ Insertados{csvMsg('modal.inserted')} {insertedCount} @@ -425,7 +425,7 @@ Actualizados{csvMsg('modal.updated')} {updatedCount} @@ -436,13 +436,13 @@
Rechazados{csvMsg('modal.rejected')}
{totalSkipped} {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}

- Revisa el detalle por línea en la tabla inferior. + {csvMsg('modal.rejected_hint')}

{/if} @@ -456,20 +456,17 @@ {#if refGaps > 0}
-

Brechas de referencia (FK / catálogos)

+

{csvMsg('modal.ref_gaps_title')}

- Hay {refGaps} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de - filas rechazadas antes de reintentar. + {csvFmt('modal.ref_gaps_body', { n: refGaps })}

{:else}
-

Estado de referencias

+

{csvMsg('modal.ref_state_title')}

- {refReady - ? 'Referencias listas para operar (sin brechas críticas reportadas).' - : 'Sin brechas numéricas; revisa el mensaje del servidor si aplica.'} + {refReady ? csvMsg('modal.ref_state_ok') : csvMsg('modal.ref_state_other')}

{/if} @@ -483,7 +480,7 @@ {#if commitSkippedSummary.length > 0}

- Resumen de motivos de rechazo + {csvMsg('modal.skipped_reasons_heading')}

{#each commitSkippedSummary as item} @@ -504,13 +501,13 @@
- Detalle de Errores + {csvMsg('modal.commit_errors_heading')}
- {commitResults.skipped_details.length} filas + {csvFmt('modal.rows_badge', { n: commitResults.skipped_details.length })}
@@ -528,10 +525,10 @@ class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm" > - Línea - Referencia - Motivo - Solución + {csvMsg('modal.th_line')} + {csvMsg('modal.th_reference')} + {csvMsg('modal.th_reason')} + {csvMsg('modal.th_solution')} @@ -558,7 +555,7 @@
{#if isPending && isUploading}
-

Importando registros…

+

{csvMsg('modal.importing_records')}

- Cancelar Operación + {csvMsg('modal.cancel_operation')} {:else if isFinished} - + {/if}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte index 0c9bdaaa..8896a5cb 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -4,7 +4,21 @@ import { UploadCloud, Lock } from 'lucide-svelte'; import { cn } from '$lib/utils'; import { toast } from 'svelte-sonner'; + import { browser } from '$app/environment'; import { api } from '$lib/api'; + import { cookieName, getLocale } from '$lib/paraglide/runtime'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + /** Misma fuente que el switch de idioma (cookie Paraglide); `getLocale()` puede quedar desincronizado. */ + function csvTemplateLocale(): 'es' | 'en' { + if (browser) { + const cookies = document.cookie.split(';').map((c) => c.trim()); + const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`)); + const current = localeCookie ? localeCookie.split('=')[1] : ''; + if (current) return current.toLowerCase().startsWith('en') ? 'en' : 'es'; + } + return String(getLocale()).toLowerCase().startsWith('en') ? 'en' : 'es'; + } let { items, @@ -62,7 +76,7 @@ const isValidExtension = file.name.toLowerCase().endsWith('.csv'); if (!isValidExtension) { - toast.error('Formato inválido. Solo se permiten archivos .csv'); + toast.error(csvMsg('toast.invalid_csv')); return; } @@ -106,8 +120,12 @@ if (!item.templateId) return; try { - toast.info(`Descargando plantilla para ${item.title}...`); - const { blob, filename } = await api.getCsvTemplateDownload(item.templateId); + const itemLabel = csvMsg(`items.${item.id}`); + toast.info(`${csvMsg('toast.download_loading')} ${itemLabel}`); + const { blob, filename } = await api.getCsvTemplateDownload( + item.templateId, + csvTemplateLocale() + ); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -116,9 +134,9 @@ link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); - toast.success(`Plantilla descargada: ${filename}`); + toast.success(`${csvMsg('toast.download_ok')} ${filename}`); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla'); + toast.error(err instanceof Error ? err.message : csvMsg('toast.download_err')); } } @@ -128,6 +146,10 @@ class:opacity-60={gridLocked} aria-busy={gridLocked ? true : undefined} > + {#snippet cardTitle(itemId: string)} +
{csvMsg(`items.${itemId}`)}
+ {/snippet} + {#if groupedItems.ungrouped.length > 0}
{#each groupedItems.ungrouped as item} @@ -171,7 +193,7 @@ - Próximamente + {csvMsg('soon')}
{/if} @@ -183,7 +205,7 @@
- ¡Suelta el archivo! + {csvMsg('drop_here')} {:else}
-
{item.title}
+ {@render cardTitle(item.id)} {/if} @@ -205,7 +227,7 @@ {#each Object.entries(groupedItems.groups) as [groupName, groupItems]}

- {groupName} + {csvMsg(`groups.${groupName}`)}

{#each groupItems as item} @@ -251,7 +273,7 @@ - Próximamente + {csvMsg('soon')}
{/if} @@ -263,7 +285,7 @@
- ¡Suelta el archivo! + {csvMsg('drop_here')} {:else}
-
{item.title}
+ {@render cardTitle(item.id)} {/if} diff --git a/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte b/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte index 519a708f..1e6b5073 100644 --- a/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte +++ b/frontend/src/lib/components/dashboard/general_catalogs/doda/doda-form-modal.svelte @@ -70,12 +70,14 @@ let { dodaIdParam, defaultLastUser: defaultLastUserProp = '', + canMutate = true, onClose, onCreatedNavigateTo }: { /** 'new' o id numérico en string; desde ?doda_id= de la URL */ dodaIdParam: string; defaultLastUser?: string; + canMutate?: boolean; onClose: () => void; /** Tras crear DODA, actualiza ?doda_id= al id creado (sin desmontar la lista) */ onCreatedNavigateTo: (dodaId: number) => void; @@ -170,13 +172,22 @@ dodaFormT(dodaLoc, 'shortcuts_scope'), obtenerAtajosFormularioDodaPagina({ cambiarPestana: (pestana) => (activeTab = pestana), - manejarGuardar: handleSubmit, + manejarGuardar: () => { + if (!canMutate) return; + handleSubmit(); + }, manejarCerrar: () => { if (browser) onClose(); } }) ); + function ensureCanMutate(): boolean { + if (canMutate) return true; + error = 'Permission denied: cat_doda.create/edit'; + return false; + } + function getEmptyForm(): DodaCreate & { pedimentos_detail?: any[]; containers?: any[]; @@ -400,6 +411,7 @@ } async function handleAddSeal() { + if (!ensureCanMutate()) return; error = null; if (!isEdit || !id) { error = t('seal_save_first'); @@ -457,6 +469,7 @@ } async function handleAddSealFromContainerModal() { + if (!ensureCanMutate()) return; const trimmed = newSealValue.trim(); if (!trimmed) { error = t('seal_empty'); @@ -493,6 +506,7 @@ } function handleAmericanPedimentoModalConfirm() { + if (!ensureCanMutate()) return; error = null; const tipo = americanModalType.trim(); const valor = americanModalValue.trim(); @@ -539,6 +553,7 @@ } async function handleDeleteSeal(sealLine: number) { + if (!ensureCanMutate()) return; error = null; if (!isEdit || !id) return; if (selectedContainerIndex == null) return; @@ -563,6 +578,7 @@ } async function handleSubmit() { + if (!ensureCanMutate()) return; submitAttempted = true; error = null; warning = null; @@ -1621,7 +1637,7 @@
@@ -147,7 +153,7 @@ id="localizacion" bind:value={formData.localizacion} maxlength={200} - disabled={loading} + disabled={loading || !canMutate} />
@@ -158,7 +164,7 @@