feature/csv-for-envoices

This commit is contained in:
hreyes
2026-02-23 13:05:02 -06:00
parent bbe74906ec
commit 0683aaa801
21 changed files with 1232 additions and 255 deletions

5
.gitignore vendored
View File

@@ -23,9 +23,11 @@ wheels/
*.egg
.pnpm-store/
# Environment
# Environment (no subir: cada quien puede usar puertos distintos vía .env)
.env
.env.local
backend/.env
frontend/.env
backend/SCRIPTS/
# IDEs
.vscode/
@@ -62,4 +64,3 @@ node_modules/
*.dockerignore
postgres-data/
backend/uploads/
docker-compose.yml

0
backend/api/__init__.py Normal file
View File

View File

View File

View File

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from uuid import uuid4
import base64
import os
import json
import logging
@@ -12,24 +13,39 @@ from core.config import settings
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from .tasks import scan_file, insert_valid_rows
from .tasks import (
scan_file,
insert_valid_rows,
IMPORT_FILE_KEY_PREFIX,
IMPORT_META_KEY_PREFIX,
IMPORT_REDIS_TTL,
)
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
router = APIRouter()
logger = logging.getLogger(__name__)
def _get_redis():
"""Redis client (same broker as Celery so worker can read)."""
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
@router.post("/upload/{model_target}", response_model=ImportJobResponse)
async def upload_import_file(
model_target: Literal["invoice_header", "invoice_details"],
file: UploadFile = File(...),
footer_config: Optional[str] = Form(None), # JSON string with settings
company_id: int = Query(..., description="Company ID"), # Required for context
template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas
company_id: int = Query(..., description="Company ID"), # Required for context
operation_type: Optional[str] = Query("imp"),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Step 1: Upload CSV, save to temp, trigger scan task.
Si se envía template_id, solo se leen las columnas de esa plantilla.
"""
# 1. Validate Access & Get Tenant
try:
@@ -40,40 +56,51 @@ async def upload_import_file(
if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Only .csv files allowed")
job_id = str(uuid4())
# Ensure directory exists (Safety check)
upload_dir = os.path.join(os.getcwd(), "uploads", "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")
contents = await file.read()
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"footer_config": footer_config,
"operation_type": operation_type,
"template_id": template_id,
}
# Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed)
try:
# Save CSV
contents = await file.read()
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,
)
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 = os.path.join(os.getcwd(), "uploads", "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)
# Save Metadata (Context)
meta_data = {
"tenant_id": tenant_id,
"company_id": company_id,
"user_id": current_user.get("id"),
"footer_config": footer_config,
"operation_type": operation_type,
}
with open(meta_path, "w") as f:
json.dump(meta_data, f)
except Exception as e:
logger.error(f"File save error: {e}")
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
logger.warning(f"Local file save failed (worker will use Redis): {e}")
# Trigger Celery Task (Async)
# Use our job_id as the Celery task_id for easier tracking
scan_file.apply_async(args=[job_id, file_path, model_target, footer_config], task_id=job_id)
# Trigger Celery Task (Async). Worker loads file from Redis.
scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id)
return ImportJobResponse(
job_id=job_id,
@@ -84,24 +111,56 @@ async def upload_import_file(
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
"""
Poll this endpoint to get % progress or final report.
Poll to get progress or final report. Always returns an object with "status".
"""
# In a real app, query Redis or DB.
# For MVP, we might mock or use Celery AsyncResult if backend shares Redis.
task_result = celery_app.AsyncResult(job_id)
if task_result.state == 'PENDING':
if task_result.state == "PENDING":
return {"status": "processing", "progress": 0}
elif task_result.state == 'PROGRESS':
if task_result.state == "PROGRESS":
return {
"status": "processing",
"progress": task_result.info.get('current', 0),
"total": task_result.info.get('total', 0)
"status": "processing",
"progress": (task_result.info or {}).get("current", 0),
"total": (task_result.info or {}).get("total", 0),
}
elif task_result.state == 'SUCCESS':
return task_result.result # Should return the report
else:
return {"status": task_result.state, "error": str(task_result.info)}
if task_result.state == "SUCCESS":
result = task_result.result
if isinstance(result, dict) and "status" in result:
return 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)
err_msg = None
tb = getattr(task_result, "traceback", None)
if tb:
logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb)
if tb and isinstance(tb, str):
lines = [l.strip() for l in tb.strip().split("\n") if l.strip()]
if lines:
err_msg = lines[-1]
if not err_msg and len(lines) > 1:
err_msg = lines[-2] + " " + (lines[-1] or "")
if not err_msg:
try:
exc = task_result.get(propagate=False)
if exc is not None:
err_msg = str(exc)
except Exception:
pass
if not err_msg:
result = getattr(task_result, "result", None)
info = getattr(task_result, "info", None)
if result is not None and not isinstance(result, dict):
err_msg = str(result)
elif isinstance(result, dict) and (result.get("error") or result.get("message")):
err_msg = result.get("error") or result.get("message")
if not err_msg and isinstance(info, str):
err_msg = info
elif not err_msg and isinstance(info, dict) and "error" in info:
err_msg = str(info["error"])
if not err_msg:
err_msg = "Task failed"
return {"status": "failed", "error": err_msg}
@router.post("/{job_id}/commit")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
"""
Configuración de plantillas CSV: columnas que trae cada plantilla y cómo se mapean.
La plantilla se respeta tal cual: solo se leen columnas definidas aquí; el resto se ignora.
Solo se escribe en BD lo que los modelos de facturas aceptan (respetando models).
"""
from typing import Dict, List, Any, Optional
# Cada plantilla define sus columnas canónicas y alias (otros nombres que aceptamos en el CSV).
# canonical = nombre estándar con el que trabajamos internamente; debe coincidir con lo que
# espera la lógica de validación e insert (tasks.py).
# aliases = cabeceras alternativas que la plantilla .xls puede traer (ej. "Num Factura" → NUM FACTURA).
TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = {
# --- Encabezado factura: Impo Temp (EstructuraEncFacImpoTemp.xls) ---
"imp_temp_header": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]},
{"canonical": "FECHA FACTURA", "aliases": ["FECHA"]},
{"canonical": "FECHA EMISION"},
{"canonical": "CLAVE PROVEEDOR"},
{"canonical": "CLAVE VENDIDO A"},
{"canonical": "CLAVE ENVIADO A"},
{"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]},
{"canonical": "ADUANA DE CRUCE"},
{"canonical": "CLAVE MONEDA"},
{"canonical": "CLAVE INCOTERM"},
{"canonical": "TIPO MONEDA"},
{"canonical": "TIPO DE CAMBIO"},
{"canonical": "TIPO PESO"},
{"canonical": "TIPO TRANSPORTE"},
{"canonical": "REMESA"},
{"canonical": "AGENTE ADUANAL"},
{"canonical": "FLETES"},
{"canonical": "VALOR SEGUROS"},
{"canonical": "SEGUROS"},
{"canonical": "EMBALAJES"},
{"canonical": "OTROS INCREMENTABLES"},
{"canonical": "NUM PROYECTO", "aliases": ["NUMPROYECTO"]},
{"canonical": "ORDEN COMPRA", "aliases": ["ORDENCOMPRA"]},
{"canonical": "FACTURA ALTERNA"},
{"canonical": "FACTURA EXPO REF", "aliases": ["FACTURAEXPOREF"]},
{"canonical": "OBSERVACIONES E"},
{"canonical": "OBSERVACIONES I"},
{"canonical": "E DOCUMENT"},
{"canonical": "NUM OPERACION"},
{"canonical": "CLAVE TRANSPORTISTA"},
{"canonical": "NOMBRE CONDUCTOR"},
{"canonical": "NUMERO TRANSPORTE"},
{"canonical": "PRECINTO"},
],
# --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - misma estructura ---
"imp_def_header": None, # se resuelve igual que imp_temp_header
# --- Encabezado factura: Expo (EstructuraEncFacExpoCamReg.xls) - misma estructura ---
"exp_def_header": None,
# --- Partidas factura: Impo Temp (EstructuraParFacImpoTempAF.xls) ---
"imp_temp_details": [
{"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA"]},
{"canonical": "LINEA", "aliases": ["RENGLON", "PARTIDA"]},
{"canonical": "NUMPARTE", "aliases": ["NUMERO PARTE"]},
{"canonical": "PRECIO UNITARIO", "aliases": ["PRECIOUNITARIO"]},
{"canonical": "VALOR COMERCIAL", "aliases": ["VALORCOMERCIAL"]},
{"canonical": "CANTIDAD"},
{"canonical": "CANTIDAD BULTOS", "aliases": ["CANTIDADBULTOS"]},
{"canonical": "DESCRIPCION"},
{"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN"]},
{"canonical": "FRACCION"},
{"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA"]},
],
# --- Partidas: Impo Def y Expo - misma estructura ---
"imp_def_details": None,
"exp_def_details": None,
}
def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]:
cols = TEMPLATE_COLUMNS.get(template_id)
if cols is not None:
return cols
if template_id in ("imp_def_header", "exp_def_header"):
return TEMPLATE_COLUMNS.get("imp_temp_header")
if template_id in ("imp_def_details", "exp_def_details"):
return TEMPLATE_COLUMNS.get("imp_temp_details")
return None
def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, str]:
"""
Construye un diccionario: normalized_header -> canonical_name.
normalize_header_fn(str) -> str debe ser la función que normaliza cabeceras (ej. mayúsculas, sin acentos).
"""
cols = _resolve_template_columns(template_id)
if not cols:
return {}
lookup: Dict[str, str] = {}
for item in cols:
canonical = item["canonical"]
lookup[normalize_header_fn(canonical)] = canonical
for alias in item.get("aliases") or []:
lookup[normalize_header_fn(alias)] = canonical
return lookup
def row_from_template(row: Dict[str, Any], template_id: str, normalize_header_fn) -> Dict[str, Any]:
"""
A partir de una fila CSV (dict header->value) y un template_id, devuelve un dict
solo con las columnas de la plantilla, usando nombres canónicos.
Así la plantilla se respeta: solo entran columnas definidas en la plantilla.
"""
lookup = build_normalized_lookup(template_id, normalize_header_fn)
if not lookup:
# Sin template definido: comportamiento legacy (normalizar todo)
return {normalize_header_fn(k): v for k, v in row.items()}
out: Dict[str, Any] = {}
for csv_header, value in row.items():
key_norm = normalize_header_fn(csv_header)
if key_norm in lookup:
out[lookup[key_norm]] = value
return out

View File

@@ -7,6 +7,7 @@ from api.v1.modules.public.reference_data.invoice_types.models import InvoiceTyp
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode

View File

@@ -19,6 +19,14 @@ class InvoiceHeaderBase(BaseModel):
operation_type: Optional[OperationType] = Field(
..., description="Operation type: imp/exp/sm/ctm"
)
@field_validator("operation_type", mode="before")
@classmethod
def normalize_operation_type(cls, v):
"""Accept DB string (e.g. 'IMP') and coerce to enum value ('imp')."""
if isinstance(v, str):
return v.lower() if v else v
return v
invoice_type: Optional[str] = Field(
None, max_length=5, description="Invoice type key"
)
@@ -198,9 +206,9 @@ class InvoiceComplianceMxBase(BaseModel):
class InvoiceFinancialsBase(BaseModel):
"""Base fields for Financials"""
currency: Currency = Field(None, max_length=7, description="Currency code")
currency: Optional[Currency] = Field(None, max_length=7, description="Currency code")
currency_type: Optional[str] = Field("USD", description="Currency type")
exchange_rate: Decimal = Field(0.00, description="Exchange rate")
exchange_rate: Optional[Decimal] = Field(0.00, description="Exchange rate")
exchange_rate_mm: Optional[Decimal] = Field(
None, description="Exchange rate currency to currency"
)

View File

@@ -45,11 +45,13 @@ class InvoiceService:
# Apply filters if provided
if filters:
if filters.get("status"):
query = query.filter(models.InvoiceHeader.status == filters["status"])
if filters.get("status") is not None:
query = query.filter(models.InvoiceHeader.is_updated == filters["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 == filters["operation_type"]
models.InvoiceHeader.operation_type == ot_val
)
if filters.get("invoice_type"):
query = query.filter(
@@ -67,10 +69,9 @@ class InvoiceService:
f"%{filters['pedimento']}%"
)
)
if (
not filters.get("invoice_type")
and filters.get("operation_type") == "exp"
):
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"):

View File

@@ -12,7 +12,7 @@ from .general_catalogs.router import router as general_catalogs_router
from .invoices.routes import router as invoices_router
from .items.routes import router as items_router
from .classes import router as classes_router
from .classes import router as classes_router
from .clients_and_providers import router as client_and_provider_router
from .imports.routes import router as imports_router
from .invoice_settings.routes import router as invoice_settings_router

View File

@@ -1,6 +1,13 @@
import os
from celery import Celery
# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen (mapper)
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import (
CodePedimentoRegimen,
)
valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0")

View File

@@ -11,11 +11,23 @@ from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from .config import settings
from .exceptions import BaseAPIException
logger = logging.getLogger(__name__)
def _cors_headers(request: Request) -> Dict[str, str]:
"""CORS headers for error responses so browser does not block on 4xx/5xx."""
origin = request.headers.get("origin")
if not origin or origin not in settings.cors_origins_list:
return {}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
}
async def base_exception_handler(
request: Request,
exc: BaseAPIException,
@@ -36,10 +48,13 @@ async def base_exception_handler(
if hasattr(exc, "errors") and exc.errors:
logger.warning(f"Validation errors details: {exc.errors}")
return JSONResponse(
response = JSONResponse(
status_code=exc.status_code,
content=jsonable_encoder(exc.to_dict()),
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def validation_exception_handler(
@@ -65,7 +80,7 @@ async def validation_exception_handler(
extra={"errors": errors},
)
return JSONResponse(
response = JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"error": "VALIDATION_ERROR",
@@ -74,6 +89,9 @@ async def validation_exception_handler(
"errors": errors,
},
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def integrity_error_handler(
@@ -102,7 +120,7 @@ async def integrity_error_handler(
elif "not null" in orig_msg:
error_message = "Falta un campo requerido."
return JSONResponse(
response = JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={
"error": "DATABASE_INTEGRITY_ERROR",
@@ -110,6 +128,9 @@ async def integrity_error_handler(
"status_code": status.HTTP_409_CONFLICT,
},
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def sqlalchemy_error_handler(
@@ -128,7 +149,7 @@ async def sqlalchemy_error_handler(
exc_info=True,
)
return JSONResponse(
response = JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "DATABASE_ERROR",
@@ -136,6 +157,9 @@ async def sqlalchemy_error_handler(
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
},
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
async def general_exception_handler(
@@ -154,7 +178,7 @@ async def general_exception_handler(
exc_info=True,
)
return JSONResponse(
response = JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "INTERNAL_SERVER_ERROR",
@@ -162,6 +186,9 @@ async def general_exception_handler(
"status_code": status.HTTP_500_INTERNAL_SERVER_ERROR,
},
)
for k, v in _cors_headers(request).items():
response.headers[k] = v
return response
def register_exception_handlers(app) -> None:

View File

@@ -17,8 +17,13 @@ from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen para que
# SQLAlchemy resuelva los nombres en relationship() al configurar el mapper
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import (
CodePedimentoRegimen,
)
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
@@ -114,6 +119,20 @@ logger = logging.getLogger(__name__)
register_exception_handlers(app)
def _cors_headers_for_request(request: Request):
"""Return CORS headers if request Origin is allowed (so error responses don't get blocked by browser)."""
origin = request.headers.get("origin")
if not origin:
return {}
allowed = settings.cors_origins_list
if origin in allowed:
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
}
return {}
# Add validation error handler
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
@@ -121,10 +140,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
f"Validation error for {request.method} {request.url.path}: {exc.errors()}"
)
logger.error(f"Request body: {await request.body()}")
return JSONResponse(
response = JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": exc.errors(), "body": exc.body},
)
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
# Add HTTP exception handler
@@ -133,10 +155,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(
f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}"
)
return JSONResponse(
response = JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
for k, v in _cors_headers_for_request(request).items():
response.headers[k] = v
return response
def run_migrations():

View File

@@ -341,6 +341,39 @@ export const api = {
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`)
},
imports: {
upload: (
file: File,
modelTarget: string,
footerConfig: any,
companyId: number,
operationType: string,
templateId?: string
) => {
const formData = new FormData();
formData.append('file', file);
if (footerConfig) {
formData.append('footer_config', JSON.stringify(footerConfig));
}
if (templateId) {
formData.append('template_id', templateId);
}
const queryParams = new URLSearchParams({
company_id: String(companyId),
operation_type: operationType || 'imp'
}).toString();
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
},
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget })
},
// Generic request for custom needs (like file uploads)
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
};

View File

@@ -143,17 +143,53 @@
{#if scanResults.error_count > 0}
<div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
>
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
<div class="text-sm text-destructive-foreground/90">
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
<p>
Las filas con errores serán omitidas automáticamente. Solo se importarán los
registros válidos.
Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para
importar solo las filas válidas (las erróneas se omitirán).
</p>
</div>
</div>
{#if scanResults.errors && scanResults.errors.length > 0}
<div class="border rounded-lg overflow-hidden shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de errores (para corregir en el CSV)
</h5>
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{scanResults.errors.length} error(es)
</span>
</div>
<div class="max-h-60 overflow-y-auto bg-card relative">
<table class="w-full text-xs text-left">
<thead
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
>
<tr>
<th class="px-4 py-2 w-16">Línea</th>
<th class="px-4 py-2 w-40">Columna</th>
<th class="px-4 py-2">Mensaje</th>
</tr>
</thead>
<tbody class="divide-y">
{#each scanResults.errors as err}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-2 font-mono text-muted-foreground">{err.line}</td>
<td class="px-4 py-2 font-mono font-medium text-foreground">{err.col || '-'}</td>
<td class="px-4 py-2 text-destructive">{err.msg || '-'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
{:else}
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />

View File

@@ -123,7 +123,7 @@
ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
roles="button"
role="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}

View File

@@ -8,6 +8,20 @@ export type { CustomsBroker };
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] {
return [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
const { id } = getId();
return {
render: () =>
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">${id}</code>`
};
});
return renderSnippet(idSnippet, { id: row.original.id });
}
},
{
accessorKey: "broker_key",
header: "Clave",

View File

@@ -249,7 +249,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Encabezado',
icon: FileText,
group: 'Impo. Def.',
modelTarget: 'InvoiceHeader',
modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
},
{
@@ -257,7 +257,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Partidas',
icon: Package,
group: 'Impo. Def.',
modelTarget: 'InvoiceSalesDetails',
modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
},
{
@@ -274,7 +274,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Encabezado',
icon: FileText,
group: 'Compras Mex.',
modelTarget: 'InvoiceHeader',
modelTarget: 'invoice_header',
disabled: true,
},
{
@@ -282,7 +282,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Partidas',
icon: Package,
group: 'Compras Mex.',
modelTarget: 'InvoiceSalesDetails',
modelTarget: 'invoice_details',
disabled: true,
},
{
@@ -302,7 +302,7 @@ export const exportacionConfig: CsvUploadItem[] = [
title: 'Encabezado',
icon: FileText,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceHeader',
modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
},
{
@@ -310,7 +310,7 @@ export const exportacionConfig: CsvUploadItem[] = [
title: 'Partidas',
icon: Package,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSalesDetails',
modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParExpoCamReg.xls'
},
{

View File

@@ -38,25 +38,45 @@
});
async function handleUpload(file: File, config: CsvUploadItem) {
console.log('handleUpload started', { file, config });
isUploading = true;
activeModelTarget = config.modelTarget || null;
scanResults = null;
const currentSettings = allSettings[activeTab] || {};
const footerConfig = { ...currentSettings };
if (activeTab === 'importacion') {
footerConfig.invoice_type = config.id?.startsWith('imp_def_') ? 'DEF' : 'TEM';
}
const companyId = companyStore.activeCompany?.id || 1;
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
const res = await api.imports.upload(
file,
config.modelTarget || '',
currentSettings,
console.log('Calling api.imports.upload', {
activeModelTarget,
footerConfig,
companyId,
opType
);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
} else {
toast.error('Error al subir el archivo');
});
try {
const res = await api.imports.upload(
file,
config.modelTarget || '',
footerConfig,
companyId,
opType,
config.id
);
console.log('Upload response', res);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
} else {
console.error('Upload failed with response', res);
toast.error(res.error || 'Error al subir el archivo');
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
isUploading = false;
}
}
@@ -64,54 +84,71 @@
async function pollStatus() {
if (!currentJobId) return;
const res = await api.imports.status(currentJobId);
if (res.data?.status === 'waiting_confirmation') {
scanResults = res.data;
showResultModal = true;
toast.success('Escaneo completado. Revisa los resultados.');
isUploading = false;
} else if (res.data?.status === 'failed') {
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
isUploading = false;
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
} else if (res.data?.status === 'warning') {
// Caso cuando no se insertaron registros pero hay información de rechazo
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 totalSkipped = skippedInvalid + skippedFk;
if (inserted === 0) {
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
} else {
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
console.log('Polling status for job', currentJobId);
try {
const res = await api.imports.status(currentJobId);
console.log('Poll response', res);
if (res.error && !res.data) {
toast.error(res.error || 'Error al consultar el estado');
isUploading = false;
currentJobId = null;
return;
}
isUploading = false;
} else if (res.data?.status === 'finished') {
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 skippedDetails = res.data?.skipped_details || [];
if (res.data?.status === 'waiting_confirmation') {
scanResults = res.data;
showResultModal = true;
toast.success('Escaneo completado. Revisa los resultados.');
isUploading = false;
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
isUploading = false;
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
} else if (res.data?.status === 'warning') {
// Caso cuando no se insertaron registros pero hay información de rechazo
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 totalSkipped = skippedInvalid + skippedFk;
if (inserted > 0) {
toast.success(`Importación completada: ${inserted} registros insertados`);
if (skippedInvalid > 0 || skippedFk > 0) {
const totalSkipped = skippedInvalid + skippedFk;
toast.warning(`${totalSkipped} registros fueron rechazados`);
if (inserted === 0) {
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
} else {
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
}
isUploading = false;
} else if (res.data?.status === 'finished') {
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 skippedDetails = res.data?.skipped_details || [];
if (inserted > 0) {
toast.success(`Importación completada: ${inserted} registros insertados`);
if (skippedInvalid > 0 || skippedFk > 0) {
const totalSkipped = skippedInvalid + skippedFk;
toast.warning(`${totalSkipped} registros fueron rechazados`);
}
} else {
toast.error('No se insertaron registros. Revisa los errores a continuación.');
}
isUploading = false;
} else {
toast.error('No se insertaron registros. Revisa los errores a continuación.');
// Continue polling
console.log('Status not final, polling again in 2s...', res.data?.status);
setTimeout(pollStatus, 2000);
}
isUploading = false;
} else {
// Continue polling
} 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);
}
}
@@ -174,36 +211,38 @@
{/if}
</div>
<ProcessingResultModal
bind:open={showResultModal}
{scanResults}
{commitResults}
{isUploading}
onConfirm={async () => {
if (currentJobId && activeModelTarget) {
try {
isUploading = true;
const res = await api.imports.commit(currentJobId, activeModelTarget);
if (res.data?.commit_job_id) {
currentJobId = res.data.commit_job_id;
pollStatus();
{#if scanResults || commitResults}
<ProcessingResultModal
bind:open={showResultModal}
{scanResults}
{commitResults}
{isUploading}
onConfirm={async () => {
if (currentJobId && activeModelTarget) {
try {
isUploading = true;
const res = await api.imports.commit(currentJobId, activeModelTarget);
if (res.data?.commit_job_id) {
currentJobId = res.data.commit_job_id;
pollStatus();
}
} catch (err) {
toast.error('Error al iniciar la importación');
isUploading = false;
}
} catch (err) {
toast.error('Error al iniciar la importación');
isUploading = false;
}
}
}}
onCancel={() => {
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
onClose={() => {
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
/>
}}
onCancel={() => {
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
onClose={() => {
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
/>
{/if}