diff --git a/.env.example b/.env.example index 46bb47bc..1ef60c19 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,25 @@ VITE_KEYCLOAK_CLIENT_ID=anexo76-frontend #------ Celery / Valkey ---------- VALKEY_URL=redis://valkey:6379/0 +# ----- MinIO (S3-compatible) ----- +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_API_PORT=9100 +MINIO_CONSOLE_PORT=9101 + +# ----- Imports CSV (layouts_csv): redis | minio ----- +CSV_IMPORT_STORAGE=minio +S3_ENDPOINT_URL=http://minio:9000 +S3_ACCESS_KEY=minioadmin +S3_SECRET_KEY=minioadmin +S3_BUCKET=anexo76 +S3_REGION=us-east-1 +S3_USE_SSL=false +# Logos, certificados, help: mismo bucket. Si CSV_IMPORT_STORAGE=minio, también se usa MinIO aquí +# (use_s3_object_storage = minio CSV o S3_FILE_STORAGE=true). +S3_FILE_STORAGE=true +S3_PRESIGNED_EXPIRES_SECONDS=3600 + # ----- Sitar API ----- SITAR_API_URL=http://api.sitar.aduanasoft.com SITAR_API_USER=user_sitar_api diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..321fead5 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,281 @@ +pipeline { + agent any + options { + timestamps() + disableConcurrentBuilds() + } + + parameters { + choice(name: 'ENVIRONMENT', choices: ['development', 'main'], description: 'Rama a ejecutar') + string(name: 'VERSION_MAYOR', defaultValue: '1', description: 'Componente mayor de versión') + } + + environment { + REGISTRY = 'dev.aduanasoft.com' + IMAGE_NAMESPACE = 'anexo76' + } + + stages { + stage('Preflight tools') { + steps { + sh ''' + set -euo pipefail + echo "Node: $(hostname)" + if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: este agente no tiene docker instalado." + echo "Usa un nodo Jenkins con label 'docker' y acceso al daemon." + exit 1 + fi + docker --version + ''' + } + } + + stage('Checkout') { + steps { + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.ENVIRONMENT}"]], + userRemoteConfigs: [[ + url: 'https://git.aduanasoft.com/ADUANASOFT/anexo76.git', + credentialsId: 'gitea_acazares' + ]], + extensions: [[$class: 'CloneOption', depth: 0, shallow: false]] + ]) + } + } + + stage('Test backend') { + steps { + sh ''' + set -euxo pipefail + echo "== Test backend stage started ==" + echo "Workspace: $WORKSPACE" + docker --version + + DB_CONTAINER="anexo76-test-db-${BUILD_NUMBER}" + DB_NAME="anexo76_test" + DB_USER="anexo76" + DB_PASS="anexo76" + export TEST_DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_CONTAINER}:5432/${DB_NAME}" + PY_CONTAINER="anexo76-test-py-${BUILD_NUMBER}" + TEST_NETWORK="anexo76-test-net-${BUILD_NUMBER}" + + cleanup() { + docker rm -f "$PY_CONTAINER" >/dev/null 2>&1 || true + docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$TEST_NETWORK" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker network rm "$TEST_NETWORK" >/dev/null 2>&1 || true + docker network create "$TEST_NETWORK" + + docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$DB_CONTAINER" \ + --network "$TEST_NETWORK" \ + -e POSTGRES_DB="$DB_NAME" \ + -e POSTGRES_USER="$DB_USER" \ + -e POSTGRES_PASSWORD="$DB_PASS" \ + postgres:16-alpine + + # Espera a que Postgres acepte conexiones (hasta ~60s) + READY=0 + for i in $(seq 1 30); do + if docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then + READY=1 + break + fi + sleep 2 + done + if [ "$READY" != "1" ]; then + echo "ERROR: Postgres no quedó listo a tiempo (30 intentos × 2s)." + exit 1 + fi + docker exec "$DB_CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" + docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -c " + CREATE SCHEMA IF NOT EXISTS core; + CREATE SCHEMA IF NOT EXISTS a24; + CREATE SCHEMA IF NOT EXISTS a76; + CREATE SCHEMA IF NOT EXISTS public; + " + + docker rm -f "$PY_CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$PY_CONTAINER" --network "$TEST_NETWORK" python:3.12-slim sleep infinity + docker exec "$PY_CONTAINER" mkdir -p /workspace + docker cp "$WORKSPACE/." "$PY_CONTAINER:/workspace" + + docker exec \ + -e TEST_DATABASE_URL="$TEST_DATABASE_URL" \ + "$PY_CONTAINER" \ + sh -lc ' + set -euxo pipefail + python --version + python -m pip install --upgrade pip + if [ -f /workspace/backend/requirements.txt ]; then + pip install -r /workspace/backend/requirements.txt + elif [ -f /workspace/backend/requirements/base.txt ]; then + pip install -r /workspace/backend/requirements/base.txt + else + echo "ERROR: No requirements file found in /workspace/backend" + ls -la /workspace || true + ls -la /workspace/backend || true + ls -la /workspace/backend/requirements || true + exit 1 + fi + cd /workspace/backend + alembic upgrade head + pytest -q tests -v -ra -s + ' + ''' + } + } + + stage('Generate version') { + steps { + script { + def year = sh(returnStdout: true, script: 'date +%y').trim() + def month = sh(returnStdout: true, script: 'date +%m').trim() + + if (params.ENVIRONMENT == 'development') { + def shortHash = sh(returnStdout: true, script: 'git rev-parse --short=8 HEAD').trim() + env.APP_VERSION = "${year}.${month}.${params.VERSION_MAYOR}.${shortHash}" + } else { + def lastTag = sh(returnStdout: true, script: 'git describe --tags --abbrev=0 2>/dev/null || true').trim() + def commitCount = lastTag + ? sh(returnStdout: true, script: "git rev-list ${lastTag}..HEAD --count").trim() + : sh(returnStdout: true, script: 'git rev-list --count HEAD').trim() + + if (commitCount == '0') { + commitCount = '1' + } + env.APP_VERSION = "${year}.${month}.${params.VERSION_MAYOR}.${commitCount}" + } + + echo "VERSION: ${env.APP_VERSION}" + } + } + } + + stage('Docker login') { + steps { + withCredentials([usernamePassword(credentialsId: 'harbor-credentials', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD')]) { + sh ''' + set -euo pipefail + echo "$HARBOR_PASSWORD" | docker login "$REGISTRY" -u "$HARBOR_USERNAME" --password-stdin + ''' + } + } + } + + stage('Build + push backend') { + steps { + script { + retry(3) { + sh ''' + set -euo pipefail + export DOCKER_BUILDKIT=1 + docker build \ + --build-arg APP_VERSION="${APP_VERSION}" \ + -t "${REGISTRY}/${IMAGE_NAMESPACE}/backend:${APP_VERSION}" \ + -t "${REGISTRY}/${IMAGE_NAMESPACE}/backend:latest" \ + -f ./backend/Dockerfile \ + ./backend + + docker push "${REGISTRY}/${IMAGE_NAMESPACE}/backend:${APP_VERSION}" + docker push "${REGISTRY}/${IMAGE_NAMESPACE}/backend:latest" + ''' + } + } + } + } + + stage('Build + push frontend') { + steps { + script { + retry(3) { + sh ''' + set -euo pipefail + export DOCKER_BUILDKIT=1 + docker build \ + --build-arg VITE_API_URL=https://anexo76-dev.aduanasoft.com/api/ \ + --build-arg VITE_KEYCLOAK_URL=https://anexo76-dev.aduanasoft.com/kcauth/ \ + --build-arg INTERNAL_API_URL=http://backend:3467/api/ \ + -t "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:${APP_VERSION}" \ + -t "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:latest" \ + -f ./frontend/Dockerfile.prod \ + ./frontend + + docker push "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:${APP_VERSION}" + docker push "${REGISTRY}/${IMAGE_NAMESPACE}/frontend:latest" + ''' + } + } + } + } + + stage('Tag release (main)') { + when { + expression { params.ENVIRONMENT == 'main' } + } + steps { + withCredentials([usernamePassword(credentialsId: 'gitea_acazares', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_PASSWORD')]) { + sh ''' + set -euo pipefail + git -c user.name='Jenkins' -c user.email='jenkins@gitea.local' \ + tag -a "v${APP_VERSION}" -m "Release version ${APP_VERSION}" + git remote set-url origin "https://${GIT_USERNAME}:${GIT_PASSWORD}@git.aduanasoft.com/ADUANASOFT/anexo76.git" + git push origin "v${APP_VERSION}" + ''' + } + } + } + + stage('Deploy development') { + when { + expression { params.ENVIRONMENT == 'development' } + } + steps { + withCredentials([ + usernamePassword(credentialsId: 'harbor-credentials', usernameVariable: 'HARBOR_USERNAME', passwordVariable: 'HARBOR_PASSWORD'), + string(credentialsId: 'dev-server-user', variable: 'DEV_SERVER_USER'), + string(credentialsId: 'dev-server-host', variable: 'DEV_SERVER_HOST'), + sshUserPrivateKey( + credentialsId: 'dev-server-ssh', + keyFileVariable: 'DEV_SERVER_KEY', + usernameVariable: 'DEV_SERVER_SSH_USER', + passphraseVariable: 'DEV_SERVER_KEY_PASSPHRASE' + ) + ]) { + script { + def devHost = env.DEV_SERVER_HOST?.trim() + def knownHostsFile = "${env.WORKSPACE}/.jenkins-anexo76-known_hosts" + sh """ + set -euo pipefail + ssh-keyscan -T 15 -H '${devHost}' > '${knownHostsFile}' + """ + def remote = [ + name: 'dev-server', + host: devHost, + user: env.DEV_SERVER_USER ?: env.DEV_SERVER_SSH_USER, + identityFile: env.DEV_SERVER_KEY, + passphrase: env.DEV_SERVER_KEY_PASSPHRASE, + allowAnyHosts: false, + knownHosts: knownHostsFile + ] + sshCommand remote: remote, command: ''' + set -euo pipefail + cd ~/projects/anexo76 + # Asume docker login ya configurado en el host destino para evitar exponer secretos por interpolación. + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d + # Solo imágenes colgantes (sin tag); no borra imágenes en uso por otros contenedores. + docker image prune -f + ''' + } + } + } + } + } + +} diff --git a/backend/Dockerfile b/backend/Dockerfile index 6f6a9c20..84d0d41d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -45,6 +45,10 @@ RUN pip install --no-cache-dir -r requirements.txt # Copiar código COPY . . +# Jenkins / CI pasan --build-arg APP_VERSION=…; debe quedar en ENV para runtime (API /version, OpenAPI, etc.) +ARG APP_VERSION=dev-local +ENV APP_VERSION=${APP_VERSION} + # Exponer puerto EXPOSE 8000 diff --git a/backend/alembic/versions/ca7d3c4e8b2a_invoice_logistics_int_fk_constraints.py b/backend/alembic/versions/ca7d3c4e8b2a_invoice_logistics_int_fk_constraints.py new file mode 100644 index 00000000..394da688 --- /dev/null +++ b/backend/alembic/versions/ca7d3c4e8b2a_invoice_logistics_int_fk_constraints.py @@ -0,0 +1,266 @@ +"""Add surrogate int IDs for transport catalogs and invoice logistics. + +Single consolidated migration for: +- transporter_id, trailer_id, vehicle_id, driver_id +- carrier_int_id, transport_int_id, trailer_int_id in invoice_logistics +- backfill mappings from existing string keys/codes +- FK constraints for the new invoice_logistics int references +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ca7d3c4e8b2a" +down_revision: Union[str, Sequence[str], None] = "c1a2b3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # transporter + op.add_column("transporter", sa.Column("transporter_id", sa.BigInteger(), nullable=True), schema="a76") + op.execute( + """ + WITH s AS ( + SELECT transporter_key, ROW_NUMBER() OVER (ORDER BY transporter_key) AS new_id + FROM a76.transporter + ) + UPDATE a76.transporter t + SET transporter_id = s.new_id + FROM s + WHERE t.transporter_key = s.transporter_key + """ + ) + op.execute("CREATE SEQUENCE IF NOT EXISTS a76.transporter_transporter_id_seq") + op.execute( + """ + SELECT setval( + 'a76.transporter_transporter_id_seq', + COALESCE((SELECT MAX(transporter_id) FROM a76.transporter), 0) + 1, + false + ) + """ + ) + op.execute( + """ + ALTER TABLE a76.transporter + ALTER COLUMN transporter_id + SET DEFAULT nextval('a76.transporter_transporter_id_seq') + """ + ) + op.execute("ALTER TABLE a76.transporter ALTER COLUMN transporter_id SET NOT NULL") + op.create_unique_constraint("uq_a76_transporter_transporter_id", "transporter", ["transporter_id"], schema="a76") + + # trailer + op.add_column("trailer", sa.Column("trailer_id", sa.BigInteger(), nullable=True), schema="a76") + op.execute( + """ + WITH s AS ( + SELECT trailer_number, ROW_NUMBER() OVER (ORDER BY trailer_number) AS new_id + FROM a76.trailer + ) + UPDATE a76.trailer t + SET trailer_id = s.new_id + FROM s + WHERE t.trailer_number = s.trailer_number + """ + ) + op.execute("CREATE SEQUENCE IF NOT EXISTS a76.trailer_trailer_id_seq") + op.execute( + """ + SELECT setval( + 'a76.trailer_trailer_id_seq', + COALESCE((SELECT MAX(trailer_id) FROM a76.trailer), 0) + 1, + false + ) + """ + ) + op.execute( + """ + ALTER TABLE a76.trailer + ALTER COLUMN trailer_id + SET DEFAULT nextval('a76.trailer_trailer_id_seq') + """ + ) + op.execute("ALTER TABLE a76.trailer ALTER COLUMN trailer_id SET NOT NULL") + op.create_unique_constraint("uq_a76_trailer_trailer_id", "trailer", ["trailer_id"], schema="a76") + + # vehicle + op.add_column("vehicle", sa.Column("vehicle_id", sa.BigInteger(), nullable=True), schema="a76") + op.execute( + """ + WITH s AS ( + SELECT vehicle_key, ROW_NUMBER() OVER (ORDER BY vehicle_key) AS new_id + FROM a76.vehicle + ) + UPDATE a76.vehicle v + SET vehicle_id = s.new_id + FROM s + WHERE v.vehicle_key = s.vehicle_key + """ + ) + op.execute("CREATE SEQUENCE IF NOT EXISTS a76.vehicle_vehicle_id_seq") + op.execute( + """ + SELECT setval( + 'a76.vehicle_vehicle_id_seq', + COALESCE((SELECT MAX(vehicle_id) FROM a76.vehicle), 0) + 1, + false + ) + """ + ) + op.execute( + """ + ALTER TABLE a76.vehicle + ALTER COLUMN vehicle_id + SET DEFAULT nextval('a76.vehicle_vehicle_id_seq') + """ + ) + op.execute("ALTER TABLE a76.vehicle ALTER COLUMN vehicle_id SET NOT NULL") + op.create_unique_constraint("uq_a76_vehicle_vehicle_id", "vehicle", ["vehicle_id"], schema="a76") + + # driver + op.add_column("driver", sa.Column("driver_id", sa.BigInteger(), nullable=True), schema="a76") + op.execute( + """ + WITH s AS ( + SELECT transporter_key, line, ROW_NUMBER() OVER (ORDER BY transporter_key, line) AS new_id + FROM a76.driver + ) + UPDATE a76.driver d + SET driver_id = s.new_id + FROM s + WHERE d.transporter_key = s.transporter_key + AND d.line = s.line + """ + ) + op.execute("CREATE SEQUENCE IF NOT EXISTS a76.driver_driver_id_seq") + op.execute( + """ + SELECT setval( + 'a76.driver_driver_id_seq', + COALESCE((SELECT MAX(driver_id) FROM a76.driver), 0) + 1, + false + ) + """ + ) + op.execute( + """ + ALTER TABLE a76.driver + ALTER COLUMN driver_id + SET DEFAULT nextval('a76.driver_driver_id_seq') + """ + ) + op.execute("ALTER TABLE a76.driver ALTER COLUMN driver_id SET NOT NULL") + op.create_unique_constraint("uq_a76_driver_driver_id", "driver", ["driver_id"], schema="a76") + + # invoice_logistics int refs + op.add_column("invoice_logistics", sa.Column("carrier_int_id", sa.BigInteger(), nullable=True), schema="a76") + op.add_column("invoice_logistics", sa.Column("transport_int_id", sa.BigInteger(), nullable=True), schema="a76") + op.add_column("invoice_logistics", sa.Column("trailer_int_id", sa.BigInteger(), nullable=True), schema="a76") + + # ensure referenced codes exist + op.execute( + """ + INSERT INTO a76.transporter (transporter_key, tenant_id, company_id) + SELECT DISTINCT ON (il.carrier_id) il.carrier_id, il.tenant_id, il.company_id + FROM a76.invoice_logistics il + WHERE il.carrier_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM a76.transporter t WHERE t.transporter_key = il.carrier_id + ) + """ + ) + op.execute( + """ + INSERT INTO a76.vehicle (vehicle_key, tenant_id, company_id) + SELECT DISTINCT ON (il.transport_id) il.transport_id, il.tenant_id, il.company_id + FROM a76.invoice_logistics il + WHERE il.transport_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM a76.vehicle v WHERE v.vehicle_key = il.transport_id + ) + """ + ) + op.execute( + """ + INSERT INTO a76.trailer (trailer_number, tenant_id, company_id) + SELECT DISTINCT ON (il.trailer_num) il.trailer_num, il.tenant_id, il.company_id + FROM a76.invoice_logistics il + WHERE il.trailer_num IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM a76.trailer tr WHERE tr.trailer_number = il.trailer_num + ) + """ + ) + + # backfill int refs + op.execute( + """ + UPDATE a76.invoice_logistics il + SET carrier_int_id = t.transporter_id + FROM a76.transporter t + WHERE il.carrier_id IS NOT NULL + AND il.carrier_id = t.transporter_key + """ + ) + op.execute( + """ + UPDATE a76.invoice_logistics il + SET transport_int_id = v.vehicle_id + FROM a76.vehicle v + WHERE il.transport_id IS NOT NULL + AND il.transport_id = v.vehicle_key + """ + ) + op.execute( + """ + UPDATE a76.invoice_logistics il + SET trailer_int_id = tr.trailer_id + FROM a76.trailer tr + WHERE il.trailer_num IS NOT NULL + AND il.trailer_num = tr.trailer_number + """ + ) + + # FK constraints + op.create_foreign_key( + "fk_a76_invoice_logistics_carrier_int_id_transporter_id", + "invoice_logistics", + "transporter", + ["carrier_int_id"], + ["transporter_id"], + source_schema="a76", + referent_schema="a76", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_a76_invoice_logistics_transport_int_id_vehicle_id", + "invoice_logistics", + "vehicle", + ["transport_int_id"], + ["vehicle_id"], + source_schema="a76", + referent_schema="a76", + ondelete="SET NULL", + ) + op.create_foreign_key( + "fk_a76_invoice_logistics_trailer_int_id_trailer_id", + "invoice_logistics", + "trailer", + ["trailer_int_id"], + ["trailer_id"], + source_schema="a76", + referent_schema="a76", + ondelete="SET NULL", + ) + + +def downgrade() -> None: + pass + diff --git a/backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py b/backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py new file mode 100644 index 00000000..2b5bc3f6 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_company_logo_s3_key_length.py @@ -0,0 +1,39 @@ +"""extend company.logo for S3 keys + +Revision ID: d4e5f6a7b8c9 +Revises: ca7d3c4e8b2a +Create Date: 2026-04-02 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, None] = "ca7d3c4e8b2a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "company", + "logo", + existing_type=sa.String(length=255), + type_=sa.String(length=512), + existing_nullable=True, + schema="a76", + ) + + +def downgrade() -> None: + op.alter_column( + "company", + "logo", + existing_type=sa.String(length=512), + type_=sa.String(length=255), + existing_nullable=True, + schema="a76", + ) diff --git a/backend/alembic/versions/ed63109b1ac8_merge_multiple_heads.py b/backend/alembic/versions/ed63109b1ac8_merge_multiple_heads.py new file mode 100644 index 00000000..b711d73b --- /dev/null +++ b/backend/alembic/versions/ed63109b1ac8_merge_multiple_heads.py @@ -0,0 +1,28 @@ +"""merge multiple heads + +Revision ID: ed63109b1ac8 +Revises: d4e5f6a7b8c9, e76_app_settings +Create Date: 2026-04-07 21:10:25.498270 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ed63109b1ac8' +down_revision: Union[str, Sequence[str], None] = ('d4e5f6a7b8c9', 'e76_app_settings') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/backend/api/v1/common/tenant_crud_routes.py b/backend/api/v1/common/tenant_crud_routes.py index 01c65047..2bb7dffe 100644 --- a/backend/api/v1/common/tenant_crud_routes.py +++ b/backend/api/v1/common/tenant_crud_routes.py @@ -149,7 +149,7 @@ class TenantCRUDRoutes( description="Page size", ), sort_by: Optional[str] = Query(None, description="Column to sort by"), - sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): @@ -226,7 +226,7 @@ class TenantCRUDRoutes( description="Page size", ), sort_by: Optional[str] = Query(None, description="Column to sort by"), - sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(self.db_dependency), current_user: Dict[str, Any] = Depends(self.auth_dependency), ): diff --git a/backend/api/v1/modules/a76/audit_log/router.py b/backend/api/v1/modules/a76/audit_log/router.py index 6bd03187..4339286f 100644 --- a/backend/api/v1/modules/a76/audit_log/router.py +++ b/backend/api/v1/modules/a76/audit_log/router.py @@ -1,19 +1,184 @@ """ Audit Log Router """ -from typing import List, Optional from datetime import date -from fastapi import APIRouter, Depends, Query, HTTPException +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import or_, desc, distinct from core.database import get_core_db -from core.security import get_current_user # Assuming this exists +from core.security import get_current_user, get_tenant_from_token +from core.storage_s3 import get_object_bytes, list_objects_tree, should_ensure_s3_bucket from .models import AuditLog -from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse +from .schemas import ( + AuditFileBreadcrumb, + AuditFileBrowserResponse, + AuditFileFolderItem, + AuditFileObjectItem, + AuditLogDetailResponse, + AuditLogListResponse, +) +from api.v1.modules.a76.general_catalogs.company.models import Company router = APIRouter() +_SEGMENT_LABELS = { + "tenants": "Espacio", + "companies": "Companias", + "users": "Usuarios", + "imports": "Importaciones", + "csv": "Archivos CSV", + "branding": "Logotipos", + "certificates": "Certificados", + "customs_brokers": "Agentes aduanales", + "keys": "Llaves", + "cove": "COVE", + "doda": "DODA", + "system": "Sistema", + "help": "Ayuda", +} + + +def _tenant_id_from_user(current_user: Dict[str, Any]) -> int: + tenant_id = get_tenant_from_token(current_user) or current_user.get("tenant_id") + if not tenant_id: + raise HTTPException(status_code=401, detail="User context is invalid") + return int(tenant_id) + + +def _normalize_relative_path(raw: Optional[str]) -> str: + if not raw: + return "" + val = raw.strip().strip("/") + if not val: + return "" + if ".." in val or "\\" in val: + raise HTTPException(status_code=400, detail="Invalid path") + parts = [p for p in val.split("/") if p] + for part in parts: + if part in (".", ".."): + raise HTTPException(status_code=400, detail="Invalid path segment") + return "/".join(parts) + + +def _tenant_prefix(tenant_id: int) -> str: + return f"tenants/{tenant_id}/" + + +def _relative_from_tenant_prefix(key: str, tenant_prefix: str) -> str: + if not key.startswith(tenant_prefix): + raise HTTPException(status_code=403, detail="Access denied to object key") + return key[len(tenant_prefix) :].strip("/") + + +def _companies_map(db: Session, tenant_id: int) -> Dict[str, str]: + rows = ( + db.query(Company.id, Company.name) + .filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) + .all() + ) + out: Dict[str, str] = {} + for company_id, company_name in rows: + if company_id is None: + continue + safe_name = (company_name or "").strip() + out[str(company_id)] = safe_name or "Compania" + return out + + +def _display_segment( + part: str, + prev_part: Optional[str], + company_names: Dict[str, str], + current_user_id: Optional[str] = None, + current_user_label: Optional[str] = None, +) -> str: + if prev_part == "companies": + return company_names.get(part, "Compania") + if prev_part == "users": + # Para carpetas de usuarios, mostrar un nombre amigable: + # - Si es el propio usuario actual, usar preferred_username/email/nombre. + # - Para otros IDs (UUIDs) mostrar un label genérico. + if current_user_id and part == str(current_user_id): + return (current_user_label or "").strip() or "Usuario" + return "Usuario" + if part in _SEGMENT_LABELS: + return _SEGMENT_LABELS[part] + # Evita exponer IDs puros en UI. + if part.isdigit(): + return "Elemento" + return part.replace("_", " ").strip().title() or "Elemento" + + +def _display_path( + rel_path: str, + company_names: Dict[str, str], + current_user_id: Optional[str] = None, + current_user_label: Optional[str] = None, +) -> str: + if not rel_path: + return "Raiz de archivos" + parts = [p for p in rel_path.split("/") if p] + labels: List[str] = [] + prev: Optional[str] = None + for part in parts: + labels.append( + _display_segment( + part, + prev, + company_names, + current_user_id=current_user_id, + current_user_label=current_user_label, + ) + ) + prev = part + return " / ".join(labels) + + +def _display_file_name(filename: str) -> str: + stem, dot, ext = filename.rpartition(".") + if not dot: + stem = filename + ext = "" + if stem.isdigit(): + return f"Archivo{f'.{ext}' if ext else ''}" + return filename + + +def _build_breadcrumbs( + rel_path: str, + company_names: Dict[str, str], + current_user_id: Optional[str] = None, + current_user_label: Optional[str] = None, +) -> List[AuditFileBreadcrumb]: + breadcrumbs: List[AuditFileBreadcrumb] = [ + AuditFileBreadcrumb(path="", display_name="Raiz de archivos") + ] + if not rel_path: + return breadcrumbs + parts = [p for p in rel_path.split("/") if p] + prev: Optional[str] = None + acc: List[str] = [] + for part in parts: + acc.append(part) + breadcrumbs.append( + AuditFileBreadcrumb( + path="/".join(acc), + display_name=_display_segment( + part, + prev, + company_names, + current_user_id=current_user_id, + current_user_label=current_user_label, + ), + ) + ) + prev = part + return breadcrumbs + @router.get("/bitacora", response_model=AuditLogListResponse) async def get_bitacora( page: int = Query(1, ge=1), @@ -91,3 +256,127 @@ async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)): if not log: raise HTTPException(status_code=404, detail="Log entry not found") return log + + +@router.get("/files", response_model=AuditFileBrowserResponse) +async def list_tenant_files( + path: Optional[str] = Query(default="", description="Ruta relativa de navegación."), + continuation_token: Optional[str] = Query(default=None), + max_keys: int = Query(default=100, ge=1, le=500), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Explorador de archivos de solo lectura para Auditoría. + """ + if not should_ensure_s3_bucket(): + raise HTTPException(status_code=400, detail="S3 storage is disabled") + + tenant_id = _tenant_id_from_user(current_user) + tenant_prefix = _tenant_prefix(tenant_id) + rel_path = _normalize_relative_path(path) + list_prefix = f"{tenant_prefix}{rel_path}/" if rel_path else tenant_prefix + + # Datos del usuario actual para etiquetas amigables bajo /users/{id}/... + current_user_id = str(current_user.get("sub") or "") + current_user_label = ( + (current_user.get("preferred_username") or "").strip() + or (current_user.get("name") or "").strip() + or (current_user.get("email") or "").strip() + or "Usuario" + ) + + data = list_objects_tree( + prefix=list_prefix, + delimiter="/", + max_keys=max_keys, + continuation_token=continuation_token, + ) + + company_names = _companies_map(db, tenant_id) + folders: List[AuditFileFolderItem] = [] + for prefix in data.get("prefixes", []): + rel = _relative_from_tenant_prefix(prefix, tenant_prefix) + folders.append( + AuditFileFolderItem( + path=rel, + # Para el gestor de archivos mostramos el nombre amigable del último segmento + # (empresa, usuario actual, etc.), no el ID bruto. + display_name=_display_path( + rel, + company_names, + current_user_id=current_user_id, + current_user_label=current_user_label, + ).split(" / ")[-1], + ) + ) + + files: List[AuditFileObjectItem] = [] + for obj in data.get("objects", []): + key = obj.get("key") + if not key: + continue + rel = _relative_from_tenant_prefix(key, tenant_prefix) + name = rel.rsplit("/", 1)[-1] + files.append( + AuditFileObjectItem( + path=rel, + display_name=_display_file_name(name), + size=int(obj.get("size", 0) or 0), + last_modified=obj.get("last_modified"), + ) + ) + + return AuditFileBrowserResponse( + current_path=rel_path, + display_path=_display_path( + rel_path, + company_names, + current_user_id=current_user_id, + current_user_label=current_user_label, + ), + breadcrumbs=_build_breadcrumbs( + rel_path, + company_names, + current_user_id=current_user_id, + current_user_label=current_user_label, + ), + folders=sorted(folders, key=lambda x: x.display_name.lower()), + files=sorted(files, key=lambda x: x.display_name.lower()), + next_token=data.get("next_continuation_token"), + ) + + +@router.get("/files/download") +async def download_tenant_file( + path: str = Query(..., description="Ruta relativa del archivo a descargar."), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Descarga segura (backend streaming) de archivos autorizados. + """ + if not should_ensure_s3_bucket(): + raise HTTPException(status_code=400, detail="S3 storage is disabled") + + tenant_id = _tenant_id_from_user(current_user) + tenant_prefix = _tenant_prefix(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") + + object_key = f"{tenant_prefix}{rel_path}" + if not object_key.startswith(tenant_prefix): + raise HTTPException(status_code=403, detail="Access denied to object key") + + try: + body = get_object_bytes(object_key) + except Exception as e: + raise HTTPException(status_code=404, detail=f"File not found: {e}") from e + + filename = rel_path.rsplit("/", 1)[-1] + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + return StreamingResponse( + iter([body]), + media_type="application/octet-stream", + headers=headers, + ) diff --git a/backend/api/v1/modules/a76/audit_log/schemas.py b/backend/api/v1/modules/a76/audit_log/schemas.py index bd803974..ae1ad0e0 100644 --- a/backend/api/v1/modules/a76/audit_log/schemas.py +++ b/backend/api/v1/modules/a76/audit_log/schemas.py @@ -50,3 +50,29 @@ class AuditLogListResponse(BaseModel): total: int page: int page_size: int + + +class AuditFileBreadcrumb(BaseModel): + path: str = Field(default="") + display_name: str + + +class AuditFileFolderItem(BaseModel): + path: str = Field(description="Ruta relativa interna del archivo, para navegación.") + display_name: str + + +class AuditFileObjectItem(BaseModel): + path: str = Field(description="Ruta relativa interna del archivo, para descarga.") + display_name: str + size: int + last_modified: Optional[datetime] = None + + +class AuditFileBrowserResponse(BaseModel): + current_path: str = Field(default="") + display_path: str = Field(default="") + breadcrumbs: List[AuditFileBreadcrumb] + folders: List[AuditFileFolderItem] + files: List[AuditFileObjectItem] + next_token: Optional[str] = None diff --git a/backend/api/v1/modules/a76/customs_brokers/routes.py b/backend/api/v1/modules/a76/customs_brokers/routes.py index 960fb1f9..8635ff2e 100644 --- a/backend/api/v1/modules/a76/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/customs_brokers/routes.py @@ -1,16 +1,69 @@ -from typing import Dict, Any -from fastapi import APIRouter, Depends, HTTPException, Query +import logging +import mimetypes +import os +from datetime import datetime +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from sqlalchemy.orm import Session +from core.config import settings from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +from core.s3_keys import ( + customs_broker_vu_certificate_key, + customs_broker_vu_cove_key, + customs_broker_vu_doda_certificate_key, + customs_broker_vu_doda_cove_key, + customs_broker_vu_doda_private_key_key, + customs_broker_vu_private_key_key, +) +from core.security import get_current_user, get_tenant_from_token, validate_access_to_resource +from core.storage_s3 import delete_object_if_exists, put_object_bytes from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from . import dto, services from ..layouts_csv.customs_brokers.routes import router as imports_router +logger = logging.getLogger(__name__) + router = APIRouter() +MAX_VU_CER_KEY_BYTES = 5 * 1024 * 1024 # 5 MB +MAX_COVE_BYTES = 15 * 1024 * 1024 # 15 MB (xml/zip) + + +def _resolve_tenant_id_int(current_user: dict) -> int: + tid = get_tenant_from_token(current_user) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if isinstance(raw, list) and raw: + raw = raw[0] + if raw is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant ID not found in user data", + ) + try: + return int(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant ID in token", + ) + + +def _remove_stored_vu_path(ref: Optional[str]) -> None: + if not ref: + return + if ref.startswith("tenants/"): + delete_object_if_exists(ref) + elif os.path.isfile(ref): + try: + os.remove(ref) + except OSError: + pass + # CSV import (mismo flujo que a76.imports: upload → scan → commit) router.include_router(imports_router, prefix="/customs-brokers/imports", tags=["customs_brokers / csv_import"]) @@ -112,4 +165,246 @@ def update_customs_broker_personnel( raise HTTPException( status_code=404, detail="Customs Broker Personnel not found" ) - return updated_personnel \ No newline at end of file + return updated_personnel + + +@router.post( + "/customs-brokers/{broker_key}/vu/upload", + summary="Sube VU/DODA (CER, KEY, COVE) al bucket bajo tenants/.../customs_brokers/{id}/...", +) +async def upload_customs_broker_vu_file( + broker_key: str, + file_kind: str = Query( + ..., + description=( + "certificate (.cer), key (.key), cove (xml/zip/txt/pdf/json), " + "doda_certificate (.cer), doda_key (.key), doda_cove (xml/zip/txt/pdf/json)" + ), + ), + company_id: int = Query(..., description="Company ID"), + file: UploadFile = File(...), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Persiste el archivo bajo la misma jerarquía que logos/avatares (tenant/company/...). + Guarda la clave S3 o ruta local en: + - VU: certificate_path, key_path, xml_files_path + - DODA: doda_certificate_path, doda_key_path, doda_xml_files_path + """ + validate_access_to_resource(db, company_id, current_user) + tenant_id = _resolve_tenant_id_int(current_user) + + broker = services.CustomsBrokerService.get_by_id(db, broker_key, tenant_id, company_id) + if not broker: + raise HTTPException(status_code=404, detail="Customs Broker not found") + + fk = file_kind.lower().strip() + if fk not in ( + "certificate", + "key", + "cove", + "doda_certificate", + "doda_key", + "doda_cove", + ): + raise HTTPException( + status_code=400, + detail=( + "file_kind must be certificate, key, cove, " + "doda_certificate, doda_key, or doda_cove" + ), + ) + + content = await file.read() + max_bytes = MAX_COVE_BYTES if fk in ("cove", "doda_cove") else MAX_VU_CER_KEY_BYTES + if len(content) > max_bytes: + raise HTTPException( + status_code=400, + detail=f"File too large (max {max_bytes // (1024 * 1024)} MB)", + ) + + file_ext = os.path.splitext(file.filename or "")[1].lower() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + vu = services.CustomsBrokerVUService.ensure_vu_for_broker(db, broker) + broker_id = broker.id + + field_name: str + stored: str + + try: + if settings.use_s3_object_storage: + if fk == "certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="certificate must be .cer") + key = customs_broker_vu_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/x-x509-ca-cert" + field_name = "certificate_path" + elif fk == "key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="key must be .key") + key = customs_broker_vu_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/pkcs8" + field_name = "key_path" + elif fk == "cove": + key = customs_broker_vu_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "cove.xml", + ) + ct = ( + file.content_type + or mimetypes.guess_type(file.filename or "")[0] + or "application/octet-stream" + ) + field_name = "xml_files_path" + elif fk == "doda_certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="doda_certificate must be .cer") + key = customs_broker_vu_doda_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/x-x509-ca-cert" + field_name = "doda_certificate_path" + elif fk == "doda_key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="doda_key must be .key") + key = customs_broker_vu_doda_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + ct = "application/pkcs8" + field_name = "doda_key_path" + else: + key = customs_broker_vu_doda_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "doda.xml", + ) + ct = ( + file.content_type + or mimetypes.guess_type(file.filename or "")[0] + or "application/octet-stream" + ) + field_name = "doda_xml_files_path" + + old = getattr(vu, field_name) + _remove_stored_vu_path(old) + put_object_bytes(key, content, content_type=ct) + logger.info( + "Customs broker VU upload kind=%s key=%s bytes=%s", + fk, + key, + len(content), + ) + stored = key + else: + base = os.path.join( + "uploads", "customs_brokers", str(company_id), str(broker_id) + ) + if fk == "certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="certificate must be .cer") + key = customs_broker_vu_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "certificate_path" + subdir = "certificates" + elif fk == "key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="key must be .key") + key = customs_broker_vu_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "key_path" + subdir = "keys" + elif fk == "cove": + try: + key = customs_broker_vu_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "cove.xml", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + field_name = "xml_files_path" + subdir = "cove" + elif fk == "doda_certificate": + if file_ext != ".cer": + raise HTTPException(status_code=400, detail="doda_certificate must be .cer") + key = customs_broker_vu_doda_certificate_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "doda_certificate_path" + subdir = "doda/certificates" + elif fk == "doda_key": + if file_ext != ".key": + raise HTTPException(status_code=400, detail="doda_key must be .key") + key = customs_broker_vu_doda_private_key_key( + tenant_id, company_id, broker_id, timestamp, file_ext + ) + field_name = "doda_key_path" + subdir = "doda/keys" + else: + try: + key = customs_broker_vu_doda_cove_key( + tenant_id, + company_id, + broker_id, + timestamp, + file.filename or "doda.xml", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + field_name = "doda_xml_files_path" + subdir = "doda/cove" + + fname = key.rsplit("/", 1)[-1] + dest_dir = os.path.join(base, subdir) + os.makedirs(dest_dir, exist_ok=True) + path = os.path.join(dest_dir, fname) + old = getattr(vu, field_name) + _remove_stored_vu_path(old) + with open(path, "wb") as f: + f.write(content) + logger.info( + "Customs broker VU upload kind=%s path=%s bytes=%s", + fk, + path, + len(content), + ) + stored = path + + setattr(vu, field_name, stored) + db.add(vu) + db.commit() + db.refresh(vu) + except HTTPException: + db.rollback() + raise + except ValueError as e: + db.rollback() + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, detail=f"Error saving file: {str(e)}" + ) from e + + return { + "message": "File uploaded successfully", + "file_kind": fk, + "field": field_name, + "path": stored, + "broker_key": broker_key, + "company_id": company_id, + } \ No newline at end of file diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index 6e62e896..32ae8469 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -160,6 +160,26 @@ class CustomsBrokerVUService: db.commit() return vu + @staticmethod + def ensure_vu_for_broker(db: Session, broker: models.CustomsBroker) -> models.CustomsBrokerVU: + """Crea fila VU vacía si no existe (p. ej. antes de subir CER/KEY/COVE al bucket).""" + vu = ( + db.query(models.CustomsBrokerVU) + .filter(models.CustomsBrokerVU.customs_broker_id == broker.id) + .first() + ) + if vu: + return vu + vu = models.CustomsBrokerVU( + customs_broker_id=broker.id, + tenant_id=broker.tenant_id, + company_id=broker.company_id, + ) + db.add(vu) + db.commit() + db.refresh(vu) + return vu + class CustomsBrokerPersonnelService: @staticmethod diff --git a/backend/api/v1/modules/a76/general_catalogs/company/dto.py b/backend/api/v1/modules/a76/general_catalogs/company/dto.py index a83faf81..d96f582a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/dto.py @@ -57,7 +57,7 @@ class CompanyCreateDTO(BaseModel): ) # Configuration - logo: Optional[str] = Field(None, max_length=255, description="Company logo") + logo: Optional[str] = Field(None, max_length=512, description="Company logo (path local o clave S3)") has_express_line: Optional[bool] = Field(None, description="Has express line") order_format_type: Optional[str] = Field( None, max_length=19, description="Order format type" diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index 4914f791..739078ea 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -60,7 +60,7 @@ class Company(Base, TimestampMixin): position: Mapped[Optional[str]] = mapped_column(String(30)) # Configuración básica - logo: Mapped[Optional[str]] = mapped_column(String(255)) + logo: Mapped[Optional[str]] = mapped_column(String(512)) has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") order_format_type: Mapped[Optional[str]] = mapped_column(String(19)) is_service_company: Mapped[Optional[bool]] = mapped_column(Boolean, default=False, server_default="false") 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 5dff4d54..d6871d64 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -2,20 +2,23 @@ Rutas para gestión de empresa """ +import logging +import mimetypes import os import shutil from typing import List, Optional -import os -import shutil from pathlib import Path from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session +from core.config import settings from core.database import get_core_db -from core.security import get_current_user, validate_access_to_resource +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 get_current_user, get_tenant_from_token, validate_access_to_resource from .....common.tenant_crud_routes import TenantCRUDRoutes from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO from .models import Company @@ -26,6 +29,44 @@ UPLOAD_DIR = "uploads/companies" ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB +logger = logging.getLogger(__name__) + + +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) + if tid is not None: + return int(tid) + raw = current_user.get("tenant_id") + if isinstance(raw, list) and raw: + raw = raw[0] + if raw is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Tenant ID not found in user data", + ) + try: + return int(raw) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant ID in token", + ) + + +def _is_s3_object_key(ref: Optional[str]) -> bool: + return bool(ref and ref.startswith("tenants/")) + + +def _remove_stored_file(ref: str) -> None: + if _is_s3_object_key(ref): + delete_object_if_exists(ref) + elif ref and os.path.isfile(ref): + try: + os.remove(ref) + except OSError: + pass + # Main router that includes base CRUD router = APIRouter(prefix="/company") @@ -219,14 +260,20 @@ async def get_company_logo_image( if not company or not company.logo: raise HTTPException(status_code=404, detail="Logo not found") + if _is_s3_object_key(company.logo): + try: + data = get_object_bytes(company.logo) + except Exception: + raise HTTPException(status_code=404, detail="Logo file not found on server") + media = mimetypes.guess_type(company.logo)[0] or "image/jpeg" + return Response(content=data, media_type=media) + file_path = Path(company.logo) if not file_path.exists(): - # Fallback for old paths or moved files - # Check if it exists in the 'standard' location even if DB thinks otherwise standard_path = Path(f"app_data/logos/{company_id}") / file_path.name if standard_path.exists(): return FileResponse(standard_path) - + raise HTTPException(status_code=404, detail="Logo file not found on server") return FileResponse(file_path) @@ -272,12 +319,7 @@ async def upload_company_logo( current_user: dict = Depends(get_current_user), ): """Upload a logo for a company""" - tenant_id = current_user.get("tenant_id") - if not tenant_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", - ) + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe company = CompanyService.get_by_id(db, company_id, tenant_id, 0) @@ -303,35 +345,34 @@ async def upload_company_logo( detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB", ) - # Crear directorio si no existe - os.makedirs(UPLOAD_DIR, exist_ok=True) - - # Eliminar logo anterior si existe if company.logo: - old_logo_path = company.logo - if os.path.exists(old_logo_path): - try: - os.remove(old_logo_path) - except Exception: - pass # No es crítico si falla + _remove_stored_file(company.logo) - # Generar nombre único timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"company_{company_id}_{timestamp}{file_ext}" - file_path = os.path.join(UPLOAD_DIR, filename) + filename = f"logo_{company_id}_{timestamp}{file_ext}" - # Guardar archivo try: - await file.seek(0) - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) + if settings.use_s3_object_storage: + ct = mimetypes.guess_type(filename)[0] or "image/jpeg" + key = company_logo_key(tenant_id, company_id, filename) + put_object_bytes(key, content, content_type=ct) + logger.info( + "Company logo stored in S3 key=%s bytes=%s", + key, + len(content), + ) + file_path = key + else: + os.makedirs(UPLOAD_DIR, exist_ok=True) + file_path = os.path.join(UPLOAD_DIR, filename) + with open(file_path, "wb") as f: + f.write(content) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error saving file: {str(e)}", ) - # Actualizar la empresa con la ruta del logo update_data = CompanyUpdateDTO(logo=file_path) service = CompanyService(db) updated_company = service.update(db, company_id, tenant_id, 0, update_data) @@ -359,12 +400,7 @@ 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 """ - tenant_id = current_user.get("tenant_id") - if not tenant_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Tenant ID not found in user data", - ) + tenant_id = _resolve_tenant_id_int(current_user) # Validar que la empresa existe service = CompanyService(db) @@ -410,27 +446,38 @@ async def upload_company_certificate( detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB", ) - # Crear directorio si no existe - certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates") - os.makedirs(certs_dir, exist_ok=True) - - # Generar nombre único timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"{certificate_type}_{timestamp}{file_ext}" - file_path = os.path.join(certs_dir, filename) - # Guardar archivo try: - await file.seek(0) - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) + if settings.use_s3_object_storage: + key = company_certificate_key( + tenant_id, company_id, certificate_type, timestamp, file_ext + ) + ct = ( + "application/x-x509-ca-cert" + if file_ext == ".cer" + else "application/pkcs8" + ) + put_object_bytes(key, content, content_type=ct) + logger.info( + "Company certificate stored in S3 key=%s bytes=%s", + key, + len(content), + ) + file_path = key + else: + certs_dir = os.path.join(UPLOAD_DIR, str(company_id), "certificates") + os.makedirs(certs_dir, exist_ok=True) + filename = f"{certificate_type}_{timestamp}{file_ext}" + file_path = os.path.join(certs_dir, filename) + with open(file_path, "wb") as f: + f.write(content) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error saving file: {str(e)}", ) - # Actualizar la base de datos service.upload_certificate(company_id, certificate_type, file_path, tenant_id) return { diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 37158c12..29cc68e0 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -10,6 +10,10 @@ from fastapi import HTTPException from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from core.config import settings +from core.s3_keys import tenant_company_prefix +from core.storage_s3 import delete_objects_with_prefix + from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO from .models import Company from ...audit_log.services.service import AuditService @@ -594,9 +598,24 @@ class CompanyService: # ---------------------- try: + # 1) Borrado lógico en base de datos company.deleted_at = datetime.utcnow() - db.flush() + + # 2) Limpieza de objetos S3/MinIO asociados a la compañía + if settings.use_s3_object_storage: + try: + prefix = tenant_company_prefix(tenant_id, company_id) + delete_objects_with_prefix(prefix) + except Exception as e: + # No bloquear la eliminación lógica si falla la limpieza de objetos + logger.error( + "Error deleting S3 objects for company %s (tenant %s): %s", + company_id, + tenant_id, + e, + ) + db.commit() # --- Audit Log --- @@ -678,6 +697,11 @@ class CompanyService: try: if target_cert: + old_path = getattr(target_cert, field_to_update, None) + if old_path and str(old_path).startswith("tenants/"): + from core.storage_s3 import delete_object_if_exists + + delete_object_if_exists(str(old_path)) # Si existe, actualizamos setattr(target_cert, field_to_update, file_path) else: diff --git a/backend/api/v1/modules/a76/invoices/common/common_validators.py b/backend/api/v1/modules/a76/invoices/common/common_validators.py index 3f7dc48b..f4c0db4c 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -13,12 +13,19 @@ from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRat from api.v1.modules.a76.clients_and_providers.models import ClientProvider from api.v1.modules.a76.customs_brokers.models import CustomsBroker from api.v1.modules.a76.transportation.transporters.models import Transporter +from api.v1.modules.a76.transportation.vehicles.models import Vehicle +from api.v1.modules.a76.transportation.trailers.models import Trailer +from api.v1.modules.a76.transportation.drivers.models import Driver from api.v1.modules.a76.manifests.manifest.models import Manifest from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +def _logistics_str_nonempty(value) -> bool: + return value is not None and str(value).strip() != "" + + def _normalize_invoice_currency_value(value) -> str: """Lowercase currency code (foreign/local/manual) for comparisons.""" if value is None or value == "": @@ -344,25 +351,40 @@ def validate_common( code="INVALID_VALUE", value=invoice.compliance_mx.remesa, ) + else: + # Misma remesa puede repetirse en distintos pedimentos; solo debe ser única + # por (pedimento_id, empresa, tenant). Creación: existing_invoice es None. + current_invoice_id = None + if existing_invoice is not None: + current_invoice_id = existing_invoice.id + else: + current_invoice_id = getattr(invoice, "id", None) - duplicated_remesa = ( - db.query(InvoiceComplianceMx) - .filter( - InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa, - InvoiceComplianceMx.tenant_id == tenant_id, - InvoiceComplianceMx.company_id == company_id, - ) - .first() - ) - if duplicated_remesa and hasattr(invoice, "id"): - if invoice.id != duplicated_remesa.invoice_id: - errors.add_error( - field="compliance_mx.remesa", - message="El valor de Remesa ya está asociado a otro Pedimento.", - solution=["Proporciona un valor único para Remesa"], - code="DUPLICATE_VALUE", - value=invoice.compliance_mx.remesa, + duplicated_remesa = ( + db.query(InvoiceComplianceMx) + .filter( + InvoiceComplianceMx.remesa == invoice.compliance_mx.remesa, + InvoiceComplianceMx.pedimento_id + == invoice.compliance_mx.pedimento_id, + InvoiceComplianceMx.tenant_id == tenant_id, + InvoiceComplianceMx.company_id == company_id, + ) + .first() ) + if duplicated_remesa and ( + current_invoice_id is None + or duplicated_remesa.invoice_id != current_invoice_id + ): + errors.add_error( + field="compliance_mx.remesa", + message=( + "El valor de Remesa ya está registrado para este mismo Pedimento " + "en otra factura." + ), + solution=["Usa otra remesa o revisa la factura que ya la tiene capturada"], + code="DUPLICATE_VALUE", + value=invoice.compliance_mx.remesa, + ) # Financials checks (if provided) if invoice.financials: @@ -511,16 +533,31 @@ def validate_common( ) if invoice.logistics: + carrier_exists = None if invoice.logistics.carrier_id: - carrier_exists = ( - db.query(Transporter) - .filter( - Transporter.id == invoice.logistics.carrier_id, - Transporter.tenant_id == tenant_id, - Transporter.company_id == company_id, + # `carrier_id` is the frontend/export key (string). Prefer validating by the internal int + # when present, otherwise validate by `transporter_key`. + if getattr(invoice.logistics, "carrier_int_id", None) is not None: + carrier_exists = ( + db.query(Transporter) + .filter( + Transporter.transporter_id + == invoice.logistics.carrier_int_id, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() + ) + else: + carrier_exists = ( + db.query(Transporter) + .filter( + Transporter.transporter_key == invoice.logistics.carrier_id, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() ) - .first() - ) if not carrier_exists: errors.add_error( field="logistics.carrier_id", @@ -528,8 +565,36 @@ def validate_common( solution=["Verifica el ID del Transportista", "Revisa el catálogo"], code="NOT_FOUND", value=invoice.logistics.carrier_id, - ) - + ) + + if carrier_exists and _logistics_str_nonempty( + getattr(invoice.logistics, "driver_name", None) + ): + driver_row = ( + db.query(Driver) + .filter( + Driver.transporter_key == str(invoice.logistics.carrier_id).strip(), + Driver.driver_name + == str(invoice.logistics.driver_name).strip(), + Driver.tenant_id == tenant_id, + Driver.company_id == company_id, + ) + .first() + ) + if not driver_row: + errors.add_error( + field="logistics.driver_name", + message=( + "El conductor no existe en el catálogo de conductores " + "para el transportista indicado." + ), + solution=[ + "Registra el conductor en el catálogo o elige uno de la lista" + ], + code="NOT_FOUND", + value=invoice.logistics.driver_name, + ) + if invoice.logistics.transport_type not in [t.value for t in TransportType]: errors.add_error( field="logistics.transport_type", @@ -541,31 +606,138 @@ def validate_common( value=invoice.logistics.transport_type, ) else: - if ( - invoice.logistics.transport_type == "none" - and invoice.logistics.transport_num - ): - errors.add_error( - field="logistics.transport_num", - message="El Número de Transporte no debe proporcionarse cuando el Tipo de Transporte es 'none'.", - solution=[ - "Elimina el Número de Transporte o selecciona un Tipo de Transporte válido" - ], - code="INVALID_VALUE", - value=invoice.logistics.transport_num, - ) - else: - if ( - not invoice.logistics.transport_num - and invoice.logistics.transport_type != "none" - ): - errors.add_error( - field="logistics.transport_num", - message="El Número de Transporte es obligatorio cuando se proporciona un Tipo de Transporte distinto de 'none'.", - solution=["Proporciona un Número de Transporte válido"], - code="REQUIRED_FIELD", - value=invoice.logistics.transport_num, + # Vehículo: solo catálogo si viene informado (cualquier tipo de transporte). + tid = getattr(invoice.logistics, "transport_int_id", None) + tcode = invoice.logistics.transport_id + if tid is not None: + v_by_int = ( + db.query(Vehicle) + .filter( + Vehicle.vehicle_id == tid, + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, ) + .first() + ) + if not v_by_int: + errors.add_error( + field="logistics.transport_int_id", + message="El Vehículo (transporte) no existe en el catálogo.", + solution=[ + "Verifica el ID interno del vehículo o usa la clave de transporte" + ], + code="NOT_FOUND", + value=tid, + ) + elif _logistics_str_nonempty(tcode): + v_by_key = ( + db.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) + == str(tcode).strip().upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if not v_by_key: + errors.add_error( + field="logistics.transport_id", + message="El Vehículo (clave transporte) no existe en el catálogo.", + solution=["Verifica la clave en el catálogo de vehículos"], + code="NOT_FOUND", + value=tcode, + ) + + # Remolque: obligatorio si tipo != 'none'; con 'none' solo se valida catálogo si hay dato. + tr_int = getattr(invoice.logistics, "trailer_int_id", None) + tr_num = invoice.logistics.trailer_num + if invoice.logistics.transport_type != "none": + if tr_int is not None: + tr = ( + db.query(Trailer) + .filter( + Trailer.trailer_id == tr_int, + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_int_id", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el ID interno del remolque"], + code="NOT_FOUND", + value=tr_int, + ) + elif not _logistics_str_nonempty(tr_num): + errors.add_error( + field="logistics.trailer_num", + message=( + "El Remolque es obligatorio cuando el Tipo de Transporte es distinto de 'none'." + ), + solution=["Selecciona un remolque del catálogo"], + code="REQUIRED_FIELD", + value=tr_num, + ) + else: + tr = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(tr_num).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_num", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el número de remolque"], + code="NOT_FOUND", + value=tr_num, + ) + else: + if tr_int is not None: + tr = ( + db.query(Trailer) + .filter( + Trailer.trailer_id == tr_int, + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_int_id", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el ID interno del remolque"], + code="NOT_FOUND", + value=tr_int, + ) + elif _logistics_str_nonempty(tr_num): + tr = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(tr_num).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if not tr: + errors.add_error( + field="logistics.trailer_num", + message="El Remolque no existe en el catálogo.", + solution=["Verifica el número de remolque"], + code="NOT_FOUND", + value=tr_num, + ) if invoice.financials: submitted = invoice.financials.currency diff --git a/backend/api/v1/modules/a76/invoices/models.py b/backend/api/v1/modules/a76/invoices/models.py index a10be8c7..f6723b3e 100644 --- a/backend/api/v1/modules/a76/invoices/models.py +++ b/backend/api/v1/modules/a76/invoices/models.py @@ -563,9 +563,15 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): carrier_id: Mapped[Optional[str]] = mapped_column( String(10) ) # TRANSPORTISTA / Transportista + carrier_int_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("a76.transporter.transporter_id"), nullable=True + ) # Internal surrogate ID for Transporter (by transporter_key) transport_id: Mapped[Optional[str]] = mapped_column( String(10) ) # NUMTRAILER / Transportista + transport_int_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("a76.vehicle.vehicle_id"), nullable=True + ) # Internal surrogate ID for Vehicle (by vehicle_key) transport_us_id: Mapped[Optional[str]] = mapped_column( String(10) ) # TRANSPORTISTAAME / Transportista americano @@ -601,6 +607,9 @@ class InvoiceLogistics(Base, TenantScopedMixin, TimestampMixin): trailer_num: Mapped[Optional[str]] = mapped_column( String(20) ) # NUMTRAILER / Número de trailer + trailer_int_id: Mapped[Optional[int]] = mapped_column( + BigInteger, ForeignKey("a76.trailer.trailer_id"), nullable=True + ) # Internal surrogate ID for Trailer (by trailer_number) seal_number: Mapped[Optional[str]] = mapped_column( String(15) ) # PRECINTO / Precinto diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index 68b9c7a9..05b983fb 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -95,8 +95,11 @@ def list_invoices( invoice_type: str = Query(None, description="Filter by invoice type"), manifest_number: str = Query(None, description="Filter by manifest number"), pedimento: str = Query(None, description="Filter by pedimento"), + invoice_number: str = Query(None, description="Filter by invoice number"), + project_number: str = Query(None, description="Filter by project number"), + year: str = Query(None, description="Filter by year"), sort_by: Optional[str] = Query(None, description="Column to sort by"), - sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -109,12 +112,14 @@ def list_invoices( skip = (page - 1) * page_size filters = { - "invoice_number": search, + "invoice_number": invoice_number or search, "status": status, "operation_type": operation_type, "invoice_type": invoice_type, "manifest_number": manifest_number, "pedimento": pedimento, + "project_number": project_number, + "year": year, } # Remove None values diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index 46d0997a..e8df1724 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -299,9 +299,14 @@ class InvoiceFinancialsBase(BaseModel): class InvoiceLogisticsBase(BaseModel): """Base fields for Logistics""" - - carrier_id: Optional[str] = Field(None, max_length=10, description="Carrier ID") + carrier_id: Optional[str] = Field(None, max_length=10, description="Carrier code (front-end key)") + carrier_int_id: Optional[int] = Field( + None, description="Internal carrier integer ID (mapped from carrier_id key)" + ) transport_id: Optional[str] = Field(None, max_length=10, description="Transport ID") + transport_int_id: Optional[int] = Field( + None, description="Internal vehicle integer ID (mapped from transport_id key)" + ) transport_us_id: Optional[str] = Field( None, max_length=10, description="US transport ID" ) @@ -329,6 +334,9 @@ class InvoiceLogisticsBase(BaseModel): trailer_num: Optional[str] = Field( None, max_length=20, description="Trailer number" ) + trailer_int_id: Optional[int] = Field( + None, description="Internal trailer integer ID (mapped from trailer_num key)" + ) seal_number: Optional[str] = Field(None, max_length=15, description="Seal number") guide_number: Optional[str] = Field(None, max_length=20, description="Guide number") bill_number: Optional[str] = Field(None, max_length=15, description="Bill number") diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index f7706436..7a553114 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -11,10 +11,142 @@ from .exports.validators.create import validate_create as validate_create_export from .exports.validators.update import validate_update as validate_update_export from .common.common_validators import invoice_exists from api.v1.modules.a76.items.models import LineItem - from . import models, schemas +def _autofill_transport_int_ids( + db: Session, + logistics_target, + tenant_id: int, + company_id: int, +) -> None: + """ + Ensures invoice logistics internal int IDs are populated when catalog rows exist. + + String keys (carrier_id, transport_id, trailer_num) are matched to existing catalog + rows for the tenant/company; no placeholder rows are created. Invalid references must + be rejected by validate_common before persist. + """ + + # Local imports to avoid circular dependencies. + from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.a76.transportation.vehicles.models import Vehicle + from api.v1.modules.a76.transportation.trailers.models import Trailer + + def _get(field: str): + if isinstance(logistics_target, dict): + return logistics_target.get(field) + return getattr(logistics_target, field, None) + + def _set(field: str, value): + if isinstance(logistics_target, dict): + logistics_target[field] = value + else: + setattr(logistics_target, field, value) + + carrier_code = _get("carrier_id") + carrier_int_id = _get("carrier_int_id") + if carrier_code and carrier_int_id is None: + transporter_obj = ( + db.query(Transporter) + .filter( + Transporter.transporter_key == carrier_code, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() + ) + if transporter_obj: + _set("carrier_int_id", transporter_obj.transporter_id) + + transport_code = _get("transport_id") + transport_int_id = _get("transport_int_id") + if transport_code and transport_int_id is None: + vehicle_obj = ( + db.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) == str(transport_code).strip().upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if vehicle_obj: + _set("transport_int_id", vehicle_obj.vehicle_id) + + trailer_code = _get("trailer_num") + trailer_int_id = _get("trailer_int_id") + if trailer_code and trailer_int_id is None: + trailer_obj = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(trailer_code).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if trailer_obj: + _set("trailer_int_id", trailer_obj.trailer_id) + + # Placa: si no vino valor, tomar del vehículo y si no del remolque (catálogo) + lp = _get("license_plate") + if lp is None or str(lp).strip() == "": + veh = None + tv_id = _get("transport_int_id") + tv_code = _get("transport_id") + if tv_id is not None: + veh = ( + db.query(Vehicle) + .filter( + Vehicle.vehicle_id == tv_id, + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + elif tv_code: + veh = ( + db.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) == str(tv_code).strip().upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if veh and getattr(veh, "plate_number", None): + _set("license_plate", veh.plate_number) + else: + tr = None + tr_id = _get("trailer_int_id") + tr_code = _get("trailer_num") + if tr_id is not None: + tr = ( + db.query(Trailer) + .filter( + Trailer.trailer_id == tr_id, + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + elif tr_code: + tr = ( + db.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) + == str(tr_code).strip().upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if tr and getattr(tr, "plate_number", None): + _set("license_plate", tr.plate_number) + + def _get_current_username() -> str: """Helper to get current username from context or fallback to System""" try: @@ -126,46 +258,70 @@ class InvoiceService: # Apply filters if provided if filters: + # Join compliance_mx if needed for filters + needs_compliance_join = any(k in filters for k in ["pedimento", "manifest_number"]) + if needs_compliance_join: + query = query.join(models.InvoiceComplianceMx) + if filters.get("status") is not None: - status = models.InvoiceStatus.PROCESSED if filters["status"] == True else models.InvoiceStatus.PENDING - + status = ( + models.InvoiceStatus.PROCESSED + if filters["status"] == True + else models.InvoiceStatus.PENDING + ) query = query.filter(models.InvoiceHeader.status == status) + if filters.get("operation_type"): ot = filters["operation_type"] ot_val = ot.value if hasattr(ot, "value") else ot - query = query.filter( - models.InvoiceHeader.operation_type == ot_val - ) + query = query.filter(models.InvoiceHeader.operation_type == ot_val) + if filters.get("invoice_type"): query = query.filter( models.InvoiceHeader.invoice_type == filters["invoice_type"] ) + if filters.get("invoice_number"): query = query.filter( models.InvoiceHeader.invoice_number.ilike( f"%{filters['invoice_number']}%" ) ) - if filters.get("pedimento"): - query = query.join(models.InvoiceComplianceMx).filter( - models.InvoiceComplianceMx.pedimento.ilike( - f"%{filters['pedimento']}%" + + if filters.get("project_number"): + query = query.filter( + models.InvoiceHeader.project_number.ilike( + f"%{filters['project_number']}%" ) ) + + if filters.get("year"): + try: + year_val = int(filters["year"]) + query = query.filter( + func.extract("year", models.InvoiceHeader.invoice_date) == year_val + ) + except (ValueError, TypeError): + pass + + if filters.get("pedimento"): + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + query = query.join(models.InvoiceComplianceMx.pedimento).filter( + Pedimentos.pedimento_number.ilike(f"%{filters['pedimento']}%") + ) + + if filters.get("manifest_number"): + query = query.filter( + models.InvoiceComplianceMx.manifest_number.ilike( + f"%{filters['manifest_number']}%" + ) + ) + + # Special case for exports: exclude REPAR if no invoice_type specified ot_exp = filters.get("operation_type") ot_exp_val = ot_exp.value if hasattr(ot_exp, "value") else ot_exp if not filters.get("invoice_type") and ot_exp_val == "exp": - query = query.filter(models.InvoiceHeader.operation_type != "REPAR") - - if filters.get("manifest_number"): - # Avoid duplicate joins if pedimento filter was also applied (though rare in this context) - # For safety, we can just use the relationship attribute directly if mapped, - # but explicit join is clearer given the previous pattern. - # Assuming SQLAlchemy handles the join overlap or we just accept it for now. - # To be safe and consistent with previous 'pedimento' block: - query = query.join(models.InvoiceComplianceMx).filter( - models.InvoiceComplianceMx.manifest_number.ilike(f"%{filters['manifest_number']}%") - ) + query = query.filter(models.InvoiceHeader.invoice_type != "REPAR") # Apply sorting if sort_by: @@ -298,6 +454,12 @@ class InvoiceService: logistics_dict["invoice_id"] = new_invoice.id logistics_dict["tenant_id"] = tenant_id logistics_dict["company_id"] = company_id + _autofill_transport_int_ids( + db, + logistics_dict, + tenant_id=tenant_id, + company_id=company_id, + ) new_logistics = models.InvoiceLogistics(**logistics_dict) db.add(new_logistics) @@ -460,11 +622,23 @@ class InvoiceService: if value == "": value = None setattr(invoice.logistics, key, value) + _autofill_transport_int_ids( + db, + invoice.logistics, + tenant_id=tenant_id, + company_id=company_id, + ) else: logistics_dict = invoice_data.logistics.model_dump() logistics_dict["invoice_id"] = invoice.id logistics_dict["tenant_id"] = tenant_id logistics_dict["company_id"] = company_id + _autofill_transport_int_ids( + db, + logistics_dict, + tenant_id=tenant_id, + company_id=company_id, + ) new_logistics = models.InvoiceLogistics(**logistics_dict) db.add(new_logistics) diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index 4e9a83e4..3f8fb8a8 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -84,7 +84,7 @@ async def list_items( search: Optional[str] = Query( None, description="Search term for invoice number, reference, order, or guide"), sort_by: Optional[str] = Query(None, description="Column to sort by"), - sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -178,7 +178,7 @@ async def list_items_by_invoice( skip: int = Query(0, ge=0, description="Number of records to skip"), limit: int = Query(100, ge=1, le=1000, description="Maximum records to return"), sort_by: Optional[str] = Query(None, description="Column to sort by"), - sort_order: Optional[str] = Query("asc", regex="^(asc|desc)$", description="Sort order (asc or desc)"), + sort_order: Optional[str] = Query("asc", pattern="^(asc|desc)$", description="Sort order (asc or desc)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py index f99183d5..d86f62cf 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para BOMs. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -22,12 +20,14 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - BOM_IMPORT_FILE_PREFIX, + JOB_TYPE, BOM_IMPORT_META_PREFIX, BOM_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -66,31 +66,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{BOM_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=BOM_IMPORT_REDIS_TTL, - ) - r.set( - f"{BOM_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=BOM_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=BOM_IMPORT_REDIS_TTL, + log_label="BOMs import", ) + except common_storage.ImportStoreError as e: + logger.error(f"BOMs import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"BOMs import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"bom_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"bom_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"BOMs import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -127,12 +119,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("BOMs import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py b/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py index 99e9c6d4..9f249de4 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py @@ -62,8 +62,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv.iter_csv_rows(file_path): - if i % 500 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py index 9f8a4ccd..89147746 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Cambio de régimen y Regularización (encabezado y partidas). Flujo: upload → scan → status (polling) → commit. Sin validaciones ni inserción aún. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Literal, Optional, Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -22,6 +20,7 @@ from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit from .schemas import ImportJobResponse, CommitRequest from .tasks import scan_file, insert_valid_rows, JOB_TYPE, CRREG_IMPORT_REDIS_TTL from ..common import storage as common_storage +from ..common.responses import normalize_commit_status_payload from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -64,7 +63,6 @@ async def upload_import_file( job_id = str(uuid4()) contents = await file.read() - file_key, meta_key, _ = common_storage.storage_keys(JOB_TYPE, job_id) meta_data = { "tenant_id": tenant_id, "company_id": company_id, @@ -75,25 +73,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set(file_key, base64.b64encode(contents), ex=CRREG_IMPORT_REDIS_TTL) - r.set(meta_key, json.dumps(meta_data).encode("utf-8"), ex=CRREG_IMPORT_REDIS_TTL) + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CRREG_IMPORT_REDIS_TTL, + log_label="Cambio régimen/Regularización import", + ) + except common_storage.ImportStoreError as e: + logger.error("Cambio régimen/Regularización import: store error: %s", e) + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error("Cambio régimen/Regularización import: Redis store error: %s", e) raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - csv_path = common_storage.file_path_for_job(JOB_TYPE, job_id) - with open(csv_path, "wb") as f: - f.write(contents) - meta_path = csv_path.replace(".csv", ".meta.json") - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning("Cambio régimen/Regularización import: local file save failed: %s", e) - track_and_dispatch( db=db, task=scan_file, @@ -133,11 +129,11 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} if isinstance(getattr(task_result, "result", None), dict) and task_result.result.get("status") in ("finished", "warning"): - return task_result.result + return normalize_commit_status_payload(task_result.result) logger.warning("Cambio régimen/Regularización import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py index c06ce2b9..a0a6c545 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/tasks.py @@ -75,8 +75,7 @@ def scan_file(self, job_id: str, model_target: str, config: str = None): try: for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=None): - if i % 500 == 0: - on_progress(i, total_rows) + on_progress(i, total_rows) _norm_row(row, template_id) processed_rows += 1 except Exception as e: diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py index 53464802..2fa3347b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Clases de Materiales. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,19 +13,20 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - CLS_IMPORT_FILE_PREFIX, + JOB_TYPE, CLS_IMPORT_META_PREFIX, CLS_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -70,31 +70,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{CLS_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=CLS_IMPORT_REDIS_TTL, - ) - r.set( - f"{CLS_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=CLS_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CLS_IMPORT_REDIS_TTL, + log_label="Classes import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Classes import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Classes import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"cls_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"cls_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Classes import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -133,12 +125,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) # Si Celery devolvió el resultado como string (p. ej. JSON), parsear y devolver como scan si aplica if isinstance(result, str): @@ -150,7 +142,7 @@ async def get_import_status(job_id: str): ): return parsed if isinstance(parsed, dict) and parsed.get("status") in ("finished", "warning"): - return parsed + return normalize_commit_status_payload(parsed) except (json.JSONDecodeError, TypeError): pass diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py index a455ce8b..1d1abb43 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py @@ -84,8 +84,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames): - if i % 500 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py index fcc092dd..ef6453ec 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Clientes y Proveedores. Mismo flujo que a76.imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,19 +13,20 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - CP_IMPORT_FILE_PREFIX, + JOB_TYPE, CP_IMPORT_META_PREFIX, CP_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -69,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{CP_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=CP_IMPORT_REDIS_TTL, - ) - r.set( - f"{CP_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=CP_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CP_IMPORT_REDIS_TTL, + log_label="CP import", ) + except common_storage.ImportStoreError as e: + logger.error(f"CP import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"CP import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"cp_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"cp_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"CP import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -135,13 +127,13 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} # A veces Celery tiene el result disponible pero state aún no es SUCCESS; si el result es éxito, devolverlo result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("CP import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py index 21cb2e46..411bcc42 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py @@ -77,7 +77,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/responses.py b/backend/api/v1/modules/a76/layouts_csv/common/responses.py index 848a2a3c..cac67a28 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/responses.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/responses.py @@ -72,3 +72,36 @@ def commit_result( if error: out["error"] = error return out + + +def normalize_commit_status_payload(d: Dict[str, Any]) -> Dict[str, Any]: + """ + Homogeneiza respuestas de commit CSV (finished / warning) al contrato usado por pedimentos: + inserted, updated, skipped_*, skipped_details, skipped_summary, critical_reference_gaps, + reference_state_ready. No modifica waiting_confirmation ni otros status. + """ + if not isinstance(d, dict): + return d + st = d.get("status") + if st not in ("finished", "warning"): + return d + out = dict(d) + out.setdefault("inserted", 0) + out.setdefault("updated", 0) + out.setdefault("skipped_invalid", 0) + out.setdefault("skipped_missing_fk", 0) + out.setdefault("skipped_duplicate", 0) + out.setdefault("skipped_missing_invoice", 0) + details = out.get("skipped_details") + if not isinstance(details, list): + out["skipped_details"] = [] + else: + out["skipped_details"] = details + out.setdefault("critical_reference_gaps", 0) + if "reference_state_ready" not in out: + out["reference_state_ready"] = out.get("critical_reference_gaps", 0) == 0 + if "skipped_summary" not in out: + out["skipped_summary"] = ( + _summarize_reasons(out["skipped_details"], "reason") if out["skipped_details"] else [] + ) + return out diff --git a/backend/api/v1/modules/a76/layouts_csv/common/storage.py b/backend/api/v1/modules/a76/layouts_csv/common/storage.py index f2875d5c..f737441a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/storage.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/storage.py @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) IMPORT_REDIS_TTL = 3600 +class ImportStoreError(Exception): + """Fallo al guardar CSV en Redis/MinIO.""" + + pass + + def _get_redis(): import redis url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0")) @@ -60,23 +66,103 @@ def error_path_for_job(job_type: str, job_id: str) -> str: return os.path.join(error_dir(), f"{job_type}_{job_id}.jsonl") +def store_import_file( + job_type: str, + job_id: str, + raw_bytes: bytes, + meta_dict: dict, + tenant_id, + company_id: int, + ttl: int = IMPORT_REDIS_TTL, + log_label: str = "", +) -> None: + """ + Guarda CSV y meta en Redis. Si CSV_IMPORT_STORAGE=minio, sube el CSV a S3 y en Redis + guarda JSON {"v":2,"s3_key":...}; si no, base64 en Redis (comportamiento anterior). + En modo redis opcionalmente escribe copia local bajo layouts/imports/temp (debug). + """ + from core.config import settings + + file_key, meta_key, _ = storage_keys(job_type, job_id) + r = _get_redis() + meta_bytes = json.dumps(meta_dict).encode("utf-8") + + if settings.CSV_IMPORT_STORAGE == "minio": + from core.storage_s3 import put_csv_object, s3_key_for_csv_import + + key = s3_key_for_csv_import(tenant_id, company_id, job_type, job_id) + try: + put_csv_object(key, raw_bytes) + except Exception as e: + logger.exception("%s MinIO put failed: %s", log_label or job_type, e) + raise ImportStoreError(str(e)) from e + payload = json.dumps({"v": 2, "s3_key": key}).encode("utf-8") + r.set(file_key, payload, ex=ttl) + else: + r.set(file_key, base64.b64encode(raw_bytes), ex=ttl) + + r.set(meta_key, meta_bytes, ex=ttl) + + if settings.CSV_IMPORT_STORAGE != "minio": + try: + path = file_path_for_job(job_type, job_id) + os.makedirs(upload_dir(), exist_ok=True) + with open(path, "wb") as f: + f.write(raw_bytes) + meta_path = path.replace(".csv", ".meta.json") + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(meta_dict, f) + except Exception as e: + logger.warning("%s local file save failed: %s", log_label or job_type, e) + + def ensure_file_from_redis(job_type: str, job_id: str, log_prefix: str = "") -> Optional[str]: """ - Descarga contenido del CSV desde Redis y lo escribe en disco. + Obtiene el CSV desde Redis (referencia MinIO o base64 legacy) y lo escribe en disco. Devuelve la ruta del archivo o None si no hay datos o falla. """ + from core.config import settings + file_key, _, _ = storage_keys(job_type, job_id) r = _get_redis() data = r.get(file_key) if not data: return None + + path = file_path_for_job(job_type, job_id) + os.makedirs(upload_dir(), exist_ok=True) + + if data.startswith(b"{"): + try: + obj = json.loads(data.decode("utf-8")) + if isinstance(obj, dict) and obj.get("v") == 2 and obj.get("s3_key"): + if settings.CSV_IMPORT_STORAGE != "minio": + logger.warning( + "%s Redis has MinIO ref but CSV_IMPORT_STORAGE=%s", + log_prefix or job_type, + settings.CSV_IMPORT_STORAGE, + ) + from core.storage_s3 import get_object_bytes + + try: + raw = get_object_bytes(obj["s3_key"]) + except Exception as e: + logger.warning("%s MinIO get failed: %s", log_prefix or job_type, e) + return None + with open(path, "wb") as f: + f.write(raw) + return path + except json.JSONDecodeError: + pass + except Exception as e: + logger.warning("%s failed to parse MinIO ref from Redis: %s", log_prefix or job_type, e) + return None + try: raw = base64.b64decode(data) except Exception as e: logger.warning("%s failed to decode file from Redis: %s", log_prefix or job_type, e) return None - path = file_path_for_job(job_type, job_id) - os.makedirs(upload_dir(), exist_ok=True) with open(path, "wb") as f: f.write(raw) return path @@ -152,7 +238,31 @@ def cleanup_import_job( error_path: Optional[str] = None, meta_path: Optional[str] = None, ) -> None: - """Elimina archivos locales y claves Redis del job.""" + """Elimina archivos locales, objeto MinIO si aplica, y claves Redis del job.""" + from core.config import settings + + if settings.CSV_IMPORT_STORAGE == "minio": + from core.storage_s3 import delete_object_if_exists + from core.s3_keys import legacy_csv_import_key + + file_key, _, _ = storage_keys(job_type, job_id) + try: + r = _get_redis() + data = r.get(file_key) + deleted = False + if data and data.startswith(b"{"): + try: + obj = json.loads(data.decode("utf-8")) + if isinstance(obj, dict) and obj.get("v") == 2 and obj.get("s3_key"): + delete_object_if_exists(obj["s3_key"]) + deleted = True + except Exception as e: + logger.warning("Cleanup: parse Redis file ref: %s", e) + if not deleted: + delete_object_if_exists(legacy_csv_import_key(job_type, job_id)) + except Exception as e: + logger.warning("Cleanup: MinIO delete failed: %s", e) + if file_path and os.path.exists(file_path): try: os.remove(file_path) diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py index f0cebaaf..36534ef0 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Agentes Aduanales. Mismo flujo que a76.imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,19 +13,20 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - CB_IMPORT_FILE_PREFIX, + JOB_TYPE, CB_IMPORT_META_PREFIX, CB_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -69,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{CB_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=CB_IMPORT_REDIS_TTL, - ) - r.set( - f"{CB_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=CB_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=CB_IMPORT_REDIS_TTL, + log_label="CB import", ) + except common_storage.ImportStoreError as e: + logger.error(f"CB import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"CB import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"cb_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"cb_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"CB import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -135,13 +127,13 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} # A veces Celery tiene el result disponible pero state aún no es SUCCESS; si el result es éxito, devolverlo result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("CB import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py index 1ddb3e51..0c13b14f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py @@ -88,7 +88,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, fieldnames=CUSTOMS_BROKERS_FIELDNAMES_ORDER, headerless_first_cell_values=CUSTOMS_BROKERS_HEADERLESS_FIRST_CELL, ): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py index 4a155a6c..9d28625a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py @@ -2,7 +2,6 @@ Rutas de importacion CSV para Conductores. Flujo: upload -> scan -> status (polling) -> commit. """ -import base64 import json import logging import os @@ -15,19 +14,19 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from .schemas import ImportJobResponse from .tasks import ( run_scan_sync, run_commit_sync, - DRV_IMPORT_FILE_PREFIX, - DRV_IMPORT_META_PREFIX, + JOB_TYPE, DRV_IMPORT_STATUS_PREFIX, DRV_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -66,31 +65,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{DRV_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=DRV_IMPORT_REDIS_TTL, - ) - r.set( - f"{DRV_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=DRV_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=DRV_IMPORT_REDIS_TTL, + log_label="Drivers import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Drivers import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Drivers import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"drv_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"drv_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Drivers import: local file save failed: {e}") - def run_scan_background(): try: run_scan_sync(job_id) @@ -113,6 +104,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{DRV_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug(f"Drivers import: could not read status from Redis: {e}") @@ -131,12 +124,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Drivers import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py index f81a341b..c03e88a1 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py @@ -135,7 +135,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect) for i, row in enumerate(dict_reader, start=1): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py index db570733..0ac58d66 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Tipos de Cambio. Mismo flujo que customs_brokers/imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,19 +13,20 @@ from typing import Dict, Any, Optional from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - ER_IMPORT_FILE_PREFIX, + JOB_TYPE, ER_IMPORT_META_PREFIX, ER_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -80,31 +80,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{ER_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=ER_IMPORT_REDIS_TTL, - ) - r.set( - f"{ER_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=ER_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=ER_IMPORT_REDIS_TTL, + log_label="ER import", ) + except common_storage.ImportStoreError as e: + logger.error(f"ER import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"ER import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"er_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"er_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"ER import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -146,12 +138,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("ER import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py index c8f5521e..3ebf4bbd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py @@ -66,7 +66,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = _norm_row(row) diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py index dca65485..ec4f7fc9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Exportación (encabezado y partidas). Flujo: upload → scan → status (polling) → commit. Sin validaciones ni inserción aún. """ -import base64 import json import logging import os @@ -17,7 +16,6 @@ from typing import Literal, Optional, Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -25,6 +23,7 @@ from .schemas import ImportJobResponse, CommitRequest from .tasks import scan_file, insert_valid_rows, JOB_TYPE, EXP_IMPORT_REDIS_TTL from ..common import storage as common_storage from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -60,7 +59,6 @@ async def upload_import_file( job_id = str(uuid4()) contents = await file.read() - file_key, meta_key, _ = common_storage.storage_keys(JOB_TYPE, job_id) default_template = ( "exp_def_header" if model_target == "invoice_header" else "exp_def_series" if model_target == "invoice_series" @@ -82,25 +80,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set(file_key, base64.b64encode(contents), ex=EXP_IMPORT_REDIS_TTL) - r.set(meta_key, json.dumps(meta_data).encode("utf-8"), ex=EXP_IMPORT_REDIS_TTL) + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=EXP_IMPORT_REDIS_TTL, + log_label="Exportación import", + ) + except common_storage.ImportStoreError as e: + logger.error("Exportación import: store error: %s", e) + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error("Exportación import: Redis store error: %s", e) raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - csv_path = common_storage.file_path_for_job(JOB_TYPE, job_id) - with open(csv_path, "wb") as f: - f.write(contents) - meta_path = csv_path.replace(".csv", ".meta.json") - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning("Exportación import: local file save failed: %s", e) - track_and_dispatch( db=db, task=scan_file, @@ -138,11 +134,11 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} if isinstance(getattr(task_result, "result", None), dict) and task_result.result.get("status") in ("finished", "warning"): - return task_result.result + return normalize_commit_status_payload(task_result.result) logger.warning("Exportación import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py index 2895bf40..bbd06283 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/tasks.py @@ -74,8 +74,7 @@ def scan_file(self, job_id: str, model_target: str, config: str = None): processed_rows = 0 try: for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=None): - if i % 500 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows}) _norm_row(row, template_id) processed_rows += 1 except Exception as e: diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index c9ac5285..8cb0df25 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -1,6 +1,5 @@ from datetime import datetime from uuid import uuid4 -import base64 import os import json import logging @@ -14,19 +13,19 @@ from typing import Optional, Literal, Dict, Any from core.celery_app import celery_app from core.config import settings from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from .tasks import ( scan_file, insert_valid_rows, - IMPORT_FILE_KEY_PREFIX, + JOB_TYPE, IMPORT_META_KEY_PREFIX, IMPORT_REDIS_TTL, ) from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest from ..common import storage as common_storage +from ..common.responses import normalize_commit_status_payload from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit router = APIRouter() @@ -82,37 +81,25 @@ async def upload_import_file( "template_id": template_id, } - # Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed) try: - redis_client = _get_redis() - redis_client.set( - f"{IMPORT_FILE_KEY_PREFIX}{job_id}", - base64.b64encode(contents), - ex=IMPORT_REDIS_TTL, - ) - redis_client.set( - f"{IMPORT_META_KEY_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=IMPORT_REDIS_TTL, + log_label="Facturas import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Import store error: {e}") + raise HTTPException(status_code=500, detail="Failed to queue file for processing.") except Exception as e: logger.error(f"Redis store error: {e}") raise HTTPException(status_code=500, detail="Failed to queue file for processing.") - # Optional: also write to local disk (e.g. for same-machine worker or debugging) - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - file_path = os.path.join(upload_dir, f"{job_id}.csv") - meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") - with open(file_path, "wb") as f: - f.write(contents) - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Local file save failed (worker will use Redis): {e}") - - # Trigger Celery Task (Async). Worker loads file from Redis. + # Trigger Celery Task (Async). Worker loads file from Redis / MinIO. track_and_dispatch( db=db, task=scan_file, @@ -150,7 +137,7 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} # FAILURE: obtener mensaje real (traceback, result o get(propagate=False)) logger.warning("Import task %s failed: state=%s", job_id, task_result.state) diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index f1ad7d80..c364981e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -27,10 +27,20 @@ from ..common import storage as common_storage from ..common import meta as common_meta from ..common import responses as common_responses from .template_config import row_from_template +from .validators.encabezados_impo_temp import csv_tipo_moneda_es_me_mn_mc # Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process logger = logging.getLogger(__name__) + +def _merge_unique_invoice_scan_error_lines( + precheck_lines: Set[int], + row_validation_lines: List[int], +) -> List[int]: + """Unión deduplicada: líneas marcadas en precheck + líneas con error en validación por fila (imp_temp_header).""" + return sorted(set(precheck_lines) | set(row_validation_lines)) + + # Job type vacío para facturas (prefijo Redis "import_" sin tipo, ver common/storage.py) JOB_TYPE = "" @@ -618,8 +628,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "imp_def_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_impo_def( @@ -812,8 +821,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "exp_def_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_expo( @@ -994,8 +1002,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "cmex_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_impo_def( @@ -1149,8 +1156,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail: List[Dict[str, Any]] = [] error_lines_list: List[int] = [] for i, row in enumerate(reader, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)}) row_norm = row_from_template(row, "imp_temp_series", normalize_header) warnings_list: List[Dict[str, Any]] = [] row_errors = validate_row_series_impo_temp( @@ -1485,8 +1491,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "imp_temp_details", normalize_header) row_errors = validate_row_partidas_impo_temp( row_norm, @@ -1796,8 +1801,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "imp_def_details", normalize_header) row_errors = validate_row_partidas_impo_def( row_norm, @@ -2363,8 +2367,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "cmex_details", normalize_header) row_errors = validate_row_partidas_impo_def( row_norm, @@ -2453,6 +2456,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_pedimento_col_a, _pedimento_key_from_parsed, ) + from .validators.transport_catalog import logistics_scan_row_errors def _ped_key_from_row(ped_str: str) -> Optional[str]: parsed = parse_pedimento_col_a(ped_str) @@ -2781,7 +2785,31 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = } ) + # No return temprano: el commit siempre valida por fila (logística, etc.). Si solo se + # devolvía el precheck, valid_rows del scan quedaba inflado vs inserted/skipped del commit. + scan_precheck_message: Optional[str] = None if precheck_errors: + scan_precheck_message = ( + "Precheck de referencias falló. Corrige catálogos/pedimentos según los errores indicados. " + "Se aplicó también validación completa por fila (logística, etc.) para paridad con el commit." + ) + + processed_rows = 0 + error_lines_list: List[int] = [] + errors_detail: List[Dict[str, Any]] = [] + for e in precheck_errors: + if len(errors_detail) < 5000: + errors_detail.append( + { + "line": e["line"], + "col": e.get("col", ""), + "msg": e.get("msg", ""), + "solution": e.get("solution", ""), + "warning": False, + } + ) + + with CoreSessionLocal() as session: with open(error_path, "w", encoding="utf-8") as f_err: for e in precheck_errors: f_err.write( @@ -2795,96 +2823,95 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = ) + "\n" ) - unique_precheck_lines = sorted(precheck_lines) - common_storage.store_error_lines(effective_job_type, job_id, unique_precheck_lines) - return common_responses.scan_result( - job_id, - len(rows_list), - len(unique_precheck_lines), - precheck_errors, - total_rows_in_file=total_rows, - message="Precheck de referencias falló. Corrige catálogos/pedimentos antes de confirmar importación.", - ) - - error_count = 0 - processed_rows = 0 - error_lines_list: List[int] = [] - errors_detail: List[Dict[str, Any]] = [] - - with open(error_path, "w", encoding="utf-8") as f_err: - for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) - row_norm = row_from_template(row, "imp_temp_header", normalize_header) - warnings_row: List[Dict[str, Any]] = [] - row_errors = validate_row_encabezados_impo_temp( - row_norm, - i, - actualizar=actualizar, - invoice_exists_by_number=invoice_exists_by_number, - invoice_processed_by_number=invoice_processed_by_number, - pedimento_rows=pedimento_rows, - remesa_por_pedimento_bd=remesa_por_pedimento_bd, - remesa_por_pedimento_csv=remesa_por_pedimento_csv, - valid_provider_ids=valid_provider_ids, - valid_sold_to_ids=valid_sold_to_ids, - valid_shipped_to_ids=valid_shipped_to_ids, - valid_provider_short_names=valid_provider_short_names, - valid_sold_to_short_names=valid_sold_to_short_names, - valid_shipped_to_short_names=valid_shipped_to_short_names, - valid_broker_ids=valid_broker_ids, - valid_broker_claves=valid_broker_claves, - valid_transporter_keys=valid_transporter_keys, - valid_incoterms=valid_incoterms, - valid_aduana_codes=valid_aduana_codes, - valid_currency_codes=valid_currency_codes, - exchange_rate_by_date=exchange_rate_by_date, - invoice_has_partidas_by_number=invoice_has_partidas_by_number, - existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, - autonumerar_remesas=autonumerar_remesas, - control_remesa=control_remesa, - remesa_inicio=remesa_inicio, - remesa_fin=remesa_fin, - date_format=date_format, - parse_date_fn=parse_date, - warnings=warnings_row, - ) - blocking = [e for e in row_errors if not e.get("warning")] - if blocking: - error_count += 1 - error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( + for i, row in enumerate(rows_list, start=1): + self.update_state( + state="PROGRESS", + meta={ + "current": i, + "total": total_rows, + "errors": len( + _merge_unique_invoice_scan_error_lines( + precheck_lines, error_lines_list + ) + ), + }, + ) + row_norm = row_from_template(row, "imp_temp_header", normalize_header) + warnings_row: List[Dict[str, Any]] = [] + row_errors = validate_row_encabezados_impo_temp( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_processed_by_number=invoice_processed_by_number, + pedimento_rows=pedimento_rows, + remesa_por_pedimento_bd=remesa_por_pedimento_bd, + remesa_por_pedimento_csv=remesa_por_pedimento_csv, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + valid_broker_ids=valid_broker_ids, + valid_broker_claves=valid_broker_claves, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_aduana_codes=valid_aduana_codes, + valid_currency_codes=valid_currency_codes, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + autonumerar_remesas=autonumerar_remesas, + control_remesa=control_remesa, + remesa_inicio=remesa_inicio, + remesa_fin=remesa_fin, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) + blocking = [e for e in row_errors if not e.get("warning")] + if blocking: + error_lines_list.append(i) + for e in blocking: + f_err.write( + json.dumps( + { + "line": e["line"], + "col": e.get("col", ""), + "msg": e.get("msg", ""), + "solution": e.get("solution", ""), + } + ) + + "\n" + ) + for e in row_errors + warnings_row: + if len(errors_detail) < 5000: + errors_detail.append( { "line": e["line"], "col": e.get("col", ""), "msg": e.get("msg", ""), "solution": e.get("solution", ""), + "warning": bool(e.get("warning", False)), } ) - + "\n" - ) - for e in row_errors + warnings_row: - if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) - processed_rows += 1 + processed_rows += 1 - # El commit usa líneas únicas para omitir filas; el resumen preliminar - # debe usar la misma base para evitar discrepancias de válidos/errores. - unique_error_lines = sorted(set(error_lines_list)) + # El commit usa líneas únicas para omitir filas; unir precheck + validación por fila. + unique_error_lines = _merge_unique_invoice_scan_error_lines(precheck_lines, error_lines_list) error_count = len(unique_error_lines) common_storage.store_error_lines(effective_job_type, job_id, unique_error_lines) return common_responses.scan_result( - job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows + job_id, + processed_rows, + error_count, + errors_detail, + total_rows_in_file=total_rows, + message=scan_precheck_message, ) except Exception as e: logger.exception("Encabezados importación temporal scan failed: %s", e) @@ -2909,6 +2936,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_pedimento_col_a_impo_def, ) from .validators.encabezados_impo_temp import _pedimento_key_from_parsed + from .validators.transport_catalog import logistics_scan_row_errors DEF_INVOICE_TYPES = ("DEF", "MATDE", "EXDEF") @@ -3169,8 +3197,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "imp_def_header", normalize_header) warnings_row = [] row_errors = validate_row_encabezados_impo_def( @@ -3205,6 +3232,9 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_date_fn=parse_date, warnings=warnings_row, ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: error_count += 1 @@ -3260,6 +3290,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = from .validators.encabezados_expo import validate_row_encabezados_expo from .validators.encabezados_impo_temp import _pedimento_key_from_parsed from .validators.encabezados_impo_def import parse_pedimento_col_a_impo_def + from .validators.transport_catalog import logistics_scan_row_errors _fc = parse_footer_config(meta.get("footer_config")) actualizar = meta.get("actualizar", False) @@ -3542,8 +3573,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) + self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) row_norm = row_from_template(row, "exp_def_header", normalize_header) warnings_row = [] row_errors = validate_row_encabezados_expo( @@ -3582,6 +3612,9 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = parse_date_fn=parse_date, warnings=warnings_row, ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: error_count += 1 @@ -3630,6 +3663,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from api.v1.modules.a76.transportation.transporters.models import Transporter from .validators.encabezados_cmex import validate_row_encabezados_cmex + from .validators.transport_catalog import logistics_scan_row_errors _fc = parse_footer_config(meta.get("footer_config")) actualizar = meta.get("actualizar", False) @@ -3751,62 +3785,68 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = error_lines_list = [] errors_detail = [] - with open(error_path, "w", encoding="utf-8") as f_err: - for i, row in enumerate(rows_list, start=1): - if i % 1000 == 0: - self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}) - row_norm = row_from_template(row, "cmex_header", normalize_header) - warnings_row = [] - row_errors = validate_row_encabezados_cmex( - row_norm, - i, - actualizar=actualizar, - invoice_exists_by_number=invoice_exists_by_number, - invoice_processed_by_number=invoice_processed_by_number, - valid_provider_ids=valid_provider_ids, - valid_sold_to_ids=valid_sold_to_ids, - valid_shipped_to_ids=valid_shipped_to_ids, - valid_transporter_keys=valid_transporter_keys, - valid_incoterms=valid_incoterms, - valid_currency_codes=valid_currency_codes, - exchange_rate_by_date=exchange_rate_by_date, - invoice_has_partidas_by_number=invoice_has_partidas_by_number, - existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, - valid_provider_short_names=valid_provider_short_names, - valid_sold_to_short_names=valid_sold_to_short_names, - valid_shipped_to_short_names=valid_shipped_to_short_names, - date_format=date_format, - parse_date_fn=parse_date, - warnings=warnings_row, - ) - blocking = [e for e in row_errors if not e.get("warning")] - if blocking: - error_count += 1 - error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( + with CoreSessionLocal() as session: + with open(error_path, "w", encoding="utf-8") as f_err: + for i, row in enumerate(rows_list, start=1): + self.update_state( + state="PROGRESS", + meta={"current": i, "total": total_rows, "errors": error_count}, + ) + row_norm = row_from_template(row, "cmex_header", normalize_header) + warnings_row = [] + row_errors = validate_row_encabezados_cmex( + row_norm, + i, + actualizar=actualizar, + invoice_exists_by_number=invoice_exists_by_number, + invoice_processed_by_number=invoice_processed_by_number, + valid_provider_ids=valid_provider_ids, + valid_sold_to_ids=valid_sold_to_ids, + valid_shipped_to_ids=valid_shipped_to_ids, + valid_transporter_keys=valid_transporter_keys, + valid_incoterms=valid_incoterms, + valid_currency_codes=valid_currency_codes, + exchange_rate_by_date=exchange_rate_by_date, + invoice_has_partidas_by_number=invoice_has_partidas_by_number, + existing_tipo_moneda_by_number=existing_tipo_moneda_by_number, + valid_provider_short_names=valid_provider_short_names, + valid_sold_to_short_names=valid_sold_to_short_names, + valid_shipped_to_short_names=valid_shipped_to_short_names, + date_format=date_format, + parse_date_fn=parse_date, + warnings=warnings_row, + ) + row_errors.extend( + logistics_scan_row_errors(row_norm, session, tenant_id, company_id, i) + ) + blocking = [e for e in row_errors if not e.get("warning")] + if blocking: + error_count += 1 + error_lines_list.append(i) + for e in blocking: + f_err.write( + json.dumps( + { + "line": e["line"], + "col": e.get("col", ""), + "msg": e.get("msg", ""), + "solution": e.get("solution", ""), + } + ) + + "\n" + ) + for e in row_errors + warnings_row: + if len(errors_detail) < 5000: + errors_detail.append( { "line": e["line"], "col": e.get("col", ""), "msg": e.get("msg", ""), "solution": e.get("solution", ""), + "warning": bool(e.get("warning", False)), } ) - + "\n" - ) - for e in row_errors + warnings_row: - if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) - processed_rows += 1 + processed_rows += 1 common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( @@ -3898,12 +3938,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = for i, row in enumerate(reader, start=1): # Check for Progress Update - if i % 1000 == 0: - self.update_state(state='PROGRESS', meta={ - 'current': i, - 'total': total_rows, - 'errors': error_count - }) + self.update_state(state='PROGRESS', meta={ + 'current': i, + 'total': total_rows, + 'errors': error_count + }) # Solo columnas de la plantilla (respetar plantilla tal cual) row_norm = row_from_template(row, template_id, normalize_header) @@ -4016,6 +4055,8 @@ def validate_row_phase_1( def check_currency(col_name): val = row.get(col_name) if val and str(val).strip(): + if csv_tipo_moneda_es_me_mn_mc(str(val)): + return None parsed_currency = parse_currency(val, None) val_norm = normalize_header(val) # parse_currency returns MANUAL if unknown, so if it wasn't explicitly MANUAL, it's invalid @@ -4223,6 +4264,18 @@ def validate_row_strict( if err: return err + from .validators.transport_catalog import logistics_scan_row_errors + + log_errs = logistics_scan_row_errors( + row, + validator.session, + validator.tenant_id, + validator.company_id, + line_num, + ) + if log_errs: + return log_errs[0] + elif target == "invoice_details": invoice_number = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() if not invoice_number: @@ -4398,6 +4451,13 @@ def parse_currency(value: Optional[str], currency_type: Optional[str]): return Currency.FOREIGN if "MANUAL" in normalized: return Currency.MANUAL + # MC (moneda por clave): paridad con encabezados_impo_temp / InvoiceFinancials + CLAVE MONEDA + if normalized.replace(" ", "") == "MC": + if currency_type and str(currency_type).strip().upper() == "MXN": + return Currency.LOCAL + if currency_type: + return Currency.FOREIGN + return Currency.FOREIGN if currency_type and str(currency_type).strip().upper() == "MXN": return Currency.LOCAL if currency_type: @@ -5370,6 +5430,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt row_to_transport_type_clarion, _patente_from_agente_aduanal, ) + from .validators.transport_catalog import ( + normalize_transport_cell, + resolve_logistics_transport_fields_for_commit, + resolve_transport_csv_fields, + validate_csv_invoice_logistics_transport, + ) from .validators.encabezados_impo_def import parse_pedimento_col_a_impo_def from .validators.pedimento_resolution import resolve_pedimento_candidates @@ -5832,14 +5898,15 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt currency_val = row_norm.get('TIPO MONEDA') if currency_val and str(currency_val).strip(): - parsed_currency = parse_currency(currency_val, None) - val_norm = normalize_header(currency_val) - if parsed_currency.value == "manual" and "MANUAL" not in val_norm: - skipped_invalid += 1 - reason = "TIPO MONEDA: Moneda invalida" - skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) - logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") - continue + if not csv_tipo_moneda_es_me_mn_mc(str(currency_val)): + parsed_currency = parse_currency(currency_val, None) + val_norm = normalize_header(currency_val) + if parsed_currency.value == "manual" and "MANUAL" not in val_norm: + skipped_invalid += 1 + reason = "TIPO MONEDA: Moneda invalida" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue # 2. Client/Provider and broker checks are handled above @@ -6064,18 +6131,84 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt weight_type = parse_weight_unit(row_norm.get('TIPO PESO')) logistics = None - if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'): + transport_key_csv, trailer_num_csv, transport_num_csv = resolve_transport_csv_fields(row_norm) + has_logistics_data = bool( + weight_type + or row_norm.get('TIPO TRANSPORTE') + or row_norm.get('NUMERO TRANSPORTE') + or transport_key_csv + or trailer_num_csv + or row_norm.get('CLAVE TRANSPORTISTA') + or row_norm.get('NOMBRE CONDUCTOR') + ) + if has_logistics_data: raw_transport = row_norm.get('TIPO TRANSPORTE') transport_str = (row_to_transport_type_clarion(raw_transport) or str(raw_transport or "").strip().lower() or "none") try: transport_type = TransportType(transport_str) except ValueError: transport_type = TransportType.NONE + err_transport = validate_csv_invoice_logistics_transport( + row_norm=row_norm, + session=session, + tenant_id=tenant_id, + company_id=company_id, + transport_type=transport_type, + ) + if err_transport: + skipped_invalid += 1 + reason = err_transport + skipped_fk_details.append( + {"line": i, "invoice": invoice_number, "reason": reason} + ) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + transport_num_effective = (transport_num_csv or None) + if transport_type == TransportType.NONE: + transport_num_effective = None + + carrier_code = normalize_transport_cell(row_norm.get('CLAVE TRANSPORTISTA')) or None + carrier_int_id = None + if carrier_code: + from api.v1.modules.a76.transportation.transporters.models import ( + Transporter, + ) + + carrier_obj = ( + session.query(Transporter) + .filter( + Transporter.transporter_key == carrier_code, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() + ) + if carrier_obj: + carrier_int_id = carrier_obj.transporter_id + + ( + transport_id_value, + trailer_num_value, + transport_int_id, + trailer_int_id, + ) = resolve_logistics_transport_fields_for_commit( + session=session, + tenant_id=tenant_id, + company_id=company_id, + clave_transporte_raw=transport_key_csv, + numero_caja_raw=trailer_num_csv, + ) logistics = InvoiceLogistics( - carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None), + carrier_id=carrier_code, + carrier_int_id=carrier_int_id, driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), transport_type=transport_type, - transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), + transport_num=transport_num_effective, + transport_id=transport_id_value, + trailer_num=trailer_num_value, + transport_int_id=transport_int_id, + trailer_int_id=trailer_int_id, weight_type=weight_type or WeightUnit.KGS, seal_number=(row_norm.get('PRECINTO') or None), incoterm=(row_norm.get('CLAVE INCOTERM') or None), diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py index 711f4653..e972528c 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py @@ -29,6 +29,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, @@ -66,6 +68,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, @@ -102,6 +106,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, @@ -135,6 +141,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "CLAVE TRANSPORTISTA"}, {"canonical": "NOMBRE CONDUCTOR"}, {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "CLAVE TRANSPORTE"}, + {"canonical": "NUMERO CAJA"}, {"canonical": "NUMERO TRANSPORTE"}, {"canonical": "TIPO MONEDA"}, {"canonical": "CLAVE MONEDA"}, diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py index 320924d3..f46b639b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py @@ -51,18 +51,6 @@ def _validaciones_transporte_cmex(row: Dict[str, Any], line_num: int) -> Optiona "NUMERO TRANSPORTE", "Error: (Celda N{}) El Tipo de Transporte está vacío y está capturado un número de transporte.".format(line_num), ) - if m_raw and (m == "NINGUNO" or m_with_space == "NINGUNO") and n: - return _err( - line_num, - "NUMERO TRANSPORTE", - "Error: (Celda N{}) El Tipo de Transporte es NINGUNO y está capturado un número de transporte.".format(line_num), - ) - if m_raw and m != "NINGUNO" and m_with_space != "NINGUNO" and not n: - return _err( - line_num, - "NUMERO TRANSPORTE", - "Error: (Celda N{}) El Tipo de Transporte es {} y no está capturado el número de transporte.".format(line_num, m_raw), - ) return None diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py index c3213dad..bd6910ab 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py @@ -19,6 +19,16 @@ REGIMENES_VALIDOS = frozenset({"ITE", "ITR"}) TIPOS_MONEDA_VALIDOS = frozenset({"ME", "MN", "MC"}) TIPO_PESO_VALIDOS = frozenset({"KILOS", "LIBRAS"}) + +def csv_tipo_moneda_es_me_mn_mc(value: Any) -> bool: + """ + ME / MN / MC con la misma regla que _validaciones_moneda (strip + upper). + Compartido entre scan (encabezados) y commit / validate_row_strict en tasks. + """ + if value is None or not str(value).strip(): + return False + return str(value).strip().upper() in TIPOS_MONEDA_VALIDOS + # Clarion Col M → valor normalizado (minúscula para TransportType enum) TIPO_TRANSPORTE_CLARION_TO_NORM = { "NINGUNO": "none", @@ -344,18 +354,6 @@ def _validaciones_transporte(row: Dict[str, Any], line_num: int) -> Optional[Dic "NUMERO TRANSPORTE", f"Error: (Celda N{line_num}) El Tipo de Transporte está vacío y está capturado un número de transporte.", ) - if m == "NINGUNO" and n: - return _err( - line_num, - "NUMERO TRANSPORTE", - f"Error: (Celda N{line_num}) El Tipo de Transporte es NINGUNO y está capturado un número de transporte.", - ) - if m and m != "NINGUNO" and not n: - return _err( - line_num, - "NUMERO TRANSPORTE", - f"Error: (Celda N{line_num}) El Tipo de Transporte es {m} y no está capturado el número de transporte.", - ) return None diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py new file mode 100644 index 00000000..9f6151e3 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py @@ -0,0 +1,239 @@ +"""Mapeo y validación de transporte para CSV (paridad con facturas manuales / validate_common).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import TransportType + + +def normalize_transport_cell(raw: Optional[str]) -> str: + if raw is None: + return "" + return str(raw).replace("\u00a0", " ").strip() + + +def resolve_transport_csv_fields(row: dict) -> Tuple[str, str, str]: + """ + Prioridad de columnas: + 1) CLAVE TRANSPORTE / NUMERO CAJA + 2) NUMERO TRANSPORTE como fallback legacy. + """ + legacy = normalize_transport_cell(row.get("NUMERO TRANSPORTE")) + clave_transporte = normalize_transport_cell(row.get("CLAVE TRANSPORTE")) or legacy + numero_caja = normalize_transport_cell(row.get("NUMERO CAJA")) or legacy + numero_transporte = legacy + return clave_transporte, numero_caja, numero_transporte + + +def resolve_logistics_transport_fields_for_commit( + session: Session, + tenant_id: int, + company_id: int, + clave_transporte_raw: str, + numero_caja_raw: str, +) -> Tuple[Optional[str], Optional[str], Optional[int], Optional[int]]: + """ + Resuelve claves y IDs internos para logística: + - transport_id / transport_int_id a partir de Vehicle.vehicle_key + - trailer_num / trailer_int_id a partir de Trailer.trailer_number + """ + from api.v1.modules.a76.transportation.vehicles.models import Vehicle + from api.v1.modules.a76.transportation.trailers.models import Trailer + + clave_transporte = normalize_transport_cell(clave_transporte_raw) + numero_caja = normalize_transport_cell(numero_caja_raw) + + transport_id: Optional[str] = clave_transporte or None + trailer_num: Optional[str] = numero_caja or None + transport_int_id: Optional[int] = None + trailer_int_id: Optional[int] = None + + if transport_id: + vehicle = ( + session.query(Vehicle) + .filter( + func.upper(Vehicle.vehicle_key) == transport_id.upper(), + Vehicle.tenant_id == tenant_id, + Vehicle.company_id == company_id, + ) + .first() + ) + if vehicle is not None: + transport_int_id = vehicle.vehicle_id + + if trailer_num: + trailer = ( + session.query(Trailer) + .filter( + func.upper(Trailer.trailer_number) == trailer_num.upper(), + Trailer.tenant_id == tenant_id, + Trailer.company_id == company_id, + ) + .first() + ) + if trailer is not None: + trailer_int_id = trailer.trailer_id + + return transport_id, trailer_num, transport_int_id, trailer_int_id + + +def validate_csv_invoice_logistics_transport( + row_norm: dict, + session: Session, + tenant_id: int, + company_id: int, + transport_type: TransportType, +) -> Optional[str]: + """ + Paridad con validate_common (factura manual): + - Transportista y conductor: solo catálogo si vienen informados. + - Vehículo: solo catálogo si viene CLAVE TRANSPORTE explícita y no resuelve. + - Remolque: obligatorio (y catálogo) si tipo ≠ none; con none, solo catálogo si NUMERO CAJA explícito. + """ + from api.v1.modules.a76.transportation.transporters.models import Transporter + from api.v1.modules.a76.transportation.drivers.models import Driver + + carrier_code = normalize_transport_cell(row_norm.get("CLAVE TRANSPORTISTA")) + carrier_ok = False + if carrier_code: + tr_obj = ( + session.query(Transporter) + .filter( + Transporter.transporter_key == carrier_code, + Transporter.tenant_id == tenant_id, + Transporter.company_id == company_id, + ) + .first() + ) + if not tr_obj: + return "CLAVE TRANSPORTISTA no existe en el catálogo" + carrier_ok = True + + driver_raw = normalize_transport_cell(row_norm.get("NOMBRE CONDUCTOR")) + if carrier_ok and driver_raw: + dr = ( + session.query(Driver) + .filter( + Driver.transporter_key == carrier_code, + Driver.driver_name == driver_raw, + Driver.tenant_id == tenant_id, + Driver.company_id == company_id, + ) + .first() + ) + if not dr: + return ( + "NOMBRE CONDUCTOR: no existe en el catálogo de conductores " + "para el transportista indicado" + ) + + transport_key_csv, trailer_num_csv, _ = resolve_transport_csv_fields(row_norm) + ( + _transport_id_value, + trailer_num_resolved, + transport_int_id, + trailer_int_id, + ) = resolve_logistics_transport_fields_for_commit( + session=session, + tenant_id=tenant_id, + company_id=company_id, + clave_transporte_raw=transport_key_csv, + numero_caja_raw=trailer_num_csv, + ) + + explicit_clave = normalize_transport_cell(row_norm.get("CLAVE TRANSPORTE")) + if explicit_clave and transport_int_id is None: + return "CLAVE TRANSPORTE: vehículo no existe en el catálogo" + + explicit_caja = normalize_transport_cell(row_norm.get("NUMERO CAJA")) + if transport_type == TransportType.NONE: + if explicit_caja and trailer_int_id is None: + return "NUMERO CAJA: remolque no existe en el catálogo" + return None + + if trailer_int_id is None: + if not (trailer_num_resolved and str(trailer_num_resolved).strip()): + return ( + "NUMERO CAJA (remolque) obligatorio cuando TIPO TRANSPORTE es distinto de NINGUNO" + ) + return "NUMERO CAJA: remolque no existe en el catálogo" + + return None + + +def _csv_row_has_resolved_weight_unit(row_norm: dict) -> bool: + """ + Paridad con insert_valid_rows: has_logistics_data incluye parse_weight_unit(TIPO PESO). + Import diferido para evitar ciclo al cargar tasks. + """ + from api.v1.modules.a76.layouts_csv.facturas.tasks import parse_weight_unit + + return parse_weight_unit(row_norm.get("TIPO PESO")) is not None + + +def logistics_scan_row_errors( + row_norm: dict, + session: Session, + tenant_id: int, + company_id: int, + line_num: int, +) -> List[Dict[str, Any]]: + """ + Errores de scan (misma lógica que commit: validate_csv_invoice_logistics_transport). + Solo corre si la fila trae algún dato de logística de transporte. + """ + from .encabezados_impo_temp import row_to_transport_type_clarion + + transport_key_csv, trailer_num_csv, _ = resolve_transport_csv_fields(row_norm) + + def _cell(key: str) -> str: + return normalize_transport_cell(row_norm.get(key)) + + has_data = bool( + _cell("TIPO TRANSPORTE") + or _cell("NUMERO TRANSPORTE") + or _cell("CLAVE TRANSPORTE") + or transport_key_csv + or trailer_num_csv + or _cell("CLAVE TRANSPORTISTA") + or _cell("NOMBRE CONDUCTOR") + or _csv_row_has_resolved_weight_unit(row_norm) + ) + if not has_data: + return [] + + raw_transport = row_norm.get("TIPO TRANSPORTE") + transport_str = ( + row_to_transport_type_clarion(raw_transport) + or str(raw_transport or "").strip().lower() + or "none" + ) + try: + tt = TransportType(transport_str) + except ValueError: + tt = TransportType.NONE + + msg = validate_csv_invoice_logistics_transport( + row_norm=row_norm, + session=session, + tenant_id=tenant_id, + company_id=company_id, + transport_type=tt, + ) + if not msg: + return [] + return [ + { + "line": line_num, + "col": "LOGISTICA TRANSPORTE", + "msg": msg, + "solution": ( + "Verifica CLAVE TRANSPORTISTA, NOMBRE CONDUCTOR, CLAVE TRANSPORTE, " + "NUMERO CAJA y TIPO TRANSPORTE (remolque obligatorio si no es NINGUNO)." + ), + } + ] diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py index 5184073e..2c479dfb 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Números de Parte. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,7 +13,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -22,12 +20,14 @@ from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - PART_IMPORT_FILE_PREFIX, + JOB_TYPE, PART_IMPORT_META_PREFIX, PART_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -70,31 +70,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{PART_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=PART_IMPORT_REDIS_TTL, - ) - r.set( - f"{PART_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=PART_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=PART_IMPORT_REDIS_TTL, + log_label="Parts import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Parts import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Parts import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"part_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"part_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Parts import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -131,12 +123,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) # Si Celery devolvió el resultado como string (p. ej. JSON), parsear y devolver como scan si aplica if isinstance(result, str): @@ -148,7 +140,7 @@ async def get_import_status(job_id: str): ): return parsed if isinstance(parsed, dict) and parsed.get("status") in ("finished", "warning"): - return parsed + return normalize_commit_status_payload(parsed) except (json.JSONDecodeError, TypeError): pass diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py index 4c3088ed..6d2fe8c2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py @@ -94,8 +94,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames): - if i % 500 == 0: - self.update_state( + self.update_state( state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count}, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py index eda3ea86..0031f9de 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Pedimentos. Mismo flujo que customs_brokers/imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,11 +13,11 @@ from typing import Dict, Any, Optional from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, @@ -63,7 +62,6 @@ async def upload_import_file( job_id = str(uuid4()) contents = await file.read() - file_key, meta_key, _ = common_storage.storage_keys(PED_JOB_TYPE, job_id) meta_data: Dict[str, Any] = { "tenant_id": tenant_id, "company_id": company_id, @@ -75,33 +73,23 @@ async def upload_import_file( meta_data["dateFormat"] = dateFormat try: - r = _get_redis() - r.set( - file_key, - base64.b64encode(contents), - ex=PED_IMPORT_REDIS_TTL, - ) - r.set( - meta_key, - json.dumps(meta_data).encode("utf-8"), - ex=PED_IMPORT_REDIS_TTL, + common_storage.store_import_file( + PED_JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=PED_IMPORT_REDIS_TTL, + log_label="Pedimentos import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Pedimentos import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Pedimentos import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - csv_path = common_storage.file_path_for_job(PED_JOB_TYPE, job_id) - with open(csv_path, "wb") as f: - f.write(contents) - meta_path = csv_path.replace(".csv", ".meta.json") - with open(meta_path, "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Pedimentos import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -143,12 +131,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Pedimentos import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py index 1ac7aa76..5bd482a9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py @@ -99,7 +99,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path, fieldnames=fieldnames): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = _norm_row(row) diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py index c87adcaa..f9e2c3cf 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/common/fk_loader.py @@ -25,8 +25,8 @@ def load_trailers_fk_sets( Devuelve: - valid_trailer_type_keys: códigos de trailer_type (GTipoTrailer) - valid_country_ame: claves americana de países (GPaises.Pais_Ame), mayúsculas - - state_descriptions_upper: descripciones de estados en mayúsculas (GEstados) - - state_country_set: set de (ame_key_pais, description_estado_upper) para validar "estado pertenece a país" + - state_descriptions_upper: descripciones y mex_key en mayúsculas (GEstados) + - state_country_set: (ame_key_pais, token_upper) para description o mex_key - state_ame_to_description: dict clave_ame_upper -> description_upper (opcional; vacío si State no tiene ame_key) """ valid_trailer_type_keys: Set[str] = set() @@ -50,21 +50,35 @@ def load_trailers_fk_sets( valid_country_ame.add((row[0] or "").strip().upper()) for state in session.query(State).all(): + country = ( + session.query(Country) + .filter(Country.m3_key == state.m3_key) + .first() + ) + ame_country = None + if country and (country.ame_key or "").strip(): + ame_country = (country.ame_key or "").strip().upper() + desc = (state.description or "").strip() if desc: state_descriptions_upper.add(desc.upper()) - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() + if ame_country: + state_country_set.add((ame_country, desc.upper())) + + mex_key = (state.mex_key or "").strip() + if mex_key: + mk = mex_key.upper() + state_descriptions_upper.add(mk) + if ame_country: + state_country_set.add((ame_country, mk)) + if desc: + state_ame_to_description[mk] = desc.upper() + + ame_state = getattr(state, "ame_key", None) + if ame_state and (ame_state or "").strip(): + state_ame_to_description[(ame_state or "").strip().upper()] = ( + desc.upper() if desc else (ame_state or "").strip().upper() ) - if country and (country.ame_key or "").strip(): - state_country_set.add( - ((country.ame_key or "").strip().upper(), desc.upper()) - ) - ame = getattr(state, "ame_key", None) - if ame and (ame or "").strip(): - state_ame_to_description[(ame or "").strip().upper()] = desc.upper() if desc else (ame or "").strip().upper() except Exception as e: logger.warning("Trailers import: could not load FK sets: %s", e) diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py index f1543f78..fc46a9af 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Trailers y Cajas. Flujo: upload -> scan -> status (polling) -> commit. """ -import base64 import json import logging import os @@ -15,7 +14,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,12 +22,13 @@ from .tasks import ( scan_file, run_scan_sync, run_commit_sync, - TRL_IMPORT_FILE_PREFIX, - TRL_IMPORT_META_PREFIX, + JOB_TYPE, TRL_IMPORT_STATUS_PREFIX, TRL_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -70,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{TRL_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=TRL_IMPORT_REDIS_TTL, - ) - r.set( - f"{TRL_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=TRL_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=TRL_IMPORT_REDIS_TTL, + log_label="Trailers import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Trailers import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Trailers import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"trl_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"trl_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Trailers import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -132,6 +123,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{TRL_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug(f"Trailers import: could not read status from Redis: {e}") @@ -150,12 +143,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Trailers import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py index a920076b..78a3ec24 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py @@ -121,7 +121,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, dict_reader = csv.DictReader(f_in, fieldnames=headers, dialect=dialect) for i, row in enumerate(dict_reader, start=1): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py index 86ad4740..4e2f7f32 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/common/fk_loader.py @@ -24,8 +24,8 @@ def load_transportistas_fk_sets( Devuelve: - existing_transporter_keys: claves de transportistas existentes (tenant/company) en mayúsculas - valid_country_ame: claves americana de países (GPaises.Pais_Ame), mayúsculas - - state_descriptions_upper: descripciones de estados en mayúsculas (GEstados) - - state_country_set: set de (ame_key_pais, description_estado_upper) para validar estado pertenece a país + - state_descriptions_upper: descripciones y claves cortas (mex_key) de estados en mayúsculas (GEstados) + - state_country_set: set de (ame_key_pais, token_upper) para description o mex_key """ existing_transporter_keys: Set[str] = set() valid_country_ame: Set[str] = set() @@ -55,18 +55,27 @@ def load_transportistas_fk_sets( valid_country_ame.add((row[0] or "").strip().upper()) for state in session.query(State).all(): + country = ( + session.query(Country) + .filter(Country.m3_key == state.m3_key) + .first() + ) + ame = None + if country and (country.ame_key or "").strip(): + ame = (country.ame_key or "").strip().upper() + desc = (state.description or "").strip() if desc: state_descriptions_upper.add(desc.upper()) - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() - ) - if country and (country.ame_key or "").strip(): - state_country_set.add( - ((country.ame_key or "").strip().upper(), desc.upper()) - ) + if ame: + state_country_set.add((ame, desc.upper())) + + mex_key = (state.mex_key or "").strip() + if mex_key: + mk = mex_key.upper() + state_descriptions_upper.add(mk) + if ame: + state_country_set.add((ame, mk)) except Exception as e: logger.warning("Transportistas import: could not load FK sets: %s", e) diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py index 18671ddb..4191393b 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Transportistas. Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -15,7 +14,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,12 +22,13 @@ from .tasks import ( scan_file, run_scan_sync, run_commit_sync, - TRP_IMPORT_FILE_PREFIX, - TRP_IMPORT_META_PREFIX, + JOB_TYPE, TRP_IMPORT_STATUS_PREFIX, TRP_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -70,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{TRP_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=TRP_IMPORT_REDIS_TTL, - ) - r.set( - f"{TRP_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=TRP_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=TRP_IMPORT_REDIS_TTL, + log_label="Transportistas import", ) + except common_storage.ImportStoreError as e: + logger.error("Transportistas import: store error: %s", e) + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error("Transportistas import: Redis store error: %s", e) raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"trp_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"trp_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning("Transportistas import: local file save failed: %s", e) - track_and_dispatch( db=db, task=scan_file, @@ -132,6 +123,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{TRP_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug("Transportistas import: could not read status from Redis: %s", e) @@ -150,12 +143,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Transportistas import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py index a1c9ac00..09664a24 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py @@ -79,7 +79,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py index afb8c3bb..4e641f17 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Fracción Americana (US Tariff Fractions). Mismo flujo que exchange_rate/imports: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -14,19 +13,20 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit +from ..common.responses import normalize_commit_status_payload from .schemas import ImportJobResponse from .tasks import ( scan_file, insert_valid_rows, - FA_IMPORT_FILE_PREFIX, + JOB_TYPE, FA_IMPORT_META_PREFIX, FA_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream router = APIRouter() @@ -72,31 +72,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{FA_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=FA_IMPORT_REDIS_TTL, - ) - r.set( - f"{FA_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=FA_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=FA_IMPORT_REDIS_TTL, + log_label="FA import", ) + except common_storage.ImportStoreError as e: + logger.error(f"FA import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"FA import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"fa_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"fa_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"FA import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -138,12 +130,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("FA import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py index 1924e229..06d8d5f2 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py @@ -68,7 +68,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) warn = validate_row_desfase_fa(row, i) diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py index f76aa77d..1cf2b300 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/common/fk_loader.py @@ -24,8 +24,8 @@ def load_vehicles_fk_sets( Devuelve: - valid_transport_codes: códigos de transport_types (GTipoTransportes) - valid_country_ame: claves americana de países (GPaises.Pais_Ame), mayúsculas - - state_descriptions_upper: descripciones de estados en mayúsculas (GEstados), para "estado existe" - - state_country_set: set de (ame_key_pais, description_estado_upper) para validar "estado pertenece a país" + - state_descriptions_upper: descripciones y mex_key en mayúsculas (GEstados) + - state_country_set: (ame_key_pais, token_upper) para description o mex_key """ valid_transport_codes: Set[str] = set() valid_country_ame: Set[str] = set() @@ -46,21 +46,28 @@ def load_vehicles_fk_sets( if row[0]: valid_country_ame.add((row[0] or "").strip().upper()) - # States: description (GEstados.Descripcion); State.m3_key = Country.m3_key for state in session.query(State).all(): + country = ( + session.query(Country) + .filter(Country.m3_key == state.m3_key) + .first() + ) + ame = None + if country and (country.ame_key or "").strip(): + ame = (country.ame_key or "").strip().upper() + desc = (state.description or "").strip() if desc: state_descriptions_upper.add(desc.upper()) - # País para este estado vía m3_key - country = ( - session.query(Country) - .filter(Country.m3_key == state.m3_key) - .first() - ) - if country and (country.ame_key or "").strip(): - state_country_set.add( - ((country.ame_key or "").strip().upper(), desc.upper()) - ) + if ame: + state_country_set.add((ame, desc.upper())) + + mex_key = (state.mex_key or "").strip() + if mex_key: + mk = mex_key.upper() + state_descriptions_upper.add(mk) + if ame: + state_country_set.add((ame, mk)) except Exception as e: logger.warning("Vehicles import: could not load FK sets: %s", e) diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py index da98d135..aff5f682 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py @@ -2,7 +2,6 @@ Rutas de importación CSV para Vehículos (Transportes). Flujo: upload → scan → status (polling) → commit. """ -import base64 import json import logging import os @@ -15,7 +14,6 @@ from typing import Dict, Any from core.celery_app import celery_app from core.database import get_core_db -from core.paths import layout_path from core.security import get_current_user, validate_access_to_resource from api.v1.modules.core.tasks_tracking import track_and_dispatch @@ -24,12 +22,13 @@ from .tasks import ( scan_file, run_scan_sync, run_commit_sync, - VEHL_IMPORT_FILE_PREFIX, - VEHL_IMPORT_META_PREFIX, + JOB_TYPE, VEHL_IMPORT_STATUS_PREFIX, VEHL_IMPORT_REDIS_TTL, ) +from ..common import storage as common_storage from ..common.error_csv import download_scan_errors_csv_stream +from ..common.responses import normalize_commit_status_payload router = APIRouter() logger = logging.getLogger(__name__) @@ -70,31 +69,23 @@ async def upload_import_file( } try: - r = _get_redis() - r.set( - f"{VEHL_IMPORT_FILE_PREFIX}{job_id}", - base64.b64encode(contents), - ex=VEHL_IMPORT_REDIS_TTL, - ) - r.set( - f"{VEHL_IMPORT_META_PREFIX}{job_id}", - json.dumps(meta_data).encode("utf-8"), - ex=VEHL_IMPORT_REDIS_TTL, + common_storage.store_import_file( + JOB_TYPE, + job_id, + contents, + meta_data, + tenant_id=int(tenant_id), + company_id=company_id, + ttl=VEHL_IMPORT_REDIS_TTL, + log_label="Vehicles import", ) + except common_storage.ImportStoreError as e: + logger.error(f"Vehicles import: store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") except Exception as e: logger.error(f"Vehicles import: Redis store error: {e}") raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") - try: - upload_dir = layout_path("imports", "temp") - os.makedirs(upload_dir, exist_ok=True) - with open(os.path.join(upload_dir, f"veh_{job_id}.csv"), "wb") as f: - f.write(contents) - with open(os.path.join(upload_dir, f"veh_{job_id}.meta.json"), "w") as f: - json.dump(meta_data, f) - except Exception as e: - logger.warning(f"Vehicles import: local file save failed: {e}") - track_and_dispatch( db=db, task=scan_file, @@ -131,6 +122,8 @@ async def get_import_status(job_id: str): raw = r.get(f"{VEHL_IMPORT_STATUS_PREFIX}{job_id}") if raw: data = json.loads(raw.decode("utf-8")) + if isinstance(data, dict) and data.get("status") in ("finished", "warning"): + return normalize_commit_status_payload(data) return data except Exception as e: logger.debug(f"Vehicles import: could not read status from Redis: {e}") @@ -149,12 +142,12 @@ async def get_import_status(job_id: str): if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: - return result + return normalize_commit_status_payload(result) return {"status": "finished", "result": result} result = getattr(task_result, "result", None) if isinstance(result, dict) and result.get("status") in ("finished", "warning"): - return result + return normalize_commit_status_payload(result) logger.warning("Vehicles import task %s failed: state=%s", job_id, task_result.state) err_msg = None diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py index 9bebb21a..ad9608ed 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py @@ -92,7 +92,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str, try: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in common_csv_reader.iter_csv_rows(file_path): - if progress_callback and i % 500 == 0: + if progress_callback: progress_callback(i, total_rows, error_count) row_norm = row_from_template(row, common_normalize.normalize_header) diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index b35c8bc1..c90b81b1 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -298,7 +298,13 @@ class ConsolidadoImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) - if logistics.carrier_id: + if logistics.carrier_int_id is not None: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_id == logistics.carrier_int_id) + .first() + ) + elif logistics.carrier_id: transporter_obj = ( db.query(Transporter) .filter(Transporter.transporter_key == logistics.carrier_id) @@ -342,11 +348,18 @@ class ConsolidadoImportacionMexService: # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = ( - db.query(Vehicle) - .filter(Vehicle.vehicle_key == logistics.transport_id) - .first() - ) + if logistics.transport_int_id is not None: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == logistics.transport_int_id) + .first() + ) + else: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: placas_val = veh_obj.plate_number or placas_val elif ( @@ -362,11 +375,18 @@ class ConsolidadoImportacionMexService: # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = ( - db.query(Trailer) - .filter(Trailer.trailer_number == logistics.trailer_num) - .first() - ) + if logistics.trailer_int_id is not None: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_id == logistics.trailer_int_id) + .first() + ) + else: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py index a3949d07..d942d1ce 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/temporary/mex/service.py @@ -175,8 +175,16 @@ class ConsolidadoImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) - if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if logistics.carrier_int_id is not None: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_id == logistics.carrier_int_id) + .first() + ) + elif logistics.carrier_id: + transporter_obj = db.query(Transporter).filter( + Transporter.transporter_key == logistics.carrier_id + ).first() if transporter_obj: caat_val = transporter_obj.caat_code or "" scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC @@ -213,7 +221,16 @@ class ConsolidadoImportacionMexService: # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if logistics.transport_int_id is not None: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == logistics.transport_int_id) + .first() + ) + else: + veh_obj = db.query(Vehicle).filter( + Vehicle.vehicle_key == logistics.transport_id + ).first() if veh_obj: placas_val = veh_obj.plate_number or placas_val elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty @@ -223,7 +240,16 @@ class ConsolidadoImportacionMexService: # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if logistics.trailer_int_id is not None: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_id == logistics.trailer_int_id) + .first() + ) + else: + trl_obj = db.query(Trailer).filter( + Trailer.trailer_number == logistics.trailer_num + ).first() if trl_obj: placas_remolque_val = trl_obj.plate_number or "" diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 185589d0..300f462e 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -317,7 +317,17 @@ class FacturaImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) - if logistics.carrier_id: + if logistics.carrier_int_id is not None: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_id == logistics.carrier_int_id) + .first() + ) + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" + transportista_val = transporter_obj.name or logistics.carrier_id + elif logistics.carrier_id: transporter_obj = ( db.query(Transporter) .filter(Transporter.transporter_key == logistics.carrier_id) @@ -332,11 +342,18 @@ class FacturaImportacionMexService: # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = ( - db.query(Vehicle) - .filter(Vehicle.vehicle_key == logistics.transport_id) - .first() - ) + if logistics.transport_int_id is not None: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == logistics.transport_int_id) + .first() + ) + else: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: placas_val = veh_obj.plate_number or placas_val elif ( @@ -352,11 +369,18 @@ class FacturaImportacionMexService: # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = ( - db.query(Trailer) - .filter(Trailer.trailer_number == logistics.trailer_num) - .first() - ) + if logistics.trailer_int_id is not None: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_id == logistics.trailer_int_id) + .first() + ) + else: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py index 192dab8a..3d0b86d5 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/temporary/mex/service.py @@ -166,8 +166,16 @@ class FacturaImportacionMexService: if logistics: # 1. Transporter (CAAT / SCAC) - if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if logistics.carrier_int_id is not None: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_id == logistics.carrier_int_id) + .first() + ) + elif logistics.carrier_id: + transporter_obj = db.query(Transporter).filter( + Transporter.transporter_key == logistics.carrier_id + ).first() if transporter_obj: caat_val = transporter_obj.caat_code or "" scac_val = transporter_obj.transport_code or "" # Mapping transport_code to SCAC @@ -175,7 +183,16 @@ class FacturaImportacionMexService: # 2. Vehicle (Placas Tracto) - Try transport_id first if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if logistics.transport_int_id is not None: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == logistics.transport_int_id) + .first() + ) + else: + veh_obj = db.query(Vehicle).filter( + Vehicle.vehicle_key == logistics.transport_id + ).first() if veh_obj: placas_val = veh_obj.plate_number or placas_val elif logistics.vehicle_num: # Fallback to vehicle_num if populated and transport_id failed/empty @@ -185,7 +202,16 @@ class FacturaImportacionMexService: # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if logistics.trailer_int_id is not None: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_id == logistics.trailer_int_id) + .first() + ) + else: + trl_obj = db.query(Trailer).filter( + Trailer.trailer_number == logistics.trailer_num + ).first() if trl_obj: placas_remolque_val = trl_obj.plate_number or "" diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py index 9bd7c40d..8a4c1a91 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/usa/service.py @@ -317,7 +317,17 @@ class FacturaImportacionUsaService: if logistics: # 1. Transporter (CAAT / SCAC) - if logistics.carrier_id: + if logistics.carrier_int_id is not None: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_id == logistics.carrier_int_id) + .first() + ) + if transporter_obj: + caat_val = transporter_obj.caat_code or "" + scac_val = transporter_obj.transport_code or "" + transportista_val = transporter_obj.name or logistics.carrier_id + elif logistics.carrier_id: transporter_obj = ( db.query(Transporter) .filter(Transporter.transporter_key == logistics.carrier_id) @@ -325,18 +335,23 @@ class FacturaImportacionUsaService: ) if transporter_obj: caat_val = transporter_obj.caat_code or "" - scac_val = ( - transporter_obj.transport_code or "" - ) # Mapping transport_code to SCAC + scac_val = transporter_obj.transport_code or "" transportista_val = transporter_obj.name or logistics.carrier_id # 2. Vehicle (Plates) if logistics.transport_id: - veh_obj = ( - db.query(Vehicle) - .filter(Vehicle.vehicle_key == logistics.transport_id) - .first() - ) + if logistics.transport_int_id is not None: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == logistics.transport_int_id) + .first() + ) + else: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: placas_val = veh_obj.plate_number or placas_val elif logistics.vehicle_num: @@ -350,11 +365,18 @@ class FacturaImportacionUsaService: # 3. Trailer if logistics.trailer_num: - trl_obj = ( - db.query(Trailer) - .filter(Trailer.trailer_number == logistics.trailer_num) - .first() - ) + if logistics.trailer_int_id is not None: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_id == logistics.trailer_int_id) + .first() + ) + else: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" diff --git a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py index 8a74bbb3..822294ae 100644 --- a/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/packing_list/service.py @@ -186,8 +186,18 @@ class PackingListService: if logistics: # 1. Transporter (CAAT / SCAC) - if logistics.carrier_id: - transporter_obj = db.query(Transporter).filter(Transporter.transporter_key == logistics.carrier_id).first() + if logistics.carrier_int_id is not None: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_id == logistics.carrier_int_id) + .first() + ) + elif logistics.carrier_id: + transporter_obj = ( + db.query(Transporter) + .filter(Transporter.transporter_key == logistics.carrier_id) + .first() + ) if transporter_obj: caat_val = transporter_obj.caat_code or "" scac_val = transporter_obj.transport_code or "" @@ -195,7 +205,18 @@ class PackingListService: # 2. Vehicle (Placas Tracto) if logistics.transport_id: - veh_obj = db.query(Vehicle).filter(Vehicle.vehicle_key == logistics.transport_id).first() + if logistics.transport_int_id is not None: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == logistics.transport_int_id) + .first() + ) + else: + veh_obj = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == logistics.transport_id) + .first() + ) if veh_obj: placas_val = veh_obj.plate_number or placas_val elif logistics.vehicle_num: @@ -205,7 +226,18 @@ class PackingListService: # 3. Trailer (Placas Remolque) if logistics.trailer_num: - trl_obj = db.query(Trailer).filter(Trailer.trailer_number == logistics.trailer_num).first() + if logistics.trailer_int_id is not None: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_id == logistics.trailer_int_id) + .first() + ) + else: + trl_obj = ( + db.query(Trailer) + .filter(Trailer.trailer_number == logistics.trailer_num) + .first() + ) if trl_obj: placas_remolque_val = trl_obj.plate_number or "" diff --git a/backend/api/v1/modules/a76/transportation/drivers/dto.py b/backend/api/v1/modules/a76/transportation/drivers/dto.py index 02420ea8..e67daee0 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/dto.py +++ b/backend/api/v1/modules/a76/transportation/drivers/dto.py @@ -5,6 +5,7 @@ from pydantic import BaseModel class DriverBaseDTO(BaseModel): transporter_key: str + driver_id: Optional[int] = None line: int driver_name: Optional[str] = None license_number: Optional[str] = None diff --git a/backend/api/v1/modules/a76/transportation/drivers/models.py b/backend/api/v1/modules/a76/transportation/drivers/models.py index 7c70a9c3..dc6d7ee9 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/models.py +++ b/backend/api/v1/modules/a76/transportation/drivers/models.py @@ -1,6 +1,6 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base -from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String +from sqlalchemy import BigInteger, Column, ForeignKey, ForeignKeyConstraint, Integer, String class Driver(Base, TenantScopedMixin, TimestampMixin): @@ -16,6 +16,8 @@ class Driver(Base, TenantScopedMixin, TimestampMixin): nullable=False, ) line = Column(Integer, primary_key=True, nullable=False) + # Internal integer surrogate ID. The frontend continues to use transporter_key/line. + driver_id = Column(BigInteger, nullable=False, unique=True, index=True) driver_name = Column(String(80), nullable=True) license_number = Column(String(29), nullable=True) express_line_id = Column(String(17), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/drivers/services.py b/backend/api/v1/modules/a76/transportation/drivers/services.py index d9d9d261..66cc1976 100644 --- a/backend/api/v1/modules/a76/transportation/drivers/services.py +++ b/backend/api/v1/modules/a76/transportation/drivers/services.py @@ -1,9 +1,17 @@ from typing import List, Optional +from sqlalchemy import text from sqlalchemy.orm import Session from . import dto, models +DRIVER_ID_SEQ = "a76.driver_driver_id_seq" + + +def allocate_driver_id(db: Session) -> int: + """Next surrogate driver_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{DRIVER_ID_SEQ}')")).scalar() + class DriverService: @staticmethod @@ -34,7 +42,10 @@ class DriverService: @staticmethod def create_driver(db: Session, driver_data: dto.DriverCreateDTO): - new_driver = models.Driver(**driver_data.dict()) + data = driver_data.model_dump() + if data.get("driver_id") is None: + data["driver_id"] = allocate_driver_id(db) + new_driver = models.Driver(**data) db.add(new_driver) db.commit() db.refresh(new_driver) diff --git a/backend/api/v1/modules/a76/transportation/trailers/dto.py b/backend/api/v1/modules/a76/transportation/trailers/dto.py index 548f1d8f..662fb5b4 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/dto.py +++ b/backend/api/v1/modules/a76/transportation/trailers/dto.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field class TrailerBaseDTO(BaseModel): trailer_number: str = Field(..., description="Trailer number (primary identifier)") + trailer_id: Optional[int] = Field(None, description="Internal trailer integer ID") ace_trailer_number: Optional[str] = None trailer_type_key: Optional[str] = None seal: Optional[str] = None diff --git a/backend/api/v1/modules/a76/transportation/trailers/models.py b/backend/api/v1/modules/a76/transportation/trailers/models.py index d3b19e8b..19e7b1c9 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/models.py +++ b/backend/api/v1/modules/a76/transportation/trailers/models.py @@ -1,6 +1,6 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base -from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, String +from sqlalchemy import BigInteger, Column, ForeignKey, ForeignKeyConstraint, String class Trailer(Base, TenantScopedMixin, TimestampMixin): @@ -10,6 +10,8 @@ class Trailer(Base, TenantScopedMixin, TimestampMixin): ) trailer_number = Column(String(20), primary_key=True, nullable=False) + # Internal integer identifier (surrogate). The frontend continues to use trailer_number. + trailer_id = Column(BigInteger, nullable=False, unique=True, index=True) ace_trailer_number = Column(String(10), nullable=True) trailer_type_key = Column( String(2), ForeignKey("public.trailer_type.trailer_type_key"), nullable=True diff --git a/backend/api/v1/modules/a76/transportation/trailers/services.py b/backend/api/v1/modules/a76/transportation/trailers/services.py index 4ffb7c30..d82caa87 100644 --- a/backend/api/v1/modules/a76/transportation/trailers/services.py +++ b/backend/api/v1/modules/a76/transportation/trailers/services.py @@ -1,9 +1,17 @@ from typing import Optional, Tuple, List, Dict, Any from sqlalchemy.orm import Session +from sqlalchemy import text from . import dto, models +TRAILER_ID_SEQ = "a76.trailer_trailer_id_seq" + + +def allocate_trailer_id(db: Session) -> int: + """Next surrogate trailer_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{TRAILER_ID_SEQ}')")).scalar() + class TrailerService: """Service for Trailer CRUD operations with tenant support""" @@ -66,8 +74,11 @@ class TrailerService: company_id: int, ) -> models.Trailer: """Create a new trailer""" + data = trailer_data.model_dump() + if data.get("trailer_id") is None: + data["trailer_id"] = allocate_trailer_id(db) new_trailer = models.Trailer( - **trailer_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **data, tenant_id=tenant_id, company_id=company_id ) db.add(new_trailer) db.commit() diff --git a/backend/api/v1/modules/a76/transportation/transporters/dto.py b/backend/api/v1/modules/a76/transportation/transporters/dto.py index 06391a6e..c4397d00 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/dto.py +++ b/backend/api/v1/modules/a76/transportation/transporters/dto.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field class TransporterBaseDTO(BaseModel): transporter_key: str = Field(..., description="Transporter key (primary identifier)") + transporter_id: Optional[int] = Field(None, description="Internal transporter integer ID") name: Optional[str] = None short_name: Optional[str] = None responsible: Optional[str] = None diff --git a/backend/api/v1/modules/a76/transportation/transporters/models.py b/backend/api/v1/modules/a76/transportation/transporters/models.py index 9f555c79..58d03367 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/models.py +++ b/backend/api/v1/modules/a76/transportation/transporters/models.py @@ -1,6 +1,6 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base -from sqlalchemy import Column, ForeignKeyConstraint, String +from sqlalchemy import BigInteger, Column, ForeignKeyConstraint, String class Transporter(Base, TenantScopedMixin, TimestampMixin): @@ -10,6 +10,8 @@ class Transporter(Base, TenantScopedMixin, TimestampMixin): ) transporter_key = Column(String(23), primary_key=True, nullable=False) + # Internal integer identifier (surrogate). The frontend continues to use transporter_key. + transporter_id = Column(BigInteger, nullable=False, unique=True, index=True) name = Column(String(256), nullable=True) short_name = Column(String(10), nullable=True) responsible = Column(String(100), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/transporters/services.py b/backend/api/v1/modules/a76/transportation/transporters/services.py index d92880a9..e71a4c68 100644 --- a/backend/api/v1/modules/a76/transportation/transporters/services.py +++ b/backend/api/v1/modules/a76/transportation/transporters/services.py @@ -2,12 +2,19 @@ from typing import Optional, Tuple, List, Dict, Any import logging from sqlalchemy.orm import Session -from sqlalchemy import func +from sqlalchemy import func, text from . import dto, models logger = logging.getLogger(__name__) +TRANSPORTER_ID_SEQ = "a76.transporter_transporter_id_seq" + + +def allocate_transporter_id(db: Session) -> int: + """Next surrogate transporter_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{TRANSPORTER_ID_SEQ}')")).scalar() + class TransporterService: """Service for Transporter CRUD operations with tenant support""" @@ -91,8 +98,11 @@ class TransporterService: company_id: int, ) -> models.Transporter: """Create a new transporter""" + data = transporter_data.model_dump() + if data.get("transporter_id") is None: + data["transporter_id"] = allocate_transporter_id(db) new_transporter = models.Transporter( - **transporter_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **data, tenant_id=tenant_id, company_id=company_id ) db.add(new_transporter) db.commit() diff --git a/backend/api/v1/modules/a76/transportation/vehicles/dto.py b/backend/api/v1/modules/a76/transportation/vehicles/dto.py index 7c7d28b9..c4cb6c8a 100644 --- a/backend/api/v1/modules/a76/transportation/vehicles/dto.py +++ b/backend/api/v1/modules/a76/transportation/vehicles/dto.py @@ -5,6 +5,7 @@ from pydantic import BaseModel, Field class VehicleBaseDTO(BaseModel): vehicle_key: str = Field(..., description="Vehicle key (primary identifier)") + vehicle_id: Optional[int] = Field(None, description="Internal vehicle integer ID") ace_vehicle_key: Optional[str] = None transporter_key: Optional[str] = None transport_identifier: Optional[str] = None diff --git a/backend/api/v1/modules/a76/transportation/vehicles/models.py b/backend/api/v1/modules/a76/transportation/vehicles/models.py index 9339daf8..0bbbb5d9 100644 --- a/backend/api/v1/modules/a76/transportation/vehicles/models.py +++ b/backend/api/v1/modules/a76/transportation/vehicles/models.py @@ -1,6 +1,6 @@ from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base -from sqlalchemy import DECIMAL, Column, Integer, String +from sqlalchemy import DECIMAL, Column, Integer, BigInteger, String class Vehicle(Base, TenantScopedMixin, TimestampMixin): @@ -10,6 +10,8 @@ class Vehicle(Base, TenantScopedMixin, TimestampMixin): ) vehicle_key = Column(String(14), primary_key=True, nullable=False) + # Internal integer identifier (surrogate). The frontend continues to use vehicle_key. + vehicle_id = Column(BigInteger, nullable=False, unique=True, index=True) ace_vehicle_key = Column(String(10), nullable=True) transporter_key = Column(String(23), nullable=True) transport_identifier = Column(String(30), nullable=True) diff --git a/backend/api/v1/modules/a76/transportation/vehicles/services.py b/backend/api/v1/modules/a76/transportation/vehicles/services.py index 2b9801ac..fd953f27 100644 --- a/backend/api/v1/modules/a76/transportation/vehicles/services.py +++ b/backend/api/v1/modules/a76/transportation/vehicles/services.py @@ -1,9 +1,17 @@ from typing import Optional, Tuple, List, Dict, Any from sqlalchemy.orm import Session +from sqlalchemy import text from . import dto, models +VEHICLE_ID_SEQ = "a76.vehicle_vehicle_id_seq" + + +def allocate_vehicle_id(db: Session) -> int: + """Next surrogate vehicle_id (sequence from migration ca7d3c4e8b2a).""" + return db.execute(text(f"SELECT nextval('{VEHICLE_ID_SEQ}')")).scalar() + class VehicleService: """Service for Vehicle CRUD operations with tenant support""" @@ -66,8 +74,11 @@ class VehicleService: company_id: int, ) -> models.Vehicle: """Create a new vehicle""" + data = vehicle_data.model_dump() + if data.get("vehicle_id") is None: + data["vehicle_id"] = allocate_vehicle_id(db) new_vehicle = models.Vehicle( - **vehicle_data.model_dump(), tenant_id=tenant_id, company_id=company_id + **data, tenant_id=tenant_id, company_id=company_id ) db.add(new_vehicle) db.commit() diff --git a/backend/api/v1/modules/core/help_center/routes.py b/backend/api/v1/modules/core/help_center/routes.py index 29f576b5..c76d697a 100644 --- a/backend/api/v1/modules/core/help_center/routes.py +++ b/backend/api/v1/modules/core/help_center/routes.py @@ -1,13 +1,24 @@ -import shutil +import mimetypes import os +import shutil import uuid from datetime import datetime from typing import List, Optional, Dict, Any from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, Header, status, UploadFile, File +from fastapi.responses import Response from sqlalchemy.orm import Session -from core.database import get_core_db + from core.config import settings +from core.database import get_core_db +from core.s3_keys import ( + help_asset_key, + help_public_api_path, + help_s3_key_to_public_relative_path, + system_help_object_key, +) +from core.storage_s3 import get_object_bytes, put_object_bytes from core.security import get_current_user, has_role from .schemas import HelpArticleInDB, HelpArticleCreate, HelpArticleUpdate, HelpSyncRequest, HelpSyncResponse from .services import HelpCenterService @@ -80,60 +91,95 @@ def sync_help_article(sync_data: HelpSyncRequest, db: Session = Depends(get_core return result +@router.get("/files/{file_path:path}") +def serve_help_file(file_path: str): + """Sirve un objeto bajo system/help/ (público vía middleware).""" + if ".." in file_path or file_path.startswith("/"): + raise HTTPException(status_code=404, detail="Not found") + try: + key = system_help_object_key(file_path) + except ValueError: + raise HTTPException(status_code=404, detail="Not found") + if not settings.use_s3_object_storage: + legacy = os.path.join("uploads", "help", file_path) + if not os.path.isfile(legacy): + raise HTTPException(status_code=404, detail="Not found") + with open(legacy, "rb") as f: + data = f.read() + media = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + return Response(content=data, media_type=media) + try: + data = get_object_bytes(key) + except Exception: + raise HTTPException(status_code=404, detail="Not found") + media = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + return Response(content=data, media_type=media) + + @router.post("/upload-image/") -def upload_help_image( +async def upload_help_image( file: UploadFile = File(...), current_user: Dict[str, Any] = Depends(has_role("admin")) ): """Sube una imagen para usar en los artículos.""" try: - file_ext = os.path.splitext(file.filename)[1] + file_ext = os.path.splitext(file.filename or "")[1] or ".png" new_filename = f"{uuid.uuid4()}{file_ext}" - file_location = f"uploads/help/{new_filename}" - - # Ensure directory exists + body = await file.read() + if settings.use_s3_object_storage: + key = help_asset_key("", new_filename) + ct = mimetypes.guess_type(new_filename)[0] or "image/png" + put_object_bytes(key, body, content_type=ct) + rel = help_s3_key_to_public_relative_path(key) + return {"url": help_public_api_path(rel)} os.makedirs("uploads/help", exist_ok=True) - - with open(file_location, "wb+") as buffer: - shutil.copyfileobj(file.file, buffer) - + file_location = f"uploads/help/{new_filename}" + with open(file_location, "wb") as f: + f.write(body) return {"url": f"/api/uploads/help/{new_filename}"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/upload-asset/") -def upload_help_asset( +async def upload_help_asset( file: UploadFile = File(...), current_user: Dict[str, Any] = Depends(has_role("admin")) ): """Sube cualquier tipo de archivo (PDF, Video, etc.) para la biblioteca.""" try: - file_ext = os.path.splitext(file.filename)[1].lower() + file_ext = os.path.splitext(file.filename or "")[1].lower() new_filename = f"{uuid.uuid4()}{file_ext}" - - # Guardar en una carpeta segun el tipo o general - folder = "uploads/help/assets" - if file_ext in ['.pdf']: - folder = "uploads/help/pdfs" - elif file_ext in ['.mp4', '.mov', '.avi']: - folder = "uploads/help/videos" - - file_location = f"{folder}/{new_filename}" - - # Ensure directory exists + subfolder = "assets" + if file_ext == ".pdf": + subfolder = "pdfs" + elif file_ext in [".mp4", ".mov", ".avi"]: + subfolder = "videos" + + if settings.use_s3_object_storage: + body = await file.read() + key = help_asset_key(subfolder, new_filename) + ct = file.content_type or mimetypes.guess_type(new_filename)[0] or "application/octet-stream" + put_object_bytes(key, body, content_type=ct) + rel = help_s3_key_to_public_relative_path(key) + return { + "url": help_public_api_path(rel), + "filename": file.filename, + "size": len(body), + "mime_type": file.content_type, + } + + folder = f"uploads/help/{subfolder}" os.makedirs(folder, exist_ok=True) - - with open(file_location, "wb+") as buffer: - shutil.copyfileobj(file.file, buffer) - - # Get file size + file_location = f"{folder}/{new_filename}" + body = await file.read() + with open(file_location, "wb") as f: + f.write(body) file_size = os.path.getsize(file_location) - return { "url": f"/api/{file_location}", "filename": file.filename, "size": file_size, - "mime_type": file.content_type + "mime_type": file.content_type, } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/api/v1/modules/core/help_center/utils.py b/backend/api/v1/modules/core/help_center/utils.py index b5fe4c58..8d9b7854 100644 --- a/backend/api/v1/modules/core/help_center/utils.py +++ b/backend/api/v1/modules/core/help_center/utils.py @@ -1,76 +1,111 @@ +import logging +import mimetypes import os import re -import httpx -import logging -import uuid from pathlib import Path +from typing import Optional + +import httpx + from core.config import settings +from core.s3_keys import SYSTEM_HELP_PREFIX +from core.storage_s3 import object_exists, put_object_bytes logger = logging.getLogger(__name__) + +def _asset_url_to_s3_key(asset_url: str) -> Optional[str]: + """Deriva la clave S3 bajo system/help/ a partir de una URL de artículo.""" + if "/help-center/files/" in asset_url: + rel = asset_url.split("/help-center/files/", 1)[1].lstrip("/") + if ".." in rel: + return None + return f"{SYSTEM_HELP_PREFIX}{rel}" + u = asset_url.replace("/api/uploads/", "uploads/") + if u.startswith("/"): + u = u[1:] + if u.startswith("uploads/help/"): + return f"{SYSTEM_HELP_PREFIX}{u[len('uploads/help/') :]}" + return None + + def download_file_from_hub(relative_path: str) -> bool: """ - Downloads a file from the Hub to the local storage. - relative_path: e.g., 'uploads/help/pdfs/myfile.pdf' or '/api/uploads/help/image.png' + Descarga un asset del Hub y lo guarda en MinIO (system/help/...) o en disco si no hay almacenamiento S3 activo. + relative_path: URL parcial, p. ej. '/api/uploads/help/x.png' o '/api/v1/core/help-center/files/pdfs/x.pdf' """ if not settings.CENTRAL_SERVER_URL or settings.CENTRAL_SERVER_URL == '""': return False - # Clean the path - clean_path = relative_path.replace("/api/uploads/", "uploads/") - if clean_path.startswith("/"): - clean_path = clean_path[1:] - - # Check if it starts with uploads - if not clean_path.startswith("uploads/"): - # If it doesn't start with uploads, it might just be the filename or a subpath - # We assume it's relative to /app/ - pass + key = _asset_url_to_s3_key(relative_path) + if not key: + logger.warning("download_file_from_hub: could not map URL to S3 key: %s", relative_path) + return False - local_path = Path(clean_path) - if local_path.exists(): - logger.info(f"File {clean_path} already exists, skipping download.") + if settings.use_s3_object_storage and object_exists(key): + logger.info("S3 object %s already exists, skipping download.", key) return True - # Ensure directories exist - local_path.parent.mkdir(parents=True, exist_ok=True) - - # Resolve Hub Base URL - # CENTRAL_SERVER_URL is usually http://hub:8000/api/v1/core/help-center/sync/ - # We want http://hub:8000/api/ base_url = settings.CENTRAL_SERVER_URL.split("/v1/")[0] - # The file in backend is served usually under /api/uploads/... - # But clean_path is just "uploads/...". So the Hub route is base_url + "/" + clean_path - hub_file_url = f"{base_url}/{clean_path}" + if "/help-center/files/" in relative_path: + rel = relative_path.split("/help-center/files/", 1)[1].lstrip("/") + hub_file_url = f"{base_url.rstrip('/')}/api/v1/core/help-center/files/{rel}" + else: + clean_path = relative_path.replace("/api/uploads/", "uploads/") + if clean_path.startswith("/"): + clean_path = clean_path[1:] + hub_file_url = f"{base_url.rstrip('/')}/{clean_path}" + + logger.info("Downloading asset from Hub: %s", hub_file_url) - logger.info(f"Downloading asset from Hub: {hub_file_url} -> {local_path}") - try: with httpx.Client() as client: response = client.get(hub_file_url, timeout=30.0) - if response.status_code == 200: - with open(local_path, "wb") as f: - f.write(response.content) - logger.info(f"Successfully downloaded {clean_path}") - return True - else: - logger.warning(f"Failed to download {clean_path}: Status {response.status_code} URL: {hub_file_url}") + if response.status_code != 200: + logger.warning( + "Failed to download %s: Status %s URL: %s", + relative_path, + response.status_code, + hub_file_url, + ) return False + body = response.content except Exception as e: - logger.error(f"Error downloading {clean_path}: {str(e)}") + logger.error("Error downloading %s: %s", relative_path, str(e)) return False + if settings.use_s3_object_storage: + rel = key[len(SYSTEM_HELP_PREFIX) :] + ct = mimetypes.guess_type(rel)[0] or "application/octet-stream" + try: + put_object_bytes(key, body, content_type=ct) + logger.info("Stored hub asset in S3: %s", key) + return True + except Exception as e: + logger.error("S3 put failed for %s: %s", key, e) + return False + + rel = key[len(SYSTEM_HELP_PREFIX) :] + local_path = Path("uploads/help") / rel + local_path.parent.mkdir(parents=True, exist_ok=True) + local_path.write_bytes(body) + logger.info("Stored hub asset locally: %s", local_path) + return True + + def sync_assets_from_content(content: str): - """ - Parses markdown content for image URLs and downloads them if they are local references. - Example: ![alt text](/api/uploads/help/uuid.png) - """ + """Parsea markdown y descarga imágenes referenciadas (rutas legacy y nuevas).""" if not content: return - # Regex for markdown images: ![...](/api/uploads/...) - image_pattern = r'!\[.*?\]\((/api/uploads/.*?)\)' - matches = re.findall(image_pattern, content) - - for asset_url in matches: - download_file_from_hub(asset_url) + patterns = [ + r'!\[.*?\]\((/api/uploads/.*?)\)', + r'!\[.*?\]\((/api/v1/core/help-center/files/.*?)\)', + ] + seen = set() + for pattern in patterns: + for asset_url in re.findall(pattern, content): + if asset_url in seen: + continue + seen.add(asset_url) + download_file_from_hub(asset_url) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 594355f5..6ea6893a 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -2,14 +2,20 @@ Rutas para gestión de usuarios de Keycloak """ +import logging +import mimetypes from typing import Optional import os import uuid from pathlib import Path +from core.config import settings from core.database import get_core_db +from core.s3_keys import public_user_avatar_api_path, user_avatar_key +from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes from core.security import get_current_user, validate_access_to_resource from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import Response from sqlalchemy.orm import Session from ..user_tenant.models import UserTenant @@ -25,6 +31,10 @@ from .service import UserService router = APIRouter(prefix="/users", tags=["Users"]) +logger = logging.getLogger(__name__) + +_AVATAR_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + @router.get("/stats", response_model=UserStatsDTO) def get_user_statistics( @@ -67,6 +77,52 @@ def list_users( return result +@router.get("/avatar/{tenant_id}/{keycloak_user_id}") +def get_user_avatar_image( + tenant_id: int, + keycloak_user_id: str, + db: Session = Depends(get_core_db), +): + """ + Sirve la imagen de avatar (público para poder usarla en sin Bearer). + El almacenamiento interno puede ser clave S3 o ruta bajo uploads/. + """ + ut = ( + db.query(UserTenant) + .filter( + UserTenant.tenant_id == tenant_id, + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + if not ut or not ut.avatar_url: + raise HTTPException(status_code=404, detail="Avatar not found") + + raw = ut.avatar_url + if raw.startswith("tenants/"): + try: + data = get_object_bytes(raw) + except Exception: + raise HTTPException(status_code=404, detail="Avatar not found") + media = mimetypes.guess_type(raw)[0] or "image/jpeg" + return Response(content=data, media_type=media) + + rel = raw.lstrip("/") + path = Path(rel) + if not path.is_file(): + path = Path.cwd() / rel + if not path.is_file(): + alt = Path("/app") / rel + if alt.is_file(): + path = alt + if not path.is_file(): + raise HTTPException(status_code=404, detail="Avatar file not found") + data = path.read_bytes() + media = mimetypes.guess_type(str(path))[0] or "image/jpeg" + return Response(content=data, media_type=media) + + @router.get("/{user_id}", response_model=UserResponseDTO) def get_user( user_id: str, @@ -282,33 +338,74 @@ async def upload_avatar( db: Session = Depends(get_core_db), ): """ - Sube un avatar para el usuario actual - Retorna la URL del avatar subido + Sube un avatar para el usuario actual. + Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant. + Retorna URL pública para (GET /users/avatar/...). """ - # Validar tipo de archivo if not file.content_type or not file.content_type.startswith("image/"): raise HTTPException(status_code=400, detail="El archivo debe ser una imagen") - # Validar tamaño (max 2MB) + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + user_tenant = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + if not user_tenant: + raise HTTPException( + status_code=400, detail="User does not belong to any tenant" + ) + + ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg" + if ext not in _AVATAR_EXT: + raise HTTPException( + status_code=400, + detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}", + ) + contents = await file.read() if len(contents) > 2 * 1024 * 1024: raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB") - # Crear directorio si no existe - upload_dir = Path("/app/uploads/avatars") - upload_dir.mkdir(parents=True, exist_ok=True) + tenant_id = user_tenant.tenant_id - # Generar nombre con keycloak_user_id (sobrescribe si existe) - keycloak_user_id = current_user.get("sub") - ext = Path(file.filename or "image.jpg").suffix - filename = f"{keycloak_user_id}{ext}" - file_path = upload_dir / filename + try: + if settings.use_s3_object_storage: + if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith( + "tenants/" + ): + delete_object_if_exists(str(user_tenant.avatar_url)) + key = user_avatar_key(tenant_id, keycloak_user_id, ext) + ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg" + put_object_bytes(key, contents, content_type=ct) + user_tenant.avatar_url = key + logger.info( + "User avatar stored in S3 key=%s bytes=%s", key, len(contents) + ) + else: + upload_dir = Path("uploads/avatars") + upload_dir.mkdir(parents=True, exist_ok=True) + filename = f"{keycloak_user_id}{ext}" + file_path = upload_dir / filename + with open(file_path, "wb") as f: + f.write(contents) + user_tenant.avatar_url = f"/uploads/avatars/{filename}" - # Guardar archivo - with open(file_path, "wb") as f: - f.write(contents) + db.add(user_tenant) + db.commit() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, detail=f"Error al guardar el avatar: {str(e)}" + ) from e - # Retornar URL relativa - avatar_url = f"/uploads/avatars/{filename}" - - return {"avatar_url": avatar_url} + public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id) + return {"avatar_url": public_url} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index b7b313ff..38464160 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -43,9 +43,16 @@ def _normalize_keycloak_user( # Agregar campos de perfil si user_tenant está disponible if user_tenant: + avatar_out = user_tenant.avatar_url + if avatar_out and str(avatar_out).startswith("tenants/"): + from core.s3_keys import public_user_avatar_api_path + + avatar_out = public_user_avatar_api_path( + user_tenant.tenant_id, user_tenant.keycloak_user_id + ) normalized.update( { - "avatar_url": user_tenant.avatar_url, + "avatar_url": avatar_out, "phone": user_tenant.phone, "bio": user_tenant.bio, "preferences": user_tenant.preferences or {}, @@ -435,7 +442,9 @@ class UserService: # Actualizar campos en UserTenant if role is not None: user_tenant.role = role - if avatar_url is not None: + if avatar_url is not None and not str(avatar_url).startswith( + "/api/v1/core/users/avatar/" + ): user_tenant.avatar_url = avatar_url if phone is not None: user_tenant.phone = phone @@ -641,7 +650,9 @@ class UserService: self.keycloak_admin.update_user(keycloak_user_id, update_data) # Actualizar campos de perfil en UserTenant - if avatar_url is not None: + if avatar_url is not None and not str(avatar_url).startswith( + "/api/v1/core/users/avatar/" + ): user_tenant.avatar_url = avatar_url if phone is not None: user_tenant.phone = phone diff --git a/backend/core/config.py b/backend/core/config.py index eaabdb82..7ad763b5 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -2,8 +2,7 @@ Configuración centralizada de la aplicación usando Pydantic Settings """ -import os -from typing import List +from typing import List, Literal from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -14,9 +13,8 @@ class Settings(BaseSettings): # Application APP_NAME: str = "Anexo76" - # La versión se obtiene de la variable de entorno APP_VERSION que se pasa desde Docker - # Si no existe, usa un valor por defecto de desarrollo - APP_VERSION: str = os.getenv("APP_VERSION", "dev-local") + # Sobreescribible con APP_VERSION (Dockerfile/Jenkins: build-arg + ENV) o entorno en runtime + APP_VERSION: str = "dev-local" DEBUG: bool = True ENVIRONMENT: str = "development" @@ -66,6 +64,18 @@ class Settings(BaseSettings): SMTP_FROM_NAME: str = "Sistema Anexo76" SMTP_USE_TLS: bool = True + # CSV imports (layouts_csv): redis = base64 en Valkey; minio = S3 + referencia en Redis + CSV_IMPORT_STORAGE: Literal["redis", "minio"] = "minio" + S3_ENDPOINT_URL: str = "http://minio:9000" + S3_ACCESS_KEY: str = "" + S3_SECRET_KEY: str = "" + S3_BUCKET: str = "anexo76" + S3_REGION: str = "us-east-1" + S3_USE_SSL: bool = False + # Logos, certificados, help (si no quieres MinIO aquí, pon false Y CSV_IMPORT_STORAGE=redis) + S3_FILE_STORAGE: bool = True + S3_PRESIGNED_EXPIRES_SECONDS: int = 3600 + model_config = SettingsConfigDict( env_file=[".env", "../.env"], case_sensitive=True, @@ -95,6 +105,14 @@ class Settings(BaseSettings): """Lista de orígenes CORS permitidos""" return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] + @property + def use_s3_object_storage(self) -> bool: + """ + Usar MinIO para logos, certificados y Help (mismo bucket que CSV). + True si los imports CSV ya usan MinIO o si S3_FILE_STORAGE está activo. + """ + return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE + # Instancia global de configuración settings = Settings() diff --git a/backend/core/middleware.py b/backend/core/middleware.py index edd39f75..9893b57f 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -22,6 +22,7 @@ class TenantMiddleware(BaseHTTPMiddleware): "/api/", "/uploads", "/api/v1/core/help-center", + "/api/v1/core/users/avatar", ] path = request.url.path @@ -88,6 +89,7 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): "/api/health", "/api/", "/api/v1/core/help-center", + "/api/v1/core/users/avatar", ] # Verificar si la ruta está exenta (comparación exacta o prefijo) diff --git a/backend/core/s3_keys.py b/backend/core/s3_keys.py new file mode 100644 index 00000000..1108b02f --- /dev/null +++ b/backend/core/s3_keys.py @@ -0,0 +1,325 @@ +""" +Convención de claves S3/MinIO para objetos persistidos. + +Todas las cargas que usen ``put_object_bytes`` deben obtener la clave mediante +funciones de este módulo (no construir ``tenants/...`` a mano en las rutas HTTP). + +Árbol canónico +-------------- + +**Multi-tenant** (datos de clientes), siempre bajo ``tenants/{tenant_id}/``: + +- ``tenants/{tid}/users/{keycloak_sub}/`` + Perfil de usuario (avatar). Ver ``tenant_user_prefix``, ``user_avatar_key``. + +- ``tenants/{tid}/companies/{company_id}/`` + Recursos ligados a una empresa: + + - ``.../branding/{filename}`` — logo. ``company_logo_key``. + - ``.../certificates/{tipo}_{timestamp}.{cer|key}`` — CER/KEY FIEL, CFDI, cancelación. + ``company_certificate_key``. + - ``.../imports/csv/{job_type}/{job_id}.csv`` — CSV de layouts (import jobs). + ``csv_import_key`` (usado por ``storage_s3.s3_key_for_csv_import``). + - ``.../customs_brokers/{broker_id}/certificates/`` — CER del VU (``.cer``). + ``customs_broker_vu_certificate_key``. + - ``.../customs_brokers/{broker_id}/keys/`` — llave privada VU (``.key``). + ``customs_broker_vu_private_key_key``. + - ``.../customs_brokers/{broker_id}/cove/`` — archivos COVE (xml, zip, etc.). + ``customs_broker_vu_cove_key``. + +**Sistema global** (no por tenant): + +- ``system/help/{carpeta opcional}/{archivo}`` — biblioteca de ayuda (imágenes, PDFs, vídeos). + ``help_asset_key``, ``global_system_prefix``. Lectura HTTP mapea a este prefijo. + +**Legado / migración**: + +- ``imports/csv/{job_type}/{job_id}.csv`` — sin tenant/company. Solo ``legacy_csv_import_key`` + (cleanup o compatibilidad). + +Constantes públicas +------------------- + +``SYSTEM_HELP_PREFIX`` — prefijo literal ``system/help/`` para lecturas y utilidades +que no pasan por ``help_asset_key``. +""" +import re +from typing import Union + +# Segmentos permitidos en claves (evita path traversal) +_SAFE_SEGMENT = re.compile(r"^[a-zA-Z0-9._\-]+$") + +# Prefijo fijo para objetos de Help Center (debe coincidir con help_asset_key / GET /files/) +SYSTEM_HELP_PREFIX = "system/help/" + + +def _segment(value: Union[int, str], label: str) -> str: + s = str(value).strip() + if not s or "/" in s or ".." in s: + raise ValueError(f"invalid {label} segment") + if not _SAFE_SEGMENT.match(s): + raise ValueError(f"invalid {label} characters") + return s + + +def tenant_company_prefix(tenant_id: Union[int, str], company_id: int) -> str: + """Prefijo `tenants/{tid}/companies/{cid}/` (termina en /).""" + tid = _segment(tenant_id, "tenant_id") + cid = _segment(company_id, "company_id") + return f"tenants/{tid}/companies/{cid}/" + + +def tenant_user_prefix(tenant_id: Union[int, str], keycloak_user_id: str) -> str: + """Prefijo `tenants/{tid}/users/{keycloak_sub}/` (avatar de perfil, sin company).""" + tid = _segment(tenant_id, "tenant_id") + kid = _segment(keycloak_user_id, "keycloak_user_id") + return f"tenants/{tid}/users/{kid}/" + + +def user_avatar_key( + tenant_id: Union[int, str], + keycloak_user_id: str, + ext: str, +) -> str: + ext = ext.lower() if ext.startswith(".") else f".{ext}" + allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp") + if ext not in allowed: + raise ValueError("invalid avatar extension") + return f"{tenant_user_prefix(tenant_id, keycloak_user_id)}avatar{ext}" + + +def public_user_avatar_api_path(tenant_id: int, keycloak_user_id: str) -> str: + """Ruta GET pública para servir la imagen (sin host).""" + return f"/api/v1/core/users/avatar/{tenant_id}/{keycloak_user_id}" + + +def global_system_prefix(subpath: str = "help") -> str: + """Prefijo bajo `system/` para contenido global (p. ej. help). Termina en /.""" + sub = subpath.strip().strip("/") + if not sub: + return SYSTEM_HELP_PREFIX + parts = sub.split("/") + for p in parts: + _segment(p, "system_subpath") + return f"system/{sub}/" + + +def customs_broker_vu_prefix( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, +) -> str: + """ + Prefijo para el bloque VU del agente aduanal. + + Forma: ``tenants/{tid}/companies/{cid}/customs_brokers/{broker_id}/`` + """ + bid = _segment(str(broker_id), "broker_id") + return f"{tenant_company_prefix(tenant_id, company_id)}customs_brokers/{bid}/" + + +def customs_broker_vu_certificate_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """CER del VU bajo ``.../customs_brokers/{id}/certificates/vu_cer_{timestamp}.cer``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".cer": + raise ValueError("VU certificate must be .cer") + base = f"vu_cer_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}certificates/{base}" + + +def customs_broker_vu_private_key_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """Llave privada del VU bajo ``.../customs_brokers/{id}/keys/vu_key_{timestamp}.key``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".key": + raise ValueError("VU private key must be .key") + base = f"vu_key_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}keys/{base}" + + +def customs_broker_vu_cove_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivos COVE bajo ``.../customs_brokers/{id}/cove/cove_{timestamp}_{filename}``. + Extensiones típicas: .xml, .zip, .txt, .pdf, .json + """ + ts = _segment(timestamp, "timestamp") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("COVE file must have an extension") + ext = "." + parts[1].lower() + allowed = (".xml", ".zip", ".txt", ".pdf", ".json") + if ext not in allowed: + raise ValueError(f"COVE extension not allowed: {ext}") + base = f"cove_{ts}_{fn}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}cove/{base}" + + +def customs_broker_vu_doda_certificate_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """CER DODA bajo ``.../customs_brokers/{id}/doda/certificates/doda_cer_{timestamp}.cer``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".cer": + raise ValueError("DODA certificate must be .cer") + base = f"doda_cer_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/certificates/{base}" + + +def customs_broker_vu_doda_private_key_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + file_ext: str, +) -> str: + """Llave DODA bajo ``.../customs_brokers/{id}/doda/keys/doda_key_{timestamp}.key``.""" + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}" + if ext != ".key": + raise ValueError("DODA private key must be .key") + base = f"doda_key_{ts}{ext}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/keys/{base}" + + +def customs_broker_vu_doda_cove_key( + tenant_id: Union[int, str], + company_id: int, + broker_id: int, + timestamp: str, + original_filename: str, +) -> str: + """ + Archivos DODA XML bajo ``.../customs_brokers/{id}/doda/cove/doda_cove_{timestamp}_{filename}``. + Extensiones permitidas: .xml, .zip, .txt, .pdf, .json + """ + ts = _segment(timestamp, "timestamp") + fn = safe_filename(original_filename) + parts = fn.rsplit(".", 1) + if len(parts) < 2: + raise ValueError("DODA file must have an extension") + ext = "." + parts[1].lower() + allowed = (".xml", ".zip", ".txt", ".pdf", ".json") + if ext not in allowed: + raise ValueError(f"DODA extension not allowed: {ext}") + base = f"doda_cove_{ts}_{fn}" + return f"{customs_broker_vu_prefix(tenant_id, company_id, broker_id)}doda/cove/{base}" + + +def job_type_segment(job_type: str) -> str: + if job_type == "" or job_type == "invoice": + return "invoice" + return job_type + + +def csv_import_key( + tenant_id: Union[int, str], + company_id: int, + job_type: str, + job_id: str, +) -> str: + _segment(job_id, "job_id") + return ( + f"{tenant_company_prefix(tenant_id, company_id)}" + f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv" + ) + + +def legacy_csv_import_key(job_type: str, job_id: str) -> str: + """Clave antigua sin tenant/company (solo migración / cleanup).""" + _segment(job_id, "job_id") + return f"imports/csv/{job_type_segment(job_type)}/{job_id}.csv" + + +def safe_filename(filename: str) -> str: + """Nombre de archivo final sin separadores.""" + base = filename.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + if not base or ".." in base: + raise ValueError("invalid filename") + return base + + +def company_logo_key( + tenant_id: Union[int, str], + company_id: int, + filename: str, +) -> str: + fn = safe_filename(filename) + return f"{tenant_company_prefix(tenant_id, company_id)}branding/{fn}" + + +def company_certificate_key( + tenant_id: Union[int, str], + company_id: int, + certificate_type: str, + timestamp: str, + file_ext: str, +) -> str: + ct = _segment(certificate_type.replace(".", "_"), "certificate_type") + ts = _segment(timestamp, "timestamp") + ext = file_ext.lower() if file_ext.startswith(".") else f".{file_ext}" + if ext not in (".cer", ".key"): + raise ValueError("certificate file must be .cer or .key") + base = f"{ct}_{ts}{ext}" + return f"{tenant_company_prefix(tenant_id, company_id)}certificates/{base}" + + +def help_asset_key(folder: str, new_filename: str) -> str: + """ + folder: '', 'pdfs', 'videos', 'assets' relativo a system/help/ + """ + folder = folder.strip().strip("/") + fn = safe_filename(new_filename) + if folder: + for p in folder.split("/"): + _segment(p, "help_folder") + return f"{global_system_prefix('help')}{folder}/{fn}" + return f"{global_system_prefix('help')}{fn}" + + +def help_s3_key_to_public_relative_path(key: str) -> str: + """Parte tras `system/help/` para el path del endpoint público.""" + if not key.startswith(SYSTEM_HELP_PREFIX): + raise ValueError("key is not under system/help/") + return key[len(SYSTEM_HELP_PREFIX) :] + + +def help_public_api_path(relative_under_help: str) -> str: + """URL de lectura pública bajo el router help-center (sin host).""" + rel = relative_under_help.lstrip("/") + return f"/api/v1/core/help-center/files/{rel}" + + +def system_help_object_key(relative_path: str) -> str: + """ + Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...). + ``relative_path`` no debe empezar por / ni contener '..'. + """ + rel = relative_path.strip().lstrip("/") + if ".." in rel or not rel: + raise ValueError("invalid help object path") + return f"{SYSTEM_HELP_PREFIX}{rel}" diff --git a/backend/core/storage_s3.py b/backend/core/storage_s3.py new file mode 100644 index 00000000..c0c7ef0a --- /dev/null +++ b/backend/core/storage_s3.py @@ -0,0 +1,226 @@ +""" +Cliente S3 (MinIO): bucket, objetos genéricos, presign, imports CSV. + +Las claves de objeto deben generarse con ``core.s3_keys`` (p. ej. ``csv_import_key`` vía +``s3_key_for_csv_import``); no construir prefijos ``tenants/...`` aquí. +""" +import logging +from typing import Any, Dict, List, Optional + +import boto3 +from botocore.config import Config +from botocore.exceptions import ClientError + +from core.config import settings +from core.s3_keys import csv_import_key + +logger = logging.getLogger(__name__) + + +def _client(): + return boto3.client( + "s3", + endpoint_url=settings.S3_ENDPOINT_URL, + aws_access_key_id=settings.S3_ACCESS_KEY, + aws_secret_access_key=settings.S3_SECRET_KEY, + region_name=settings.S3_REGION, + use_ssl=settings.S3_USE_SSL, + config=Config( + signature_version="s3v4", + s3={"addressing_style": "path"}, + ), + ) + + +def should_ensure_s3_bucket() -> bool: + return settings.use_s3_object_storage + + +def ensure_s3_bucket() -> None: + """Crea el bucket si no existe (idempotente).""" + if not should_ensure_s3_bucket(): + return + bucket = settings.S3_BUCKET + client = _client() + try: + client.head_bucket(Bucket=bucket) + logger.info("S3 bucket %s exists", bucket) + return + except ClientError as e: + code = e.response.get("Error", {}).get("Code", "") + if code not in ("404", "NoSuchBucket", "403"): + logger.warning("head_bucket %s: %s", bucket, e) + try: + if settings.S3_REGION == "us-east-1": + client.create_bucket(Bucket=bucket) + else: + client.create_bucket( + Bucket=bucket, + CreateBucketConfiguration={"LocationConstraint": settings.S3_REGION}, + ) + logger.info("S3 bucket %s created", bucket) + except ClientError as e: + logger.error("create_bucket %s failed: %s", bucket, e) + raise + + +# Alias para código existente +def ensure_csv_import_bucket() -> None: + ensure_s3_bucket() + + +def put_object_bytes(key: str, body: bytes, content_type: str = "application/octet-stream") -> None: + _client().put_object( + Bucket=settings.S3_BUCKET, + Key=key, + Body=body, + ContentType=content_type, + ) + + +def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> None: + put_object_bytes(key, body, content_type=content_type) + + +def get_object_bytes(key: str) -> bytes: + resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key) + return resp["Body"].read() + + +def delete_object_if_exists(key: str) -> None: + try: + _client().delete_object(Bucket=settings.S3_BUCKET, Key=key) + except ClientError as e: + logger.warning("delete_object %s: %s", key, e) + + +def delete_objects_with_prefix(prefix: str, batch_size: int = 1000) -> None: + """ + Elimina en cascada todos los objetos cuyo Key empieza con `prefix`. + + Pensado para limpiar recursos ligados a una entidad (por ejemplo, + todos los objetos de una compañía bajo `tenants/{tid}/companies/{cid}/`). + """ + if not settings.use_s3_object_storage: + return + + client = _client() + continuation_token: Optional[str] = None + + while True: + params: Dict[str, Any] = { + "Bucket": settings.S3_BUCKET, + "Prefix": prefix, + "MaxKeys": max(1, min(int(batch_size), 1000)), + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + try: + resp = client.list_objects_v2(**params) + except ClientError as e: + logger.warning("list_objects_v2 for prefix %s failed: %s", prefix, e) + break + + contents = resp.get("Contents") or [] + if not contents: + break + + to_delete = [{"Key": obj.get("Key")} for obj in contents if obj.get("Key")] + if to_delete: + try: + client.delete_objects( + Bucket=settings.S3_BUCKET, + Delete={"Objects": to_delete, "Quiet": True}, + ) + except ClientError as e: + logger.warning( + "delete_objects_with_prefix %s (batch_size=%s) failed: %s", + prefix, + len(to_delete), + e, + ) + + if not resp.get("IsTruncated"): + break + + continuation_token = resp.get("NextContinuationToken") + + +def object_exists(key: str) -> bool: + try: + _client().head_object(Bucket=settings.S3_BUCKET, Key=key) + return True + except ClientError: + return False + + +def presigned_get_url(key: str, expires_in: Optional[int] = None) -> str: + sec = expires_in if expires_in is not None else settings.S3_PRESIGNED_EXPIRES_SECONDS + return _client().generate_presigned_url( + "get_object", + Params={"Bucket": settings.S3_BUCKET, "Key": key}, + ExpiresIn=sec, + ) + + +def list_objects_tree( + prefix: str, + delimiter: str = "/", + max_keys: int = 100, + continuation_token: Optional[str] = None, +) -> Dict[str, Any]: + """ + Lista objetos/prefijos como árbol virtual. + + Retorna: + - ``prefixes``: subcarpetas (CommonPrefixes) + - ``objects``: objetos directos bajo ``prefix`` + - ``next_continuation_token`` y ``is_truncated`` para paginación + """ + params: Dict[str, Any] = { + "Bucket": settings.S3_BUCKET, + "Prefix": prefix, + "Delimiter": delimiter, + "MaxKeys": max(1, min(int(max_keys), 500)), + } + if continuation_token: + params["ContinuationToken"] = continuation_token + + resp = _client().list_objects_v2(**params) + common_prefixes: List[str] = [ + p.get("Prefix", "") for p in (resp.get("CommonPrefixes") or []) if p.get("Prefix") + ] + objects: List[Dict[str, Any]] = [] + for obj in resp.get("Contents") or []: + key = obj.get("Key") + if not key: + continue + if key == prefix: + # Marcador de carpeta (objeto vacío con mismo nombre del prefijo). + continue + objects.append( + { + "key": key, + "size": int(obj.get("Size", 0) or 0), + "last_modified": obj.get("LastModified"), + "etag": obj.get("ETag"), + "storage_class": obj.get("StorageClass"), + } + ) + + return { + "prefixes": common_prefixes, + "objects": objects, + "next_continuation_token": resp.get("NextContinuationToken"), + "is_truncated": bool(resp.get("IsTruncated")), + } + + +def s3_key_for_csv_import( + tenant_id, + company_id: int, + job_type: str, + job_id: str, +) -> str: + return csv_import_key(tenant_id, company_id, job_type, job_id) diff --git a/backend/main.py b/backend/main.py index c2d060ac..54685087 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,7 @@ from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware # Midd from api.v1.modules.a76.audit_log.register import register_audit from api.v1.router import router as api_v1_router from core.config import settings +from core.storage_s3 import ensure_s3_bucket from core.paths import layout_path from core.error_handlers import register_exception_handlers from core.middleware import ( @@ -54,7 +55,9 @@ async def on_startup(): """Evento de inicio de la aplicación""" logger.info("Iniciando la aplicación Anexo76...") #init_db() - run_migrations() + run_migrations() + if settings.use_s3_object_storage: + ensure_s3_bucket() logger.info("Base de datos inicializada correctamente.") diff --git a/backend/requirements.txt b/backend/requirements.txt index 7480be9f..86175896 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -20,6 +20,7 @@ passlib[bcrypt]==1.7.4 # HTTP & API httpx==0.28.1 requests==2.32.5 +boto3==1.35.36 # Utilities diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3f601f19..a1335dd1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -10,6 +10,9 @@ from sqlalchemy.orm import Session, sessionmaker # Evita errores tipo: expression 'Pedimentos' failed to locate a name. from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos # noqa: F401 from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.transportation.trailers.models import Trailer # noqa: F401 +from api.v1.modules.a76.transportation.transporters.models import Transporter # noqa: F401 +from api.v1.modules.a76.transportation.vehicles.models import Vehicle # noqa: F401 from api.v1.modules.a76.invoices.imports.process import routes as process_routes from api.v1.modules.a76.invoices.imports.process import main_process as import_main_process from api.v1.modules.a76.invoices.exports.process import main_process as export_main_process diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e823b73e..75af28a0 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -178,6 +178,15 @@ services: - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - SPOKE_URLS=${SPOKE_URLS:-""} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + - S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600} ports: - "3467:8000" depends_on: @@ -185,6 +194,8 @@ services: condition: service_healthy keycloak: condition: service_healthy + minio: + condition: service_healthy volumes: - backend_uploads:/app/uploads - backend_layouts:/app/layouts @@ -231,6 +242,14 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -256,6 +275,14 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -274,6 +301,33 @@ services: networks: - backend-net + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: anexo76-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${MINIO_API_PORT:-9100}:9000" + - "${MINIO_CONSOLE_PORT:-9101}:9001" + volumes: + - minio_data:/data + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live || exit 1" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # Frontend - SvelteKit frontend: image: dev.aduanasoft.com/anexo76/frontend:latest @@ -336,6 +390,8 @@ volumes: driver: local backend_layouts: driver: local + minio_data: + driver: local networks: backend-net: diff --git a/docker-compose.yml b/docker-compose.yml index 6bb012de..68d14cd0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -182,6 +182,15 @@ services: - CENTRAL_SERVER_URL=${CENTRAL_SERVER_URL:-""} - SYNC_SECRET_TOKEN=${SYNC_SECRET_TOKEN:-change-this-sync-token-in-production} - SPOKE_URLS=${SPOKE_URLS:-""} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} + - S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600} ports: - "8000:8000" depends_on: @@ -189,6 +198,8 @@ services: condition: service_healthy keycloak: condition: service_healthy + minio: + condition: service_healthy volumes: - ./backend:/app - backend_cache:/app/__pycache__ @@ -293,6 +304,14 @@ services: - SITAR_API_URL=${SITAR_API_URL} - SITAR_API_USER=${SITAR_API_USER} - SITAR_API_PASSWORD=${SITAR_API_PASSWORD} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -317,6 +336,14 @@ services: - CORE_DB_NAME=${CORE_DB_NAME:-anexo76_core} - CORE_DB_USER=${CORE_DB_USER:-postgres} - CORE_DB_PASSWORD=${POSTGRES_APP_PASSWORD:-postgres} + - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} + - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} + - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} + - S3_SECRET_KEY=${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-minioadmin}} + - S3_BUCKET=${S3_BUCKET:-anexo76} + - S3_REGION=${S3_REGION:-us-east-1} + - S3_USE_SSL=${S3_USE_SSL:-false} + - S3_FILE_STORAGE=${S3_FILE_STORAGE:-true} depends_on: - backend - valkey @@ -336,6 +363,33 @@ services: networks: - backend-net + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: anexo76-minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + ports: + - "${MINIO_API_PORT:-9100}:9000" + - "${MINIO_CONSOLE_PORT:-9101}:9001" + volumes: + - minio_data:/data + networks: + - backend-net + restart: unless-stopped + healthcheck: + test: [ "CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live || exit 1" ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + volumes: postgres_app_data: driver: local @@ -349,6 +403,8 @@ volumes: driver: local backend_uploads: driver: local + minio_data: + driver: local networks: backend-net: diff --git a/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md b/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md new file mode 100644 index 00000000..730c66ae --- /dev/null +++ b/docs/analisis_flujo_migracion_csv_transporte_sin_validaciones.md @@ -0,0 +1,141 @@ +# Analisis de flujo de migracion CSV de transporte (sin validaciones) + +## Objetivo + +Definir como migrar el flujo de importacion CSV de facturas hacia columnas nuevas de transporte, priorizando continuidad operativa y consistencia de IDs en `invoice_logistics`, sin depender de reglas de validacion funcional. + +## Estado actual de la rama + +- El pipeline actual ya separa `scan` y `commit` en `tasks.py`. +- El mapeo de plantillas (`template_config.py`) aun esta centrado en `NUMERO TRANSPORTE`. +- En `commit`, la persistencia de logistica usa principalmente: + - `carrier_id` / `carrier_int_id` desde `CLAVE TRANSPORTISTA`. + - `transport_type` desde `TIPO TRANSPORTE`. + - `transport_num` desde `NUMERO TRANSPORTE`. +- No existe aun una ruta establecida para columnas nuevas `CLAVE TRANSPORTE` y `NUMERO CAJA`. + +## Intencion funcional identificada en los commits analizados + +Sin copiar su logica de validacion, la idea de flujo que se quiso introducir es: + +1. Separar los datos de transporte en dos entradas semanticas: + - clave de vehiculo (`CLAVE TRANSPORTE`) + - numero de caja/remolque (`NUMERO CAJA`) +2. Mantener compatibilidad con archivos legacy: + - usar `NUMERO TRANSPORTE` como fallback cuando no existan columnas nuevas. +3. Resolver IDs internos en commit contra catalogos: + - `vehicle_key` -> `vehicle_id` + - `trailer_number` -> `trailer_id` +4. Persistir tanto la clave legible (string) como su ID interno (int) en `invoice_logistics`. +5. Mantener paridad de flujo entre scan y commit en cuanto a normalizacion y resolucion de campos, pero sin convertir scan en un bloqueo por validaciones de negocio. + +## Flujo propuesto (sin validaciones de negocio) + +### 1) Scan (preprocesamiento y trazabilidad) + +Objetivo: preparar datos y metadatos, no rechazar por reglas funcionales. + +- Leer CSV con `row_from_template`. +- Normalizar celdas relevantes de transporte: + - trim + - reemplazo de NBSP por espacio + - `None`/vacio a cadena vacia +- Construir una estructura por renglon con campos de transporte efectivos: + - `effective_transport_type` + - `effective_vehicle_key` + - `effective_trailer_number` + - `effective_legacy_transport_num` (solo trazabilidad) +- Guardar errores tecnicos (parseo CSV, formato bruto no interpretable), pero no bloquear por reglas de catalogo/negocio. + +### 2) Commit (resolucion y persistencia de IDs) + +Objetivo: persistir de forma consistente los IDs nuevos de migracion. + +- Determinar campos efectivos por precedencia (ver tabla siguiente). +- Resolver `carrier_int_id` con `CLAVE TRANSPORTISTA` (flujo ya existente). +- Resolver `transport_int_id` cuando exista `effective_vehicle_key`: + - lookup en `vehicle.vehicle_key` +- Resolver `trailer_int_id` cuando exista `effective_trailer_number`: + - lookup en `trailer.trailer_number` +- Persistir en `InvoiceLogistics`: + - string keys: `carrier_id`, `transport_id`, `trailer_num`, `transport_num` + - int refs: `carrier_int_id`, `transport_int_id`, `trailer_int_id` + +Importante: para migracion, si hay string pero no hay match de ID, **no romper flujo**; persistir string y dejar int en `NULL` (el FK ya permite `SET NULL`). + +## Matriz de mapeo CSV -> `invoice_logistics` + +| Entrada CSV | Rol | Campo destino (string) | Campo destino (int) | Catalogo/lookup | +|---|---|---|---|---| +| `CLAVE TRANSPORTISTA` | Transportista | `carrier_id` | `carrier_int_id` | `transporter.transporter_key -> transporter_id` | +| `CLAVE TRANSPORTE` | Vehiculo | `transport_id` | `transport_int_id` | `vehicle.vehicle_key -> vehicle_id` | +| `NUMERO CAJA` | Caja/Remolque | `trailer_num` | `trailer_int_id` | `trailer.trailer_number -> trailer_id` | +| `NUMERO TRANSPORTE` (legacy) | Fallback | `transport_num` (siempre trazable) y apoyo para resolver vehicle/trailer segun precedencia | opcional | depende de reglas de precedencia | +| `TIPO TRANSPORTE` | Tipo logistica | `transport_type` | n/a | enum interno | + +## Reglas de precedencia de datos (nuevas vs legacy) + +Definicion para no generar ambiguedad: + +1. Si viene `CLAVE TRANSPORTE`, usarla para `transport_id`. +2. Si viene `NUMERO CAJA`, usarla para `trailer_num`. +3. Si faltan columnas nuevas y viene `NUMERO TRANSPORTE`: + - usarlo como `transport_num` (trazabilidad legacy) + - y usarlo como fallback de resolucion para `transport_id`/`trailer_num` solo cuando el campo nuevo correspondiente este vacio. +4. Nunca sobreescribir un dato nuevo con legacy si el nuevo viene poblado. + +Sugerencia para parciales (`actualizar=true`): +- aplicar merge campo a campo: solo actualizar datos de transporte que lleguen informados en CSV; conservar los demas en la fila existente. + +## Relacion con la migracion de IDs + +La migracion `ca7d3c4e8b2a` ya formaliza: + +- `carrier_int_id` -> FK a `transporter.transporter_id` +- `transport_int_id` -> FK a `vehicle.vehicle_id` +- `trailer_int_id` -> FK a `trailer.trailer_id` + +Por lo tanto, el flujo CSV debe priorizar: + +- resolver claves string de catalogo de forma determinista +- poblar IDs internos cuando haya match +- mantener string keys para trazabilidad y backfill futuro + +## Impacto de implementacion por archivo (sin validaciones) + +### `backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py` + +- Agregar columnas canonicas nuevas en encabezados: + - `CLAVE TRANSPORTE` + - `NUMERO CAJA` +- Mantener `NUMERO TRANSPORTE` como compatibilidad legacy (no eliminar de inmediato). +- Ajustar aliases para tolerar variantes de cabecera. + +### `backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py` + +- Incorporar helper de resolucion de campos efectivos de transporte: + - prioridad nuevas columnas + - fallback legacy +- En `scan`, registrar estructura normalizada (sin rechazo por reglas de catalogo). +- En `commit`, poblar `InvoiceLogistics` con: + - `transport_id`/`transport_int_id` + - `trailer_num`/`trailer_int_id` + - `transport_num` como legado/trazabilidad +- Mantener comportamiento de no falla por ausencia de match de IDs. + +### Nuevo helper recomendado: `backend/api/v1/modules/a76/layouts_csv/facturas/validators/transport_catalog.py` + +- Aunque no se usen validaciones de negocio, centralizar funciones de: + - normalizacion de celdas + - resolucion de keys efectivas + - lookups de IDs de vehiculo/remolque +- Evita duplicar logica entre scan y commit. + +## Criterios de aceptacion de esta migracion de flujo + +- Queda definida una sola fuente de verdad para precedencia de columnas nuevas/legacy. +- `commit` persiste consistentemente claves string e IDs int de logistica. +- El flujo funciona aun cuando no haya match de catalogo (sin bloqueo por validacion). +- No hay ambiguedad entre: + - `transport_id` vs `transport_num` + - `transport_int_id` vs `trailer_int_id` diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a4985a02..ed08c90a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -119,6 +119,19 @@ "audit_logs_description": "Audit trail of operations and background task (Celery) status.", "audit_logs_tab_bitacora": "Audit trail", "audit_logs_tab_tasks": "Background tasks", + "audit_logs_tab_files": "File manager", + "audit_logs_files_title": "File manager", + "audit_logs_files_root": "Files root", + "audit_logs_files_refresh": "Refresh", + "audit_logs_files_list_title": "Contents", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Name", + "audit_logs_files_col_size": "Size", + "audit_logs_files_col_modified": "Modified", + "audit_logs_files_col_actions": "Actions", + "audit_logs_files_loading": "Loading files...", + "audit_logs_files_empty": "No files or folders found in this location.", + "audit_logs_files_download": "Download", "client_provider_type": { "client_indicator": "C", "provider_indicator": "P", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 53e27d56..b0ab3e96 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -119,6 +119,19 @@ "audit_logs_description": "Auditoría de operaciones y seguimiento de tareas en segundo plano (Celery).", "audit_logs_tab_bitacora": "Bitácora", "audit_logs_tab_tasks": "Tareas en segundo plano", + "audit_logs_tab_files": "Gestor de archivos", + "audit_logs_files_title": "Gestor de archivos", + "audit_logs_files_root": "Raíz de archivos", + "audit_logs_files_refresh": "Actualizar", + "audit_logs_files_list_title": "Contenido", + "audit_logs_files_error_prefix": "Error:", + "audit_logs_files_col_name": "Nombre", + "audit_logs_files_col_size": "Tamaño", + "audit_logs_files_col_modified": "Modificado", + "audit_logs_files_col_actions": "Acciones", + "audit_logs_files_loading": "Cargando archivos...", + "audit_logs_files_empty": "No hay archivos o carpetas en esta ubicación.", + "audit_logs_files_download": "Descargar", "client_provider_type": { "client_indicator": "C", "provider_indicator": "P", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 42b5290a..ce180b4f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -456,6 +456,7 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise(endpoint: string) => fetchApi(endpoint, { method: 'GET' }), + getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }), post: (endpoint: string, body: any, options: RequestInit = {}) => fetchApi(endpoint, { diff --git a/frontend/src/lib/api/dashboard/a76/audit_files.ts b/frontend/src/lib/api/dashboard/a76/audit_files.ts new file mode 100644 index 00000000..def3ba73 --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/audit_files.ts @@ -0,0 +1,53 @@ +import { api } from '$lib/api'; + +const BASE_PATH = '/v1/a76/audit-log/files'; + +export interface AuditFileBreadcrumb { + path: string; + display_name: string; +} + +export interface AuditFolderItem { + path: string; + display_name: string; +} + +export interface AuditFileItem { + path: string; + display_name: string; + size: number; + last_modified?: string | null; +} + +export interface AuditFileListResponse { + current_path: string; + display_path: string; + breadcrumbs: AuditFileBreadcrumb[]; + folders: AuditFolderItem[]; + files: AuditFileItem[]; + next_token?: string | null; +} + +export const AuditFilesAPI = { + list: async (params?: { + path?: string; + continuation_token?: string; + max_keys?: number; + }): Promise => { + const query = new URLSearchParams(); + if (params?.path) query.set('path', params.path); + if (params?.continuation_token) query.set('continuation_token', params.continuation_token); + if (params?.max_keys) query.set('max_keys', String(params.max_keys)); + + const qs = query.toString(); + const endpoint = qs ? `${BASE_PATH}?${qs}` : BASE_PATH; + const response = await api.get(endpoint); + if (response.error || !response.data) { + throw new Error(response.error || 'Failed to list tenant files'); + } + return response.data; + }, + + downloadBlob: (path: string) => + api.getBlob(`${BASE_PATH}/download?path=${encodeURIComponent(path)}`) +}; diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index 1362a11a..4dc83595 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -1,6 +1,24 @@ import { api } from '$lib/api'; import { companyStore } from '$lib/stores/company.svelte'; // <--- NUEVO: Importamos el store para el fallback import type { ApiResponse } from '$lib/api'; +import { getToken } from '$lib/auth'; + +export type VuUploadFileKind = + | 'certificate' + | 'key' + | 'cove' + | 'doda_certificate' + | 'doda_key' + | 'doda_cove'; + +export interface CustomsBrokerVuUploadResult { + message: string; + file_kind: string; + field: string; + path: string; + broker_key: string; + company_id: number; +} export interface CustomsBroker { id: number; @@ -165,4 +183,40 @@ export const customsBrokersApi = { data ); } -}; \ No newline at end of file +}; + +/** + * Sube CER, KEY o COVE del VU al bucket (MinIO: tenants/.../customs_brokers/...). + */ +export async function uploadCustomsBrokerVuFile( + brokerKey: string, + companyId: string, + fileKind: VuUploadFileKind, + file: File +): Promise> { + const formData = new FormData(); + formData.append('file', file); + const token = getToken(); + const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); + const q = new URLSearchParams({ + company_id: companyId, + file_kind: fileKind + }); + const response = await fetch( + `${API_BASE_URL}/v1/a76/customs-brokers/${encodeURIComponent(brokerKey)}/vu/upload?${q.toString()}`, + { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + credentials: 'include' + } + ); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { + error: (data as { detail?: string }).detail || (data as { message?: string }).message || 'Error al subir el archivo VU', + status: response.status + }; + } + return { data: data as CustomsBrokerVuUploadResult, status: response.status }; +} \ No newline at end of file diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index 11b037a1..f5814474 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -1,5 +1,6 @@ import { api } from '$lib/api'; import type { ApiResponse } from '$lib/api'; +import { getToken } from '$lib/auth'; export interface CompanyAddress { id?: number; @@ -450,14 +451,12 @@ export async function uploadCompanyLogo(id: number, file: File): Promise; activeTab: string; tabSettingsValues: Record; + /** Si true, el padre aporta el contenedor fijo (p. ej. pie apilado con progreso CSV). */ + embedded?: boolean; } = $props(); const globalParamNames = $derived(new Set(globalCsvParams.map((p) => p.name))); @@ -27,7 +31,12 @@
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 05a98de3..dd342907 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -11,6 +11,11 @@ } from 'lucide-svelte'; import { tick } from 'svelte'; import { api } from '$lib/api'; + import { + totalSkippedFromCommit, + criticalReferenceGaps, + referenceStateReady + } from '$lib/csv-import-commit-metrics'; let { open = $bindable(false), @@ -50,29 +55,33 @@ let isPending = $derived(!!scanResults && !commitResults); let isFinished = $derived(!!commitResults); - // Derived metrics for UI logic - let hasErrors = $derived( - scanResults?.error_count > 0 || - commitResults?.skipped_invalid > 0 || - commitResults?.skipped_missing_fk > 0 - ); - let scanErrorsShown = $derived(Array.isArray(scanResults?.errors) ? scanResults.errors.length : 0); let scanErrorsTotal = $derived(scanResults?.error_count || 0); let scanErrorsTruncated = $derived(scanErrorsShown > 0 && scanErrorsShown < scanErrorsTotal); - let totalSkipped = $derived( - (commitResults?.skipped_invalid || 0) + - (commitResults?.skipped_missing_fk || 0) + - (commitResults?.skipped_missing_invoice || 0) + - (commitResults?.skipped_duplicate || 0) + let scanErrorSummary = $derived( + Array.isArray(scanResults?.error_summary) ? scanResults.error_summary : [] ); - let insertedCount = $derived(commitResults?.inserted || 0); - let updatedCount = $derived(commitResults?.updated || 0); - // Algunos módulos usan `inserted` solo para altas y `updated` para modo "actualizar". - // Para la UI, reflejamos "cambios positivos" como insertados + actualizados. - let insertedOrUpdatedCount = $derived(insertedCount + updatedCount); + let totalSkipped = $derived(totalSkippedFromCommit(commitResults)); + let refGaps = $derived(criticalReferenceGaps(commitResults)); + let refReady = $derived(referenceStateReady(commitResults)); + + let insertedCount = $derived(Number(commitResults?.inserted) || 0); + let updatedCount = $derived(Number(commitResults?.updated) || 0); + + /** Cabecera: errores de escaneo (solo pendiente) o observaciones de commit. */ + let hasErrors = $derived( + (isPending && (scanResults?.error_count ?? 0) > 0) || + (isFinished && + (totalSkipped > 0 || + refGaps > 0 || + commitResults?.status === 'warning')) + ); + + let commitSkippedSummary = $derived( + Array.isArray(commitResults?.skipped_summary) ? commitResults.skipped_summary : [] + ); function handleOpenChange(newOpen: boolean) { if (!newOpen) { @@ -298,6 +307,24 @@
+ {#if scanResults.message} +

+ {scanResults.message} +

+ {/if} + {#if scanErrorSummary.length > 0} +
+ {#each scanErrorSummary as item} + + {(item as { reason?: string; count?: number }).reason ?? '—'} · {(item as { count?: number }) + .count ?? 0} + + {/each} +
+ {/if} + {#if scanResults.error_count > 0}
{#if isFinished}
- -
- +
-
- +
+ Insertados
- {insertedOrUpdatedCount} - {#if updatedCount > 0} -

- {insertedCount} insertados, {updatedCount} actualizados -

- {/if} + {insertedCount}
- -
-
- - + + Actualizados +
+ {updatedCount} +
+
+
+ + Rechazados
{totalSkipped} {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0} -

+

Revisa el detalle por línea en la tabla inferior.

{/if}
+
+ {#if refGaps > 0} + +
+

Brechas de referencia (FK / catálogos)

+

+ Hay {refGaps} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de + filas rechazadas antes de reintentar. +

+
+ {:else} + +
+

Estado de referencias

+

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

+
+ {/if} +
+ + {#if commitResults.message} +

+ {commitResults.message} +

+ {/if} + {#if commitSkippedSummary.length > 0} +
+

+ Resumen de motivos de rechazo +

+
+ {#each commitSkippedSummary as item} + + {(item as { reason?: string; count?: number }).reason ?? '—'} · {(item as { + count?: number; + }).count ?? 0} + + {/each} +
+
+ {/if} + {#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
@@ -466,7 +545,7 @@ {detail.invoice || '-'} - {detail.reason} + {detail.reason ?? '-'} {detail.solution || '-'} {/each} diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte index 35d1f218..0c9bdaaa 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -8,12 +8,17 @@ let { items, - onUpload + onUpload, + busy = false }: { items: CsvUploadItem[]; onUpload: (file: File, config: CsvUploadItem) => void; + /** Bloquea nuevas cargas (p. ej. importación en curso sin overlay). */ + busy?: boolean; } = $props(); + const gridLocked = $derived(busy); + let dragOverId = $state(null); // Group items @@ -34,7 +39,7 @@ }); function handleDragEnter(e: DragEvent, id: string, disabled?: boolean) { - if (disabled) return; + if (disabled || gridLocked) return; e.preventDefault(); e.stopPropagation(); dragOverId = id; @@ -47,7 +52,7 @@ } function handleDragOver(e: DragEvent, disabled?: boolean) { - if (disabled) return; + if (disabled || gridLocked) return; e.preventDefault(); e.stopPropagation(); dragOverId = null; @@ -65,7 +70,7 @@ } function handleDrop(e: DragEvent, item: CsvUploadItem) { - if (item.disabled) return; + if (item.disabled || gridLocked) return; e.preventDefault(); e.stopPropagation(); dragOverId = null; @@ -76,12 +81,13 @@ } function handleClick(id: string, disabled?: boolean) { - if (disabled) return; + if (disabled || gridLocked) return; const input = document.getElementById(`file-input-${id}`) as HTMLInputElement; if (input) input.click(); } function handleFileChange(e: Event, item: CsvUploadItem) { + if (gridLocked) return; const target = e.target as HTMLInputElement; if (target.files && target.files.length > 0) { validateAndUpload(target.files[0], item); @@ -90,7 +96,7 @@ } async function handleContextMenu(e: MouseEvent, item: CsvUploadItem) { - if (item.disabled) { + if (item.disabled || gridLocked) { e.preventDefault(); return; } @@ -117,25 +123,30 @@ } -
+
{#if groupedItems.ungrouped.length > 0}
{#each groupedItems.ungrouped as item}
handleDragEnter(e, item.id, item.disabled)} + ondragenter={(e) => handleDragEnter(e, item.id, item.disabled || gridLocked)} ondragleave={handleDragLeave} - ondragover={(e) => handleDragOver(e, item.disabled)} + ondragover={(e) => handleDragOver(e, item.disabled || gridLocked)} ondrop={(e) => handleDrop(e, item)} oncontextmenu={(e) => handleContextMenu(e, item)} role="button" - tabindex={item.disabled ? -1 : 0} - onclick={() => handleClick(item.id, item.disabled)} - onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)} + tabindex={item.disabled || gridLocked ? -1 : 0} + onclick={() => handleClick(item.id, item.disabled || gridLocked)} + onkeydown={(e) => + !item.disabled && !gridLocked && e.key === 'Enter' && handleClick(item.id)} > handleFileChange(e, item)} - disabled={item.disabled} + disabled={item.disabled || gridLocked} /> - {#if !item.disabled && dragOverId === item.id} + {#if !item.disabled && !gridLocked && dragOverId === item.id}
@@ -177,7 +188,7 @@
@@ -201,18 +212,19 @@
handleDragEnter(e, item.id, item.disabled)} + ondragenter={(e) => handleDragEnter(e, item.id, item.disabled || gridLocked)} ondragleave={handleDragLeave} - ondragover={(e) => handleDragOver(e, item.disabled)} + ondragover={(e) => handleDragOver(e, item.disabled || gridLocked)} ondrop={(e) => handleDrop(e, item)} oncontextmenu={(e) => handleContextMenu(e, item)} role="button" - tabindex={item.disabled ? -1 : 0} - onclick={() => handleClick(item.id, item.disabled)} - onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)} + tabindex={item.disabled || gridLocked ? -1 : 0} + onclick={() => handleClick(item.id, item.disabled || gridLocked)} + onkeydown={(e) => + !item.disabled && !gridLocked && e.key === 'Enter' && handleClick(item.id)} > handleFileChange(e, item)} - disabled={item.disabled} + disabled={item.disabled || gridLocked} /> - {#if !item.disabled && dragOverId === item.id} + {#if !item.disabled && !gridLocked && dragOverId === item.id}
@@ -256,7 +268,7 @@
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte index 2aa6b9e5..9b8deaf4 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/continuation-tab-form.svelte @@ -28,7 +28,7 @@ formData = { // Campos de esta pestaña numero_tipo_transporte: - invoice.logistics?.transport_num || invoice.logistics?.vehicle_num || '', + invoice.logistics?.trailer_num || invoice.logistics?.transport_num || invoice.logistics?.vehicle_num || '', es_ferrocarril: invoice.logistics?.is_rail ? 'si' : 'no', numero_bl: invoice.logistics?.bill_number || '', cantidad_guias_embarque: invoice.logistics?.guide_number || null, diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index 64a1da37..f1df88a6 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -97,7 +97,7 @@ transport_id: invoice.logistics?.transport_id || '', driver_name: invoice.logistics?.driver_name || '', transport_type: invoice.logistics?.transport_type || '', - transport_num: invoice.logistics?.vehicle_num || '', + trailer_num: invoice.logistics?.trailer_num || '', aduana: invoice.compliance_mx?.aduana || '', document_type: invoice.document_type || '', manifest_number: invoice.compliance_mx?.manifest_number || '', @@ -129,7 +129,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '', manifest_number: '', @@ -149,6 +149,13 @@ if (formData.manifest_number === undefined) formData.manifest_number = ''; if (formData.code_signature === undefined) formData.code_signature = ''; if (formData.electronic_signature === undefined) formData.electronic_signature = ''; + + // Migración de transport_num a trailer_num (Upstream change) + if (formData.trailer_num === undefined && (formData as any).transport_num) { + formData.trailer_num = (formData as any).transport_num; + delete (formData as any).transport_num; + } + if (formData.trailer_num === undefined) formData.trailer_num = ''; } }); @@ -202,6 +209,52 @@ ] ); + /** Conductores del transportista elegido (evita opciones inválidas ante validate_common). */ + const driversForCarrier = $derived.by(() => { + if (invoiceType === 'MEX') { + return drivers; + } + const cid = + formData.carrier_id !== null && formData.carrier_id !== undefined + ? String(formData.carrier_id).trim() + : ''; + if (!cid) { + return []; + } + return drivers.filter((d) => String(d.transporter_key) === cid); + }); + + $effect(() => { + if (invoiceType === 'MEX') { + return; + } + const cid = + formData.carrier_id !== null && formData.carrier_id !== undefined + ? String(formData.carrier_id).trim() + : ''; + if (!cid) { + if (formData.driver_name) { + formData.driver_name = ''; + } + if (formData.trailer_num) { + formData.trailer_num = ''; + } + return; + } + if (!drivers.length) { + return; + } + if (formData.driver_name) { + const ok = drivers.some( + (d) => + String(d.transporter_key) === cid && d.driver_name === formData.driver_name + ); + if (!ok) { + formData.driver_name = ''; + } + } + }); + const shippedByHeaderOptions = $derived( operationType === 1 || invoiceType === 'CR' ? [ @@ -291,12 +344,12 @@ -
+
{#if invoiceType !== 'MEX'}

Datos del pedimento

-
+
Fecha del:

{formData.fecha_pedimento_del || '-'}

@@ -319,7 +372,7 @@

Clientes - Proveedores - Agente Aduanal

-
+
- {#if !isSettings}*{/if} + {#if !isSettings}*{/if} - {#if !isSettings}*{/if} + {#if !isSettings}*{/if} - {#if !isSettings}*{/if} + {#if !isSettings}*{/if}
@@ -480,7 +533,7 @@ formData.customs_broker_id = v ? parseInt(v) : null; }} > - + {formData.customs_broker_id ? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name || @@ -529,14 +582,14 @@
-
+

Tipo de Moneda - Pesos Netos y Brutos

Tipo de cambio: - + {exchangeRate !== undefined && exchangeRate !== null ? exchangeRate === 0 ? 'N/A' @@ -553,19 +606,19 @@
-
-
-
@@ -581,12 +634,12 @@ formData.currency_type = v ?? ''; }} > - + {formData.currency_type || '...'} - + {#each currencyTypes as currencyType} {currencyType.code} @@ -596,7 +649,7 @@

{/if} -
+
- + {weightTypeOptions.find((w) => w.value === formData.weight_type)?.label || 'Kilogramos (kg)'} @@ -660,10 +713,10 @@
-
+

Transportista

-
+
{#if invoiceType !== 'MEX'}
@@ -672,9 +725,11 @@ value={formData.carrier_id ? String(formData.carrier_id) : ''} onValueChange={(v) => { formData.carrier_id = v || null; + formData.driver_name = ''; + formData.trailer_num = ''; }} > - + {#if formData.carrier_id} {transporters.find( @@ -736,6 +791,8 @@ { formData.driver_name = v ?? ''; }} @@ -744,7 +801,11 @@ {#if formData.driver_name} {formData.driver_name} - {:else if drivers.length > 0} + {:else if invoiceType !== 'MEX' && !formData.carrier_id} + Primero elige transportista... + {:else if invoiceType !== 'MEX' && driversForCarrier.length === 0} + Sin conductores para este transportista + {:else if driversForCarrier.length > 0} Selecciona conductor... {:else} Sin datos @@ -752,8 +813,8 @@ - {#each drivers as driver} - + {#each driversForCarrier as driver} + {driver.driver_name} {/each} @@ -761,7 +822,7 @@
-
+
-
- +
+ { - formData.transport_num = v ?? ''; + formData.trailer_num = v ?? ''; }} > - + - {#if formData.transport_num} - {trailers.find((t) => t.trailer_number === formData.transport_num) - ?.plate_number || formData.transport_num} + {#if formData.trailer_num} + {trailers.find((t) => t.trailer_number === formData.trailer_num)?.plate_number || + formData.trailer_num} + {:else if invoiceType !== 'MEX' && !formData.carrier_id} + Primero elige transportista... {:else if trailers.length > 0} Selecciona remolque... {:else} @@ -829,7 +896,7 @@ formData.aduana = v ?? ''; }} > - + {#if formData.aduana} {customsSections.find((cs) => cs.customs_code === formData.aduana) @@ -864,7 +931,7 @@ formData.document_type = v ?? ''; }} > - + {#if formData.document_type} {codePedimentoRegimens.find((r) => r.regimen_code === formData.document_type) diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 8cd4db12..bb555672 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -1,5 +1,5 @@ import { goto } from '$app/navigation'; -import { invoicesApi, type CreateInvoiceData, type UpdateInvoiceData, type OperationType } from '$lib/api/dashboard/a76/invoices'; +import { invoicesApi, type CreateInvoiceData, type UpdateInvoiceData } from '$lib/api/dashboard/a76/invoices'; interface FormDataSet { InvoiceTopFieldsFormData: any; @@ -30,6 +30,28 @@ interface SaveInvoiceResult { }>; } +function _logisticsStrNonempty(value: unknown): boolean { + return value != null && String(value).trim() !== ''; +} + +/** Remolque obligatorio si tipo != none (mismo criterio que validate_common). */ +function validateClientLogistics(formData: FormDataSet): SaveInvoiceResult['validationErrors'] { + const g = formData.generalFormData; + const transportType = _logisticsStrNonempty(g?.transport_type) ? String(g.transport_type).trim() : 'none'; + if (transportType === 'none' || _logisticsStrNonempty(g?.trailer_num)) { + return undefined; + } + return [ + { + field: 'logistics.trailer_num', + message: + 'El Remolque es obligatorio cuando el Tipo de Transporte es distinto de Ninguno.', + code: 'REQUIRED_FIELD', + value: g?.trailer_num + } + ]; +} + export async function saveInvoice(options: SaveInvoiceOptions): Promise { const { invoiceId, isCreate, companyId, formData } = options; const { InvoiceTopFieldsFormData, generalFormData, observationFormData, itemsFormData, othersFormData, continuationFormData } = formData; @@ -66,6 +88,13 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise | null | undefined): number { + if (!cr || typeof cr !== 'object') return 0; + const n = (k: string) => { + const v = (cr as Record)[k]; + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() !== '') return parseInt(v, 10) || 0; + return 0; + }; + return ( + n('skipped_invalid') + + n('skipped_missing_fk') + + n('skipped_missing_invoice') + + n('skipped_duplicate') + ); +} + +export function criticalReferenceGaps(cr: Record | null | undefined): number { + if (!cr || typeof cr !== 'object') return 0; + const v = (cr as Record).critical_reference_gaps; + if (typeof v === 'number' && Number.isFinite(v)) return v; + return 0; +} + +export function referenceStateReady(cr: Record | null | undefined): boolean { + if (!cr || typeof cr !== 'object') return true; + if ('reference_state_ready' in cr) { + const v = (cr as Record).reference_state_ready; + if (typeof v === 'boolean') return v; + if (v === 'True' || v === 'true') return true; + if (v === 'False' || v === 'false') return false; + } + return criticalReferenceGaps(cr) === 0; +} diff --git a/frontend/src/lib/csv-upload-row-count.ts b/frontend/src/lib/csv-upload-row-count.ts new file mode 100644 index 00000000..0fb17515 --- /dev/null +++ b/frontend/src/lib/csv-upload-row-count.ts @@ -0,0 +1,15 @@ +/** + * Cuenta filas de datos (excluye 1 línea de encabezado) en un CSV local. + * Asume primera línea no vacía = encabezado. + */ +export async function countCsvDataRows(file: File): Promise { + const text = await file.text(); + if (!text.trim()) return 0; + const lines = text.split(/\r\n|\r|\n/); + let nonEmpty = 0; + for (const line of lines) { + if (line.trim().length > 0) nonEmpty += 1; + } + if (nonEmpty <= 1) return 0; + return nonEmpty - 1; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index e7df9728..b57dde05 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -7,8 +7,8 @@ export function cn(...inputs: ClassValue[]) { /** * Convierte una ruta relativa del backend en una URL completa - * @param path Ruta relativa (ej: "/uploads/avatars/file.png") - * @returns URL completa del backend (ej: "http://localhost:8000/uploads/avatars/file.png") + * @param path Ruta relativa (ej: "/uploads/avatars/file.png") o absoluta API (ej: "/api/v1/...") + * @returns URL completa del backend */ export function getBackendAssetUrl(path: string | null | undefined): string { if (!path) return ''; @@ -18,16 +18,49 @@ export function getBackendAssetUrl(path: string | null | undefined): string { return path; } - // Eliminar la / inicial si existe para evitar // - const cleanPath = path.startsWith('/') ? path.slice(1) : path; + const normalized = path.startsWith('/') ? path : `/${path}`; // Obtener la base URL del API y limpiar el / final si existe let baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; - baseUrl = baseUrl.replace(/\/+$/, ''); // Eliminar todas las / del final + baseUrl = baseUrl.replace(/\/+$/, ''); + // VITE_API_URL suele ser .../api; las rutas del backend a veces vienen como /api/v1/... + // Evitar http://host/api/api/v1/... + if (normalized.startsWith('/api/') && baseUrl.endsWith('/api')) { + const origin = baseUrl.slice(0, -'/api'.length); + return `${origin}${normalized}`; + } + + const cleanPath = normalized.startsWith('/') ? normalized.slice(1) : normalized; return `${baseUrl}/${cleanPath}`; } +/** + * Obtiene únicamente el nombre del archivo a partir de una ruta/URL. + */ +export function getFileNameFromPath(filePath: string | null | undefined): string { + if (!filePath) return ''; + + const withoutQuery = filePath.split('?')[0].split('#')[0]; + const normalizedPath = withoutQuery.replace(/\\/g, '/'); + return normalizedPath.split('/').filter(Boolean).pop() || withoutQuery; +} + +/** + * Formatea el texto de visualización para archivos evitando mostrar rutas completas. + */ +export function getFileDisplayName( + filePath: string | null | undefined, + fileType?: string, + defaultLabel = 'Seleccionar archivo' +): string { + if (!filePath) return defaultLabel; + + const fileName = getFileNameFromPath(filePath); + if (!fileName) return defaultLabel; + return fileType ? `${fileName} (${fileType.toUpperCase()})` : fileName; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WithoutChild = T extends { child?: any } ? Omit : T; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/routes/dashboard/audit_logs/+page.svelte b/frontend/src/routes/dashboard/audit_logs/+page.svelte index 7de90e27..6d41408e 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.svelte +++ b/frontend/src/routes/dashboard/audit_logs/+page.svelte @@ -2,10 +2,11 @@ import { afterNavigate, goto } from '$app/navigation'; import { page } from '$app/state'; import * as Tabs from '$lib/components/ui/tabs'; - import { ScrollText, ListTodo } from 'lucide-svelte'; + import { ScrollText, ListTodo, FolderTree } from 'lucide-svelte'; import * as m from '$lib/paraglide/messages.js'; import BitacoraTab from './bitacora-tab.svelte'; import TasksTab from './tasks-tab.svelte'; + import FilesTab from './files-tab.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -17,17 +18,19 @@ }); afterNavigate(() => { - const t = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora'; + const raw = page.url.searchParams.get('tab'); + const t = raw === 'tasks' || raw === 'files' ? raw : 'bitacora'; tabValue = t; }); function onTabValueChange(v: string) { - const fromUrl = page.url.searchParams.get('tab') === 'tasks' ? 'tasks' : 'bitacora'; + const raw = page.url.searchParams.get('tab'); + const fromUrl = raw === 'tasks' || raw === 'files' ? raw : 'bitacora'; if (v === fromUrl) return; const u = new URL(page.url.href); - if (v === 'tasks') { - u.searchParams.set('tab', 'tasks'); + if (v === 'tasks' || v === 'files') { + u.searchParams.set('tab', v); } else { u.searchParams.delete('tab'); } @@ -53,7 +56,7 @@ onValueChange={onTabValueChange} class="flex min-h-0 w-full flex-1 flex-col" > - + {m['sidebar.audit_logs_tab_bitacora']()} @@ -62,6 +65,10 @@ {m['sidebar.audit_logs_tab_tasks']()} + + + {m['sidebar.audit_logs_tab_files']()} + @@ -74,5 +81,10 @@ {/if} + + {#if tabValue === 'files'} + + {/if} +
diff --git a/frontend/src/routes/dashboard/audit_logs/+page.ts b/frontend/src/routes/dashboard/audit_logs/+page.ts index 1bf476b2..a653eef6 100644 --- a/frontend/src/routes/dashboard/audit_logs/+page.ts +++ b/frontend/src/routes/dashboard/audit_logs/+page.ts @@ -2,6 +2,6 @@ import type { PageLoad } from './$types'; export const load: PageLoad = ({ url }) => { const tab = url.searchParams.get('tab'); - const initialTab = tab === 'tasks' ? 'tasks' : 'bitacora'; + const initialTab = tab === 'tasks' || tab === 'files' ? tab : 'bitacora'; return { initialTab }; }; diff --git a/frontend/src/routes/dashboard/audit_logs/files-tab.svelte b/frontend/src/routes/dashboard/audit_logs/files-tab.svelte new file mode 100644 index 00000000..051df209 --- /dev/null +++ b/frontend/src/routes/dashboard/audit_logs/files-tab.svelte @@ -0,0 +1,205 @@ + + +
+
+
+

{m['sidebar.audit_logs_files_title']()}

+

{displayPath || m['sidebar.audit_logs_files_root']()}

+
+ +
+ + + +
+ {#each breadcrumbs as crumb, idx} + {#if idx > 0} + / + {/if} + {#if idx === breadcrumbs.length - 1} + + {crumb.display_name} + + {:else} + + {/if} + {/each} +
+
+
+ + + + {m['sidebar.audit_logs_files_list_title']()} + + + {#if error} +
+ {m['sidebar.audit_logs_files_error_prefix']()} {error} +
+ {/if} +
+ + + + + {m['sidebar.audit_logs_files_col_name']()} + + + {m['sidebar.audit_logs_files_col_size']()} + + + {m['sidebar.audit_logs_files_col_modified']()} + + + {m['sidebar.audit_logs_files_col_actions']()} + + + + + {#if loading} + + + {m['sidebar.audit_logs_files_loading']()} + + + {:else if folders.length === 0 && files.length === 0} + + + {m['sidebar.audit_logs_files_empty']()} + + + {:else} + {#each folders as folder} + void loadPath(folder.path)} + > + + + + {folder.display_name} + + + + + + + {/each} + {#each files as file} + + + {file.display_name} + + {formatSize(file.size)} + {formatDate(file.last_modified)} + + + + + {/each} + {/if} + + +
+
+
+
diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 26cdc4bc..4fef2855 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -32,6 +32,8 @@ type CsvImportPendingEntry } from '$lib/csv-import-pending'; import { fetchCsvImportStatus } from '$lib/csv-import-status-api'; + import { totalSkippedFromCommit } from '$lib/csv-import-commit-metrics'; + import { countCsvDataRows } from '$lib/csv-upload-row-count'; import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte'; /** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */ @@ -113,6 +115,24 @@ ? safeInt(skippedDuplicateMatch[1]) : 0; + const skippedMissingInvMatch = + s.match(/['"]skipped_missing_invoice['"]:\s*['"]?(\d+)['"]?/) || + s.match(/['"]skipped_missing_invoice['"]:\s*([^,}]+)/); + const skipped_missing_invoice = skippedMissingInvMatch + ? safeInt(skippedMissingInvMatch[1]) + : 0; + + const gapsMatch = + s.match(/['"]critical_reference_gaps['"]:\s*['"]?(\d+)['"]?/) || + s.match(/['"]critical_reference_gaps['"]:\s*([^,}]+)/); + const critical_reference_gaps = gapsMatch ? safeInt(gapsMatch[1]) : 0; + + const refReadyMatch = s.match(/['"]reference_state_ready['"]:\s*(True|False|true|false)/); + let reference_state_ready = true; + if (refReadyMatch) { + reference_state_ready = refReadyMatch[1].toLowerCase() === 'true'; + } + // Detalle opcional: lista de dicts con {line, reason, solution?} const skipped_details: Array<{ line: number; reason: string; solution?: string }> = []; const detailRegex = @@ -132,11 +152,19 @@ const message = messageMatch ? unescapePy(messageMatch[1]) : undefined; // Si no hay contadores en el texto, no es un commit. - if (inserted === 0 && updated === 0 && skipped_invalid === 0 && skipped_missing_fk === 0 && skipped_duplicate === 0) { + if ( + inserted === 0 && + updated === 0 && + skipped_invalid === 0 && + skipped_missing_fk === 0 && + skipped_duplicate === 0 && + skipped_missing_invoice === 0 && + critical_reference_gaps === 0 + ) { return null; } - // Mínimo requerido por el modal + // Mínimo requerido por el modal (contrato tipo pedimentos) return { status, inserted, @@ -144,6 +172,9 @@ skipped_invalid, skipped_missing_fk, skipped_duplicate, + skipped_missing_invoice, + critical_reference_gaps, + reference_state_ready, skipped_details, ...(message ? { message } : {}) }; @@ -198,58 +229,85 @@ /** Job id del escaneo listo para confirmar (para quitar de pendientes al hacer commit). */ let scanPhaseJobId = $state(null); + /** + * Total de filas de datos conocido para la barra (máx. entre conteo local, total del poll Celery y total_rows del escaneo). + * No se reinicia mientras el modal de resultados siga abierto, para poder usar el mismo total en el commit. + */ + let csvImportRowTotal = $state(0); + const csvProgressStepTitle = $derived.by(() => { switch (csvProgressPhase) { case 'upload': - return 'Paso 1 de 2 — Subiendo el archivo'; + return 'Subiendo archivo CSV'; case 'scan': - return 'Paso 2 de 2 — Escaneando en el servidor'; + return 'Validando registros en el servidor'; case 'commit': - return 'Finalizando importación'; + return 'Grabando registros en base de datos'; default: return ''; } }); + /** Denominador único: filas de la tarea (registros del CSV / total reportado por el worker). */ + const csvImportProgressDenom = $derived.by(() => { + const d = Math.max(csvImportRowTotal || 0, scanProgressTotal || 0); + return d > 0 ? d : 1; + }); + const csvProgressDetailLine = $derived.by(() => { + const denom = csvImportProgressDenom; switch (csvProgressPhase) { case 'upload': - return ''; + if (csvImportRowTotal > 0) { + return `Archivo: ~${csvImportRowTotal} fila(s) de datos — subiendo (aún no se validan registros en servidor)…`; + } + return uploadLengthComputable + ? 'Subiendo archivo…' + : 'Subiendo archivo (tamaño desconocido en el navegador)…'; case 'scan': - return scanProgressTotal > 0 - ? `Filas procesadas: ${scanProgressCurrent} / ${scanProgressTotal}` - : 'Preparando resultados…'; + return `Registros procesados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`; case 'commit': - return 'Escribiendo registros en base de datos…'; + return scanProgressTotal > 0 + ? `Registros grabados: ${Math.min(scanProgressCurrent, denom)} / ${denom}` + : `Grabando en base de datos… (${Math.min(scanProgressCurrent, denom)} / ${denom} según último total conocido)`; default: return ''; } }); - const csvProgressBarIndeterminate = $derived( - csvProgressPhase === 'commit' || - (csvProgressPhase === 'scan' && scanProgressTotal <= 0) || - (csvProgressPhase === 'upload' && !uploadLengthComputable) + /** Porcentaje según filas procesadas / total de filas (sin tramos artificiales por etapa). */ + const csvCombinedProgressPct = $derived.by(() => { + if (!isUploading || csvProgressPhase === 'idle') return 0; + if (csvProgressPhase === 'upload') { + return 0; + } + const denom = csvImportProgressDenom; + const numer = Math.min(Math.max(0, scanProgressCurrent), denom); + return Math.min(100, Math.round((100 * numer) / denom)); + }); + + /** + * Commit: muchas tareas no publican PROGRESS por fila; si current sigue en 0, barra indeterminada. + * Cuando Celery envía current > 0, la barra pasa a reflejar registros reales. + */ + const csvFooterProgressIndeterminate = $derived( + isUploading && !showResultModal && csvProgressPhase === 'commit' && scanProgressCurrent <= 0 ); - const csvProgressBarValue = $derived( - csvProgressPhase === 'upload' - ? uploadProgressPct - : csvProgressPhase === 'scan' && scanProgressTotal > 0 - ? Math.min(100, Math.round((scanProgressCurrent / scanProgressTotal) * 100)) - : 0 + const csvFooterPercentLabel = $derived( + csvFooterProgressIndeterminate + ? null + : csvProgressPhase === 'upload' + ? '0%' + : `${csvCombinedProgressPct}%` ); - const csvProgressPercentText = $derived( - csvProgressBarIndeterminate ? null : `${csvProgressBarValue}%` - ); - - const csvProgressAriaValueText = $derived( - csvProgressPercentText - ? `${csvProgressStepTitle}, ${csvProgressPercentText}` + const csvFooterAriaValueText = $derived( + csvFooterProgressIndeterminate + ? `${csvProgressStepTitle}. ${csvProgressDetailLine || 'En proceso…'}` : csvProgressDetailLine - ? `${csvProgressStepTitle}. ${csvProgressDetailLine}` - : csvProgressStepTitle + ? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}` + : `${csvProgressStepTitle}, ${csvFooterPercentLabel}` ); $effect(() => { @@ -257,8 +315,12 @@ csvProgressPhase = 'idle'; uploadProgressPct = 0; uploadLengthComputable = false; - scanProgressCurrent = 0; - scanProgressTotal = 0; + // Con modal abierto (escaneo listo o resultado de commit) conservar totales para la siguiente fase o la UI. + if (!showResultModal) { + scanProgressCurrent = 0; + scanProgressTotal = 0; + csvImportRowTotal = 0; + } currentImportLabel = null; csvResumeOverlayHint = false; scanPhaseJobId = null; @@ -523,6 +585,12 @@ uploadLengthComputable = false; scanProgressCurrent = 0; scanProgressTotal = 0; + csvImportRowTotal = 0; + try { + csvImportRowTotal = await countCsvDataRows(file); + } catch (e) { + console.warn('csv-upload: no se pudo contar filas del archivo', e); + } csvResumeOverlayHint = false; skipScanCompleteToastOnce = false; scanPhaseJobId = null; @@ -914,13 +982,20 @@ const p = (res.data as { progress?: unknown }).progress; const t = (res.data as { total?: unknown }).total; if (typeof p === 'number') scanProgressCurrent = p; - if (typeof t === 'number') scanProgressTotal = t; + if (typeof t === 'number') { + scanProgressTotal = t; + csvImportRowTotal = Math.max(csvImportRowTotal, t); + } } // Tratar como resultado de escaneo si viene status waiting_confirmation O si el payload tiene forma de scan (job_id + total_rows) const looksLikeScanResult = res.data?.status === 'waiting_confirmation' || (res.data?.job_id && typeof res.data?.total_rows === 'number'); if (looksLikeScanResult) { + const tr = (res.data as { total_rows?: unknown }).total_rows; + if (typeof tr === 'number' && tr > 0) { + csvImportRowTotal = Math.max(csvImportRowTotal, tr); + } scanResults = res.data; pushScanToPendingLocal(res.data as Record); showResultModal = true; @@ -965,6 +1040,10 @@ } } if (parsedScan) { + const tr = parsedScan.total_rows; + if (typeof tr === 'number' && tr > 0) { + csvImportRowTotal = Math.max(csvImportRowTotal, tr); + } scanResults = parsedScan; pushScanToPendingLocal(parsedScan); showResultModal = true; @@ -1019,16 +1098,21 @@ commitResults = res.data; showResultModal = true; const inserted = res.data?.inserted || 0; - const skippedInvalid = res.data?.skipped_invalid || 0; - const skippedFk = res.data?.skipped_missing_fk || 0; - const skippedDup = res.data?.skipped_duplicate || 0; - const totalSkipped = skippedInvalid + skippedFk + skippedDup; + const updated = res.data?.updated || 0; + const totalSkipped = totalSkippedFromCommit(res.data as Record); const backendMessage = res.data?.message; + const totalOk = inserted + updated; - if (inserted === 0) { - toast.error(backendMessage || `No se insertaron registros. ${totalSkipped} fueron rechazados.`); + if (totalOk === 0) { + toast.error( + backendMessage || + `No se insertaron ni actualizaron registros. ${totalSkipped} fueron rechazados.` + ); } else { - toast.warning(backendMessage || `Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`); + toast.warning( + backendMessage || + `Se aplicaron ${totalOk} registros (${inserted} insertados, ${updated} actualizados). ${totalSkipped} rechazados.` + ); } finalizeCommitAndClearPending(); isUploading = false; @@ -1037,18 +1121,14 @@ showResultModal = true; const inserted = res.data?.inserted || 0; const updated = res.data?.updated || 0; - const skippedInvalid = res.data?.skipped_invalid || 0; - const skippedFk = res.data?.skipped_missing_fk || 0; - const skippedDup = res.data?.skipped_duplicate || 0; - const skippedDetails = res.data?.skipped_details || []; + const totalSkipped = totalSkippedFromCommit(res.data as Record); if (inserted > 0 || updated > 0) { const parts = []; if (inserted > 0) parts.push(`${inserted} insertados`); if (updated > 0) parts.push(`${updated} actualizados`); toast.success(`Importación completada: ${parts.join(', ')}`); - if (skippedInvalid > 0 || skippedFk > 0 || skippedDup > 0) { - const totalSkipped = skippedInvalid + skippedFk + skippedDup; + if (totalSkipped > 0) { toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`); } } else { @@ -1059,14 +1139,14 @@ } else { // Continue polling console.log('Status not final, polling again in 2s...', res.data?.status); - setTimeout(pollStatus, 2000); + setTimeout(pollStatus, 800); } } catch (e) { console.error('Poll exception', e); // Retry on network error? Or fail? // For now, let's keep retrying a few times or hard fail. // Let's just log and retry. - setTimeout(pollStatus, 2000); + setTimeout(pollStatus, 800); } } @@ -1093,7 +1173,11 @@

Catálogos Generales

- + @@ -1101,7 +1185,11 @@

Logística y Transporte

- +
@@ -1109,67 +1197,93 @@

Operaciones de Importación

- +

Operaciones de Exportación

- +
- - + +
- {#if isUploading && !showResultModal} -
- {#if csvResumeOverlayHint} -

- Reanudando la importación guardada en esta pestaña… -

- {/if} - {#if currentImportLabel} -

{currentImportLabel}

- {/if} -
-

{csvProgressStepTitle}

- {#if csvProgressPercentText} - - {/if} -
- {#if csvProgressDetailLine} -

{csvProgressDetailLine}

- {/if} -
- {#if csvProgressBarIndeterminate} -
-
+ +
+ {#if isUploading && !showResultModal} +
+
+ {#if csvResumeOverlayHint} +

Reanudando la importación guardada en esta pestaña…

+ {/if} + {#if currentImportLabel} +

{currentImportLabel}

+ {/if} +
+

+ {csvProgressStepTitle} +

+ {#if csvFooterPercentLabel} + + {/if}
- {:else} - - {/if} + {#if csvProgressDetailLine} +

{csvProgressDetailLine}

+ {/if} +
+ {#if csvFooterProgressIndeterminate} +
+
+
+ {:else} + + {/if} +
+
-
- {/if} - - - + {/if} + +
{#if scanResults || commitResults} @@ -1213,7 +1327,6 @@ isUploading = true; csvProgressPhase = 'commit'; scanProgressCurrent = 0; - scanProgressTotal = 0; const res = useCustomsBrokerImport ? await api.customsBrokerImports.commit(currentJobId) : useClientProviderImport diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 0c001f08..43de3704 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -4,6 +4,7 @@ import { companyStore } from '$lib/stores/company.svelte'; import { customsBrokersApi, + uploadCustomsBrokerVuFile, type CreateCustomsBrokerData } from '$lib/api/dashboard/a76/customs-brokers'; @@ -41,6 +42,7 @@ ShieldCheck } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; + import { getFileDisplayName } from '$lib/utils'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; import { obtenerAtajosEdicionAgente } from '$lib/config/shortcuts/dashboard/customs_brokers/edit'; @@ -100,6 +102,15 @@ archive_path: '' }); + let pendingVuFiles = $state<{ + certificate?: File; + key?: File; + cove?: File; + dodaCertificate?: File; + dodaKey?: File; + dodaCove?: File; + }>({}); + let brokerKeyError = $state(false); let licenseError = $state(false); let brokerKeyTimeout: ReturnType; @@ -121,10 +132,88 @@ const file = input.files?.[0]; if (file) { vuData[targetKey] = file.name; + if (targetKey === 'certificate_path') pendingVuFiles.certificate = file; + if (targetKey === 'key_path') pendingVuFiles.key = file; + if (targetKey === 'xml_files_path') pendingVuFiles.cove = file; + if (targetKey === 'doda_certificate_path') pendingVuFiles.dodaCertificate = file; + if (targetKey === 'doda_key_path') pendingVuFiles.dodaKey = file; + if (targetKey === 'doda_xml_files_path') pendingVuFiles.dodaCove = file; toast.success(`Archivo ${file.name} seleccionado`); } } + async function uploadPendingVuFiles(companyId: string, brokerKey: string) { + if (pendingVuFiles.certificate) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'certificate', + pendingVuFiles.certificate + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir certificado VU (.cer)'); + } + vuData.certificate_path = (res as any).data.path; + } + + if (pendingVuFiles.key) { + const res = await uploadCustomsBrokerVuFile(brokerKey, companyId, 'key', pendingVuFiles.key); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir llave VU (.key)'); + } + vuData.key_path = (res as any).data.path; + } + + if (pendingVuFiles.cove) { + const res = await uploadCustomsBrokerVuFile(brokerKey, companyId, 'cove', pendingVuFiles.cove); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir archivo COVE'); + } + vuData.xml_files_path = (res as any).data.path; + } + + if (pendingVuFiles.dodaCertificate) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'doda_certificate', + pendingVuFiles.dodaCertificate + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir certificado DODA (.cer)'); + } + vuData.doda_certificate_path = (res as any).data.path; + } + + if (pendingVuFiles.dodaKey) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'doda_key', + pendingVuFiles.dodaKey + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir llave DODA (.key)'); + } + vuData.doda_key_path = (res as any).data.path; + } + + if (pendingVuFiles.dodaCove) { + const res = await uploadCustomsBrokerVuFile( + brokerKey, + companyId, + 'doda_cove', + pendingVuFiles.dodaCove + ); + if ((res as any).error || !(res as any).data?.path) { + throw new Error((res as any).error || 'Error al subir archivo DODA'); + } + vuData.doda_xml_files_path = (res as any).data.path; + } + + pendingVuFiles = {}; + } + async function loadBrokerData(key: string, cId: string) { if (!key || key === 'undefined') return; loading = true; @@ -279,6 +368,9 @@ if ((res as any).error) throw new Error((res as any).error); + // Primero sube archivos CER/KEY/COVE al bucket y guarda la ruta real en vuData. + await uploadPendingVuFiles(cId, formData.broker_key); + // UPSERT VU try { const vuRes = await customsBrokersApi.updateVU(formData.broker_key, vuData, cId); @@ -694,15 +786,20 @@
@@ -726,15 +823,20 @@
@@ -836,8 +938,13 @@ >
@@ -950,15 +1057,20 @@
@@ -982,15 +1094,20 @@
@@ -1039,8 +1156,13 @@ >
diff --git a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte index 09f7ced6..1401d421 100644 --- a/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/company_information/edit/[[id]]/+page.svelte @@ -16,7 +16,7 @@ type Company } from '$lib/api/dashboard/a76/general_catalogs/company'; import { companyStore } from '$lib/stores/company.svelte'; - import { getBackendAssetUrl } from '$lib/utils'; + import { getBackendAssetUrl, getFileDisplayName } from '$lib/utils'; import { ArrowLeft, LoaderCircle, @@ -498,7 +498,7 @@ successMessage = null; try { - const response = await uploadCompanyCertificate(Number(id), file, type); + const response = await uploadCompanyCertificate(Number(id), type, file); if (response.error) { throw new Error(response.error); @@ -1614,9 +1614,7 @@ > - {formData.fiel_cer - ? formData.fiel_cer.split('/').pop() - : 'Seleccionar archivo .cer'} + {getFileDisplayName(formData.fiel_cer, 'CER', 'Seleccionar archivo .cer')}
@@ -1638,9 +1636,7 @@ > - {formData.fiel_key - ? formData.fiel_key.split('/').pop() - : 'Seleccionar archivo .key'} + {getFileDisplayName(formData.fiel_key, 'KEY', 'Seleccionar archivo .key')}
@@ -1688,9 +1684,11 @@ > - {formData.cfdi_cert_cer - ? formData.cfdi_cert_cer.split('/').pop() - : 'Seleccionar archivo .cer'} + {getFileDisplayName( + formData.cfdi_cert_cer, + 'CER', + 'Seleccionar archivo .cer' + )}
@@ -1712,9 +1710,11 @@ > - {formData.cfdi_cert_key - ? formData.cfdi_cert_key.split('/').pop() - : 'Seleccionar archivo .key'} + {getFileDisplayName( + formData.cfdi_cert_key, + 'KEY', + 'Seleccionar archivo .key' + )}
@@ -1774,9 +1774,7 @@ > - {formData.cancel_cer - ? formData.cancel_cer.split('/').pop() - : 'Seleccionar archivo .cer'} + {getFileDisplayName(formData.cancel_cer, 'CER', 'Seleccionar archivo .cer')}
@@ -1798,9 +1796,7 @@ > - {formData.cancel_key - ? formData.cancel_key.split('/').pop() - : 'Seleccionar archivo .key'} + {getFileDisplayName(formData.cancel_key, 'KEY', 'Seleccionar archivo .key')}
diff --git a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte index 2455fea8..d8ea7064 100644 --- a/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte +++ b/frontend/src/routes/dashboard/help-center/editor/[uuid]/+page.svelte @@ -26,6 +26,7 @@ import { toast } from 'svelte-sonner'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; + import { getFileNameFromPath } from '$lib/utils'; // Helper to resolve an API path from the DB (e.g., /api/uploads/...) to a full URL pointing to the local backend. function resolveAssetUrl(url: string | undefined): string { @@ -439,7 +440,9 @@

Archivo Cargado

-

{file_url}

+

+ {getFileNameFromPath(file_url)} +

{mime_type} • {file_size ? (file_size / 1024 / 1024).toFixed(2) : '??'} MB

diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index a12dfe38..cebf8012 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -132,7 +132,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '' }; @@ -223,6 +223,11 @@ const filtered = Object.fromEntries( Object.entries(partial).filter(([_, value]) => value !== undefined) ); + // Compat: remolque vivía en transport_num en layouts antiguos + if (filtered.trailer_num === undefined && filtered.transport_num !== undefined) { + filtered.trailer_num = filtered.transport_num; + delete filtered.transport_num; + } return { ...skeleton, ...filtered }; } @@ -274,7 +279,7 @@ transport_id: invoice.logistics?.transport_id || '', driver_name: invoice.logistics?.driver_name || '', transport_type: invoice.logistics?.transport_type || '', - transport_num: invoice.logistics?.vehicle_num || '', + trailer_num: invoice.logistics?.trailer_num || '', aduana: invoice.compliance_mx?.aduana || '', document_type: invoice.document_type || '' }; @@ -342,28 +347,38 @@ function mapInvoiceToContinuation(invoice: any) { if (!invoice) return continuationSkeleton; + const logistics = invoice.logistics ?? {}; + const complianceMx = invoice.compliance_mx ?? {}; + return { - numero_tipo_transporte: invoice.logistics?.numero_tipo_transporte || '', - es_ferrocarril: invoice.logistics?.es_ferrocarril || 'no', - numero_bl: invoice.logistics?.numero_bl || '', - cantidad_guias_embarque: invoice.logistics?.cantidad_guias_embarque || null, - destino_origen: invoice.logistics?.destino_origen || '', - puerto_entrada: invoice.logistics?.puerto_entrada || '', - vehicle_data: invoice.logistics?.vehicle_data || '', - fue_revisado_equipo: invoice.logistics?.fue_revisado_equipo || false, - sub_division: invoice.compliance_mx?.subdivision || false, - funge_como_cd: invoice.logistics?.acts_as_cd || false, - llego_pedimento: invoice.compliance_mx?.llego_pedimento || false, + numero_tipo_transporte: + logistics.transport_num ?? logistics.vehicle_num ?? logistics.numero_tipo_transporte ?? '', + es_ferrocarril: + typeof logistics.is_rail === 'boolean' + ? logistics.is_rail + ? 'si' + : 'no' + : (logistics.es_ferrocarril ?? 'no'), + numero_bl: logistics.bill_number ?? logistics.numero_bl ?? '', + cantidad_guias_embarque: logistics.guide_number ?? logistics.cantidad_guias_embarque ?? null, + destino_origen: logistics.destination_location ?? logistics.destino_origen ?? '', + puerto_entrada: logistics.origin_location ?? logistics.puerto_entrada ?? '', + vehicle_data: logistics.vehicle_data ?? '', + fue_revisado_equipo: logistics.equipment_reviewed ?? logistics.fue_revisado_equipo ?? false, + sub_division: logistics.is_subdivision ?? complianceMx.subdivision ?? false, + funge_como_cd: logistics.acts_as_cd ?? false, + llego_pedimento: logistics.pedimento_arrived ?? complianceMx.llego_pedimento ?? false, errores_facturacion: invoice.errores_facturacion || [], semaforo_verde_aduana_mexicana: - invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, + logistics.green_light_mx ?? complianceMx.semaforo_verde_aduana_mexicana ?? false, semaforo_verde_aduana_americana: - invoice.compliance_mx?.semaforo_verde_aduana_americana || false, - semaforo_rojo_aduana_mexicana: invoice.compliance_mx?.semaforo_rojo_aduana_mexicana || false, + logistics.green_light_us ?? complianceMx.semaforo_verde_aduana_americana ?? false, + semaforo_rojo_aduana_mexicana: + logistics.red_light_mx ?? complianceMx.semaforo_rojo_aduana_mexicana ?? false, semaforo_rojo_aduana_americana: - invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, - is_mixed: invoice.compliance_mx?.is_mixed || false, - reason_export: invoice.compliance_mx?.reason_export || '1', + logistics.red_light_us ?? complianceMx.semaforo_rojo_aduana_americana ?? false, + is_mixed: complianceMx.is_mixed ?? false, + reason_export: complianceMx.reason_export ?? '1', purchase_order: invoice.purchase_order || '', payment_terms: invoice.payment_terms || '', handling_fees: invoice.financials?.handling_fees || 0, diff --git a/frontend/src/routes/dashboard/invoices/settings/+page.svelte b/frontend/src/routes/dashboard/invoices/settings/+page.svelte index e513b760..ed0131d8 100644 --- a/frontend/src/routes/dashboard/invoices/settings/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/settings/+page.svelte @@ -185,7 +185,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '', ...(systemData.generalFormData || {}) @@ -311,7 +311,7 @@ transport_id: '', driver_name: '', transport_type: '', - transport_num: '', + trailer_num: '', aduana: '', document_type: '' };