diff --git a/.gitignore b/.gitignore index 1da35013..48bbc048 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ node_modules/ # Docker *.dockerignore postgres-data/ -backend/uploads/ \ No newline at end of file +backend/uploads/ +docker-compose.yml diff --git a/backend/api/v1/modules/a76/imports/__init__.py b/backend/api/v1/modules/a76/imports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/imports/routes.py b/backend/api/v1/modules/a76/imports/routes.py new file mode 100644 index 00000000..1ea76a74 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/routes.py @@ -0,0 +1,118 @@ +from datetime import datetime +from uuid import uuid4 +import os +import json +import logging +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query +from sqlalchemy.orm import Session +from typing import Optional, Literal, Dict, Any + +from core.celery_app import celery_app +from core.config import settings +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .tasks import scan_file, insert_valid_rows +from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.post("/upload/{model_target}", response_model=ImportJobResponse) +async def upload_import_file( + model_target: Literal["invoice_header", "invoice_details"], + file: UploadFile = File(...), + footer_config: Optional[str] = Form(None), # JSON string with settings + company_id: int = Query(..., description="Company ID"), # Required for context + operation_type: Optional[str] = Query("imp"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Step 1: Upload CSV, save to temp, trigger scan task. + """ + # 1. Validate Access & Get Tenant + try: + tenant_id = validate_access_to_resource(db, company_id, current_user) + except Exception as e: + logger.error(f"Access validation failed: {e}") + raise HTTPException(status_code=403, detail="Invalid company access") + + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only .csv files allowed") + + job_id = str(uuid4()) + + # Ensure directory exists (Safety check) + upload_dir = os.path.join(os.getcwd(), "uploads", "temp") + os.makedirs(upload_dir, exist_ok=True) + + file_path = os.path.join(upload_dir, f"{job_id}.csv") + meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") + + try: + # Save CSV + contents = await file.read() + with open(file_path, "wb") as f: + f.write(contents) + + # Save Metadata (Context) + meta_data = { + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": current_user.get("id"), + "footer_config": footer_config, + "operation_type": operation_type, + } + with open(meta_path, "w") as f: + json.dump(meta_data, f) + + except Exception as e: + logger.error(f"File save error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}") + + # Trigger Celery Task (Async) + # Use our job_id as the Celery task_id for easier tracking + scan_file.apply_async(args=[job_id, file_path, model_target, footer_config], task_id=job_id) + + return ImportJobResponse( + job_id=job_id, + status="queued", + message="File uploaded. Scanning started." + ) + +@router.get("/{job_id}/status") +async def get_import_status(job_id: str): + """ + Poll this endpoint to get % progress or final report. + """ + # In a real app, query Redis or DB. + # For MVP, we might mock or use Celery AsyncResult if backend shares Redis. + task_result = celery_app.AsyncResult(job_id) + + if task_result.state == 'PENDING': + return {"status": "processing", "progress": 0} + elif task_result.state == 'PROGRESS': + return { + "status": "processing", + "progress": task_result.info.get('current', 0), + "total": task_result.info.get('total', 0) + } + elif task_result.state == 'SUCCESS': + return task_result.result # Should return the report + else: + return {"status": task_result.state, "error": str(task_result.info)} + + +@router.post("/{job_id}/commit") +async def commit_import_job(job_id: str, body: CommitRequest): + """ + Step 2: User confirms import. Trigger bulk insert. + """ + task = insert_valid_rows.delay(job_id, body.model_target) + + return { + "status": "committing", + "message": "Bulk insert started.", + "commit_job_id": task.id + } diff --git a/backend/api/v1/modules/a76/imports/schemas.py b/backend/api/v1/modules/a76/imports/schemas.py new file mode 100644 index 00000000..63c0202a --- /dev/null +++ b/backend/api/v1/modules/a76/imports/schemas.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel +from typing import Optional, Literal + +class ImportJobResponse(BaseModel): + job_id: str + status: str + message: str + +class CommitRequest(BaseModel): + model_target: Literal["invoice_header", "invoice_details"] + +class ImportJobStatus(BaseModel): + status: str + job_id: str + total_rows: Optional[int] = 0 + error_count: Optional[int] = 0 + valid_rows: Optional[int] = 0 + error: Optional[str] = None + inserted: Optional[int] = 0 + error_file: Optional[str] = None diff --git a/backend/api/v1/modules/a76/imports/tasks.py b/backend/api/v1/modules/a76/imports/tasks.py new file mode 100644 index 00000000..dfb82e74 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/tasks.py @@ -0,0 +1,876 @@ +import os +from datetime import datetime +from decimal import Decimal +import csv +import json +import logging +import re +import unicodedata +from celery import shared_task +from typing import Dict, Any, Optional +from core.database import CoreSessionLocal +# Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process + +# We'll need schemas for validation +# from api.v1.modules.a76.invoices.schemas import InvoiceHeaderCreate +# But for Phase 1 we use a lighter check + +logger = logging.getLogger(__name__) + +class ForeignKeyValidator: + def __init__(self, session, tenant_id, company_id): + self.session = session + self.tenant_id = tenant_id + self.company_id = company_id + self.cache = {} # {(model_name, value): bool} + + def check_exists(self, model, value, field_name="id", is_public=False): + if value is None: + return True # Assume optional if None, or let DB handle not-null + + key = (model.__name__, value) + if key in self.cache: + return self.cache[key] + + query = self.session.query(getattr(model, field_name)).filter(getattr(model, field_name) == value) + if not is_public: + query = query.filter(model.tenant_id == self.tenant_id, model.company_id == self.company_id) + + exists = query.first() is not None + self.cache[key] = exists + return exists + +@shared_task(bind=True) +def scan_file(self, job_id: str, file_path: str, model_target: str, config: str = None): + """ + Pass 1: Read CSV, Validate types, Write Errors to JSONL. + """ + logger.info(f"Starting scan for job {job_id} target {model_target}") + + # 1. Setup Error Log + error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl") + os.makedirs(os.path.dirname(error_path), exist_ok=True) + + total_rows = 0 + error_count = 0 + processed_rows = 0 + + # 2. Count Total (Quick Pass) or just estimate + # For better progress, we can get file line count first + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + total_rows = sum(1 for _ in f) - 1 # Minus header + except Exception as e: + return {"status": "failed", "error": f"Cannot read file: {e}"} + + footer_config = parse_footer_config(config) + date_format = footer_config.get("dateFormat") + + # Validate and set default date_format if not provided + if not date_format: + date_format = "yyyy-mm-dd" # Default to ISO format + logger.info(f"No date_format specified in config, using default: {date_format}") + + try: + with open(file_path, 'r', encoding='utf-8-sig') as f_in, \ + open(error_path, 'w', encoding='utf-8') as f_err: + + # Detect Delimiter + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except: + dialect = 'excel' + + reader = csv.DictReader(f_in, dialect=dialect) + + for i, row in enumerate(reader, start=1): + # Check for Progress Update + if i % 1000 == 0: + self.update_state(state='PROGRESS', meta={ + 'current': i, + 'total': total_rows, + 'errors': error_count + }) + + # Validation (Phase 1: Minimal) + row_norm = normalize_row(row) + errors = validate_row_phase_1(row_norm, model_target, i, date_format) + + if errors: + error_count += 1 + # Write simple JSON error + f_err.write(json.dumps(errors) + "\n") + + processed_rows += 1 + + except Exception as e: + logger.error(f"Scan failed: {e}") + return {"status": "failed", "error": str(e)} + + # 4. Result + return { + "status": "waiting_confirmation", + "job_id": job_id, + "total_rows": processed_rows, + "error_count": error_count, + "valid_rows": processed_rows - error_count, + "error_file": error_path + } + +def validate_row_phase_1( + row: Dict[str, Any], + target: str, + line_num: int, + date_format: Optional[str], +) -> Dict[str, Any]: + """ + Minimal validation: Unique IDs and Dates. + Target: 'invoice_header' or 'invoice_details' + """ + errors = {} + + # A. Invoice Header + if target == 'invoice_header': + # 1. Unique ID + if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'): + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + # 2. Date Format + date_str = row.get('FECHA FACTURA') + if date_str: + if not is_valid_date(date_str, date_format): + expected = display_date_format(date_format) + return { + "line": line_num, + "col": "FECHA FACTURA", + "msg": f"Formato inválido ({expected})", + } + else: + return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"} + + # B. Invoice Details (Parts) + elif target == 'invoice_details': + # 1. Line Number + if not row.get('LINEA'): + return {"line": line_num, "col": "LINEA", "msg": "Requerido"} + + # 2. Parent Link (Invoice Number) + if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')): + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + # 2. Parent Link (Simplified for now, we assume parent exists or is in same batch) + # In a real scenario, we'd check if the invoice exists. + pass + + return errors if errors else None + +def parse_footer_config(config: Optional[str]) -> Dict[str, Any]: + if not config: + return {} + try: + if isinstance(config, str): + return json.loads(config) + if isinstance(config, dict): + return config + except Exception: + return {} + return {} + + +def display_date_format(date_format: Optional[str]) -> str: + if not date_format: + return "YYYY-MM-DD" + return date_format.upper() + + +def parse_date(date_text: Optional[str], date_format: Optional[str]) -> Optional[datetime.date]: + if not date_text: + return None + candidates = [] + fmt_map = { + "dd/mm/yyyy": "%d/%m/%Y", + "mm/dd/yyyy": "%m/%d/%Y", + "yyyy-mm-dd": "%Y-%m-%d", + } + if date_format and date_format in fmt_map: + candidates.append(fmt_map[date_format]) + candidates.extend(["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"]) + for fmt in candidates: + try: + return datetime.strptime(str(date_text).strip(), fmt).date() + except ValueError: + continue + return None + + +def is_valid_date(date_text: Optional[str], date_format: Optional[str]) -> bool: + return parse_date(date_text, date_format) is not None + + +def normalize_header(name: Optional[str]) -> str: + if not name: + return "" + name = unicodedata.normalize("NFKD", str(name)).upper() + name = "".join(ch for ch in name if not unicodedata.combining(ch)) + name = re.sub(r"[^A-Z0-9]+", " ", name) + return re.sub(r"\s+", " ", name).strip() + + +def normalize_row(row: Dict[str, Any]) -> Dict[str, Any]: + return {normalize_header(k): v for k, v in row.items()} + + +def parse_int(value: Any) -> Optional[int]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + return int(text) + except ValueError: + return None + + +def parse_decimal(value: Any) -> Optional[Decimal]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + text = text.replace(",", "") + try: + return Decimal(text) + except Exception: + return None + + +def parse_currency(value: Optional[str], currency_type: Optional[str]): + from api.v1.modules.a76.invoices.models import Currency + if value: + normalized = normalize_header(value) + if normalized in {"MN", "M N", "NACIONAL", "LOCAL", "PESOS", "PESO"}: + return Currency.LOCAL + if normalized in {"ME", "M E", "EXTRANJERA", "EXTRANJERO", "FOREIGN", "USD", "DOLAR", "DOLARES"}: + return Currency.FOREIGN + if "MANUAL" in normalized: + return Currency.MANUAL + if currency_type and str(currency_type).strip().upper() == "MXN": + return Currency.LOCAL + if currency_type: + return Currency.FOREIGN + return Currency.MANUAL + + +def parse_weight_unit(value: Optional[str]): + from api.v1.modules.a76.invoices.models import WeightUnit + if not value: + return None + normalized = normalize_header(value) + if normalized in {"KG", "KGS", "KILOS", "KILOGRAMOS"}: + return WeightUnit.KGS + if normalized in {"LB", "LBS", "LIBRAS"}: + return WeightUnit.LBS + return None + + +def resolve_tenant_fk_id( + session: CoreSessionLocal, + model, + value: Optional[int], + tenant_id: int, + company_id: int, + cache: Dict[int, Optional[int]], +) -> Optional[int]: + if value is None: + return None + if value in cache: + return cache[value] + exists = ( + session.query(model.id) + .filter( + model.id == value, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = value if exists is not None else None + return cache[value] + + +def resolve_public_code( + session: CoreSessionLocal, + model, + column, + value: Optional[str], + cache: Dict[str, Optional[str]], +) -> Optional[str]: + if not value: + return None + normalized = str(value).strip().upper() + if not normalized: + return None + if normalized in cache: + return cache[normalized] + exists = session.query(column).filter(column == normalized).scalar() + cache[normalized] = normalized if exists is not None else None + return cache[normalized] + +@shared_task(bind=True) +def insert_valid_rows(self, job_id: str, model_target: str): + """ + Pass 2: Re-read CSV, Skip Errors, Bulk Insert. + """ + logger.info(f"Starting Commit for {job_id} target {model_target}") + + try: + from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, + InvoiceComplianceMx, + InvoiceFinancials, + InvoiceLogistics, + InvoiceSalesDetails, + OperationType, + WeightUnit, + ) + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.customs_brokers.models import CustomsBroker + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen + from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode + from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType + from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + + from api.v1.modules.a76.items.models import Item + from api.v1.modules.a76.items.line_items.models import LineItem + from api.v1.modules.a76.items.line_financials.models import LineFinancial + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from api.v1.modules.a76.items.line_customs.models import LineCustom + from api.v1.modules.a76.items.line_descriptions.models import LineDescription + from api.v1.modules.a76.parts.models import Part + + upload_dir = os.path.join(os.getcwd(), "uploads", "temp") + file_path = os.path.join(upload_dir, f"{job_id}.csv") + error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl") + + # 1. Load Error Line Numbers + error_lines = set() + if os.path.exists(error_path): + with open(error_path, 'r', encoding='utf-8') as f: + for line in f: + try: + err = json.loads(line) + error_lines.add(err['line']) + except: pass + + # Load Metadata (Context) + meta_path = file_path.replace("temp", "temp").replace(".csv", ".meta.json") + tenant_id = None + company_id = None + footer_config = {} + + if os.path.exists(meta_path): + try: + with open(meta_path, 'r') as f: + meta = json.load(f) + tenant_id = meta.get('tenant_id') + company_id = meta.get('company_id') + operation_type_raw = meta.get('operation_type', 'imp') + footer_config = parse_footer_config(meta.get('footer_config')) + except: pass + + if not tenant_id or not company_id: + return {"status": "failed", "error": "Missing context (tenant/company)"} + + # 2. Re-read and Map + # Initialize counters outside the session block so they're accessible later + headers_to_insert = [] + details_to_insert = [] + skipped_invalid = 0 + skipped_missing_invoice = 0 + skipped_missing_fk = 0 + skipped_fk_details = [] + inserted_count = 0 + response = None # Will be set inside the session block + + date_format = footer_config.get("dateFormat") + + # Validate and set default date_format if not provided + if not date_format: + date_format = "yyyy-mm-dd" # Default to ISO format + logger.info(f"No date_format specified in config, using default: {date_format}") + else: + logger.info(f"Using date_format from config: {date_format}") + + # Default types from config or fallback + op_type_value = OperationType(meta.get('operation_type', 'imp').lower()) + inv_type_value = footer_config.get('invoice_type', 'TEM') + + logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}") + + with CoreSessionLocal() as session: + invoice_id_cache = {} + cleared_invoices = set() # Track invoices where we've already cleared items in this job + provider_cache: Dict[int, Optional[int]] = {} + sold_to_cache: Dict[int, Optional[int]] = {} + shipped_to_cache: Dict[int, Optional[int]] = {} + broker_cache: Dict[int, Optional[int]] = {} + regimen_cache: Dict[str, Optional[str]] = {} + currency_type_cache: Dict[str, Optional[str]] = {} + customs_section_cache: Dict[str, Optional[str]] = {} + part_cache: Dict[str, Optional[int]] = {} + + validator = ForeignKeyValidator(session, tenant_id, company_id) + + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Detect Delimiter + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except: + dialect = 'excel' + + reader = csv.DictReader(f, dialect=dialect) + + for i, row in enumerate(reader, start=1): + if i in error_lines: + continue + + row_norm = normalize_row(row) + + # Mapping Logic + if model_target == 'invoice_header': + invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() + invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format) + + if not invoice_number or not invoice_date: + skipped_invalid += 1 + logger.debug(f"Row {i}: Skipped - missing invoice_number or invalid invoice_date. " + f"Invoice: {invoice_number}, Date: {row_norm.get('FECHA FACTURA') or row_norm.get('FECHA')}") + continue + + # --- NEW: Foreign Key Validations --- + # 1. Invoice Type (Public) + if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): + skipped_missing_fk += 1 + reason = f"Tipo de factura '{inv_type_value}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # 2. Client/Provider (Tenant) + provider_id = parse_int(row_norm.get('CLAVE PROVEEDOR')) + if provider_id and not validator.check_exists(ClientProvider, provider_id): + skipped_missing_fk += 1 + reason = f"Proveedor ID '{provider_id}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # 3. Customs Broker (Tenant) + broker_id = parse_int(row_norm.get('AGENTE ADUANAL')) + if broker_id and not validator.check_exists(CustomsBroker, broker_id): + skipped_missing_fk += 1 + reason = f"Agente Aduanal ID '{broker_id}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # --- 4. Check for Existing Invoice (Upsert Logic) --- + existing_header = None + if invoice_number: + existing_header = ( + session.query(InvoiceHeader) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value + ) + .first() + ) + + if existing_header: + # UPDATE existing header + header = existing_header + header.invoice_date = invoice_date + header.operation_type = op_type_value + header.is_updated = True # Mark as updated + header.updated_date = datetime.utcnow() + header.document_type = resolve_public_code( + session, + RegimenPedimento, + RegimenPedimento.code, + (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), + regimen_cache, + ) + header.project_number = (row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None) + header.purchase_order = (row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None) + header.alternate_invoice = (row_norm.get('FACTURA ALTERNA') or None) + header.invoice_ref = (row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None) + header.emission_date = parse_date(row_norm.get('FECHA EMISION'), date_format) + header.observation_es = (row_norm.get('OBSERVACIONES E') or None) + header.observation_en = (row_norm.get('OBSERVACIONES I') or None) + + logger.info(f"Row {i}: Updating existing invoice {invoice_number}") + + # Clean up related data that will be re-inserted/updated + # Note: compliance, financials, logistics are 1-to-1 relationships and will be updated by assignment below + # but we might want to be explicit if ORM doesn't handle replace well. + # SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly. + + else: + # CREATE new header + header = InvoiceHeader( + invoice_number=invoice_number, + invoice_date=invoice_date, + operation_type=op_type_value, + is_updated=False, + system="CSV", + capture_date=datetime.utcnow(), + invoice_type=inv_type_value, + document_type=resolve_public_code( + session, + RegimenPedimento, + RegimenPedimento.code, + (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), + regimen_cache, + ), + project_number=(row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None), + purchase_order=(row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None), + alternate_invoice=(row_norm.get('FACTURA ALTERNA') or None), + invoice_ref=(row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None), + emission_date=parse_date(row_norm.get('FECHA EMISION'), date_format), + observation_es=(row_norm.get('OBSERVACIONES E') or None), + observation_en=(row_norm.get('OBSERVACIONES I') or None), + tenant_id=tenant_id, + company_id=company_id, + ) + + compliance = InvoiceComplianceMx( + remesa=parse_int(row_norm.get('REMESA')), + aduana=resolve_public_code( + session, + CustomsSection, + CustomsSection.customs_code, + row_norm.get('ADUANA DE CRUCE'), + customs_section_cache, + ), + provider_id=resolve_tenant_fk_id( + session, + ClientProvider, + parse_int(row_norm.get('CLAVE PROVEEDOR')), + tenant_id, + company_id, + provider_cache, + ), + sold_to_id=resolve_tenant_fk_id( + session, + ClientProvider, + parse_int(row_norm.get('CLAVE VENDIDO A')), + tenant_id, + company_id, + sold_to_cache, + ), + shipped_to_id=resolve_tenant_fk_id( + session, + ClientProvider, + parse_int(row_norm.get('CLAVE ENVIADO A')), + tenant_id, + company_id, + shipped_to_cache, + ), + customs_broker_id=resolve_tenant_fk_id( + session, + CustomsBroker, + parse_int(row_norm.get('AGENTE ADUANAL')), + tenant_id, + company_id, + broker_cache, + ), + edocument=(row_norm.get('E DOCUMENT') or None), + vucem_operation_num=(row_norm.get('NUM OPERACION') or None), + tenant_id=tenant_id, + company_id=company_id, + ) + + financials_currency_type = resolve_public_code( + session, + CurrencyType, + CurrencyType.code, + row_norm.get('CLAVE MONEDA'), + currency_type_cache, + ) + financials = InvoiceFinancials( + currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type), + currency_type=financials_currency_type, + exchange_rate=parse_decimal(row_norm.get('TIPO DE CAMBIO')), + freight=parse_decimal(row_norm.get('FLETES')), + insurance_value=parse_decimal(row_norm.get('VALOR SEGUROS')), + insurance=parse_decimal(row_norm.get('SEGUROS')), + packaging=parse_decimal(row_norm.get('EMBALAJES')), + other_increments=parse_decimal(row_norm.get('OTROS INCREMENTABLES')), + tenant_id=tenant_id, + company_id=company_id, + ) + + weight_type = parse_weight_unit(row_norm.get('TIPO PESO')) + logistics = None + if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'): + logistics = InvoiceLogistics( + carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None), + driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), + transport_type=str(row_norm.get('TIPO TRANSPORTE') or "none").lower(), + transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), + weight_type=weight_type or WeightUnit.KGS, + seal_number=(row_norm.get('PRECINTO') or None), + incoterm=(row_norm.get('CLAVE INCOTERM') or None), + entry_exit_date=parse_date(row_norm.get('FECHA EMISION'), date_format), + tenant_id=tenant_id, + company_id=company_id, + ) + + header.compliance_mx = compliance + header.financials = financials + if logistics: + header.logistics = logistics + + headers_to_insert.append(header) + + elif model_target == 'invoice_details': + invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip() + if not invoice_number: + skipped_invalid += 1 + continue + + if invoice_number in invoice_id_cache: + invoice_id = invoice_id_cache[invoice_number] + else: + invoice_id = ( + session.query(InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + ) + .scalar() + ) + invoice_id_cache[invoice_number] = invoice_id + + if not invoice_id: + logger.warning( + "Invoice not found for details row %s (invoice_number=%s)", + i, + invoice_number, + ) + skipped_missing_invoice += 1 + continue + + # --- Prevent Duplicates: Clear existing items for this invoice (Once per job) --- + if invoice_id not in cleared_invoices: + logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates") + + # 1. Delete Items (Cascades to LineItem, LineFinancial, etc. if DB configured, check models) + # Checking Item model, we usually need to be careful. + # Assuming Cascade delete is set up on FKs or we rely on ORM cascade if using relationships. + # Here we use bulk delete. + session.query(Item).filter(Item.invoice_id == invoice_id).delete(synchronize_session=False) + + # 2. Delete InvoiceSalesDetails + session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) + + cleared_invoices.add(invoice_id) + + # --- NEW LOGIC: Expanded Anexo 76 Structure --- + + # A. Find/Cache Part + part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip() + part_id = None + if part_num: + part_id = part_cache.get(part_num) + if part_id is None: + p = session.query(Part.id).filter( + Part.part_number == part_num, + Part.tenant_id == tenant_id, + Part.company_id == company_id + ).first() + if p: + part_id = p.id + part_cache[part_num] = part_id + + line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA')) + line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) + + # 1. Parent Item + item = Item( + invoice_id=invoice_id, + tenant_id=tenant_id, + company_id=company_id, + item_type="N", # Default to Normal + system_origin="CSV" + ) + session.add(item) + session.flush() # Need item.id + + # 2. Main Line + line = LineItem( + item_id=item.id, + line_number=line_num, + part_number=part_id, + tenant_id=tenant_id, + company_id=company_id + ) + session.add(line) + session.flush() # Need line.id + + # 3. Financial Data + price = parse_decimal(row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO')) + val_com = parse_decimal(row_norm.get('VALOR COMERCIAL') or row_norm.get('VALORCOMERCIAL')) + qty = parse_decimal(row_norm.get('CANTIDAD')) + + session.add(LineFinancial( + item_line_id=line.id, + unit_price=price, + commercial_value=val_com or (price * qty if price and qty else None), + )) + + # 4. Quantities + if qty: + session.add(LineQuantity( + item_line_id=line.id, + quantity=qty, + )) + + # 5. Customs/Fraction + origin = row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') + fraction = row_norm.get('FRACCION') + if origin or fraction: + session.add(LineCustom( + item_line_id=line.id, + fraction=fraction, + origin_country=origin, + )) + + # 6. Description + desc = row_norm.get('DESCRIPCION') + if desc: + session.add(LineDescription( + item_line_id=line.id, + description_spanish=desc, + )) + + # 7. Legacy Sales Details (For specific audit/UI fields) + detail = InvoiceSalesDetails( + invoice_id=invoice_id, + line_number=line_num, + sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), + line_bundles=parse_int(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + tenant_id=tenant_id, + company_id=company_id, + ) + session.add(detail) + details_to_insert.append(item) # Use as counter/ref + + # 3. Bulk Insert (ORM Transaction) + try: + if model_target == 'invoice_header': + if headers_to_insert: + logger.info(f"Attempting to commit {len(headers_to_insert)} headers") + session.add_all(headers_to_insert) + session.commit() + inserted_count = len(headers_to_insert) + logger.info(f"Headers commit successful. Inserted: {inserted_count}") + else: + logger.warning(f"No headers to insert for job {job_id}") + else: + if details_to_insert: + logger.info(f"Attempting to commit {len(details_to_insert)} items and related data") + session.commit() # Everything was already added with session.add() + inserted_count = len(details_to_insert) + logger.info(f"Details commit successful. Inserted: {inserted_count}") + else: + logger.warning(f"No details to insert for job {job_id}") + + except Exception as db_err: + session.rollback() + logger.error(f"DB Error during {model_target} commit: {db_err}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(db_err)} + + # 4. Determine final status and prepare response (inside session block to access variables) + total_skipped = skipped_invalid + skipped_missing_fk + skipped_missing_invoice + + # Log summary + logger.info(f"Job {job_id} completed. Inserted: {inserted_count}, Skipped: {total_skipped} " + f"(invalid: {skipped_invalid}, missing_fk: {skipped_missing_fk}, missing_invoice: {skipped_missing_invoice})") + + # Prepare response based on results + if inserted_count == 0: + if total_skipped > 0: + logger.warning(f"No valid records to insert for job {job_id}. All {total_skipped} records were rejected.") + response = { + "status": "warning", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details, + "message": f"No se insertaron registros. {total_skipped} fueron rechazados." + } + else: + logger.error(f"No valid records found in CSV for job {job_id}") + response = { + "status": "failed", + "error": "No hay registros válidos en el archivo CSV", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + else: + # Success case - at least some records were inserted + response = { + "status": "finished", + "inserted": inserted_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + + except Exception as e: + logger.error(f"Task failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(e)} + + # 5. Cleanup + try: + if os.path.exists(file_path): + os.remove(file_path) + if os.path.exists(error_path): + os.remove(error_path) + except: + logger.warning("Failed to cleanup temp files") + + # Ensure response is defined (fallback in case of unexpected errors) + if response is None: + logger.error(f"Unexpected error: response not set for job {job_id}") + response = { + "status": "failed", + "error": "Error inesperado durante el procesamiento", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + + return response diff --git a/backend/api/v1/modules/a76/invoice_settings/dto.py b/backend/api/v1/modules/a76/invoice_settings/dto.py index 07c954a8..d330bf29 100644 --- a/backend/api/v1/modules/a76/invoice_settings/dto.py +++ b/backend/api/v1/modules/a76/invoice_settings/dto.py @@ -2,13 +2,7 @@ from typing import Any, Dict, Optional from pydantic import BaseModel, ConfigDict from enum import Enum -class OperationType(str, Enum): - IMP = "imp" # Importación - EXP = "exp" # Exportación - SM_IN = "sm_in" # Entrada SM - SM_OUT = "sm_out" # Salida SM - CTM_SEND = "ctm_send" # EnvÃo CTM - CTM_RECEIVE = "ctm_receive" # Recibo CTM +from .models import OperationType class InvoiceSettingsBase(BaseModel): invoice_type: str diff --git a/backend/api/v1/modules/a76/invoice_settings/routes.py b/backend/api/v1/modules/a76/invoice_settings/routes.py index 07e37459..9a6f83c3 100644 --- a/backend/api/v1/modules/a76/invoice_settings/routes.py +++ b/backend/api/v1/modules/a76/invoice_settings/routes.py @@ -40,8 +40,7 @@ def get_invoice_settings( tenant_id=tenant_id, company_id=company_id ) - - return settings + return InvoiceSettingsResponse.model_validate(settings) @router.get("/", response_model=List[InvoiceSettingsResponse]) def list_invoice_settings( diff --git a/backend/api/v1/modules/a76/invoice_settings/services.py b/backend/api/v1/modules/a76/invoice_settings/services.py index 3499a700..996fcac7 100644 --- a/backend/api/v1/modules/a76/invoice_settings/services.py +++ b/backend/api/v1/modules/a76/invoice_settings/services.py @@ -17,7 +17,7 @@ def get_settings( InvoiceSettings.tenant_id == tenant_id, InvoiceSettings.company_id == company_id, InvoiceSettings.invoice_type == invoice_type, - InvoiceSettings.operation_type == operation_type + InvoiceSettings.operation_type == operation_type.value ) return db.execute(stmt).scalar_one_or_none() @@ -60,7 +60,7 @@ def upsert_settings( tenant_id=tenant_id, company_id=company_id, invoice_type=settings_data.invoice_type, - operation_type=settings_data.operation_type, + operation_type=settings_data.operation_type.value, settings=settings_data.settings ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 093aa20b..96d93ab9 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -12,7 +12,9 @@ from .general_catalogs.router import router as general_catalogs_router from .invoices.routes import router as invoices_router from .items.routes import router as items_router from .classes import router as classes_router +from .classes import router as classes_router from .clients_and_providers import router as client_and_provider_router +from .imports.routes import router as imports_router from .invoice_settings.routes import router as invoice_settings_router from .item_presets.routes import router as item_presets_router from .general_catalogs.company import router as company_router @@ -47,6 +49,7 @@ router = APIRouter() router.include_router(general_catalogs_router, prefix="/a76", tags=["a76 / general_catalogs"]) router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"]) router.include_router(items_router, prefix="/a76", tags=["a76 / items"]) +router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"]) router.include_router(invoice_settings_router) router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"]) router.include_router(pedimentos_router, prefix="/a76") diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 47ad480d..b5df8f37 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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 diff --git a/backend/main.py b/backend/main.py index a77f6cb6..97c804d9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,6 +6,75 @@ Backend API con FastAPI + Keycloak + SQLAlchemy import logging import subprocess +# Importar modelos para registrar con SQLAlchemy + +# Reference Data (Dependencies) +from api.v1.modules.public.reference_data.countries.models import Country +from api.v1.modules.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse +from api.v1.modules.public.reference_data.incoterms.models import Incoterm +from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType +from api.v1.modules.public.reference_data.material_types.models import MaterialType +from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.sectors.models import Sector +from api.v1.modules.public.reference_data.states.models import State +from api.v1.modules.public.reference_data.transport_modes.models import TransportMode +from api.v1.modules.public.reference_data.transport_types.models import TransportType +from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept +from api.v1.modules.a76.general_catalogs.concepts.models import Concept +from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept +from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog +from api.v1.modules.a76.general_catalogs.doda.models import Doda +from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice +from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency +from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog +from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog +from api.v1.modules.a76.general_catalogs.inpc.models import INPC +from api.v1.modules.a76.general_catalogs.legends.models import Legend +from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType +from api.v1.modules.a76.general_catalogs.packages.models import Package +from api.v1.modules.a76.general_catalogs.ports.models import Port +from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator +from api.v1.modules.a76.general_catalogs.seal.models import Seal +from api.v1.modules.a76.general_catalogs.signatures.models import Signature +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import ( + TariffFraction, +) +from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion +from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( + USTariffFraction, +) + +# Core Modules & Reference Data (Dependencies) +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.general_catalogs.company.models import Company + +# Core Modules & Transactional Models +from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a24.fa.fa_parts.models import FaPart +from api.v1.modules.a24.inv.inv_parts.models import InvPart +from api.v1.modules.a76.manifests.manifest.models import Manifest +from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation +from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation + +# Transactional Primary Models +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails +from api.v1.modules.a76.audit_log.events import register_audit_listeners + +# Core Modules (Secondary) + from api.v1.router import router as api_v1_router from core.config import settings from core.database import init_db diff --git a/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte new file mode 100644 index 00000000..1adb87aa --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte @@ -0,0 +1,96 @@ + + + +
Se detectaron problemas en el archivo
++ Las filas con errores serán omitidas automáticamente. Solo se importarán los + registros válidos. +
+Archivo validado correctamente
+Todos los registros parecen correctos y listos para importar.
+| LÃnea | +Referencia | +Motivo | +
|---|---|---|
| {detail.line} | +{detail.invoice || '-'} | +{detail.reason} | +