diff --git a/.gitignore b/.gitignore index 1da35013..48bbc048 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ node_modules/ # Docker *.dockerignore postgres-data/ -backend/uploads/ \ No newline at end of file +backend/uploads/ +docker-compose.yml diff --git a/backend/api/v1/modules/a76/imports/__init__.py b/backend/api/v1/modules/a76/imports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/imports/routes.py b/backend/api/v1/modules/a76/imports/routes.py new file mode 100644 index 00000000..1ea76a74 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/routes.py @@ -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 + } diff --git a/backend/api/v1/modules/a76/imports/schemas.py b/backend/api/v1/modules/a76/imports/schemas.py new file mode 100644 index 00000000..63c0202a --- /dev/null +++ b/backend/api/v1/modules/a76/imports/schemas.py @@ -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 diff --git a/backend/api/v1/modules/a76/imports/tasks.py b/backend/api/v1/modules/a76/imports/tasks.py new file mode 100644 index 00000000..dfb82e74 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/tasks.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoice_settings/dto.py b/backend/api/v1/modules/a76/invoice_settings/dto.py index 07c954a8..d330bf29 100644 --- a/backend/api/v1/modules/a76/invoice_settings/dto.py +++ b/backend/api/v1/modules/a76/invoice_settings/dto.py @@ -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 diff --git a/backend/api/v1/modules/a76/invoice_settings/routes.py b/backend/api/v1/modules/a76/invoice_settings/routes.py index 07e37459..9a6f83c3 100644 --- a/backend/api/v1/modules/a76/invoice_settings/routes.py +++ b/backend/api/v1/modules/a76/invoice_settings/routes.py @@ -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( diff --git a/backend/api/v1/modules/a76/invoice_settings/services.py b/backend/api/v1/modules/a76/invoice_settings/services.py index 3499a700..996fcac7 100644 --- a/backend/api/v1/modules/a76/invoice_settings/services.py +++ b/backend/api/v1/modules/a76/invoice_settings/services.py @@ -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 ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 093aa20b..96d93ab9 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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") diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index e0d19372..1665bb6a 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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 ) diff --git a/backend/main.py b/backend/main.py index ae673fbf..005dd653 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte new file mode 100644 index 00000000..1adb87aa --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte @@ -0,0 +1,96 @@ + + + +
+
+
+ + Configuración: {activeTab} +
+ + {#if currentFields.length > 0} +
+ {#each currentFields as field} +
+ {#if field.type !== 'boolean'} + + {/if} + + {#if field.type === 'text'} + + {:else if field.type === 'boolean'} +
+ + +
+ {:else if field.type === 'select' && field.options} + + {:else if field.type === 'radio' && field.options} + + {#each field.options as opt} +
+ + +
+ {/each} +
+ {/if} +
+ {/each} +
+ {:else} +
+ No hay configuraciones específicas para este módulo. +
+ {/if} +
+
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte new file mode 100644 index 00000000..7375b3ab --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -0,0 +1,275 @@ + + + + + +
+
+ {#if isPending} + + {:else if isFinished && !hasErrors} + + {:else} + + {/if} +
+ +
+ + {#if isPending} + Validación de Importación + {:else if isFinished} + {hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'} + {/if} + + + {#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} + +
+
+ + +
+ + {#if isPending} +
+ +
+ Total Filas + {scanResults.total_rows || 0} +
+ + +
+ Válidos + {scanResults.valid_rows || 0} +
+ + +
+ Errores + {scanResults.error_count || 0} +
+
+ + {#if scanResults.error_count > 0} +
+ +
+

Se detectaron problemas en el archivo

+

+ Las filas con errores serán omitidas automáticamente. Solo se importarán los + registros válidos. +

+
+
+ {:else} +
+ +
+

Archivo validado correctamente

+

Todos los registros parecen correctos y listos para importar.

+
+
+ {/if} + {/if} + + + {#if isFinished} +
+ +
+ +
+
+ + Insertados +
+ {commitResults.inserted || 0} +
+ + +
+
+ + Rechazados +
+ {totalSkipped} +
+
+ + + {#if commitResults.skipped_details && commitResults.skipped_details.length > 0} +
+
+
+ Detalle de Errores +
+ + {commitResults.skipped_details.length} filas + +
+
+ + + + + + + + + + {#each commitResults.skipped_details as detail} + + + + + + {/each} + +
LíneaReferenciaMotivo
{detail.line}{detail.invoice || '-'}{detail.reason}
+
+
+ {/if} +
+ {/if} +
+ + +
+ {#if isPending} + + + {:else if isFinished} + + {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/csv-upload/UniversalUploadModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/UniversalUploadModal.svelte new file mode 100644 index 00000000..721cd901 --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/UniversalUploadModal.svelte @@ -0,0 +1,123 @@ + + + + + + + {#if config.icon} + + {/if} + Importar {config.title} + + + Selecciona tu archivo CSV para procesar. +
+ + La configuración activa del pie de página se aplicará a esta carga. + +
+
+ +
+ +
+
+ + {#if file} + +
+ {file.name} + {(file.size / 1024).toFixed(2)} KB +
+ {:else} + + Arrastra tu archivo aquí o haz clic + Soporta CSV, TXT + {/if} +
+
+ + + {#if Object.keys(currentSettings).length > 0} +
+ Configuración Activa: +
    + {#each Object.entries(currentSettings) as [key, value]} +
  • {key}: {value}
  • + {/each} +
+
+ {/if} +
+ + + + + +
+
diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte new file mode 100644 index 00000000..8084f135 --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -0,0 +1,264 @@ + + +
+ {#if groupedItems.ungrouped.length > 0} +
+ {#each groupedItems.ungrouped as item} +
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)} + > + handleFileChange(e, item)} + disabled={item.disabled} + /> + + + {#if item.disabled} +
+ + Próximamente + +
+ {/if} + + + {#if !item.disabled && dragOverId === item.id} +
+ +
+ ¡Suelta el archivo! + {:else} +
+ +
+
{item.title}
+ {/if} +
+
+
+ {/each} +
+ {/if} + + {#each Object.entries(groupedItems.groups) as [groupName, groupItems]} +
+

+ {groupName} +

+
+ {#each groupItems as item} +
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)} + > + handleFileChange(e, item)} + disabled={item.disabled} + /> + + + {#if item.disabled} +
+ + Próximamente + +
+ {/if} + + + {#if !item.disabled && dragOverId === item.id} +
+ +
+ ¡Suelta el archivo! + {:else} +
+ +
+
{item.title}
+ {/if} +
+
+
+ {/each} +
+
+ {/each} +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index 2ff9c055..3b55cdb2 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -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 @@ * -
- { - formData.shipped_to_header = v ?? ''; - }} - > - - - {shippedToHeaderOptions.find(o => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value))?.label || 'Selecciona encabezado...'} - - - - {#each shippedToHeaderOptions as option} - - {option.label} - - {/each} - - - { - formData.shipped_to_id = v ? parseInt(v) : null; - }} - > - - - {#if formData.shipped_to_id} - {allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'} - {:else} - Selecciona... - {/if} - - - - {#each allClientsProviders as cp} - - {cp.name} - - {/each} - - - * -
+
+ { + formData.shipped_to_header = v ?? ''; + }} + > + + + {shippedToHeaderOptions.find( + (o) => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value) + )?.label || 'Selecciona encabezado...'} + + + + {#each shippedToHeaderOptions as option} + + {option.label} + + {/each} + + + { + formData.shipped_to_id = v ? parseInt(v) : null; + }} + > + + + {#if formData.shipped_to_id} + {allClientsProviders.find((cp) => cp.id === formData.shipped_to_id)?.name || + 'Selecciona...'} + {:else} + Selecciona... + {/if} + + + + {#each allClientsProviders as cp} + + {cp.name} + + {/each} + + + * +
- -
- - { - formData.customs_broker_id = v ? parseInt(v) : null; - }} - > - - - {formData.customs_broker_id - ? customsBrokers.find(cb => cb.id === formData.customs_broker_id)?.name || 'Selecciona...' - : 'Selecciona...'} - - - - {#each customsBrokers as broker} - - {broker.name} - - {/each} - - -
+
+ + { + formData.customs_broker_id = v ? parseInt(v) : null; + }} + > + + + {formData.customs_broker_id + ? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name || + 'Selecciona...' + : 'Selecciona...'} + + + + {#each customsBrokers as broker} + + {broker.name} + + {/each} + + +
@@ -530,88 +538,101 @@
- -
- -
-
-

Tipo de Moneda - Pesos Netos y Brutos

-

- Tipo de cambio: - - {(exchangeRate !== undefined && exchangeRate !== null) - ? (exchangeRate === 0 ? 'N/A' : Number(exchangeRate).toFixed(4)) - : (formData.exchange_rate ? Number(formData.exchange_rate).toFixed(4) : 'N/A')} - -

-
- - -
- -
- - -
-
- - -
-
- - -
-
-
- {#if formData.currency === 'manual'} -
- - { - formData.currency_type = v ?? ''; - }} - > - - - {formData.currency_type || '...'} - - - - {#each currencyTypes as currencyType} - - {currencyType.code} - - {/each} - - -
- {/if} -
-
- - { - formData.weight_type = v ?? 'kgs'; - }} - > - - - {weightTypeOptions.find(w => w.value === formData.weight_type)?.label || 'Kilogramos (kg)'} - - - - {#each weightTypeOptions as weightType} - - {weightType.label} - - {/each} - - -
+ +
+ +
+
+

+ Tipo de Moneda - Pesos Netos y Brutos +

+

+ Tipo de cambio: + + {exchangeRate !== undefined && exchangeRate !== null + ? exchangeRate === 0 + ? 'N/A' + : Number(exchangeRate).toFixed(4) + : formData.exchange_rate + ? Number(formData.exchange_rate).toFixed(4) + : 'N/A'} + +

+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ {#if formData.currency === 'manual'} +
+ + { + formData.currency_type = v ?? ''; + }} + > + + + {formData.currency_type || '...'} + + + + {#each currencyTypes as currencyType} + + {currencyType.code} + + {/each} + + +
+ {/if} +
+
+ + { + formData.weight_type = v ?? 'kgs'; + }} + > + + + {weightTypeOptions.find((w) => w.value === formData.weight_type)?.label || + 'Kilogramos (kg)'} + + + + {#each weightTypeOptions as weightType} + + {weightType.label} + + {/each} + + +
{#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR' && invoiceType !== 'REP' && invoiceType !== 'REPAR'}
@@ -647,44 +668,46 @@
{/if} -
-
+
+
- -
-

Transportista

- -
+ +
+

Transportista

+ +
{#if invoiceType !== 'MEX'}
- { - formData.carrier_id = v || null; - }} - > - - - {#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} - - - - {#each transporters as transporter} - - {transporter.transporter_key} - - {/each} - - -
+ { + formData.carrier_id = v || null; + }} + > + + + {#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} + + + + {#each transporters as transporter} + + {transporter.transporter_key} + + {/each} + + +
{/if}
@@ -811,33 +834,34 @@ {#if invoiceType !== 'MEX'}
- { - formData.aduana = v ?? ''; - }} - > - - - {#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} - - - - {#each customsSections as section} - - {section.customs_code} - {section.section_name} - - {/each} - - -
+ { + formData.aduana = v ?? ''; + }} + > + + + {#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} + + + + {#each customsSections as section} + + {section.customs_code} - {section.section_name} + + {/each} + + +
{/if} {#if invoiceType !== 'MEX'} @@ -845,35 +869,36 @@ - { - formData.document_type = v ?? ''; - }} - > - - - {#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} - - - - {#each filteredRegimens as regimen} - - {regimen.regimen_code} - - {/each} - - -
+ { + formData.document_type = v ?? ''; + }} + > + + + {#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} + + + + {#each filteredRegimens as regimen} + + {regimen.regimen_code} + + {/each} + + +
{/if} {#if invoiceType === 'MEX'} @@ -891,8 +916,8 @@
{/if} - - + + diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 264c7683..887ba8bc 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -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", diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index e231955d..f67a27e2 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -1,4 +1,5 @@ - @@ -66,11 +78,28 @@ {/each} + - + More + + + + + + + + Carga CSV + + + + diff --git a/frontend/src/lib/config/csv-upload.ts b/frontend/src/lib/config/csv-upload.ts new file mode 100644 index 00000000..656b0459 --- /dev/null +++ b/frontend/src/lib/config/csv-upload.ts @@ -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 = { + 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, + }, +]; diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte new file mode 100644 index 00000000..6c55bc56 --- /dev/null +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -0,0 +1,209 @@ + + +
+ +
+
+

Importación Masiva de Datos (CSV)

+
+ + + + Catálogos + Transportes + Importación + Exportación + + +
+ +
+

Catálogos Generales

+
+ +
+ + +
+

Logística y Transporte

+
+ +
+ + +
+

Operaciones de Importación

+
+ +
+ + +
+

Operaciones de Exportación

+
+ +
+
+
+ +
+
+ + + {#if allSettings[activeTab]} +
+ +
+ {/if} +
+ + { + 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; + }} +/> diff --git a/frontend/static/csv/EstructuraCatAgenteAduanal.xls b/frontend/static/csv/EstructuraCatAgenteAduanal.xls new file mode 100644 index 00000000..8978a33e --- /dev/null +++ b/frontend/static/csv/EstructuraCatAgenteAduanal.xls @@ -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 diff --git a/frontend/static/csv/EstructuraCatClasesAF.xls b/frontend/static/csv/EstructuraCatClasesAF.xls new file mode 100644 index 00000000..96b7891a --- /dev/null +++ b/frontend/static/csv/EstructuraCatClasesAF.xls @@ -0,0 +1 @@ +CLAVE CLASE DESCRIPCION ESPAÑ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 diff --git a/frontend/static/csv/EstructuraCatClienteProv.xls b/frontend/static/csv/EstructuraCatClienteProv.xls new file mode 100644 index 00000000..1b2096cc --- /dev/null +++ b/frontend/static/csv/EstructuraCatClienteProv.xls @@ -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 diff --git a/frontend/static/csv/EstructuraCatConductor.xls b/frontend/static/csv/EstructuraCatConductor.xls new file mode 100644 index 00000000..30482cb7 --- /dev/null +++ b/frontend/static/csv/EstructuraCatConductor.xls @@ -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 diff --git a/frontend/static/csv/EstructuraCatFraccAme.xls b/frontend/static/csv/EstructuraCatFraccAme.xls new file mode 100644 index 00000000..dc1f5495 --- /dev/null +++ b/frontend/static/csv/EstructuraCatFraccAme.xls @@ -0,0 +1 @@ +FRACCION ARANCELARIA PREFIJO UNIDAD DE MEDIDA DESCRIPCION TIPO DE ADVALOREM ADVALOREM % ADVALOREM DLLS diff --git a/frontend/static/csv/EstructuraCatPartesAF.xls b/frontend/static/csv/EstructuraCatPartesAF.xls new file mode 100644 index 00000000..e989d04f --- /dev/null +++ b/frontend/static/csv/EstructuraCatPartesAF.xls @@ -0,0 +1 @@ +NUMERO DE PARTE DESCRIPCION EN ESPAÑ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 diff --git a/frontend/static/csv/EstructuraCatPedimentos.xls b/frontend/static/csv/EstructuraCatPedimentos.xls new file mode 100644 index 00000000..b2e2f7f2 --- /dev/null +++ b/frontend/static/csv/EstructuraCatPedimentos.xls @@ -0,0 +1 @@ +NUMERO DE PEDIMENTO (##-####-######) TIPO MOV(I=Impotación,E=Expotació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ó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 diff --git a/frontend/static/csv/EstructuraCatTiposCambio.xls b/frontend/static/csv/EstructuraCatTiposCambio.xls new file mode 100644 index 00000000..916d24d3 --- /dev/null +++ b/frontend/static/csv/EstructuraCatTiposCambio.xls @@ -0,0 +1 @@ +FECHA (##/##/####) TIPO DE CAMBIO diff --git a/frontend/static/csv/EstructuraCatTrailers.xls b/frontend/static/csv/EstructuraCatTrailers.xls new file mode 100644 index 00000000..52f1e1d0 --- /dev/null +++ b/frontend/static/csv/EstructuraCatTrailers.xls @@ -0,0 +1 @@ +CLAVE TRAILER/CAJA NUMERO ACE TIPO DE TRAILER PRECINTO CODIGO DE ENTIDAD PLACAS ESTADO PAIS diff --git a/frontend/static/csv/EstructuraCatTransportes.xls b/frontend/static/csv/EstructuraCatTransportes.xls new file mode 100644 index 00000000..32093ebf --- /dev/null +++ b/frontend/static/csv/EstructuraCatTransportes.xls @@ -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 diff --git a/frontend/static/csv/EstructuraEncFacExpoCamReg.xls b/frontend/static/csv/EstructuraEncFacExpoCamReg.xls new file mode 100644 index 00000000..0fe4fd4c --- /dev/null +++ b/frontend/static/csv/EstructuraEncFacExpoCamReg.xls @@ -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 diff --git a/frontend/static/csv/EstructuraEncFacImpoDef.xls b/frontend/static/csv/EstructuraEncFacImpoDef.xls new file mode 100644 index 00000000..2708c0ed --- /dev/null +++ b/frontend/static/csv/EstructuraEncFacImpoDef.xls @@ -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 diff --git a/frontend/static/csv/EstructuraEncFacImpoTemp.xls b/frontend/static/csv/EstructuraEncFacImpoTemp.xls new file mode 100644 index 00000000..13fcbd22 --- /dev/null +++ b/frontend/static/csv/EstructuraEncFacImpoTemp.xls @@ -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 diff --git a/frontend/static/csv/EstructuraParExpoCamReg.xls b/frontend/static/csv/EstructuraParExpoCamReg.xls new file mode 100644 index 00000000..76bd69af --- /dev/null +++ b/frontend/static/csv/EstructuraParExpoCamReg.xls @@ -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 diff --git a/frontend/static/csv/EstructuraParFacImpoDefAF.xls b/frontend/static/csv/EstructuraParFacImpoDefAF.xls new file mode 100644 index 00000000..93bcae74 --- /dev/null +++ b/frontend/static/csv/EstructuraParFacImpoDefAF.xls @@ -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Ñ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 diff --git a/frontend/static/csv/EstructuraParFacImpoTempAF.xls b/frontend/static/csv/EstructuraParFacImpoTempAF.xls new file mode 100644 index 00000000..cc78d0b5 --- /dev/null +++ b/frontend/static/csv/EstructuraParFacImpoTempAF.xls @@ -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Ñ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