feature/csv-envoices-integration

This commit is contained in:
hreyes
2026-02-16 17:17:07 -06:00
parent eab10e6fbc
commit 91c3f7105b
14 changed files with 1777 additions and 362 deletions

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

@@ -11,7 +11,9 @@ from .customs_brokers.routes import router as customs_broker_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
@@ -68,6 +70,7 @@ router = APIRouter()
# Registrar módulos
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,71 @@ 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.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.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,16 +87,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,
@@ -114,59 +169,6 @@ app.add_middleware(TenantMiddleware)
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.tariff_fractions.models import TariffFraction
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
# Registrar Listeners de Auditoría
@app.on_event("startup")
def register_audit():

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

@@ -34,7 +34,7 @@
defaultOperationType = undefined,
exchangeRate = undefined,
invoiceType = undefined
}: {
}: {
invoice: Invoice | null;
formData?: any;
invoiceTypes?: InvoiceType[];
@@ -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>
<!-- 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>
<!-- 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>
{#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>
<div class="grid grid-cols-3 gap-2">
<!-- 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">
{#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

@@ -224,7 +224,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Encabezado',
icon: FileText,
group: 'Impo. Temp.',
modelTarget: 'InvoiceHeader',
modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
},
{
@@ -232,7 +232,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Partidas',
icon: Package,
group: 'Impo. Temp.',
modelTarget: 'InvoiceSalesDetails',
modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
},
{

View File

@@ -2,6 +2,7 @@
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,
@@ -10,42 +11,109 @@
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 allSettings = $state<Record<string, any>>({});
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);
$effect(() => {
const fields = tabSettings[activeTab] || [];
if (!allSettings[activeTab]) {
allSettings[activeTab] = {};
fields.forEach((f) => {
allSettings[activeTab][f.name] = f.defaultValue;
// 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;
});
function handleUpload(file: File, config: CsvUploadItem) {
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';
console.log('🚀 Starting Direct Upload Processing', {
file: file.name,
size: file.size,
target: config.modelTarget,
config: config.title,
settings: currentSettings
});
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;
}
}
// Mock Processing Feedback
const promise = new Promise((resolve) => setTimeout(resolve, 2000));
async function pollStatus() {
if (!currentJobId) return;
toast.promise(promise, {
loading: `Procesando ${file.name} para ${config.title}...`,
success: `Archivo cargado correctamente con configuración: ${JSON.stringify(currentSettings)}`,
error: 'Error al cargar el archivo'
});
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>
@@ -105,3 +173,37 @@
</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;
}}
/>