Merge pull request 'feature/carga-via-csv' (#147) from feature/carga-via-csv into development

Reviewed-on: ADUANASOFT/anexo76#147
This commit is contained in:
2026-02-17 15:27:16 +00:00
36 changed files with 2764 additions and 364 deletions

1
.gitignore vendored
View File

@@ -62,3 +62,4 @@ node_modules/
*.dockerignore
postgres-data/
backend/uploads/
docker-compose.yml

View File

@@ -0,0 +1,118 @@
from datetime import datetime
from uuid import uuid4
import os
import json
import logging
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query
from sqlalchemy.orm import Session
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.security import get_current_user, validate_access_to_resource
from .tasks import scan_file, insert_valid_rows
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
router = APIRouter()
logger = logging.getLogger(__name__)
@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
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.
"""
# 1. Validate Access & Get Tenant
try:
tenant_id = validate_access_to_resource(db, company_id, current_user)
except Exception as e:
logger.error(f"Access validation failed: {e}")
raise HTTPException(status_code=403, detail="Invalid company access")
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")
try:
# Save CSV
contents = await file.read()
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)}")
# 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)
return ImportJobResponse(
job_id=job_id,
status="queued",
message="File uploaded. Scanning started."
)
@router.get("/{job_id}/status")
async def get_import_status(job_id: str):
"""
Poll this endpoint to get % progress or final report.
"""
# 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':
return {"status": "processing", "progress": 0}
elif task_result.state == 'PROGRESS':
return {
"status": "processing",
"progress": task_result.info.get('current', 0),
"total": task_result.info.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)}
@router.post("/{job_id}/commit")
async def commit_import_job(job_id: str, body: CommitRequest):
"""
Step 2: User confirms import. Trigger bulk insert.
"""
task = insert_valid_rows.delay(job_id, body.model_target)
return {
"status": "committing",
"message": "Bulk insert started.",
"commit_job_id": task.id
}

View File

@@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import Optional, Literal
class ImportJobResponse(BaseModel):
job_id: str
status: str
message: str
class CommitRequest(BaseModel):
model_target: Literal["invoice_header", "invoice_details"]
class ImportJobStatus(BaseModel):
status: str
job_id: str
total_rows: Optional[int] = 0
error_count: Optional[int] = 0
valid_rows: Optional[int] = 0
error: Optional[str] = None
inserted: Optional[int] = 0
error_file: Optional[str] = None

View File

@@ -0,0 +1,876 @@
import os
from datetime import datetime
from decimal import Decimal
import csv
import json
import logging
import re
import unicodedata
from celery import shared_task
from typing import Dict, Any, Optional
from core.database import CoreSessionLocal
# Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process
# We'll need schemas for validation
# from api.v1.modules.a76.invoices.schemas import InvoiceHeaderCreate
# But for Phase 1 we use a lighter check
logger = logging.getLogger(__name__)
class ForeignKeyValidator:
def __init__(self, session, tenant_id, company_id):
self.session = session
self.tenant_id = tenant_id
self.company_id = company_id
self.cache = {} # {(model_name, value): bool}
def check_exists(self, model, value, field_name="id", is_public=False):
if value is None:
return True # Assume optional if None, or let DB handle not-null
key = (model.__name__, value)
if key in self.cache:
return self.cache[key]
query = self.session.query(getattr(model, field_name)).filter(getattr(model, field_name) == value)
if not is_public:
query = query.filter(model.tenant_id == self.tenant_id, model.company_id == self.company_id)
exists = query.first() is not None
self.cache[key] = exists
return exists
@shared_task(bind=True)
def scan_file(self, job_id: str, file_path: str, model_target: str, config: str = None):
"""
Pass 1: Read CSV, Validate types, Write Errors to JSONL.
"""
logger.info(f"Starting scan for job {job_id} target {model_target}")
# 1. Setup Error Log
error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl")
os.makedirs(os.path.dirname(error_path), exist_ok=True)
total_rows = 0
error_count = 0
processed_rows = 0
# 2. Count Total (Quick Pass) or just estimate
# For better progress, we can get file line count first
try:
with open(file_path, 'r', encoding='utf-8-sig') as f:
total_rows = sum(1 for _ in f) - 1 # Minus header
except Exception as e:
return {"status": "failed", "error": f"Cannot read file: {e}"}
footer_config = parse_footer_config(config)
date_format = footer_config.get("dateFormat")
# Validate and set default date_format if not provided
if not date_format:
date_format = "yyyy-mm-dd" # Default to ISO format
logger.info(f"No date_format specified in config, using default: {date_format}")
try:
with open(file_path, 'r', encoding='utf-8-sig') as f_in, \
open(error_path, 'w', encoding='utf-8') as f_err:
# Detect Delimiter
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except:
dialect = 'excel'
reader = csv.DictReader(f_in, dialect=dialect)
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
})
# Validation (Phase 1: Minimal)
row_norm = normalize_row(row)
errors = validate_row_phase_1(row_norm, model_target, i, date_format)
if errors:
error_count += 1
# Write simple JSON error
f_err.write(json.dumps(errors) + "\n")
processed_rows += 1
except Exception as e:
logger.error(f"Scan failed: {e}")
return {"status": "failed", "error": str(e)}
# 4. Result
return {
"status": "waiting_confirmation",
"job_id": job_id,
"total_rows": processed_rows,
"error_count": error_count,
"valid_rows": processed_rows - error_count,
"error_file": error_path
}
def validate_row_phase_1(
row: Dict[str, Any],
target: str,
line_num: int,
date_format: Optional[str],
) -> Dict[str, Any]:
"""
Minimal validation: Unique IDs and Dates.
Target: 'invoice_header' or 'invoice_details'
"""
errors = {}
# A. Invoice Header
if target == 'invoice_header':
# 1. Unique ID
if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'):
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
# 2. Date Format
date_str = row.get('FECHA FACTURA')
if date_str:
if not is_valid_date(date_str, date_format):
expected = display_date_format(date_format)
return {
"line": line_num,
"col": "FECHA FACTURA",
"msg": f"Formato inválido ({expected})",
}
else:
return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"}
# B. Invoice Details (Parts)
elif target == 'invoice_details':
# 1. Line Number
if not row.get('LINEA'):
return {"line": line_num, "col": "LINEA", "msg": "Requerido"}
# 2. Parent Link (Invoice Number)
if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')):
return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
# 2. Parent Link (Simplified for now, we assume parent exists or is in same batch)
# In a real scenario, we'd check if the invoice exists.
pass
return errors if errors else None
def parse_footer_config(config: Optional[str]) -> Dict[str, Any]:
if not config:
return {}
try:
if isinstance(config, str):
return json.loads(config)
if isinstance(config, dict):
return config
except Exception:
return {}
return {}
def display_date_format(date_format: Optional[str]) -> str:
if not date_format:
return "YYYY-MM-DD"
return date_format.upper()
def parse_date(date_text: Optional[str], date_format: Optional[str]) -> Optional[datetime.date]:
if not date_text:
return None
candidates = []
fmt_map = {
"dd/mm/yyyy": "%d/%m/%Y",
"mm/dd/yyyy": "%m/%d/%Y",
"yyyy-mm-dd": "%Y-%m-%d",
}
if date_format and date_format in fmt_map:
candidates.append(fmt_map[date_format])
candidates.extend(["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"])
for fmt in candidates:
try:
return datetime.strptime(str(date_text).strip(), fmt).date()
except ValueError:
continue
return None
def is_valid_date(date_text: Optional[str], date_format: Optional[str]) -> bool:
return parse_date(date_text, date_format) is not None
def normalize_header(name: Optional[str]) -> str:
if not name:
return ""
name = unicodedata.normalize("NFKD", str(name)).upper()
name = "".join(ch for ch in name if not unicodedata.combining(ch))
name = re.sub(r"[^A-Z0-9]+", " ", name)
return re.sub(r"\s+", " ", name).strip()
def normalize_row(row: Dict[str, Any]) -> Dict[str, Any]:
return {normalize_header(k): v for k, v in row.items()}
def parse_int(value: Any) -> Optional[int]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
try:
return int(text)
except ValueError:
return None
def parse_decimal(value: Any) -> Optional[Decimal]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
text = text.replace(",", "")
try:
return Decimal(text)
except Exception:
return None
def parse_currency(value: Optional[str], currency_type: Optional[str]):
from api.v1.modules.a76.invoices.models import Currency
if value:
normalized = normalize_header(value)
if normalized in {"MN", "M N", "NACIONAL", "LOCAL", "PESOS", "PESO"}:
return Currency.LOCAL
if normalized in {"ME", "M E", "EXTRANJERA", "EXTRANJERO", "FOREIGN", "USD", "DOLAR", "DOLARES"}:
return Currency.FOREIGN
if "MANUAL" in normalized:
return Currency.MANUAL
if currency_type and str(currency_type).strip().upper() == "MXN":
return Currency.LOCAL
if currency_type:
return Currency.FOREIGN
return Currency.MANUAL
def parse_weight_unit(value: Optional[str]):
from api.v1.modules.a76.invoices.models import WeightUnit
if not value:
return None
normalized = normalize_header(value)
if normalized in {"KG", "KGS", "KILOS", "KILOGRAMOS"}:
return WeightUnit.KGS
if normalized in {"LB", "LBS", "LIBRAS"}:
return WeightUnit.LBS
return None
def resolve_tenant_fk_id(
session: CoreSessionLocal,
model,
value: Optional[int],
tenant_id: int,
company_id: int,
cache: Dict[int, Optional[int]],
) -> Optional[int]:
if value is None:
return None
if value in cache:
return cache[value]
exists = (
session.query(model.id)
.filter(
model.id == value,
model.tenant_id == tenant_id,
model.company_id == company_id,
)
.scalar()
)
cache[value] = value if exists is not None else None
return cache[value]
def resolve_public_code(
session: CoreSessionLocal,
model,
column,
value: Optional[str],
cache: Dict[str, Optional[str]],
) -> Optional[str]:
if not value:
return None
normalized = str(value).strip().upper()
if not normalized:
return None
if normalized in cache:
return cache[normalized]
exists = session.query(column).filter(column == normalized).scalar()
cache[normalized] = normalized if exists is not None else None
return cache[normalized]
@shared_task(bind=True)
def insert_valid_rows(self, job_id: str, model_target: str):
"""
Pass 2: Re-read CSV, Skip Errors, Bulk Insert.
"""
logger.info(f"Starting Commit for {job_id} target {model_target}")
try:
from api.v1.modules.a76.invoices.models import (
InvoiceHeader,
InvoiceComplianceMx,
InvoiceFinancials,
InvoiceLogistics,
InvoiceSalesDetails,
OperationType,
WeightUnit,
)
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.public.reference_data.currency_types.models import CurrencyType
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.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.items.line_financials.models import LineFinancial
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.line_customs.models import LineCustom
from api.v1.modules.a76.items.line_descriptions.models import LineDescription
from api.v1.modules.a76.parts.models import Part
upload_dir = os.path.join(os.getcwd(), "uploads", "temp")
file_path = os.path.join(upload_dir, f"{job_id}.csv")
error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl")
# 1. Load Error Line Numbers
error_lines = set()
if os.path.exists(error_path):
with open(error_path, 'r', encoding='utf-8') as f:
for line in f:
try:
err = json.loads(line)
error_lines.add(err['line'])
except: pass
# Load Metadata (Context)
meta_path = file_path.replace("temp", "temp").replace(".csv", ".meta.json")
tenant_id = None
company_id = None
footer_config = {}
if os.path.exists(meta_path):
try:
with open(meta_path, 'r') as f:
meta = json.load(f)
tenant_id = meta.get('tenant_id')
company_id = meta.get('company_id')
operation_type_raw = meta.get('operation_type', 'imp')
footer_config = parse_footer_config(meta.get('footer_config'))
except: pass
if not tenant_id or not company_id:
return {"status": "failed", "error": "Missing context (tenant/company)"}
# 2. Re-read and Map
# Initialize counters outside the session block so they're accessible later
headers_to_insert = []
details_to_insert = []
skipped_invalid = 0
skipped_missing_invoice = 0
skipped_missing_fk = 0
skipped_fk_details = []
inserted_count = 0
response = None # Will be set inside the session block
date_format = footer_config.get("dateFormat")
# Validate and set default date_format if not provided
if not date_format:
date_format = "yyyy-mm-dd" # Default to ISO format
logger.info(f"No date_format specified in config, using default: {date_format}")
else:
logger.info(f"Using date_format from config: {date_format}")
# Default types from config or fallback
op_type_value = OperationType(meta.get('operation_type', 'imp').lower())
inv_type_value = footer_config.get('invoice_type', 'TEM')
logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}")
with CoreSessionLocal() as session:
invoice_id_cache = {}
cleared_invoices = set() # Track invoices where we've already cleared items in this job
provider_cache: Dict[int, Optional[int]] = {}
sold_to_cache: Dict[int, Optional[int]] = {}
shipped_to_cache: Dict[int, Optional[int]] = {}
broker_cache: Dict[int, Optional[int]] = {}
regimen_cache: Dict[str, Optional[str]] = {}
currency_type_cache: Dict[str, Optional[str]] = {}
customs_section_cache: Dict[str, Optional[str]] = {}
part_cache: Dict[str, Optional[int]] = {}
validator = ForeignKeyValidator(session, tenant_id, company_id)
with open(file_path, 'r', encoding='utf-8-sig') as f:
# Detect Delimiter
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except:
dialect = 'excel'
reader = csv.DictReader(f, dialect=dialect)
for i, row in enumerate(reader, start=1):
if i in error_lines:
continue
row_norm = normalize_row(row)
# Mapping Logic
if model_target == 'invoice_header':
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip()
invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format)
if not invoice_number or not invoice_date:
skipped_invalid += 1
logger.debug(f"Row {i}: Skipped - missing invoice_number or invalid invoice_date. "
f"Invoice: {invoice_number}, Date: {row_norm.get('FECHA FACTURA') or row_norm.get('FECHA')}")
continue
# --- NEW: Foreign Key Validations ---
# 1. Invoice Type (Public)
if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True):
skipped_missing_fk += 1
reason = f"Tipo de factura '{inv_type_value}' no existe"
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason})
logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}")
continue
# 2. Client/Provider (Tenant)
provider_id = parse_int(row_norm.get('CLAVE PROVEEDOR'))
if provider_id and not validator.check_exists(ClientProvider, provider_id):
skipped_missing_fk += 1
reason = f"Proveedor ID '{provider_id}' no existe"
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason})
logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}")
continue
# 3. Customs Broker (Tenant)
broker_id = parse_int(row_norm.get('AGENTE ADUANAL'))
if broker_id and not validator.check_exists(CustomsBroker, broker_id):
skipped_missing_fk += 1
reason = f"Agente Aduanal ID '{broker_id}' no existe"
skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason})
logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}")
continue
# --- 4. Check for Existing Invoice (Upsert Logic) ---
existing_header = None
if invoice_number:
existing_header = (
session.query(InvoiceHeader)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.invoice_type == inv_type_value
)
.first()
)
if existing_header:
# UPDATE existing header
header = existing_header
header.invoice_date = invoice_date
header.operation_type = op_type_value
header.is_updated = True # Mark as updated
header.updated_date = datetime.utcnow()
header.document_type = resolve_public_code(
session,
RegimenPedimento,
RegimenPedimento.code,
(row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')),
regimen_cache,
)
header.project_number = (row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None)
header.purchase_order = (row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None)
header.alternate_invoice = (row_norm.get('FACTURA ALTERNA') or None)
header.invoice_ref = (row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None)
header.emission_date = parse_date(row_norm.get('FECHA EMISION'), date_format)
header.observation_es = (row_norm.get('OBSERVACIONES E') or None)
header.observation_en = (row_norm.get('OBSERVACIONES I') or None)
logger.info(f"Row {i}: Updating existing invoice {invoice_number}")
# Clean up related data that will be re-inserted/updated
# Note: compliance, financials, logistics are 1-to-1 relationships and will be updated by assignment below
# but we might want to be explicit if ORM doesn't handle replace well.
# SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly.
else:
# CREATE new header
header = InvoiceHeader(
invoice_number=invoice_number,
invoice_date=invoice_date,
operation_type=op_type_value,
is_updated=False,
system="CSV",
capture_date=datetime.utcnow(),
invoice_type=inv_type_value,
document_type=resolve_public_code(
session,
RegimenPedimento,
RegimenPedimento.code,
(row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')),
regimen_cache,
),
project_number=(row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None),
purchase_order=(row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None),
alternate_invoice=(row_norm.get('FACTURA ALTERNA') or None),
invoice_ref=(row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None),
emission_date=parse_date(row_norm.get('FECHA EMISION'), date_format),
observation_es=(row_norm.get('OBSERVACIONES E') or None),
observation_en=(row_norm.get('OBSERVACIONES I') or None),
tenant_id=tenant_id,
company_id=company_id,
)
compliance = InvoiceComplianceMx(
remesa=parse_int(row_norm.get('REMESA')),
aduana=resolve_public_code(
session,
CustomsSection,
CustomsSection.customs_code,
row_norm.get('ADUANA DE CRUCE'),
customs_section_cache,
),
provider_id=resolve_tenant_fk_id(
session,
ClientProvider,
parse_int(row_norm.get('CLAVE PROVEEDOR')),
tenant_id,
company_id,
provider_cache,
),
sold_to_id=resolve_tenant_fk_id(
session,
ClientProvider,
parse_int(row_norm.get('CLAVE VENDIDO A')),
tenant_id,
company_id,
sold_to_cache,
),
shipped_to_id=resolve_tenant_fk_id(
session,
ClientProvider,
parse_int(row_norm.get('CLAVE ENVIADO A')),
tenant_id,
company_id,
shipped_to_cache,
),
customs_broker_id=resolve_tenant_fk_id(
session,
CustomsBroker,
parse_int(row_norm.get('AGENTE ADUANAL')),
tenant_id,
company_id,
broker_cache,
),
edocument=(row_norm.get('E DOCUMENT') or None),
vucem_operation_num=(row_norm.get('NUM OPERACION') or None),
tenant_id=tenant_id,
company_id=company_id,
)
financials_currency_type = resolve_public_code(
session,
CurrencyType,
CurrencyType.code,
row_norm.get('CLAVE MONEDA'),
currency_type_cache,
)
financials = InvoiceFinancials(
currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type),
currency_type=financials_currency_type,
exchange_rate=parse_decimal(row_norm.get('TIPO DE CAMBIO')),
freight=parse_decimal(row_norm.get('FLETES')),
insurance_value=parse_decimal(row_norm.get('VALOR SEGUROS')),
insurance=parse_decimal(row_norm.get('SEGUROS')),
packaging=parse_decimal(row_norm.get('EMBALAJES')),
other_increments=parse_decimal(row_norm.get('OTROS INCREMENTABLES')),
tenant_id=tenant_id,
company_id=company_id,
)
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'):
logistics = InvoiceLogistics(
carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None),
driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None),
transport_type=str(row_norm.get('TIPO TRANSPORTE') or "none").lower(),
transport_num=(row_norm.get('NUMERO TRANSPORTE') or None),
weight_type=weight_type or WeightUnit.KGS,
seal_number=(row_norm.get('PRECINTO') or None),
incoterm=(row_norm.get('CLAVE INCOTERM') or None),
entry_exit_date=parse_date(row_norm.get('FECHA EMISION'), date_format),
tenant_id=tenant_id,
company_id=company_id,
)
header.compliance_mx = compliance
header.financials = financials
if logistics:
header.logistics = logistics
headers_to_insert.append(header)
elif model_target == 'invoice_details':
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip()
if not invoice_number:
skipped_invalid += 1
continue
if invoice_number in invoice_id_cache:
invoice_id = invoice_id_cache[invoice_number]
else:
invoice_id = (
session.query(InvoiceHeader.id)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
)
.scalar()
)
invoice_id_cache[invoice_number] = invoice_id
if not invoice_id:
logger.warning(
"Invoice not found for details row %s (invoice_number=%s)",
i,
invoice_number,
)
skipped_missing_invoice += 1
continue
# --- Prevent Duplicates: Clear existing items for this invoice (Once per job) ---
if invoice_id not in cleared_invoices:
logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates")
# 1. Delete Items (Cascades to LineItem, LineFinancial, etc. if DB configured, check models)
# Checking Item model, we usually need to be careful.
# Assuming Cascade delete is set up on FKs or we rely on ORM cascade if using relationships.
# Here we use bulk delete.
session.query(Item).filter(Item.invoice_id == invoice_id).delete(synchronize_session=False)
# 2. Delete InvoiceSalesDetails
session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False)
cleared_invoices.add(invoice_id)
# --- NEW LOGIC: Expanded Anexo 76 Structure ---
# A. Find/Cache Part
part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip()
part_id = None
if part_num:
part_id = part_cache.get(part_num)
if part_id is None:
p = session.query(Part.id).filter(
Part.part_number == part_num,
Part.tenant_id == tenant_id,
Part.company_id == company_id
).first()
if p:
part_id = p.id
part_cache[part_num] = part_id
line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA'))
line_num = parse_int(line_num_val) or (len(details_to_insert) + 1)
# 1. Parent Item
item = Item(
invoice_id=invoice_id,
tenant_id=tenant_id,
company_id=company_id,
item_type="N", # Default to Normal
system_origin="CSV"
)
session.add(item)
session.flush() # Need item.id
# 2. Main Line
line = LineItem(
item_id=item.id,
line_number=line_num,
part_number=part_id,
tenant_id=tenant_id,
company_id=company_id
)
session.add(line)
session.flush() # Need line.id
# 3. Financial Data
price = parse_decimal(row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO'))
val_com = parse_decimal(row_norm.get('VALOR COMERCIAL') or row_norm.get('VALORCOMERCIAL'))
qty = parse_decimal(row_norm.get('CANTIDAD'))
session.add(LineFinancial(
item_line_id=line.id,
unit_price=price,
commercial_value=val_com or (price * qty if price and qty else None),
))
# 4. Quantities
if qty:
session.add(LineQuantity(
item_line_id=line.id,
quantity=qty,
))
# 5. Customs/Fraction
origin = row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN')
fraction = row_norm.get('FRACCION')
if origin or fraction:
session.add(LineCustom(
item_line_id=line.id,
fraction=fraction,
origin_country=origin,
))
# 6. Description
desc = row_norm.get('DESCRIPCION')
if desc:
session.add(LineDescription(
item_line_id=line.id,
description_spanish=desc,
))
# 7. Legacy Sales Details (For specific audit/UI fields)
detail = InvoiceSalesDetails(
invoice_id=invoice_id,
line_number=line_num,
sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None),
line_bundles=parse_int(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
tenant_id=tenant_id,
company_id=company_id,
)
session.add(detail)
details_to_insert.append(item) # Use as counter/ref
# 3. Bulk Insert (ORM Transaction)
try:
if model_target == 'invoice_header':
if headers_to_insert:
logger.info(f"Attempting to commit {len(headers_to_insert)} headers")
session.add_all(headers_to_insert)
session.commit()
inserted_count = len(headers_to_insert)
logger.info(f"Headers commit successful. Inserted: {inserted_count}")
else:
logger.warning(f"No headers to insert for job {job_id}")
else:
if details_to_insert:
logger.info(f"Attempting to commit {len(details_to_insert)} items and related data")
session.commit() # Everything was already added with session.add()
inserted_count = len(details_to_insert)
logger.info(f"Details commit successful. Inserted: {inserted_count}")
else:
logger.warning(f"No details to insert for job {job_id}")
except Exception as db_err:
session.rollback()
logger.error(f"DB Error during {model_target} commit: {db_err}")
import traceback
logger.error(traceback.format_exc())
return {"status": "failed", "error": str(db_err)}
# 4. Determine final status and prepare response (inside session block to access variables)
total_skipped = skipped_invalid + skipped_missing_fk + skipped_missing_invoice
# Log summary
logger.info(f"Job {job_id} completed. Inserted: {inserted_count}, Skipped: {total_skipped} "
f"(invalid: {skipped_invalid}, missing_fk: {skipped_missing_fk}, missing_invoice: {skipped_missing_invoice})")
# Prepare response based on results
if inserted_count == 0:
if total_skipped > 0:
logger.warning(f"No valid records to insert for job {job_id}. All {total_skipped} records were rejected.")
response = {
"status": "warning",
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details,
"message": f"No se insertaron registros. {total_skipped} fueron rechazados."
}
else:
logger.error(f"No valid records found in CSV for job {job_id}")
response = {
"status": "failed",
"error": "No hay registros válidos en el archivo CSV",
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details
}
else:
# Success case - at least some records were inserted
response = {
"status": "finished",
"inserted": inserted_count,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details
}
except Exception as e:
logger.error(f"Task failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"status": "failed", "error": str(e)}
# 5. Cleanup
try:
if os.path.exists(file_path):
os.remove(file_path)
if os.path.exists(error_path):
os.remove(error_path)
except:
logger.warning("Failed to cleanup temp files")
# Ensure response is defined (fallback in case of unexpected errors)
if response is None:
logger.error(f"Unexpected error: response not set for job {job_id}")
response = {
"status": "failed",
"error": "Error inesperado durante el procesamiento",
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_invoice": skipped_missing_invoice,
"skipped_missing_fk": skipped_missing_fk,
"skipped_details": skipped_fk_details
}
return response

View File

@@ -2,13 +2,7 @@ from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict
from enum import Enum
class OperationType(str, Enum):
IMP = "imp" # Importación
EXP = "exp" # Exportación
SM_IN = "sm_in" # Entrada SM
SM_OUT = "sm_out" # Salida SM
CTM_SEND = "ctm_send" # Envío CTM
CTM_RECEIVE = "ctm_receive" # Recibo CTM
from .models import OperationType
class InvoiceSettingsBase(BaseModel):
invoice_type: str

View File

@@ -40,8 +40,7 @@ def get_invoice_settings(
tenant_id=tenant_id,
company_id=company_id
)
return settings
return InvoiceSettingsResponse.model_validate(settings)
@router.get("/", response_model=List[InvoiceSettingsResponse])
def list_invoice_settings(

View File

@@ -17,7 +17,7 @@ def get_settings(
InvoiceSettings.tenant_id == tenant_id,
InvoiceSettings.company_id == company_id,
InvoiceSettings.invoice_type == invoice_type,
InvoiceSettings.operation_type == operation_type
InvoiceSettings.operation_type == operation_type.value
)
return db.execute(stmt).scalar_one_or_none()
@@ -60,7 +60,7 @@ def upsert_settings(
tenant_id=tenant_id,
company_id=company_id,
invoice_type=settings_data.invoice_type,
operation_type=settings_data.operation_type,
operation_type=settings_data.operation_type.value,
settings=settings_data.settings
)

View File

@@ -12,7 +12,9 @@ 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
from .item_presets.routes import router as item_presets_router
from .general_catalogs.company import router as company_router
@@ -47,6 +49,7 @@ router = APIRouter()
router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"])
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"])
router.include_router(invoice_settings_router)
router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"])
router.include_router(pedimentos_router, prefix="/a76")

View File

@@ -13,7 +13,8 @@ celery_app = Celery(
"api.v1.modules.a76.reports.importacion.consolidados.task",
"api.v1.modules.a76.reports.importacion.packing_list.task",
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task",
"api.v1.modules.a76.reports.exportacion.descargo.task"
"api.v1.modules.a76.reports.exportacion.descargo.task",
"api.v1.modules.a76.imports.tasks"
] # Ruta al módulo donde están las tareas
)

View File

@@ -6,6 +6,75 @@ Backend API con FastAPI + Keycloak + SQLAlchemy
import logging
import subprocess
# Importar modelos para registrar con SQLAlchemy
# Reference Data (Dependencies)
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse
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
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.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
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog
from api.v1.modules.a76.general_catalogs.doda.models import Doda
from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
from api.v1.modules.a76.general_catalogs.legends.models import Legend
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import (
TariffFraction,
)
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
# Core Modules & Reference Data (Dependencies)
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.general_catalogs.company.models import Company
# Core Modules & Transactional Models
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation
from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation
# Transactional Primary Models
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
from api.v1.modules.a76.audit_log.events import register_audit_listeners
# Core Modules (Secondary)
from api.v1.router import router as api_v1_router
from core.config import settings
from core.database import init_db
@@ -22,18 +91,6 @@ from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path
# Importar modelos para registrar con SQLAlchemy
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.series.models import Serie
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a24.fa.fa_parts.models import FaPart
from api.v1.modules.a24.inv.inv_parts.models import InvPart
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.a76.manifests.concept_manifestation.models import (
ConceptManifestation,
)
from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation
# Configurar logging
logging.basicConfig(
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
@@ -116,80 +173,6 @@ from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware
app.add_middleware(UserContextMiddleware)
# Importar modelos para Audit Log
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
from api.v1.modules.a76.audit_log.events import register_audit_listeners
# Core Modules
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.parts.models import Part
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.general_catalogs.company.models import Company
# Reference Data
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.customs_warehouses.models import (
CustomsWarehouse,
)
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
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.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
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.valuation_methods.models import (
ValuationMethod,
)
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
from api.v1.modules.a76.classes.models import Class
from api.v1.modules.a76.general_catalogs.classification_concepts.models import (
ClassificationConcept,
)
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import (
CustomsBrokerConcept,
)
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import (
DepreciationCatalog,
)
from api.v1.modules.a76.general_catalogs.doda.models import Doda
from api.v1.modules.a76.general_catalogs.electronic_notices.models import (
ElectronicNotice,
)
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
from api.v1.modules.a76.general_catalogs.legends.models import Legend
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import (
MultiCurrencyType,
)
from api.v1.modules.a76.general_catalogs.packages.models import Package
from api.v1.modules.a76.general_catalogs.ports.models import Port
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
from api.v1.modules.a76.general_catalogs.seal.models import Seal
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import (
TariffFraction,
)
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import (
USTariffFraction,
)
# Registrar Listeners de Auditoría
@app.on_event("startup")
def register_audit():

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { Settings2 } from 'lucide-svelte';
import { tabSettings } from '$lib/config/csv-upload';
let {
activeTab,
settings = $bindable()
}: {
activeTab: string;
settings: Record<string, any>;
} = $props();
let currentFields = $derived(tabSettings[activeTab] || []);
// Determine grid columns based on number of fields
let gridClass = $derived(
currentFields.length > 4
? 'grid-cols-4'
: currentFields.length > 2
? 'grid-cols-3'
: currentFields.length > 1
? 'grid-cols-2'
: 'grid-cols-1'
);
</script>
<!--
No positioning here. Parent layout controls placement.
Just styling the "Island".
-->
<div
class="border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4 rounded-xl shadow-2xl mx-4 mb-4"
>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2 text-muted-foreground border-b pb-2">
<Settings2 class="h-4 w-4" />
<span class="text-xs font-semibold uppercase tracking-wider">Configuración: {activeTab}</span>
</div>
{#if currentFields.length > 0}
<div class="grid {gridClass} gap-6">
{#each currentFields as field}
<div class="flex flex-col gap-2">
{#if field.type !== 'boolean'}
<Label class="text-xs font-medium text-muted-foreground uppercase"
>{field.label}</Label
>
{/if}
{#if field.type === 'text'}
<Input type="text" bind:value={settings[field.name]} class="h-8" />
{:else if field.type === 'boolean'}
<div class="flex items-center space-x-2 h-8">
<Checkbox id={field.name} bind:checked={settings[field.name]} />
<Label
for={field.name}
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>{field.label}</Label
>
</div>
{:else if field.type === 'select' && field.options}
<select
class="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
bind:value={settings[field.name]}
>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else if field.type === 'radio' && field.options}
<RadioGroup.Root
bind:value={settings[field.name]}
class="flex gap-4 h-8 items-center"
>
{#each field.options as opt}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={opt.value} id={`${field.name}-${opt.value}`} />
<Label for={`${field.name}-${opt.value}`}>{opt.label}</Label>
</div>
{/each}
</RadioGroup.Root>
{/if}
</div>
{/each}
</div>
{:else}
<div class="flex items-center justify-center h-8 text-sm text-muted-foreground italic">
No hay configuraciones específicas para este módulo.
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import {
Loader2,
CheckCircle2,
AlertTriangle,
XCircle,
FileText,
UploadCloud
} from 'lucide-svelte';
let {
open = $bindable(false),
scanResults = null,
commitResults = null,
isUploading = false,
onConfirm,
onCancel,
onClose
}: {
open: boolean;
scanResults: any;
commitResults: any;
isUploading: boolean;
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
} = $props();
// Determine state
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 totalSkipped = $derived(
(commitResults?.skipped_invalid || 0) +
(commitResults?.skipped_missing_fk || 0) +
(commitResults?.skipped_missing_invoice || 0)
);
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
if (isPending) onCancel();
else onClose();
}
open = newOpen;
}
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content
class="sm:max-w-[650px] p-0 gap-0 overflow-hidden border-0 shadow-xl flex flex-col max-h-[90vh] bg-background text-foreground"
>
<!-- Header Section with Theme Colors -->
<div
class="px-6 py-5 border-b flex items-start gap-4
{isPending
? 'bg-primary/5'
: isFinished && !hasErrors
? 'bg-green-50/50 dark:bg-green-900/10'
: 'bg-destructive/5'}"
>
<div
class="p-2 rounded-full ring-1 ring-inset
{isPending
? 'bg-primary/10 text-primary ring-primary/20'
: isFinished && !hasErrors
? 'bg-green-100 text-green-700 ring-green-200 dark:bg-green-900/20 dark:text-green-400 dark:ring-green-900/40'
: 'bg-destructive/10 text-destructive ring-destructive/20'}"
>
{#if isPending}
<FileText class="w-6 h-6" />
{:else if isFinished && !hasErrors}
<CheckCircle2 class="w-6 h-6" />
{:else}
<AlertTriangle class="w-6 h-6" />
{/if}
</div>
<div class="flex-1">
<Dialog.Title class="text-xl font-semibold tracking-tight text-foreground">
{#if isPending}
Validación de Importación
{:else if isFinished}
{hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'}
{/if}
</Dialog.Title>
<Dialog.Description class="mt-1 text-muted-foreground">
{#if isPending}
Revise el análisis preliminar antes de confirmar la carga de datos.
{:else if isFinished}
El proceso de importación ha finalizado.
{/if}
</Dialog.Description>
</div>
</div>
<!-- Scrollable Content -->
<div class="px-6 py-6 overflow-y-auto">
<!-- PENDING STATE CONTENT -->
{#if isPending}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<!-- Card: Total -->
<div
class="bg-card p-4 rounded-lg border flex flex-col items-center justify-center text-center shadow-sm"
>
<span class="text-muted-foreground text-xs uppercase font-bold tracking-wider mb-1"
>Total Filas</span
>
<span class="text-2xl font-bold text-foreground">{scanResults.total_rows || 0}</span>
</div>
<!-- Card: Valid -->
<div
class="bg-green-50/50 dark:bg-green-900/10 p-4 rounded-lg border border-green-100 dark:border-green-900/30 flex flex-col items-center justify-center text-center shadow-sm"
>
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider mb-1"
>Válidos</span
>
<span class="text-2xl font-bold text-green-700 dark:text-green-300"
>{scanResults.valid_rows || 0}</span
>
</div>
<!-- Card: Errors -->
<div
class="bg-destructive/5 p-4 rounded-lg border border-destructive/10 flex flex-col items-center justify-center text-center shadow-sm"
>
<span class="text-destructive text-xs uppercase font-bold tracking-wider mb-1"
>Errores</span
>
<span class="text-2xl font-bold text-destructive">{scanResults.error_count || 0}</span>
</div>
</div>
{#if scanResults.error_count > 0}
<div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
>
<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.
</p>
</div>
</div>
{: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" />
<div class="text-sm text-primary/90">
<p class="font-semibold mb-1">Archivo validado correctamente</p>
<p>Todos los registros parecen correctos y listos para importar.</p>
</div>
</div>
{/if}
{/if}
<!-- FINISHED STATE CONTENT -->
{#if isFinished}
<div class="space-y-6">
<!-- Simplified 2-Column Stats Grid -->
<div class="grid grid-cols-2 gap-4">
<!-- Inserted (Green) -->
<div
class="bg-green-50/50 dark:bg-green-900/10 p-4 rounded-lg border border-green-100 dark:border-green-900/30 flex flex-col items-center justify-center text-center shadow-sm"
>
<div class="flex items-center gap-2 mb-1">
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-400" />
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider"
>Insertados</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300"
>{commitResults.inserted || 0}</span
>
</div>
<!-- Rejected (Red) -->
<div
class="bg-destructive/5 p-4 rounded-lg border border-destructive/10 flex flex-col items-center justify-center text-center shadow-sm"
>
<div class="flex items-center gap-2 mb-1">
<XCircle class="w-4 h-4 text-destructive" />
<span class="text-destructive text-xs uppercase font-bold tracking-wider"
>Rechazados</span
>
</div>
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
</div>
</div>
<!-- Error Details Table -->
{#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
<div class="border rounded-lg overflow-hidden mt-2 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
</h5>
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{commitResults.skipped_details.length} filas
</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-20">Línea</th>
<th class="px-4 py-2 w-32">Referencia</th>
<th class="px-4 py-2">Motivo</th>
</tr>
</thead>
<tbody class="divide-y">
{#each commitResults.skipped_details as detail}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-2 font-mono text-muted-foreground">{detail.line}</td>
<td class="px-4 py-2 font-mono font-medium text-foreground"
>{detail.invoice || '-'}</td
>
<td class="px-4 py-2 text-destructive">{detail.reason}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer Actions -->
<div class="px-6 py-4 bg-muted/20 border-t flex items-center justify-end gap-3">
{#if isPending}
<Button
variant="ghost"
onclick={onCancel}
disabled={isUploading}
class="text-muted-foreground hover:bg-muted/50"
>
Cancelar Operación
</Button>
<Button
onclick={onConfirm}
disabled={isUploading}
class="bg-primary hover:bg-primary/90 text-primary-foreground min-w-[140px] shadow-sm"
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Procesando...
{:else}
<UploadCloud class="mr-2 h-4 w-4" />
Confirmar Carga
{/if}
</Button>
{:else if isFinished}
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Upload, FileType } from 'lucide-svelte';
import type { CsvUploadItem } from '$lib/config/csv-upload';
let {
open = $bindable(false),
config,
currentSettings // Read-only prop passed from page
}: {
open: boolean;
config: CsvUploadItem;
currentSettings: Record<string, any>;
} = $props();
// State
let file: File | null = $state(null);
let isProcessing = $state(false);
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
file = target.files[0];
}
}
function handleProcess() {
isProcessing = true;
console.log('Processing Upload Request:', {
entityConfig: config,
file: file,
activeSettings: currentSettings // Log the global/tab settings being applied
});
// Mock processing time
setTimeout(() => {
isProcessing = false;
open = false;
alert(
`Procesando archivo para: ${config.title}\nConfiguración aplicada: ${JSON.stringify(currentSettings, null, 2)}`
);
}, 1000);
}
// Reset file state when config changes or modal opens
$effect(() => {
if (config) {
file = null;
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[500px]">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
{#if config.icon}
<config.icon class="h-5 w-5" />
{/if}
Importar {config.title}
</Dialog.Title>
<Dialog.Description>
Selecciona tu archivo CSV para procesar.
<br />
<span class="text-xs text-muted-foreground mt-1 block">
La configuración activa del pie de página se aplicará a esta carga.
</span>
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-6 py-4">
<!-- Stage 1: File Selection -->
<div class="flex flex-col gap-3">
<div
class="border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center gap-2 hover:bg-muted/50 transition-colors cursor-pointer relative"
>
<input
type="file"
accept=".csv,.txt"
class="absolute inset-0 opacity-0 cursor-pointer"
onchange={handleFileChange}
/>
{#if file}
<FileType class="h-12 w-12 text-primary" />
<div class="text-center">
<span class="font-medium text-sm block">{file.name}</span>
<span class="text-xs text-muted-foreground">{(file.size / 1024).toFixed(2)} KB</span>
</div>
{:else}
<Upload class="h-10 w-10 text-muted-foreground" />
<span class="font-medium text-sm">Arrastra tu archivo aquí o haz clic</span>
<span class="text-xs text-muted-foreground">Soporta CSV, TXT</span>
{/if}
</div>
</div>
<!-- Preview of Current Settings (Read Only) -->
{#if Object.keys(currentSettings).length > 0}
<div class="bg-muted/40 p-3 rounded text-xs text-muted-foreground">
<strong>Configuración Activa:</strong>
<ul class="list-disc pl-4 mt-1 space-y-0.5">
{#each Object.entries(currentSettings) as [key, value]}
<li>{key}: <span class="font-mono">{value}</span></li>
{/each}
</ul>
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
<Button onclick={handleProcess} disabled={!file || isProcessing}>
{#if isProcessing}
Procesando...
{:else}
Procesar
{/if}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,264 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import type { CsvUploadItem } from '$lib/config/csv-upload';
import { UploadCloud, Lock } from 'lucide-svelte';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
let {
items,
onUpload
}: {
items: CsvUploadItem[];
onUpload: (file: File, config: CsvUploadItem) => void;
} = $props();
let dragOverId = $state<string | null>(null);
// Group items
let groupedItems = $derived.by(() => {
const groups: Record<string, CsvUploadItem[]> = {};
const ungrouped: CsvUploadItem[] = [];
items.forEach((item) => {
if (item.group) {
if (!groups[item.group]) groups[item.group] = [];
groups[item.group].push(item);
} else {
ungrouped.push(item);
}
});
return { groups, ungrouped };
});
function handleDragEnter(e: DragEvent, id: string, disabled?: boolean) {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
dragOverId = id;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
dragOverId = null;
}
function handleDragOver(e: DragEvent, disabled?: boolean) {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
dragOverId = null;
}
function validateAndUpload(file: File, item: CsvUploadItem) {
const isValidExtension = file.name.toLowerCase().endsWith('.csv');
if (!isValidExtension) {
toast.error('Formato inválido. Solo se permiten archivos .csv');
return;
}
onUpload(file, item);
}
function handleDrop(e: DragEvent, item: CsvUploadItem) {
if (item.disabled) return;
e.preventDefault();
e.stopPropagation();
dragOverId = null;
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
validateAndUpload(e.dataTransfer.files[0], item);
}
}
function handleClick(id: string, disabled?: boolean) {
if (disabled) return;
const input = document.getElementById(`file-input-${id}`) as HTMLInputElement;
if (input) input.click();
}
function handleFileChange(e: Event, item: CsvUploadItem) {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
validateAndUpload(target.files[0], item);
target.value = '';
}
}
function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
if (item.disabled) {
e.preventDefault();
return;
}
if (!item.templateUrl) return;
e.preventDefault();
const link = document.createElement('a');
link.href = item.templateUrl;
link.download = item.templateUrl.split('/').pop() || 'plantilla.xls';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.info(`Descargando plantilla para ${item.title}...`);
}
</script>
<div class="flex flex-col gap-6 select-none">
{#if groupedItems.ungrouped.length > 0}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupedItems.ungrouped as item}
<div
class={cn(
'relative group transition-all duration-200 ease-in-out transform',
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
ondragleave={handleDragLeave}
ondragover={(e) => handleDragOver(e, item.disabled)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
roles="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
>
<input
type="file"
id={`file-input-${item.id}`}
class="hidden"
accept=".csv"
onchange={(e) => handleFileChange(e, item)}
disabled={item.disabled}
/>
<Card.Root
class={cn(
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && dragOverId === item.id
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
: ''
)}
>
{#if item.disabled}
<div class="absolute inset-0 bg-background/50 z-20 flex items-center justify-center">
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
</span>
</div>
{/if}
<Card.Content
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
>
{#if !item.disabled && dragOverId === item.id}
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
{:else}
<div
class={cn(
'p-3 bg-muted rounded-full transition-transform duration-200',
!item.disabled && 'group-hover:scale-110'
)}
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/each}
</div>
{/if}
{#each Object.entries(groupedItems.groups) as [groupName, groupItems]}
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">
{groupName}
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupItems as item}
<div
class={cn(
'relative group transition-all duration-200 ease-in-out transform',
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
ondragleave={handleDragLeave}
ondragover={(e) => handleDragOver(e, item.disabled)}
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)}
>
<input
type="file"
id={`file-input-${item.id}`}
class="hidden"
accept=".csv"
onchange={(e) => handleFileChange(e, item)}
disabled={item.disabled}
/>
<Card.Root
class={cn(
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && dragOverId === item.id
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
: ''
)}
>
{#if item.disabled}
<div
class="absolute inset-0 bg-background/50 z-20 flex items-center justify-center"
>
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
</span>
</div>
{/if}
<Card.Content
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
>
{#if !item.disabled && dragOverId === item.id}
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
{:else}
<div
class={cn(
'p-3 bg-muted rounded-full transition-transform duration-200',
!item.disabled && 'group-hover:scale-110'
)}
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/each}
</div>
</div>
{/each}
</div>

View File

@@ -193,7 +193,10 @@
const soldToHeaderOptions = $derived([
{ value: 'consignado_a', label: 'Consignado a' },
{ value: 'vendido_a', label: 'Vendido a' },
{ value: operationType === 1 ? 'exportado_a' : 'importador', label: operationType === 1 ? 'Exportado a' : 'Importador' }
{
value: operationType === 1 ? 'exportado_a' : 'importador',
label: operationType === 1 ? 'Exportado a' : 'Importador'
}
]);
const shippedToHeaderOptions = $derived(
@@ -213,21 +216,21 @@
const shippedByHeaderOptions = $derived(
operationType === 1 || invoiceType === 'CR'
? [
{ value: 'enviado_por', label: 'Enviado Por' },
{ value: 'destinatario', label: 'Destinatario' },
{ value: 'vendido_por', label: 'Vendido Por' },
{ value: 'consignado_a', label: 'Consignado a' },
{ value: 'vendido_a', label: 'Vendido a' },
{ value: 'exportado_a', label: 'Exportado a' },
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' },
{ value: 'donado_a', label: 'Donado a' },
{ value: 'notificar_a', label: 'Notificar a' }
]
{ value: 'enviado_por', label: 'Enviado Por' },
{ value: 'destinatario', label: 'Destinatario' },
{ value: 'vendido_por', label: 'Vendido Por' },
{ value: 'consignado_a', label: 'Consignado a' },
{ value: 'vendido_a', label: 'Vendido a' },
{ value: 'exportado_a', label: 'Exportado a' },
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' },
{ value: 'donado_a', label: 'Donado a' },
{ value: 'notificar_a', label: 'Notificar a' }
]
: [
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' }
]
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' }
]
);
// Combinar clientes y proveedores para shipped_to, evitando duplicados de tipo "both"
@@ -427,80 +430,85 @@
<span class="text-red-500">*</span>
</div>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<Select.Root
type="single"
value={formData.shipped_to_header || shippedToHeaderOptions[0]?.value || ''}
onValueChange={(v) => {
formData.shipped_to_header = v ?? '';
}}
>
<Select.Trigger id="shipped_to_header" class="h-7 text-xs min-w-[125px] max-w-[250px]">
<span class="truncate">
{shippedToHeaderOptions.find(o => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value))?.label || 'Selecciona encabezado...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each shippedToHeaderOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={formData.shipped_to_id ? String(formData.shipped_to_id) : ''}
onValueChange={(v) => {
formData.shipped_to_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="shipped_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.shipped_to_id}
{allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'}
{:else}
Selecciona...
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each allClientsProviders as cp}
<Select.Item value={String(cp.id)}>
{cp.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
</div>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<Select.Root
type="single"
value={formData.shipped_to_header || shippedToHeaderOptions[0]?.value || ''}
onValueChange={(v) => {
formData.shipped_to_header = v ?? '';
}}
>
<Select.Trigger id="shipped_to_header" class="h-7 text-xs min-w-[125px] max-w-[250px]">
<span class="truncate">
{shippedToHeaderOptions.find(
(o) => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value)
)?.label || 'Selecciona encabezado...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each shippedToHeaderOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={formData.shipped_to_id ? String(formData.shipped_to_id) : ''}
onValueChange={(v) => {
formData.shipped_to_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="shipped_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.shipped_to_id}
{allClientsProviders.find((cp) => cp.id === formData.shipped_to_id)?.name ||
'Selecciona...'}
{:else}
Selecciona...
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each allClientsProviders as cp}
<Select.Item value={String(cp.id)}>
{cp.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
</div>
<div class="space-y-1.5">
<Label for="customs_broker_id" class="text-xs">Agente Aduanal Mex: <span class="text-red-500">*</span></Label>
<Select.Root
type="single"
value={formData.customs_broker_id ? String(formData.customs_broker_id) : ''}
onValueChange={(v) => {
formData.customs_broker_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="customs_broker_id" class="h-7 text-xs min-w-[150px] max-w-[300px]">
<span class="truncate">
{formData.customs_broker_id
? customsBrokers.find(cb => cb.id === formData.customs_broker_id)?.name || 'Selecciona...'
: 'Selecciona...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsBrokers as broker}
<Select.Item value={broker.id.toString()}>
{broker.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="customs_broker_id" class="text-xs"
>Agente Aduanal Mex: <span class="text-red-500">*</span></Label
>
<Select.Root
type="single"
value={formData.customs_broker_id ? String(formData.customs_broker_id) : ''}
onValueChange={(v) => {
formData.customs_broker_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="customs_broker_id" class="h-7 text-xs min-w-[150px] max-w-[300px]">
<span class="truncate">
{formData.customs_broker_id
? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name ||
'Selecciona...'
: 'Selecciona...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsBrokers as broker}
<Select.Item value={broker.id.toString()}>
{broker.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="customs_broker_us_id" class="text-xs">Agente Aduanal Ame:</Label>
@@ -530,88 +538,101 @@
</div>
</div>
<!-- Columna Derecha: Tipo de Moneda y Transportista -->
<div class="space-y-3">
<!-- Tipo de Moneda - Pesos Netos y Brutos -->
<div class="border rounded-md p-3 space-y-2">
<div class="flex justify-between">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Tipo de Moneda - Pesos Netos y Brutos</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de cambio:
<span class="text-primary ml-1">
{(exchangeRate !== undefined && exchangeRate !== null)
? (exchangeRate === 0 ? 'N/A' : Number(exchangeRate).toFixed(4))
: (formData.exchange_rate ? Number(formData.exchange_rate).toFixed(4) : 'N/A')}
</span>
</h4>
</div>
<!-- Columna Derecha: Tipo de Moneda y Transportista -->
<div class="space-y-3">
<!-- Tipo de Moneda - Pesos Netos y Brutos -->
<div class="border rounded-md p-3 space-y-2">
<div class="flex justify-between">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de Moneda - Pesos Netos y Brutos
</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de cambio:
<span class="text-primary ml-1">
{exchangeRate !== undefined && exchangeRate !== null
? exchangeRate === 0
? 'N/A'
: Number(exchangeRate).toFixed(4)
: formData.exchange_rate
? Number(formData.exchange_rate).toFixed(4)
: 'N/A'}
</span>
</h4>
</div>
<!-- Radio buttons para tipo de moneda -->
<div class="space-y-1.5">
<RadioGroup.Root bind:value={formData.currency} class="flex gap-4">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
<Label for="currency-foreign" class="text-xs font-normal cursor-pointer">Extranjera (Dlls)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="currency-local" class="h-4 w-4" />
<Label for="currency-local" class="text-xs font-normal cursor-pointer">Nacional (Pesos)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="manual" id="currency-manual" class="h-4 w-4" />
<Label for="currency-manual" class="text-xs font-normal cursor-pointer">De Captura</Label>
</div>
</RadioGroup.Root>
</div>
{#if formData.currency === 'manual'}
<div class="space-y-1.5">
<Label for="currency_type" class="text-xs">Moneda:</Label>
<Select.Root
type="single"
value={formData.currency_type || ''}
onValueChange={(v) => {
formData.currency_type = v ?? '';
}}
>
<Select.Trigger id="currency_type" class="h-7 text-xs min-w-[80px]">
<span class="truncate">
{formData.currency_type || '...'}
</span>
</Select.Trigger>
<Select.Content class="min-w-[80px] max-h-[300px]">
{#each currencyTypes as currencyType}
<Select.Item value={currencyType.code}>
{currencyType.code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1.5">
<Label for="weight_type" class="text-xs">Tipo Peso:</Label>
<Select.Root
type="single"
value={formData.weight_type || 'kgs'}
onValueChange={(v) => {
formData.weight_type = v ?? 'kgs';
}}
>
<Select.Trigger id="weight_type" class="min-w-[150px] h-7 text-xs">
<span class="truncate">
{weightTypeOptions.find(w => w.value === formData.weight_type)?.label || 'Kilogramos (kg)'}
</span>
</Select.Trigger>
<Select.Content>
{#each weightTypeOptions as weightType}
<Select.Item value={weightType.value}>
{weightType.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Radio buttons para tipo de moneda -->
<div class="space-y-1.5">
<RadioGroup.Root bind:value={formData.currency} class="flex gap-4">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
<Label for="currency-foreign" class="text-xs font-normal cursor-pointer"
>Extranjera (Dlls)</Label
>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="currency-local" class="h-4 w-4" />
<Label for="currency-local" class="text-xs font-normal cursor-pointer"
>Nacional (Pesos)</Label
>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="manual" id="currency-manual" class="h-4 w-4" />
<Label for="currency-manual" class="text-xs font-normal cursor-pointer"
>De Captura</Label
>
</div>
</RadioGroup.Root>
</div>
{#if formData.currency === 'manual'}
<div class="space-y-1.5">
<Label for="currency_type" class="text-xs">Moneda:</Label>
<Select.Root
type="single"
value={formData.currency_type || ''}
onValueChange={(v) => {
formData.currency_type = v ?? '';
}}
>
<Select.Trigger id="currency_type" class="h-7 text-xs min-w-[80px]">
<span class="truncate">
{formData.currency_type || '...'}
</span>
</Select.Trigger>
<Select.Content class="min-w-[80px] max-h-[300px]">
{#each currencyTypes as currencyType}
<Select.Item value={currencyType.code}>
{currencyType.code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1.5">
<Label for="weight_type" class="text-xs">Tipo Peso:</Label>
<Select.Root
type="single"
value={formData.weight_type || 'kgs'}
onValueChange={(v) => {
formData.weight_type = v ?? 'kgs';
}}
>
<Select.Trigger id="weight_type" class="min-w-[150px] h-7 text-xs">
<span class="truncate">
{weightTypeOptions.find((w) => w.value === formData.weight_type)?.label ||
'Kilogramos (kg)'}
</span>
</Select.Trigger>
<Select.Content>
{#each weightTypeOptions as weightType}
<Select.Item value={weightType.value}>
{weightType.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR' && invoiceType !== 'REP' && invoiceType !== 'REPAR'}
<div class="space-y-1.5">
@@ -647,44 +668,46 @@
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Transportista -->
<div class="border rounded-md p-3 space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Transportista</h4>
<!-- Transportista -->
<div class="border rounded-md p-3 space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Transportista</h4>
<div class="grid grid-cols-3 gap-2">
<div class="grid grid-cols-3 gap-2">
{#if invoiceType !== 'MEX'}
<div class="space-y-1.5">
<Label for="carrier_id" class="text-xs">Transportista:</Label>
<Select.Root
type="single"
value={formData.carrier_id ? String(formData.carrier_id) : ''}
onValueChange={(v) => {
formData.carrier_id = v || null;
}}
>
<Select.Trigger id="carrier_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.carrier_id}
{transporters.find(t => String(t.transporter_key) === String(formData.carrier_id))?.name || formData.carrier_id}
{:else if transporters.length > 0}
Selecciona transportista...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each transporters as transporter}
<Select.Item value={String(transporter.transporter_key)}>
{transporter.transporter_key}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Select.Root
type="single"
value={formData.carrier_id ? String(formData.carrier_id) : ''}
onValueChange={(v) => {
formData.carrier_id = v || null;
}}
>
<Select.Trigger id="carrier_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.carrier_id}
{transporters.find(
(t) => String(t.transporter_key) === String(formData.carrier_id)
)?.name || formData.carrier_id}
{:else if transporters.length > 0}
Selecciona transportista...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each transporters as transporter}
<Select.Item value={String(transporter.transporter_key)}>
{transporter.transporter_key}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="col-span-2 space-y-1.5">
@@ -811,33 +834,34 @@
{#if invoiceType !== 'MEX'}
<div class="space-y-1.5">
<Label for="aduana" class="text-xs">Aduana y Sección de Despacho:</Label>
<Select.Root
type="single"
value={formData.aduana || ''}
onValueChange={(v) => {
formData.aduana = v ?? '';
}}
>
<Select.Trigger id="aduana" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.aduana}
{customsSections.find(cs => cs.customs_code === formData.aduana)?.section_name || formData.aduana}
{:else if customsSections.length > 0}
Selecciona aduana...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsSections as section}
<Select.Item value={section.customs_code}>
{section.customs_code} - {section.section_name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Select.Root
type="single"
value={formData.aduana || ''}
onValueChange={(v) => {
formData.aduana = v ?? '';
}}
>
<Select.Trigger id="aduana" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.aduana}
{customsSections.find((cs) => cs.customs_code === formData.aduana)
?.section_name || formData.aduana}
{:else if customsSections.length > 0}
Selecciona aduana...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsSections as section}
<Select.Item value={section.customs_code}>
{section.customs_code} - {section.section_name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{#if invoiceType !== 'MEX'}
@@ -845,35 +869,36 @@
<Label for="document_type" class="text-xs"
>Clave de Régimen Aduanero: <span class="text-red-500">*</span></Label
>
<Select.Root
type="single"
value={formData.document_type || ''}
onValueChange={(v) => {
formData.document_type = v ?? '';
}}
>
<Select.Trigger id="document_type" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.document_type}
{codePedimentoRegimens.find(r => r.regimen_code === formData.document_type)?.regimen_code || formData.document_type}
{:else if filteredRegimens.length > 0}
Selecciona régimen...
{:else if operationType}
Sin regímenes para tipo {operationType}
{:else}
Selecciona tipo de operación primero
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each filteredRegimens as regimen}
<Select.Item value={regimen.regimen_code}>
{regimen.regimen_code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Select.Root
type="single"
value={formData.document_type || ''}
onValueChange={(v) => {
formData.document_type = v ?? '';
}}
>
<Select.Trigger id="document_type" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.document_type}
{codePedimentoRegimens.find((r) => r.regimen_code === formData.document_type)
?.regimen_code || formData.document_type}
{:else if filteredRegimens.length > 0}
Selecciona régimen...
{:else if operationType}
Sin regímenes para tipo {operationType}
{:else}
Selecciona tipo de operación primero
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each filteredRegimens as regimen}
<Select.Item value={regimen.regimen_code}>
{regimen.regimen_code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{#if invoiceType === 'MEX'}
@@ -891,8 +916,8 @@
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<ManifestSelectorModal bind:open={showManifestModal} onSelect={handleManifestSelect} />

View File

@@ -15,6 +15,7 @@ import {
Shield,
Users,
Ship,
MoreHorizontal,
} from 'lucide-svelte';
import * as m from "$lib/paraglide/messages.js";
import { Title } from '../ui/alert';
@@ -459,6 +460,7 @@ export function getSidebarData(): SidebarData {
},
],
},
{
title: m["sidebar.clients_and_providers"](),
url: "/dashboard/clients_and_providers",

View File

@@ -1,4 +1,5 @@
<script lang="ts">
<script lang="ts">
import { tick } from "svelte";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
@@ -20,6 +21,17 @@
} = $props();
const sidebar = useSidebar();
let open = $state(false);
let position = $state({ x: 0, y: 0 });
async function handleMoreClick(e: MouseEvent) {
e.preventDefault();
open = false;
position = { x: e.clientX, y: e.clientY };
await tick();
open = true;
}
</script>
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
@@ -66,11 +78,28 @@
</DropdownMenu.Root>
</Sidebar.MenuItem>
{/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="text-sidebar-foreground/70">
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
<EllipsisIcon class="text-sidebar-foreground/70" />
<span>More</span>
</Sidebar.MenuButton>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger class="fixed z-50 size-0" style="top: {position.y}px; left: {position.x}px" />
<DropdownMenu.Content
class="w-48 rounded-lg"
side="right"
align="start"
>
<DropdownMenu.Item>
<a href="/dashboard/csv-upload" class="flex items-center gap-2 w-full">
<FolderIcon class="size-4 text-muted-foreground" />
<span>Carga CSV</span>
</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>

View File

@@ -0,0 +1,366 @@
import {
User,
Users,
FileText,
Truck,
Container,
Ship,
Plane,
Package,
Briefcase,
Globe,
CreditCard,
DollarSign,
Calendar,
Hash,
MapPin,
ShieldCheck,
FileDigit,
Scale,
} from 'lucide-svelte';
// --- Interfaces ---
export interface CsvUploadItem {
id: string;
title: string;
icon: any;
group?: string; // For grouping within a tab
modelTarget?: string; // The backend model this maps to
description?: string;
templateUrl?: string; // Path to the template file in static/
disabled?: boolean; // New property to mark items as "Coming Soon"
}
export interface CsvUploadField {
name: string;
label: string;
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
options?: { label: string; value: string | boolean | number }[];
required?: boolean;
defaultValue?: any;
}
// Map of Tab ID -> Array of Fields
export const tabSettings: Record<string, CsvUploadField[]> = {
catalogos: [
{
name: 'mode',
label: 'Modo de Carga',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
],
defaultValue: 'update'
}
],
transportes: [
{
name: 'mode',
label: 'Modo de Carga',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
],
defaultValue: 'update'
}
],
importacion: [
{
name: 'autonumber_remesas',
label: 'Autonumerar Remesas',
type: 'boolean',
defaultValue: false
},
{
name: 'recalculate_dates',
label: 'Recalcular Fechas',
type: 'boolean',
defaultValue: false
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
],
exportacion: [
{
name: 'invoice_type',
label: 'Tipo de Factura',
type: 'select',
options: [
{ label: 'AFIJO', value: 'AFIJO' },
{ label: 'NORMAL', value: 'NORMAL' },
],
defaultValue: 'AFIJO',
},
{
name: 'is_regime_change',
label: 'Es Cambio de Régimen',
type: 'boolean',
defaultValue: false,
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
]
};
// --- DATA DEFINITIONS (Items only, no config) ---
export const catalogosConfig: CsvUploadItem[] = [
{
id: 'customs_brokers',
title: 'Agentes Aduanales',
icon: User,
modelTarget: 'CustomsBroker',
templateUrl: '/csv/EstructuraCatAgenteAduanal.xls'
},
{
id: 'clients_providers',
title: 'Clientes y Proveedores',
icon: Users,
modelTarget: 'ClientProvider',
templateUrl: '/csv/EstructuraCatClienteProv.xls'
},
{
id: 'exchange_rates',
title: 'Tipo de Cambios',
icon: DollarSign,
modelTarget: 'ExchangeRate',
templateUrl: '/csv/EstructuraCatTiposCambio.xls'
},
{
id: 'american_fractions',
title: 'Fracc. Ame.',
icon: Globe,
modelTarget: 'AmericanFraction',
templateUrl: '/csv/EstructuraCatFraccAme.xls'
},
{
id: 'material_classes',
title: 'Clases de Materiales',
icon: Package,
modelTarget: 'MaterialClass',
templateUrl: '/csv/EstructuraCatClasesAF.xls'
},
{
id: 'items',
title: 'Partidas (Permisos)',
icon: FileText,
group: 'Permisos',
modelTarget: 'ItemPermission',
templateUrl: '/csv/EstructuraCatPartesAF.xls'
},
{
id: 'headers',
title: 'Encabezados (Permisos)',
icon: FileText,
group: 'Permisos',
modelTarget: 'HeaderPermission',
disabled: true,
},
{
id: 'historical_fractions',
title: 'Fracciones Históricas',
icon: Calendar,
modelTarget: 'HistoricalFraction',
disabled: true,
},
{
id: 'pedimentos',
title: 'Pedimentos',
icon: FileDigit,
modelTarget: 'Pedimento',
templateUrl: '/csv/EstructuraCatPedimentos.xls'
},
];
export const transportesConfig: CsvUploadItem[] = [
{
id: 'transports',
title: 'Transportes',
icon: Truck,
modelTarget: 'Transport',
templateUrl: '/csv/EstructuraCatTransportes.xls'
},
{
id: 'drivers',
title: 'Conductores',
icon: User,
modelTarget: 'Driver',
templateUrl: '/csv/EstructuraCatConductor.xls'
},
{
id: 'trailers',
title: 'Trailers y Cajas',
icon: Container,
modelTarget: 'Trailer',
templateUrl: '/csv/EstructuraCatTrailers.xls'
},
];
export const importacionConfig: CsvUploadItem[] = [
// Impo Temp
{
id: 'imp_temp_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Temp.',
modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
},
{
id: 'imp_temp_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Temp.',
modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
},
{
id: 'imp_temp_series',
title: 'Series',
icon: Hash,
group: 'Impo. Temp.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Impo Def
{
id: 'imp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Def.',
modelTarget: 'InvoiceHeader',
templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
},
{
id: 'imp_def_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Def.',
modelTarget: 'InvoiceSalesDetails',
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
},
{
id: 'imp_def_series',
title: 'Series',
icon: Hash,
group: 'Impo. Def.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Compras Mex
{
id: 'comp_mex_header',
title: 'Encabezado',
icon: FileText,
group: 'Compras Mex.',
modelTarget: 'InvoiceHeader',
disabled: true,
},
{
id: 'comp_mex_details',
title: 'Partidas',
icon: Package,
group: 'Compras Mex.',
modelTarget: 'InvoiceSalesDetails',
disabled: true,
},
{
id: 'comp_mex_series',
title: 'Series',
icon: Hash,
group: 'Compras Mex.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
];
export const exportacionConfig: CsvUploadItem[] = [
// Expo Def / Cam. Reg.
{
id: 'exp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceHeader',
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
},
{
id: 'exp_def_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSalesDetails',
templateUrl: '/csv/EstructuraParExpoCamReg.xls'
},
{
id: 'exp_def_series',
title: 'Series',
icon: Hash,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
{
id: 'exp_def_nodes',
title: 'NODES',
icon: Briefcase,
group: 'Expo. Def./Cam. Reg.',
modelTarget: 'Nodes',
disabled: true,
},
// Expo Rep
{
id: 'exp_rep_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Rep.',
modelTarget: 'InvoiceHeader',
disabled: true,
},
{
id: 'exp_rep_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Rep.',
modelTarget: 'InvoiceSalesDetails',
disabled: true,
},
{
id: 'exp_rep_series',
title: 'Series',
icon: Hash,
group: 'Expo. Rep.',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Manifiesto
{
id: 'manifest_header',
title: 'Encabezado',
icon: FileText,
group: 'Manifiesto',
modelTarget: 'Manifest',
disabled: true,
},
];

View File

@@ -0,0 +1,209 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs/index.js';
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
import {
catalogosConfig,
transportesConfig,
importacionConfig,
exportacionConfig,
tabSettings,
type CsvUploadItem
} from '$lib/config/csv-upload';
import { api } from '$lib/api';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
// We no longer need modal state
let activeTab = $state('catalogos');
let isUploading = $state(false);
let currentJobId = $state<string | null>(null);
let activeModelTarget = $state<string | null>(null);
let scanResults = $state<any>(null);
let commitResults = $state<any>(null);
let showResultModal = $state(false);
// Initialize settings for all tabs upfront to avoid reactivity loops
let allSettings = $state<Record<string, any>>(() => {
const initial: Record<string, any> = {};
for (const tab in tabSettings) {
initial[tab] = {};
tabSettings[tab].forEach((f) => {
initial[tab][f.name] = f.defaultValue;
});
}
return initial;
});
async function handleUpload(file: File, config: CsvUploadItem) {
isUploading = true;
activeModelTarget = config.modelTarget || null;
scanResults = null;
const currentSettings = allSettings[activeTab] || {};
const companyId = companyStore.activeCompany?.id || 1;
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
const res = await api.imports.upload(
file,
config.modelTarget || '',
currentSettings,
companyId,
opType
);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
pollStatus();
} else {
toast.error('Error al subir el archivo');
isUploading = false;
}
}
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.`);
}
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 {
// Continue polling
setTimeout(pollStatus, 2000);
}
}
</script>
<div class="flex flex-col h-[calc(100vh-4rem)] -m-4 overflow-hidden">
<!-- Scrollable Content Area -->
<div class="flex-1 overflow-y-auto p-4 md:p-8 space-y-4">
<div class="flex items-center gap-4">
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
</div>
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-2 md:grid-cols-4 lg:w-auto">
<Tabs.Trigger value="catalogos">Catálogos</Tabs.Trigger>
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
</Tabs.List>
<div class="mt-6">
<Tabs.Content value="catalogos" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
</div>
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
</Tabs.Content>
<Tabs.Content value="transportes" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
</div>
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
</Tabs.Content>
<Tabs.Content value="importacion" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
</div>
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
</Tabs.Content>
<Tabs.Content value="exportacion" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Operaciones de Exportación</h2>
</div>
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
</Tabs.Content>
</div>
</Tabs.Root>
<div class="h-4"></div>
</div>
<!-- Fixed Footer Area -->
{#if allSettings[activeTab]}
<div class="flex-none z-20">
<ConfigFooter {activeTab} bind:settings={allSettings[activeTab]} />
</div>
{/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();
}
} 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;
}}
/>

View File

@@ -0,0 +1 @@
TIPO(MEX=Mexicano,AME=AMERICANO) CLAVE AADUANAL PATENTE NOMBRE RFC DIRECCION CODIGO POSTAL CIUDAD ESTADO PAIS TELEFONO NUMERO FAX CORREO ELECTRONICO CURP

View File

@@ -0,0 +1 @@
CLAVE CLASE DESCRIPCION ESPA<50>OL DESCRIPCION INGLES TIPO DE MATERIAL U.M. COMERCIAL FRACCION ARANCELARIA FRACCION AMERICANA TASA DE DEPRECIACION REVISION FISICA (1/0) CODIGO DE PRODUCTO/SERVICIO CP

View File

@@ -0,0 +1 @@
PROCEDENCIA CLIENTE(E=Extranjero, N=Nacional) TIPO(C=Cliente,P=Proveedor,A=Ambos) CLAVE CLIENTE NOMBRE RFC CALLES NUM. EXTERIOR CODIGO POSTAL COLONIA o PARQUE IND. CIUDAD ESTADO PAIS TELEFONO NUMERO FAX CORREO ELECTRONICO CURP TIPO DE PROGRAMA SECON NUMERO DE PROGRAMA SECON FECHA AUT. SECON ##/##/#### ES PROGRAMA PROSEC? (SI o NO) NUMERO DE PROGRAMA PROSEC VINCULACION ES EMPRESA CERTIFICADA? REGISTRO DE EMPRESA CERT. INFORMACION ADICIONAL CONTACTO CLAVE MANUFACTURERO TAX I.D. CLAVE BROKER AMERICANO EXPO CLAVE BROKER AMERICANO IMPO CLAVE TRANSFERENCIA A.A. TRANSFORMADOR/SUBMAQUILA CLAVE INTERFACE

View File

@@ -0,0 +1 @@
TRANSPORTISTA LINEA CLAVE CONDUCTOR LICENCIA PERMISO LINEA EXPRESS IDENTIFICACION ACE FECHA NACIMIENTO SEXO PAIS NACIMIENTO TRANSPORTA MAT. PELIGROSO? PERMISO MAT. PELIGROSO NOMBRE(S) APELLIDO PATERNO FORMA IDENTIFICACION 1 NUM. IDENTIFICACION 1 ESTADO PAIS FORMA IDENTIFICACION 2 NUM. IDENTIFICACION 2 ESTADO PAIS

View File

@@ -0,0 +1 @@
FRACCION ARANCELARIA PREFIJO UNIDAD DE MEDIDA DESCRIPCION TIPO DE ADVALOREM ADVALOREM % ADVALOREM DLLS

View File

@@ -0,0 +1 @@
NUMERO DE PARTE DESCRIPCION EN ESPA<50>OL DESCRIPCION EN INGLES CLASE UNIDAD DE MEDIDA COMERCIAL COSTO UNITARIO TIPO MONEDA COSTO CLAVE MONEDA PESO UNITARIO TIPO PESO FRACCION PAIS PREFERENCIA SECTOR RUTA DE LA IMAGEN

View File

@@ -0,0 +1 @@
NUMERO DE PEDIMENTO (##-####-######) TIPO MOV(I=Impotaci<63>n,E=Expotaci<63>n) CLAVE PEDIMENTO REGIMEN FECHA INICIO FECHA FINAL FECHA DE PAGO ADUANA Y SECCION DE CRUCE ACUSE ELECTRONICO INDIVIDUAL o CONSOLIDADO (IND,CON) MET TRANS ENTRADA MET TRANS ARRIVO MET TRANS SALIDA IEPS DTA CNT PREVALIDACION MONTO TIGIE PAGO IMPUESTO? (S/N) ES MIXTO (SI/NO) OBS RECTIFICA OPCION DESTINO(Interior del Pais/Regi<67>n Fronteriza/Franja Fronteriza) VALOR IVA VALOR ME VALOR ADUANAS FLETE VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES ESTATUS (ABIERTO/CERRADO) PERSONA REV FECHA CIERRE FECHA REVISION FECHA AUTORIZACION FECHA RECIBIDO REPRESENTANTE AA CLAVE DEST ORIGEN FECHA ENTRADA RECINTO FECHA EXTRACCION RECINTO ERRORES FORMA PAGO DTA FORMA PAGO IGI FORMA PAGO PREVAL FORMA PAGO IVA RECARGOS MULTAS IVA DE PREV CUOTAS CONPENSATORIAS IDENTIFICADORES IEPS 2 FORMA DE PAGO IEPS 2 DTA 2 FORMA DE PAGO DTA 2 IVA 2 FORMA DE PAGO IVA 2 IGI 2 FORMA DE PAGO IGI 2 PREVALIDACION FORMA DE PAGO PREVALIDACION 2 CNT 2 FORMA DE PAGO CNT 2

View File

@@ -0,0 +1 @@
FECHA (##/##/####) TIPO DE CAMBIO

View File

@@ -0,0 +1 @@
CLAVE TRAILER/CAJA NUMERO ACE TIPO DE TRAILER PRECINTO CODIGO DE ENTIDAD PLACAS ESTADO PAIS

View File

@@ -0,0 +1 @@
CLAVE CLAVE ACE CLAVE TRANSPORTE VIN TIPO TRANSPORTE CODIGO DE ENTIDAD TRANSPONDEDOR NUMERO DOT PLACAS CIUDAD ESTADO PAIS PRECINTO EMPRESA ASEGURADORA NUM. ASEGURADORA MONTO ASEGURADO FECHA DE ASEGURADORA

View File

@@ -0,0 +1 @@
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO TIPO PESO MANIFIESTO E-DOCUMENT NUM. OPERACION ENVIADO POR ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I FACTURA ALTERNA

View File

@@ -0,0 +1 @@
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO FECHA EMISION TIPO PESO E-DOCUMENT NUM. OPERACION ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I

View File

@@ -0,0 +1 @@
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO FECHA EMISION TIPO PESO E-DOCUMENT NUM. OPERACION ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I FACTURA ALTERNA

View File

@@ -0,0 +1 @@
NUMERO FACTURA EXPO. LINEA EXPO. TIPO DE IMPO. FACTURA IMPO. LINEA IMPO. GENERA DESCARGA CANTIDAD EXPORTADA/DESCARGAR COSTO UNITARIO PESO NETO PESO BRUTO SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) LOTE NUMERO ENTRADA ES PARTIDA/SUBPARTIDA LINEA PRINCIPAL FRACCION AMERICANA FRACCION ARANCELARIA

View File

@@ -0,0 +1 @@
NUMERO FACTURA LINEA CLASE CANTIDAD IMPORTADA UNIDAD DE MEDIDA COSTO UNITARIO PESO NETO PESO BRUTO CANTIDAD BULTOS CLAVE BULTOS PAIS ORIGEN FRACCION ARANCELARIA PREFERENCIA ARANCELARIA SECTOR FRACCION AMERICANA ORDEN DE COMPRA DESCRIPCION ESPA<50>OL DESCRIPCION INGLES MARCA MODELO ES PARTIDA O SUBPARTIDA LINEA PRINCIPAL NUM. PARTE SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO METODO DE VALORACION DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) VALOR TOTAL LOTE NUMERO ENTRADA ID TYPE

View File

@@ -0,0 +1 @@
NUMERO FACTURA LINEA CLASE CANTIDAD IMPORTADA UNIDAD DE MEDIDA COSTO UNITARIO PESO NETO PESO BRUTO CANTIDAD BULTOS CLAVE BULTOS PAIS ORIGEN FRACCION ARANCELARIA PREFERENCIA ARANCELARIA SECTOR FRACCION AMERICANA ORDEN DE COMPRA DESCRIPCION ESPA<50>OL DESCRIPCION INGLES MARCA MODELO ES PARTIDA O SUBPARTIDA LINEA PRINCIPAL NUM. PARTE SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO METODO DE VALORACION DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) TOTAL NUMERO ENTRADA LOTE ID TYPE