Merge remote-tracking branch 'origin/development' into feature/items-calculation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -61,4 +61,5 @@ node_modules/
|
||||
# Docker
|
||||
*.dockerignore
|
||||
postgres-data/
|
||||
backend/uploads/
|
||||
backend/uploads/
|
||||
docker-compose.yml
|
||||
|
||||
0
backend/api/v1/modules/a76/imports/__init__.py
Normal file
0
backend/api/v1/modules/a76/imports/__init__.py
Normal file
118
backend/api/v1/modules/a76/imports/routes.py
Normal file
118
backend/api/v1/modules/a76/imports/routes.py
Normal 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
|
||||
}
|
||||
20
backend/api/v1/modules/a76/imports/schemas.py
Normal file
20
backend/api/v1/modules/a76/imports/schemas.py
Normal 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
|
||||
876
backend/api/v1/modules/a76/imports/tasks.py
Normal file
876
backend/api/v1/modules/a76/imports/tasks.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -18,7 +18,8 @@ celery_app = Celery(
|
||||
"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",
|
||||
], # Ruta al módulo donde están las tareas
|
||||
"api.v1.modules.a76.imports.tasks"
|
||||
] # Ruta al módulo donde están las tareas
|
||||
)
|
||||
|
||||
# Configuraciones adicionales
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
|
||||
import { Settings2 } from 'lucide-svelte';
|
||||
import { tabSettings } from '$lib/config/csv-upload';
|
||||
|
||||
let {
|
||||
activeTab,
|
||||
settings = $bindable()
|
||||
}: {
|
||||
activeTab: string;
|
||||
settings: Record<string, any>;
|
||||
} = $props();
|
||||
|
||||
let currentFields = $derived(tabSettings[activeTab] || []);
|
||||
|
||||
// Determine grid columns based on number of fields
|
||||
let gridClass = $derived(
|
||||
currentFields.length > 4
|
||||
? 'grid-cols-4'
|
||||
: currentFields.length > 2
|
||||
? 'grid-cols-3'
|
||||
: currentFields.length > 1
|
||||
? 'grid-cols-2'
|
||||
: 'grid-cols-1'
|
||||
);
|
||||
</script>
|
||||
|
||||
<!--
|
||||
No positioning here. Parent layout controls placement.
|
||||
Just styling the "Island".
|
||||
-->
|
||||
<div
|
||||
class="border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 p-4 rounded-xl shadow-2xl mx-4 mb-4"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2 text-muted-foreground border-b pb-2">
|
||||
<Settings2 class="h-4 w-4" />
|
||||
<span class="text-xs font-semibold uppercase tracking-wider">Configuración: {activeTab}</span>
|
||||
</div>
|
||||
|
||||
{#if currentFields.length > 0}
|
||||
<div class="grid {gridClass} gap-6">
|
||||
{#each currentFields as field}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if field.type !== 'boolean'}
|
||||
<Label class="text-xs font-medium text-muted-foreground uppercase"
|
||||
>{field.label}</Label
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if field.type === 'text'}
|
||||
<Input type="text" bind:value={settings[field.name]} class="h-8" />
|
||||
{:else if field.type === 'boolean'}
|
||||
<div class="flex items-center space-x-2 h-8">
|
||||
<Checkbox id={field.name} bind:checked={settings[field.name]} />
|
||||
<Label
|
||||
for={field.name}
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>{field.label}</Label
|
||||
>
|
||||
</div>
|
||||
{:else if field.type === 'select' && field.options}
|
||||
<select
|
||||
class="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
bind:value={settings[field.name]}
|
||||
>
|
||||
{#each field.options as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if field.type === 'radio' && field.options}
|
||||
<RadioGroup.Root
|
||||
bind:value={settings[field.name]}
|
||||
class="flex gap-4 h-8 items-center"
|
||||
>
|
||||
{#each field.options as opt}
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={opt.value} id={`${field.name}-${opt.value}`} />
|
||||
<Label for={`${field.name}-${opt.value}`}>{opt.label}</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center h-8 text-sm text-muted-foreground italic">
|
||||
No hay configuraciones específicas para este módulo.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Upload, FileType } from 'lucide-svelte';
|
||||
import type { CsvUploadItem } from '$lib/config/csv-upload';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
config,
|
||||
currentSettings // Read-only prop passed from page
|
||||
}: {
|
||||
open: boolean;
|
||||
config: CsvUploadItem;
|
||||
currentSettings: Record<string, any>;
|
||||
} = $props();
|
||||
|
||||
// State
|
||||
let file: File | null = $state(null);
|
||||
let isProcessing = $state(false);
|
||||
|
||||
function handleFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
file = target.files[0];
|
||||
}
|
||||
}
|
||||
|
||||
function handleProcess() {
|
||||
isProcessing = true;
|
||||
console.log('Processing Upload Request:', {
|
||||
entityConfig: config,
|
||||
file: file,
|
||||
activeSettings: currentSettings // Log the global/tab settings being applied
|
||||
});
|
||||
|
||||
// Mock processing time
|
||||
setTimeout(() => {
|
||||
isProcessing = false;
|
||||
open = false;
|
||||
alert(
|
||||
`Procesando archivo para: ${config.title}\nConfiguración aplicada: ${JSON.stringify(currentSettings, null, 2)}`
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Reset file state when config changes or modal opens
|
||||
$effect(() => {
|
||||
if (config) {
|
||||
file = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-[500px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if config.icon}
|
||||
<config.icon class="h-5 w-5" />
|
||||
{/if}
|
||||
Importar {config.title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Selecciona tu archivo CSV para procesar.
|
||||
<br />
|
||||
<span class="text-xs text-muted-foreground mt-1 block">
|
||||
La configuración activa del pie de página se aplicará a esta carga.
|
||||
</span>
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-6 py-4">
|
||||
<!-- Stage 1: File Selection -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-8 flex flex-col items-center justify-center gap-2 hover:bg-muted/50 transition-colors cursor-pointer relative"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onchange={handleFileChange}
|
||||
/>
|
||||
{#if file}
|
||||
<FileType class="h-12 w-12 text-primary" />
|
||||
<div class="text-center">
|
||||
<span class="font-medium text-sm block">{file.name}</span>
|
||||
<span class="text-xs text-muted-foreground">{(file.size / 1024).toFixed(2)} KB</span>
|
||||
</div>
|
||||
{:else}
|
||||
<Upload class="h-10 w-10 text-muted-foreground" />
|
||||
<span class="font-medium text-sm">Arrastra tu archivo aquí o haz clic</span>
|
||||
<span class="text-xs text-muted-foreground">Soporta CSV, TXT</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview of Current Settings (Read Only) -->
|
||||
{#if Object.keys(currentSettings).length > 0}
|
||||
<div class="bg-muted/40 p-3 rounded text-xs text-muted-foreground">
|
||||
<strong>Configuración Activa:</strong>
|
||||
<ul class="list-disc pl-4 mt-1 space-y-0.5">
|
||||
{#each Object.entries(currentSettings) as [key, value]}
|
||||
<li>{key}: <span class="font-mono">{value}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (open = false)}>Cancelar</Button>
|
||||
<Button onclick={handleProcess} disabled={!file || isProcessing}>
|
||||
{#if isProcessing}
|
||||
Procesando...
|
||||
{:else}
|
||||
Procesar
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import type { CsvUploadItem } from '$lib/config/csv-upload';
|
||||
import { UploadCloud, Lock } from 'lucide-svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
items,
|
||||
onUpload
|
||||
}: {
|
||||
items: CsvUploadItem[];
|
||||
onUpload: (file: File, config: CsvUploadItem) => void;
|
||||
} = $props();
|
||||
|
||||
let dragOverId = $state<string | null>(null);
|
||||
|
||||
// Group items
|
||||
let groupedItems = $derived.by(() => {
|
||||
const groups: Record<string, CsvUploadItem[]> = {};
|
||||
const ungrouped: CsvUploadItem[] = [];
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.group) {
|
||||
if (!groups[item.group]) groups[item.group] = [];
|
||||
groups[item.group].push(item);
|
||||
} else {
|
||||
ungrouped.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return { groups, ungrouped };
|
||||
});
|
||||
|
||||
function handleDragEnter(e: DragEvent, id: string, disabled?: boolean) {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverId = id;
|
||||
}
|
||||
|
||||
function handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverId = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, disabled?: boolean) {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverId = null;
|
||||
}
|
||||
|
||||
function validateAndUpload(file: File, item: CsvUploadItem) {
|
||||
const isValidExtension = file.name.toLowerCase().endsWith('.csv');
|
||||
|
||||
if (!isValidExtension) {
|
||||
toast.error('Formato inválido. Solo se permiten archivos .csv');
|
||||
return;
|
||||
}
|
||||
|
||||
onUpload(file, item);
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, item: CsvUploadItem) {
|
||||
if (item.disabled) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragOverId = null;
|
||||
|
||||
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||
validateAndUpload(e.dataTransfer.files[0], item);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(id: string, disabled?: boolean) {
|
||||
if (disabled) return;
|
||||
const input = document.getElementById(`file-input-${id}`) as HTMLInputElement;
|
||||
if (input) input.click();
|
||||
}
|
||||
|
||||
function handleFileChange(e: Event, item: CsvUploadItem) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
validateAndUpload(target.files[0], item);
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
|
||||
if (item.disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!item.templateUrl) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = item.templateUrl;
|
||||
link.download = item.templateUrl.split('/').pop() || 'plantilla.xls';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.info(`Descargando plantilla para ${item.title}...`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6 select-none">
|
||||
{#if groupedItems.ungrouped.length > 0}
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{#each groupedItems.ungrouped as item}
|
||||
<div
|
||||
class={cn(
|
||||
'relative group transition-all duration-200 ease-in-out transform',
|
||||
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
|
||||
)}
|
||||
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondragover={(e) => handleDragOver(e, item.disabled)}
|
||||
ondrop={(e) => handleDrop(e, item)}
|
||||
oncontextmenu={(e) => handleContextMenu(e, item)}
|
||||
roles="button"
|
||||
tabindex={item.disabled ? -1 : 0}
|
||||
onclick={() => handleClick(item.id, item.disabled)}
|
||||
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id={`file-input-${item.id}`}
|
||||
class="hidden"
|
||||
accept=".csv"
|
||||
onchange={(e) => handleFileChange(e, item)}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
|
||||
<Card.Root
|
||||
class={cn(
|
||||
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
|
||||
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
|
||||
!item.disabled && dragOverId === item.id
|
||||
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{#if item.disabled}
|
||||
<div class="absolute inset-0 bg-background/50 z-20 flex items-center justify-center">
|
||||
<span
|
||||
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
|
||||
>
|
||||
<Lock class="h-3 w-3" /> Próximamente
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Card.Content
|
||||
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
|
||||
>
|
||||
{#if !item.disabled && dragOverId === item.id}
|
||||
<div class="animate-bounce">
|
||||
<UploadCloud class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
|
||||
{:else}
|
||||
<div
|
||||
class={cn(
|
||||
'p-3 bg-muted rounded-full transition-transform duration-200',
|
||||
!item.disabled && 'group-hover:scale-110'
|
||||
)}
|
||||
>
|
||||
<item.icon class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div class="font-medium text-sm text-balance">{item.title}</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each Object.entries(groupedItems.groups) as [groupName, groupItems]}
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">
|
||||
{groupName}
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{#each groupItems as item}
|
||||
<div
|
||||
class={cn(
|
||||
'relative group transition-all duration-200 ease-in-out transform',
|
||||
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
|
||||
)}
|
||||
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondragover={(e) => handleDragOver(e, item.disabled)}
|
||||
ondrop={(e) => handleDrop(e, item)}
|
||||
oncontextmenu={(e) => handleContextMenu(e, item)}
|
||||
role="button"
|
||||
tabindex={item.disabled ? -1 : 0}
|
||||
onclick={() => handleClick(item.id, item.disabled)}
|
||||
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id={`file-input-${item.id}`}
|
||||
class="hidden"
|
||||
accept=".csv"
|
||||
onchange={(e) => handleFileChange(e, item)}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
|
||||
<Card.Root
|
||||
class={cn(
|
||||
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
|
||||
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
|
||||
!item.disabled && dragOverId === item.id
|
||||
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{#if item.disabled}
|
||||
<div
|
||||
class="absolute inset-0 bg-background/50 z-20 flex items-center justify-center"
|
||||
>
|
||||
<span
|
||||
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
|
||||
>
|
||||
<Lock class="h-3 w-3" /> Próximamente
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Card.Content
|
||||
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
|
||||
>
|
||||
{#if !item.disabled && dragOverId === item.id}
|
||||
<div class="animate-bounce">
|
||||
<UploadCloud class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
|
||||
{:else}
|
||||
<div
|
||||
class={cn(
|
||||
'p-3 bg-muted rounded-full transition-transform duration-200',
|
||||
!item.disabled && 'group-hover:scale-110'
|
||||
)}
|
||||
>
|
||||
<item.icon class="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div class="font-medium text-sm text-balance">{item.title}</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -23,18 +23,21 @@
|
||||
let isLoadingFractions = $state(false);
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
// Cargar datos al abrir
|
||||
let wasOpen = $state(false);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// Reiniciar estado al abrir
|
||||
if (tariffFractions.length === 0) {
|
||||
searchFraction = '';
|
||||
currentPage = 1;
|
||||
totalFractions = 0;
|
||||
hasMoreFractions = true;
|
||||
loadFractions('', 1);
|
||||
}
|
||||
if (open && !wasOpen) {
|
||||
// Reiniciar estado SOLO al abrir
|
||||
searchFraction = '';
|
||||
currentPage = 1;
|
||||
totalFractions = 0;
|
||||
hasMoreFractions = true;
|
||||
tariffFractions = [];
|
||||
loadFractions('', 1);
|
||||
}
|
||||
wasOpen = open;
|
||||
});
|
||||
|
||||
async function loadFractions(search: string, page: number = currentPage) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
|
||||
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
@@ -20,6 +21,17 @@
|
||||
} = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let open = $state(false);
|
||||
let position = $state({ x: 0, y: 0 });
|
||||
|
||||
async function handleMoreClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
open = false;
|
||||
position = { x: e.clientX, y: e.clientY };
|
||||
await tick();
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
|
||||
@@ -66,11 +78,28 @@
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton class="text-sidebar-foreground/70">
|
||||
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
|
||||
<EllipsisIcon class="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</Sidebar.MenuButton>
|
||||
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger class="fixed z-50 size-0" style="top: {position.y}px; left: {position.x}px" />
|
||||
<DropdownMenu.Content
|
||||
class="w-48 rounded-lg"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenu.Item>
|
||||
<a href="/dashboard/csv-upload" class="flex items-center gap-2 w-full">
|
||||
<FolderIcon class="size-4 text-muted-foreground" />
|
||||
<span>Carga CSV</span>
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
366
frontend/src/lib/config/csv-upload.ts
Normal file
366
frontend/src/lib/config/csv-upload.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import {
|
||||
User,
|
||||
Users,
|
||||
FileText,
|
||||
Truck,
|
||||
Container,
|
||||
Ship,
|
||||
Plane,
|
||||
Package,
|
||||
Briefcase,
|
||||
Globe,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Hash,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
FileDigit,
|
||||
Scale,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
export interface CsvUploadItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: any;
|
||||
group?: string; // For grouping within a tab
|
||||
modelTarget?: string; // The backend model this maps to
|
||||
description?: string;
|
||||
templateUrl?: string; // Path to the template file in static/
|
||||
disabled?: boolean; // New property to mark items as "Coming Soon"
|
||||
}
|
||||
|
||||
export interface CsvUploadField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
|
||||
options?: { label: string; value: string | boolean | number }[];
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
// Map of Tab ID -> Array of Fields
|
||||
export const tabSettings: Record<string, CsvUploadField[]> = {
|
||||
catalogos: [
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Modo de Carga',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Actualizar', value: 'update' },
|
||||
{ label: 'Reemplazar', value: 'replace' }
|
||||
],
|
||||
defaultValue: 'update'
|
||||
}
|
||||
],
|
||||
transportes: [
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Modo de Carga',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Actualizar', value: 'update' },
|
||||
{ label: 'Reemplazar', value: 'replace' }
|
||||
],
|
||||
defaultValue: 'update'
|
||||
}
|
||||
],
|
||||
importacion: [
|
||||
{
|
||||
name: 'autonumber_remesas',
|
||||
label: 'Autonumerar Remesas',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
name: 'recalculate_dates',
|
||||
label: 'Recalcular Fechas',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
name: 'dateFormat',
|
||||
label: 'Formato de Fecha',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
|
||||
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
|
||||
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
|
||||
],
|
||||
defaultValue: 'dd/mm/yyyy'
|
||||
}
|
||||
],
|
||||
exportacion: [
|
||||
{
|
||||
name: 'invoice_type',
|
||||
label: 'Tipo de Factura',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'AFIJO', value: 'AFIJO' },
|
||||
{ label: 'NORMAL', value: 'NORMAL' },
|
||||
],
|
||||
defaultValue: 'AFIJO',
|
||||
},
|
||||
{
|
||||
name: 'is_regime_change',
|
||||
label: 'Es Cambio de Régimen',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'dateFormat',
|
||||
label: 'Formato de Fecha',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
|
||||
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
|
||||
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
|
||||
],
|
||||
defaultValue: 'dd/mm/yyyy'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// --- DATA DEFINITIONS (Items only, no config) ---
|
||||
|
||||
export const catalogosConfig: CsvUploadItem[] = [
|
||||
{
|
||||
id: 'customs_brokers',
|
||||
title: 'Agentes Aduanales',
|
||||
icon: User,
|
||||
modelTarget: 'CustomsBroker',
|
||||
templateUrl: '/csv/EstructuraCatAgenteAduanal.xls'
|
||||
},
|
||||
{
|
||||
id: 'clients_providers',
|
||||
title: 'Clientes y Proveedores',
|
||||
icon: Users,
|
||||
modelTarget: 'ClientProvider',
|
||||
templateUrl: '/csv/EstructuraCatClienteProv.xls'
|
||||
},
|
||||
{
|
||||
id: 'exchange_rates',
|
||||
title: 'Tipo de Cambios',
|
||||
icon: DollarSign,
|
||||
modelTarget: 'ExchangeRate',
|
||||
templateUrl: '/csv/EstructuraCatTiposCambio.xls'
|
||||
},
|
||||
{
|
||||
id: 'american_fractions',
|
||||
title: 'Fracc. Ame.',
|
||||
icon: Globe,
|
||||
modelTarget: 'AmericanFraction',
|
||||
templateUrl: '/csv/EstructuraCatFraccAme.xls'
|
||||
},
|
||||
{
|
||||
id: 'material_classes',
|
||||
title: 'Clases de Materiales',
|
||||
icon: Package,
|
||||
modelTarget: 'MaterialClass',
|
||||
templateUrl: '/csv/EstructuraCatClasesAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
title: 'Partidas (Permisos)',
|
||||
icon: FileText,
|
||||
group: 'Permisos',
|
||||
modelTarget: 'ItemPermission',
|
||||
templateUrl: '/csv/EstructuraCatPartesAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'headers',
|
||||
title: 'Encabezados (Permisos)',
|
||||
icon: FileText,
|
||||
group: 'Permisos',
|
||||
modelTarget: 'HeaderPermission',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'historical_fractions',
|
||||
title: 'Fracciones Históricas',
|
||||
icon: Calendar,
|
||||
modelTarget: 'HistoricalFraction',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'pedimentos',
|
||||
title: 'Pedimentos',
|
||||
icon: FileDigit,
|
||||
modelTarget: 'Pedimento',
|
||||
templateUrl: '/csv/EstructuraCatPedimentos.xls'
|
||||
},
|
||||
];
|
||||
|
||||
export const transportesConfig: CsvUploadItem[] = [
|
||||
{
|
||||
id: 'transports',
|
||||
title: 'Transportes',
|
||||
icon: Truck,
|
||||
modelTarget: 'Transport',
|
||||
templateUrl: '/csv/EstructuraCatTransportes.xls'
|
||||
},
|
||||
{
|
||||
id: 'drivers',
|
||||
title: 'Conductores',
|
||||
icon: User,
|
||||
modelTarget: 'Driver',
|
||||
templateUrl: '/csv/EstructuraCatConductor.xls'
|
||||
},
|
||||
{
|
||||
id: 'trailers',
|
||||
title: 'Trailers y Cajas',
|
||||
icon: Container,
|
||||
modelTarget: 'Trailer',
|
||||
templateUrl: '/csv/EstructuraCatTrailers.xls'
|
||||
},
|
||||
];
|
||||
|
||||
export const importacionConfig: CsvUploadItem[] = [
|
||||
// Impo Temp
|
||||
{
|
||||
id: 'imp_temp_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'invoice_header',
|
||||
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_temp_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'invoice_details',
|
||||
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_temp_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Impo Def
|
||||
{
|
||||
id: 'imp_def_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_def_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_def_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Compras Mex
|
||||
{
|
||||
id: 'comp_mex_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'comp_mex_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'comp_mex_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const exportacionConfig: CsvUploadItem[] = [
|
||||
// Expo Def / Cam. Reg.
|
||||
{
|
||||
id: 'exp_def_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
|
||||
},
|
||||
{
|
||||
id: 'exp_def_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
templateUrl: '/csv/EstructuraParExpoCamReg.xls'
|
||||
},
|
||||
{
|
||||
id: 'exp_def_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_def_nodes',
|
||||
title: 'NODES',
|
||||
icon: Briefcase,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'Nodes',
|
||||
disabled: true,
|
||||
},
|
||||
// Expo Rep
|
||||
{
|
||||
id: 'exp_rep_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_rep_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_rep_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Manifiesto
|
||||
{
|
||||
id: 'manifest_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Manifiesto',
|
||||
modelTarget: 'Manifest',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
209
frontend/src/routes/dashboard/csv-upload/+page.svelte
Normal file
209
frontend/src/routes/dashboard/csv-upload/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
|
||||
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
|
||||
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
|
||||
import {
|
||||
catalogosConfig,
|
||||
transportesConfig,
|
||||
importacionConfig,
|
||||
exportacionConfig,
|
||||
tabSettings,
|
||||
type CsvUploadItem
|
||||
} from '$lib/config/csv-upload';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// We no longer need modal state
|
||||
let activeTab = $state('catalogos');
|
||||
|
||||
let isUploading = $state(false);
|
||||
let currentJobId = $state<string | null>(null);
|
||||
let activeModelTarget = $state<string | null>(null);
|
||||
let scanResults = $state<any>(null);
|
||||
let commitResults = $state<any>(null);
|
||||
let showResultModal = $state(false);
|
||||
|
||||
// Initialize settings for all tabs upfront to avoid reactivity loops
|
||||
let allSettings = $state<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const tab in tabSettings) {
|
||||
initial[tab] = {};
|
||||
tabSettings[tab].forEach((f) => {
|
||||
initial[tab][f.name] = f.defaultValue;
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
async function handleUpload(file: File, config: CsvUploadItem) {
|
||||
isUploading = true;
|
||||
activeModelTarget = config.modelTarget || null;
|
||||
scanResults = null;
|
||||
const currentSettings = allSettings[activeTab] || {};
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
|
||||
|
||||
const res = await api.imports.upload(
|
||||
file,
|
||||
config.modelTarget || '',
|
||||
currentSettings,
|
||||
companyId,
|
||||
opType
|
||||
);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error('Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
const res = await api.imports.status(currentJobId);
|
||||
if (res.data?.status === 'waiting_confirmation') {
|
||||
scanResults = res.data;
|
||||
showResultModal = true;
|
||||
toast.success('Escaneo completado. Revisa los resultados.');
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'failed') {
|
||||
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
|
||||
isUploading = false;
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
} else if (res.data?.status === 'warning') {
|
||||
// Caso cuando no se insertaron registros pero hay información de rechazo
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
|
||||
if (inserted === 0) {
|
||||
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
|
||||
} else {
|
||||
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
|
||||
}
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'finished') {
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDetails = res.data?.skipped_details || [];
|
||||
|
||||
if (inserted > 0) {
|
||||
toast.success(`Importación completada: ${inserted} registros insertados`);
|
||||
if (skippedInvalid > 0 || skippedFk > 0) {
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
toast.warning(`${totalSkipped} registros fueron rechazados`);
|
||||
}
|
||||
} else {
|
||||
toast.error('No se insertaron registros. Revisa los errores a continuación.');
|
||||
}
|
||||
isUploading = false;
|
||||
} else {
|
||||
// Continue polling
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)] -m-4 overflow-hidden">
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-2 md:grid-cols-4 lg:w-auto">
|
||||
<Tabs.Trigger value="catalogos">Catálogos</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
|
||||
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="mt-6">
|
||||
<Tabs.Content value="catalogos" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="transportes" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="importacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="exportacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Exportación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="h-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer Area -->
|
||||
{#if allSettings[activeTab]}
|
||||
<div class="flex-none z-20">
|
||||
<ConfigFooter {activeTab} bind:settings={allSettings[activeTab]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ProcessingResultModal
|
||||
bind:open={showResultModal}
|
||||
{scanResults}
|
||||
{commitResults}
|
||||
{isUploading}
|
||||
onConfirm={async () => {
|
||||
if (currentJobId && activeModelTarget) {
|
||||
try {
|
||||
isUploading = true;
|
||||
const res = await api.imports.commit(currentJobId, activeModelTarget);
|
||||
if (res.data?.commit_job_id) {
|
||||
currentJobId = res.data.commit_job_id;
|
||||
pollStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al iniciar la importación');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
onClose={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
/>
|
||||
1
frontend/static/csv/EstructuraCatAgenteAduanal.xls
Normal file
1
frontend/static/csv/EstructuraCatAgenteAduanal.xls
Normal file
@@ -0,0 +1 @@
|
||||
TIPO(MEX=Mexicano,AME=AMERICANO) CLAVE AADUANAL PATENTE NOMBRE RFC DIRECCION CODIGO POSTAL CIUDAD ESTADO PAIS TELEFONO NUMERO FAX CORREO ELECTRONICO CURP
|
||||
1
frontend/static/csv/EstructuraCatClasesAF.xls
Normal file
1
frontend/static/csv/EstructuraCatClasesAF.xls
Normal file
@@ -0,0 +1 @@
|
||||
CLAVE CLASE DESCRIPCION ESPA<50>OL DESCRIPCION INGLES TIPO DE MATERIAL U.M. COMERCIAL FRACCION ARANCELARIA FRACCION AMERICANA TASA DE DEPRECIACION REVISION FISICA (1/0) CODIGO DE PRODUCTO/SERVICIO CP
|
||||
1
frontend/static/csv/EstructuraCatClienteProv.xls
Normal file
1
frontend/static/csv/EstructuraCatClienteProv.xls
Normal file
@@ -0,0 +1 @@
|
||||
PROCEDENCIA CLIENTE(E=Extranjero, N=Nacional) TIPO(C=Cliente,P=Proveedor,A=Ambos) CLAVE CLIENTE NOMBRE RFC CALLES NUM. EXTERIOR CODIGO POSTAL COLONIA o PARQUE IND. CIUDAD ESTADO PAIS TELEFONO NUMERO FAX CORREO ELECTRONICO CURP TIPO DE PROGRAMA SECON NUMERO DE PROGRAMA SECON FECHA AUT. SECON ##/##/#### ES PROGRAMA PROSEC? (SI o NO) NUMERO DE PROGRAMA PROSEC VINCULACION ES EMPRESA CERTIFICADA? REGISTRO DE EMPRESA CERT. INFORMACION ADICIONAL CONTACTO CLAVE MANUFACTURERO TAX I.D. CLAVE BROKER AMERICANO EXPO CLAVE BROKER AMERICANO IMPO CLAVE TRANSFERENCIA A.A. TRANSFORMADOR/SUBMAQUILA CLAVE INTERFACE
|
||||
1
frontend/static/csv/EstructuraCatConductor.xls
Normal file
1
frontend/static/csv/EstructuraCatConductor.xls
Normal file
@@ -0,0 +1 @@
|
||||
TRANSPORTISTA LINEA CLAVE CONDUCTOR LICENCIA PERMISO LINEA EXPRESS IDENTIFICACION ACE FECHA NACIMIENTO SEXO PAIS NACIMIENTO TRANSPORTA MAT. PELIGROSO? PERMISO MAT. PELIGROSO NOMBRE(S) APELLIDO PATERNO FORMA IDENTIFICACION 1 NUM. IDENTIFICACION 1 ESTADO PAIS FORMA IDENTIFICACION 2 NUM. IDENTIFICACION 2 ESTADO PAIS
|
||||
1
frontend/static/csv/EstructuraCatFraccAme.xls
Normal file
1
frontend/static/csv/EstructuraCatFraccAme.xls
Normal file
@@ -0,0 +1 @@
|
||||
FRACCION ARANCELARIA PREFIJO UNIDAD DE MEDIDA DESCRIPCION TIPO DE ADVALOREM ADVALOREM % ADVALOREM DLLS
|
||||
1
frontend/static/csv/EstructuraCatPartesAF.xls
Normal file
1
frontend/static/csv/EstructuraCatPartesAF.xls
Normal file
@@ -0,0 +1 @@
|
||||
NUMERO DE PARTE DESCRIPCION EN ESPA<50>OL DESCRIPCION EN INGLES CLASE UNIDAD DE MEDIDA COMERCIAL COSTO UNITARIO TIPO MONEDA COSTO CLAVE MONEDA PESO UNITARIO TIPO PESO FRACCION PAIS PREFERENCIA SECTOR RUTA DE LA IMAGEN
|
||||
1
frontend/static/csv/EstructuraCatPedimentos.xls
Normal file
1
frontend/static/csv/EstructuraCatPedimentos.xls
Normal file
@@ -0,0 +1 @@
|
||||
NUMERO DE PEDIMENTO (##-####-######) TIPO MOV(I=Impotaci<63>n,E=Expotaci<63>n) CLAVE PEDIMENTO REGIMEN FECHA INICIO FECHA FINAL FECHA DE PAGO ADUANA Y SECCION DE CRUCE ACUSE ELECTRONICO INDIVIDUAL o CONSOLIDADO (IND,CON) MET TRANS ENTRADA MET TRANS ARRIVO MET TRANS SALIDA IEPS DTA CNT PREVALIDACION MONTO TIGIE PAGO IMPUESTO? (S/N) ES MIXTO (SI/NO) OBS RECTIFICA OPCION DESTINO(Interior del Pais/Regi<67>n Fronteriza/Franja Fronteriza) VALOR IVA VALOR ME VALOR ADUANAS FLETE VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES ESTATUS (ABIERTO/CERRADO) PERSONA REV FECHA CIERRE FECHA REVISION FECHA AUTORIZACION FECHA RECIBIDO REPRESENTANTE AA CLAVE DEST ORIGEN FECHA ENTRADA RECINTO FECHA EXTRACCION RECINTO ERRORES FORMA PAGO DTA FORMA PAGO IGI FORMA PAGO PREVAL FORMA PAGO IVA RECARGOS MULTAS IVA DE PREV CUOTAS CONPENSATORIAS IDENTIFICADORES IEPS 2 FORMA DE PAGO IEPS 2 DTA 2 FORMA DE PAGO DTA 2 IVA 2 FORMA DE PAGO IVA 2 IGI 2 FORMA DE PAGO IGI 2 PREVALIDACION FORMA DE PAGO PREVALIDACION 2 CNT 2 FORMA DE PAGO CNT 2
|
||||
1
frontend/static/csv/EstructuraCatTiposCambio.xls
Normal file
1
frontend/static/csv/EstructuraCatTiposCambio.xls
Normal file
@@ -0,0 +1 @@
|
||||
FECHA (##/##/####) TIPO DE CAMBIO
|
||||
1
frontend/static/csv/EstructuraCatTrailers.xls
Normal file
1
frontend/static/csv/EstructuraCatTrailers.xls
Normal file
@@ -0,0 +1 @@
|
||||
CLAVE TRAILER/CAJA NUMERO ACE TIPO DE TRAILER PRECINTO CODIGO DE ENTIDAD PLACAS ESTADO PAIS
|
||||
1
frontend/static/csv/EstructuraCatTransportes.xls
Normal file
1
frontend/static/csv/EstructuraCatTransportes.xls
Normal file
@@ -0,0 +1 @@
|
||||
CLAVE CLAVE ACE CLAVE TRANSPORTE VIN TIPO TRANSPORTE CODIGO DE ENTIDAD TRANSPONDEDOR NUMERO DOT PLACAS CIUDAD ESTADO PAIS PRECINTO EMPRESA ASEGURADORA NUM. ASEGURADORA MONTO ASEGURADO FECHA DE ASEGURADORA
|
||||
1
frontend/static/csv/EstructuraEncFacExpoCamReg.xls
Normal file
1
frontend/static/csv/EstructuraEncFacExpoCamReg.xls
Normal file
@@ -0,0 +1 @@
|
||||
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO TIPO PESO MANIFIESTO E-DOCUMENT NUM. OPERACION ENVIADO POR ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I FACTURA ALTERNA
|
||||
1
frontend/static/csv/EstructuraEncFacImpoDef.xls
Normal file
1
frontend/static/csv/EstructuraEncFacImpoDef.xls
Normal file
@@ -0,0 +1 @@
|
||||
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO FECHA EMISION TIPO PESO E-DOCUMENT NUM. OPERACION ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I
|
||||
1
frontend/static/csv/EstructuraEncFacImpoTemp.xls
Normal file
1
frontend/static/csv/EstructuraEncFacImpoTemp.xls
Normal file
@@ -0,0 +1 @@
|
||||
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO FECHA EMISION TIPO PESO E-DOCUMENT NUM. OPERACION ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I FACTURA ALTERNA
|
||||
1
frontend/static/csv/EstructuraParExpoCamReg.xls
Normal file
1
frontend/static/csv/EstructuraParExpoCamReg.xls
Normal file
@@ -0,0 +1 @@
|
||||
NUMERO FACTURA EXPO. LINEA EXPO. TIPO DE IMPO. FACTURA IMPO. LINEA IMPO. GENERA DESCARGA CANTIDAD EXPORTADA/DESCARGAR COSTO UNITARIO PESO NETO PESO BRUTO SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) LOTE NUMERO ENTRADA ES PARTIDA/SUBPARTIDA LINEA PRINCIPAL FRACCION AMERICANA FRACCION ARANCELARIA
|
||||
1
frontend/static/csv/EstructuraParFacImpoDefAF.xls
Normal file
1
frontend/static/csv/EstructuraParFacImpoDefAF.xls
Normal file
@@ -0,0 +1 @@
|
||||
NUMERO FACTURA LINEA CLASE CANTIDAD IMPORTADA UNIDAD DE MEDIDA COSTO UNITARIO PESO NETO PESO BRUTO CANTIDAD BULTOS CLAVE BULTOS PAIS ORIGEN FRACCION ARANCELARIA PREFERENCIA ARANCELARIA SECTOR FRACCION AMERICANA ORDEN DE COMPRA DESCRIPCION ESPA<50>OL DESCRIPCION INGLES MARCA MODELO ES PARTIDA O SUBPARTIDA LINEA PRINCIPAL NUM. PARTE SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO METODO DE VALORACION DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) VALOR TOTAL LOTE NUMERO ENTRADA ID TYPE
|
||||
1
frontend/static/csv/EstructuraParFacImpoTempAF.xls
Normal file
1
frontend/static/csv/EstructuraParFacImpoTempAF.xls
Normal file
@@ -0,0 +1 @@
|
||||
NUMERO FACTURA LINEA CLASE CANTIDAD IMPORTADA UNIDAD DE MEDIDA COSTO UNITARIO PESO NETO PESO BRUTO CANTIDAD BULTOS CLAVE BULTOS PAIS ORIGEN FRACCION ARANCELARIA PREFERENCIA ARANCELARIA SECTOR FRACCION AMERICANA ORDEN DE COMPRA DESCRIPCION ESPA<50>OL DESCRIPCION INGLES MARCA MODELO ES PARTIDA O SUBPARTIDA LINEA PRINCIPAL NUM. PARTE SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO METODO DE VALORACION DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) TOTAL NUMERO ENTRADA LOTE ID TYPE
|
||||
Reference in New Issue
Block a user