feature/csv-envoices-integration
This commit is contained in:
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
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ from .customs_brokers.routes import router as customs_broker_router
|
||||
from .invoices.routes import router as invoices_router
|
||||
from .items.routes import router as items_router
|
||||
from .classes import router as classes_router
|
||||
from .classes import router as classes_router
|
||||
from .clients_and_providers import router as client_and_provider_router
|
||||
from .imports.routes import router as imports_router
|
||||
from .invoice_settings.routes import router as invoice_settings_router
|
||||
from .item_presets.routes import router as item_presets_router
|
||||
from .general_catalogs.company import router as company_router
|
||||
@@ -68,6 +70,7 @@ router = APIRouter()
|
||||
# Registrar módulos
|
||||
router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"])
|
||||
router.include_router(items_router, prefix="/a76", tags=["a76 / items"])
|
||||
router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"])
|
||||
router.include_router(invoice_settings_router)
|
||||
router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"])
|
||||
router.include_router(pedimentos_router, prefix="/a76")
|
||||
|
||||
@@ -13,7 +13,8 @@ celery_app = Celery(
|
||||
"api.v1.modules.a76.reports.importacion.consolidados.task",
|
||||
"api.v1.modules.a76.reports.importacion.packing_list.task",
|
||||
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task",
|
||||
"api.v1.modules.a76.reports.exportacion.descargo.task"
|
||||
"api.v1.modules.a76.reports.exportacion.descargo.task",
|
||||
"api.v1.modules.a76.imports.tasks"
|
||||
] # Ruta al módulo donde están las tareas
|
||||
)
|
||||
|
||||
|
||||
128
backend/main.py
128
backend/main.py
@@ -6,6 +6,71 @@ Backend API con FastAPI + Keycloak + SQLAlchemy
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
# Importar modelos para registrar con SQLAlchemy
|
||||
|
||||
# Reference Data (Dependencies)
|
||||
from api.v1.modules.public.reference_data.countries.models import Country
|
||||
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse
|
||||
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
|
||||
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
|
||||
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
|
||||
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
|
||||
from api.v1.modules.public.reference_data.sectors.models import Sector
|
||||
from api.v1.modules.public.reference_data.states.models import State
|
||||
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
|
||||
from api.v1.modules.public.reference_data.transport_types.models import TransportType
|
||||
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
|
||||
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept
|
||||
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
|
||||
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept
|
||||
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog
|
||||
from api.v1.modules.a76.general_catalogs.doda.models import Doda
|
||||
from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice
|
||||
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
|
||||
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
|
||||
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
|
||||
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
|
||||
from api.v1.modules.a76.general_catalogs.legends.models import Legend
|
||||
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType
|
||||
from api.v1.modules.a76.general_catalogs.packages.models import Package
|
||||
from api.v1.modules.a76.general_catalogs.ports.models import Port
|
||||
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
|
||||
from api.v1.modules.a76.general_catalogs.seal.models import Seal
|
||||
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
|
||||
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
|
||||
|
||||
# Core Modules & Reference Data (Dependencies)
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
|
||||
# Core Modules & Transactional Models
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a24.fa.fa_parts.models import FaPart
|
||||
from api.v1.modules.a24.inv.inv_parts.models import InvPart
|
||||
from api.v1.modules.a76.manifests.manifest.models import Manifest
|
||||
from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation
|
||||
from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation
|
||||
|
||||
# Transactional Primary Models
|
||||
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
|
||||
from api.v1.modules.a76.audit_log.events import register_audit_listeners
|
||||
|
||||
# Core Modules (Secondary)
|
||||
|
||||
from api.v1.router import router as api_v1_router
|
||||
from core.config import settings
|
||||
from core.database import init_db
|
||||
@@ -22,16 +87,6 @@ from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
|
||||
# Importar modelos para registrar con SQLAlchemy
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.items.series.models import Serie
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a24.fa.fa_parts.models import FaPart
|
||||
from api.v1.modules.a24.inv.inv_parts.models import InvPart
|
||||
from api.v1.modules.a76.manifests.manifest.models import Manifest
|
||||
from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation
|
||||
from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation
|
||||
|
||||
# Configurar logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
|
||||
@@ -114,59 +169,6 @@ app.add_middleware(TenantMiddleware)
|
||||
from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware
|
||||
app.add_middleware(UserContextMiddleware)
|
||||
|
||||
# Importar modelos para Audit Log
|
||||
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
|
||||
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
|
||||
from api.v1.modules.a76.audit_log.events import register_audit_listeners
|
||||
|
||||
# Core Modules
|
||||
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
|
||||
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
|
||||
from api.v1.modules.a76.parts.models import Part
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
from api.v1.modules.a76.general_catalogs.company.models import Company
|
||||
|
||||
# Reference Data
|
||||
from api.v1.modules.public.reference_data.countries.models import Country
|
||||
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
|
||||
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
|
||||
from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse
|
||||
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
|
||||
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
|
||||
from api.v1.modules.public.reference_data.material_types.models import MaterialType
|
||||
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
|
||||
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
|
||||
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
|
||||
from api.v1.modules.public.reference_data.sectors.models import Sector
|
||||
from api.v1.modules.public.reference_data.states.models import State
|
||||
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
|
||||
from api.v1.modules.public.reference_data.transport_types.models import TransportType
|
||||
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
|
||||
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
|
||||
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
|
||||
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
|
||||
from api.v1.modules.a76.classes.models import Class
|
||||
from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept
|
||||
from api.v1.modules.a76.general_catalogs.concepts.models import Concept
|
||||
from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept
|
||||
from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog
|
||||
from api.v1.modules.a76.general_catalogs.doda.models import Doda
|
||||
from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice
|
||||
from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency
|
||||
from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog
|
||||
from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog
|
||||
from api.v1.modules.a76.general_catalogs.inpc.models import INPC
|
||||
from api.v1.modules.a76.general_catalogs.legends.models import Legend
|
||||
from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType
|
||||
from api.v1.modules.a76.general_catalogs.packages.models import Package
|
||||
from api.v1.modules.a76.general_catalogs.ports.models import Port
|
||||
from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator
|
||||
from api.v1.modules.a76.general_catalogs.seal.models import Seal
|
||||
from api.v1.modules.a76.general_catalogs.signatures.models import Signature
|
||||
from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction
|
||||
from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion
|
||||
from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction
|
||||
|
||||
# Registrar Listeners de Auditoría
|
||||
@app.on_event("startup")
|
||||
def register_audit():
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -224,7 +224,7 @@ export const importacionConfig: CsvUploadItem[] = [
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
modelTarget: 'invoice_header',
|
||||
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
|
||||
},
|
||||
{
|
||||
@@ -232,7 +232,7 @@ export const importacionConfig: CsvUploadItem[] = [
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
modelTarget: 'invoice_details',
|
||||
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
|
||||
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
|
||||
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
|
||||
import {
|
||||
catalogosConfig,
|
||||
transportesConfig,
|
||||
@@ -10,42 +11,109 @@
|
||||
tabSettings,
|
||||
type CsvUploadItem
|
||||
} from '$lib/config/csv-upload';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// We no longer need modal state
|
||||
let activeTab = $state('catalogos');
|
||||
|
||||
let allSettings = $state<Record<string, any>>({});
|
||||
let isUploading = $state(false);
|
||||
let currentJobId = $state<string | null>(null);
|
||||
let activeModelTarget = $state<string | null>(null);
|
||||
let scanResults = $state<any>(null);
|
||||
let commitResults = $state<any>(null);
|
||||
let showResultModal = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const fields = tabSettings[activeTab] || [];
|
||||
if (!allSettings[activeTab]) {
|
||||
allSettings[activeTab] = {};
|
||||
fields.forEach((f) => {
|
||||
allSettings[activeTab][f.name] = f.defaultValue;
|
||||
// Initialize settings for all tabs upfront to avoid reactivity loops
|
||||
let allSettings = $state<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const tab in tabSettings) {
|
||||
initial[tab] = {};
|
||||
tabSettings[tab].forEach((f) => {
|
||||
initial[tab][f.name] = f.defaultValue;
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
function handleUpload(file: File, config: CsvUploadItem) {
|
||||
async function handleUpload(file: File, config: CsvUploadItem) {
|
||||
isUploading = true;
|
||||
activeModelTarget = config.modelTarget || null;
|
||||
scanResults = null;
|
||||
const currentSettings = allSettings[activeTab] || {};
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
|
||||
|
||||
console.log('🚀 Starting Direct Upload Processing', {
|
||||
file: file.name,
|
||||
size: file.size,
|
||||
target: config.modelTarget,
|
||||
config: config.title,
|
||||
settings: currentSettings
|
||||
});
|
||||
const res = await api.imports.upload(
|
||||
file,
|
||||
config.modelTarget || '',
|
||||
currentSettings,
|
||||
companyId,
|
||||
opType
|
||||
);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error('Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock Processing Feedback
|
||||
const promise = new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
async function pollStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: `Procesando ${file.name} para ${config.title}...`,
|
||||
success: `Archivo cargado correctamente con configuración: ${JSON.stringify(currentSettings)}`,
|
||||
error: 'Error al cargar el archivo'
|
||||
});
|
||||
const res = await api.imports.status(currentJobId);
|
||||
if (res.data?.status === 'waiting_confirmation') {
|
||||
scanResults = res.data;
|
||||
showResultModal = true;
|
||||
toast.success('Escaneo completado. Revisa los resultados.');
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'failed') {
|
||||
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
|
||||
isUploading = false;
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
} else if (res.data?.status === 'warning') {
|
||||
// Caso cuando no se insertaron registros pero hay información de rechazo
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
|
||||
if (inserted === 0) {
|
||||
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
|
||||
} else {
|
||||
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
|
||||
}
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'finished') {
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDetails = res.data?.skipped_details || [];
|
||||
|
||||
if (inserted > 0) {
|
||||
toast.success(`Importación completada: ${inserted} registros insertados`);
|
||||
if (skippedInvalid > 0 || skippedFk > 0) {
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
toast.warning(`${totalSkipped} registros fueron rechazados`);
|
||||
}
|
||||
} else {
|
||||
toast.error('No se insertaron registros. Revisa los errores a continuación.');
|
||||
}
|
||||
isUploading = false;
|
||||
} else {
|
||||
// Continue polling
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -105,3 +173,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ProcessingResultModal
|
||||
bind:open={showResultModal}
|
||||
{scanResults}
|
||||
{commitResults}
|
||||
{isUploading}
|
||||
onConfirm={async () => {
|
||||
if (currentJobId && activeModelTarget) {
|
||||
try {
|
||||
isUploading = true;
|
||||
const res = await api.imports.commit(currentJobId, activeModelTarget);
|
||||
if (res.data?.commit_job_id) {
|
||||
currentJobId = res.data.commit_job_id;
|
||||
pollStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al iniciar la importación');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
onClose={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user