From 5e6ee5d4d1e86632badf4d6ef107387abe6921d5 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 16 Apr 2026 11:30:40 -0500 Subject: [PATCH] Funciones y logia de exportacion, como mejoras CRUD en las partes de exportacion --- .../api/v1/modules/a76/audit_log/register.py | 2 + backend/api/v1/modules/a76/classes/service.py | 18 + .../historical_tariff_fractions/routes.py | 41 ++ .../historical_tariff_fractions/service.py | 112 ++++- .../invoices/exports/process/main_process.py | 10 +- .../sub_process/review_qty_vs_weight.py | 2 +- backend/api/v1/modules/a76/invoices/routes.py | 2 +- .../api/v1/modules/a76/invoices/services.py | 15 +- .../items/exports/validators/calculations.py | 7 +- .../a76/items/exports/validators/create.py | 91 ++++ .../a76/items/exports/validators/update.py | 117 ++++- .../a76/items/imports/validators/update.py | 4 +- backend/api/v1/modules/a76/items/models.py | 5 + backend/api/v1/modules/a76/items/routes.py | 28 +- backend/api/v1/modules/a76/items/service.py | 137 +++++- backend/api/v1/modules/a76/parts/service.py | 2 + backend/core/error_handlers.py | 3 +- frontend/src/lib/api/dashboard/a76/items.ts | 33 +- .../invoices/edit/InvoiceSelectorModal.svelte | 165 ++++--- .../edit/items/fa/class-dialog.svelte | 274 +++++++----- .../edit/items/fa/item-configuration.svelte | 24 +- .../edit/items/fa/item-sheet-fa.svelte | 403 +++++++++++++----- .../invoices/edit/items/fa/main-data.svelte | 206 +++++++-- .../edit/items/fa/packages-section.svelte | 45 +- .../edit/items/fa/part-number-dialog.svelte | 281 +++++++----- .../edit/items/fa/summary-section.svelte | 5 +- .../edit/items/fa/tab-continuation.svelte | 73 ++-- .../edit/items/fa/tab-identifiers.svelte | 70 +-- .../edit/items/fa/tab-labeling.svelte | 56 ++- .../invoices/edit/items/fa/tab-series.svelte | 147 +++++-- .../invoices/edit/items/items-tab-form.svelte | 22 +- .../rate/+server.ts | 2 +- .../src/routes/api-sveltekit/parts/+server.ts | 2 + 33 files changed, 1815 insertions(+), 589 deletions(-) diff --git a/backend/api/v1/modules/a76/audit_log/register.py b/backend/api/v1/modules/a76/audit_log/register.py index 054dfb39..b90f7284 100644 --- a/backend/api/v1/modules/a76/audit_log/register.py +++ b/backend/api/v1/modules/a76/audit_log/register.py @@ -8,6 +8,7 @@ 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 LineItem +from api.v1.modules.a76.items.series.models import Serie from api.v1.modules.a76.general_catalogs.company.models import Company # Reference Data @@ -92,6 +93,7 @@ def register_audit(): InvoiceHeader, InvoiceSalesDetails, LineItem, + Serie, # Sidebar Core Modules ClientProvider, CustomsBroker, diff --git a/backend/api/v1/modules/a76/classes/service.py b/backend/api/v1/modules/a76/classes/service.py index 107faaf9..49c06bcb 100644 --- a/backend/api/v1/modules/a76/classes/service.py +++ b/backend/api/v1/modules/a76/classes/service.py @@ -48,6 +48,15 @@ class ClassService: query = query.filter(Class.company_id == company_id) if filters: + if filters.get("q"): + search = f"%{filters['q']}%" + query = query.filter( + or_( + Class.class_code.ilike(search), + Class.description_es.ilike(search), + Class.description_en.ilike(search) + ) + ) if filters.get("class_code"): query = query.filter( Class.class_code.ilike(f"%{filters['class_code']}%") @@ -122,6 +131,15 @@ class ClassService: # Apply filters if provided if filters: + if filters.get("q"): + search = f"%{filters['q']}%" + query = query.filter( + or_( + Class.class_code.ilike(search), + Class.description_es.ilike(search), + Class.description_en.ilike(search) + ) + ) if filters.get("class_code"): query = query.filter( Class.class_code.ilike(f"%{filters['class_code']}%") diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py index 7a020605..b9efb44f 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/routes.py @@ -1,4 +1,5 @@ from typing import List, Optional +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from core.database import get_core_db @@ -34,6 +35,46 @@ def get_historical_fractions( "pages": (total + page_size - 1) // page_size if page_size > 0 else 1 } +@router.get("/rate") +async def get_rate( + company_id: int = Query(..., description="Company ID"), + historical_fraction: str = Query(..., description="8-character fraction code"), + nico: str = Query(..., description="2-character NICO code"), + direction: str = Query("export", description="Movement direction: 'import' or 'export'"), + tariff_type: str = Query("GENERAL", description="Tariff regimen: 'GENERAL', 'PROSEC', etc."), + invoice_date: str = Query(..., description="ISO Date (YYYY-MM-DD)"), + is_regime_change: bool = Query(False, description="Whether the invoice is a regime change (Cambio de Régimen)"), + db: Session = Depends(get_core_db), + current_user = Depends(get_current_user) +): + """ + Get the historical tariff rate for a specific fraction, nico, and date. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + # Parse date + try: + parsed_date = datetime.fromisoformat(invoice_date.split('T')[0]) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + service = HistoricalTariffFractionService(db) + rate = await service.get_historical_rate( + tenant_id=int(tenant_id), + company_id=company_id, + historical_fraction=historical_fraction, + nico=nico, + direction=direction, + tariff_type=tariff_type, + invoice_date=parsed_date, + is_regime_change=is_regime_change + ) + + if rate is None: + return {"found": False, "rate": 0} + + return {"found": True, "rate": float(rate)} + @router.get("/{id}", response_model=HistoricalTariffFractionResponse) def get_historical_fraction( id: int, diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py index 01579a4f..afa14ec5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/historical_tariff_fractions/service.py @@ -1,9 +1,11 @@ - +from datetime import datetime +from decimal import Decimal from typing import Optional, List, Tuple from sqlalchemy import select, or_, func from sqlalchemy.orm import Session from .models import HistoricalTariffFraction -from .schemas import HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate +from .schemas import HistoricalTariffFractionResponse, HistoricalTariffFractionCreate, HistoricalTariffFractionUpdate +from api.v1.modules.sitar.fracciones.service import FraccionesService class HistoricalTariffFractionService: def __init__(self, db: Session): @@ -67,3 +69,109 @@ class HistoricalTariffFractionService: self.db.delete(obj) self.db.commit() return obj + async def get_historical_rate( + self, + tenant_id: int, + company_id: int, + historical_fraction: str, + nico: str, + direction: str, + tariff_type: str, + invoice_date: datetime, + is_regime_change: bool = False + ) -> Optional[Decimal]: + """ + Gets the historical tax rate based on the fraction, nico, and date. + Equivalent to Clarion BUSCA_FRACCION_HISTORICA. + """ + # Normalize input fraction to handle both 8-digit and unpadded (7-digit) versions + fraction_variants = [historical_fraction] + unpadded = historical_fraction.lstrip('0') + if unpadded and unpadded != historical_fraction: + fraction_variants.append(unpadded) + + query = select(HistoricalTariffFraction).where( + HistoricalTariffFraction.tenant_id == tenant_id, + HistoricalTariffFraction.company_id == company_id, + HistoricalTariffFraction.historical_fraction.in_(fraction_variants), + HistoricalTariffFraction.fraction_type.ilike(tariff_type), # Match GENERAL, PROSEC, etc. + or_( + HistoricalTariffFraction.nico == nico, + HistoricalTariffFraction.nico.is_(None), + HistoricalTariffFraction.nico == '' + ), + HistoricalTariffFraction.publication_date <= invoice_date + ).order_by(HistoricalTariffFraction.publication_date.desc()) + + result = self.db.execute(query).scalars().first() + + if result: + # Special rule: If it's a regime change, always return the import rate (TasaImNum) + # as per Clarion logic ASIGNA_FRACCION_HISTORICA + if is_regime_change: + return result.import_tax_rate + + # Otherwise return based on direction + if direction.lower() == 'import': + return result.import_tax_rate + else: + return result.export_tax_rate + + # 2. Priority 2: Try SITAR API (Modern source of truth) + try: + sitar_service = FraccionesService.get_instance() + # Search by 8-digit fraction and 2-digit NICO + sitar_data = await sitar_service.search( + fraccion=historical_fraction, + nico=nico, + limit=1 + ) + + if sitar_data: + first_record = sitar_data[0] + # Special rule: If it's a regime change, always return the import rate (TasaImNum) + if is_regime_change: + return first_record.ADVIMPONUM + + # Otherwise return based on direction + if direction.lower() == 'import': + return first_record.ADVIMPONUM + else: + return first_record.ADVEXPONUM + except Exception as e: + # Log error but continue to fallback + print(f"Error fetching data from SITAR API: {e}") + + # 3. Priority 3: Fallback to main TariffFraction catalog if not found in historical or SITAR + from ..tariff_fractions.models import TariffFraction + + # In the main catalog, fractions might be stored with dots (e.g. 0101.90.99) + # or as code (e.g. 01019099) + # We search primarily by fraction code (8 digits) and take the first one found. + # This handles cases where NICO doesn't match perfectly. + fallback_query = select(TariffFraction).where( + or_( + TariffFraction.code == historical_fraction, + TariffFraction.fraction == f"{historical_fraction[:4]}.{historical_fraction[4:6]}.{historical_fraction[6:8]}" + ) + ).order_by(TariffFraction.nico) # Order so we get a consistent result if multiple NICOs exist + + main_result = self.db.execute(fallback_query).scalars().first() + + if main_result: + # Apply same regime change rule to fallback if found + if is_regime_change: + rate_str = main_result.adv_impo + else: + rate_str = main_result.adv_impo if direction.lower() == 'import' else main_result.adv_expo + + if rate_str: + try: + # Remove non-numeric characters (like % or text) + import re + clean_rate = re.sub(r'[^\d.]', '', rate_str) + return Decimal(clean_rate) if clean_rate else Decimal(0) + except: + return Decimal(0) + + return None diff --git a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py index 9a258c6a..33c96e3c 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py @@ -43,8 +43,14 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id errors.raise_if_errors() review_class(db, lines, errors) - review_qty_vs_weight(db, lines, errors) - review_unit_cost(db, lines, errors) + + # REVISA_CANT_KG (KGS) and REVISA_CANT_LB (LBS) + review_qty_vs_weight(lines, "KGS", errors) + review_qty_vs_weight(lines, "LBS", errors) + + # REVISA_LINEA_COSTO + review_unit_cost(lines, errors) + review_qty_series(db, invoice, lines, tenant_id, company_id, errors) errors.raise_if_errors() diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py index 3aa3b74e..4aa64b87 100644 --- a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py @@ -40,7 +40,7 @@ def review_qty_vs_weight( Generic validator used by both KGS and LBS variants. For every line whose unit of measure code matches ``unit_code``, - checks that the exported quantity equals the exported quantity. Adds a PESO_NETO error for each mismatch. + checks that the exported quantity equals the net weight. Adds a PESO_NETO error for each mismatch. Parameters ---------- diff --git a/backend/api/v1/modules/a76/invoices/routes.py b/backend/api/v1/modules/a76/invoices/routes.py index 05b983fb..8d767fd0 100644 --- a/backend/api/v1/modules/a76/invoices/routes.py +++ b/backend/api/v1/modules/a76/invoices/routes.py @@ -90,7 +90,7 @@ def list_invoices( page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(50, ge=1, le=200, description="Items per page"), search: str = Query(None, description="Search by invoice number"), - status: bool = Query(None, description="Filter by status"), + status: Optional[Any] = Query(None, description="Filter by status"), operation_type: schemas.OperationType = Query(None, description="Filter by operation type"), invoice_type: str = Query(None, description="Filter by invoice type"), manifest_number: str = Query(None, description="Filter by manifest number"), diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index 7a553114..f354047e 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -257,6 +257,7 @@ class InvoiceService: ) # Apply filters if provided + print(f"DEBUG: Invoice Query - Company: {company_id}, Filters: {filters}, Skip: {skip}, Limit: {limit}") if filters: # Join compliance_mx if needed for filters needs_compliance_join = any(k in filters for k in ["pedimento", "manifest_number"]) @@ -264,12 +265,14 @@ class InvoiceService: query = query.join(models.InvoiceComplianceMx) if filters.get("status") is not None: - status = ( - models.InvoiceStatus.PROCESSED - if filters["status"] == True - else models.InvoiceStatus.PENDING - ) - query = query.filter(models.InvoiceHeader.status == status) + status_val = filters["status"] + if status_val in [True, "processed", models.InvoiceStatus.PROCESSED]: + target_status = models.InvoiceStatus.PROCESSED + elif status_val in [False, "pending", models.InvoiceStatus.PENDING]: + target_status = models.InvoiceStatus.PENDING + else: + target_status = status_val + query = query.filter(models.InvoiceHeader.status == target_status) if filters.get("operation_type"): ot = filters["operation_type"] diff --git a/backend/api/v1/modules/a76/items/exports/validators/calculations.py b/backend/api/v1/modules/a76/items/exports/validators/calculations.py index 55e21997..d5703cd4 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/exports/validators/calculations.py @@ -114,7 +114,9 @@ def apply_calculations( .first() ) if class_desc: - line.description.description_spanish, line.description.description_english = class_desc + line.description.description_spanish = class_desc[0] + if not line.description.description_english: + line.description.description_english = class_desc[1] def calculate_values( @@ -213,7 +215,8 @@ def calculate_values( line.quantity.package_quantity = import_line.quantity.package_quantity line.quantity.package_id = import_line.quantity.package_id - if import_line.description: + if import_line.description and not line.description.description_english: + # Only fill from import if the user hasn't captured their own English description line.description.description_english = import_line.description.description_english # ========================================== diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py index d73470bd..4c189ee0 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/create.py +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -135,6 +135,97 @@ def validate_create( if not fa_data.search_line: errors.add_required_error(field=f"line[{line_number}].fa_data.search_line") + # REVISA_FACTURA logic if both invoice and line are present + if fa_data.search_invoice and fa_data.search_line: + linked_inv = db.query(InvoiceHeader).filter( + InvoiceHeader.invoice_number == fa_data.search_invoice, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp" # Assuming link is always to an import + ).first() + + if not linked_inv: + errors.add_error( + field=f"line[{line_number}].fa_data.search_invoice", + message="La Factura No Existe Capture o seleccione una que si exista", + code="LINKED_INVOICE_NOT_FOUND", + solution=["Verificar el número de factura de importación."] + ) + elif linked_inv.status != "processed": + errors.add_error( + field=f"line[{line_number}].fa_data.search_invoice", + message="La Factura no esta Actualizada, Capture o seleccione una que si este Actualizada", + code="LINKED_INVOICE_NOT_PROCESSED", + solution=["Actualizar/Procesar la factura de importación antes de descargarla."] + ) + else: + # Invoice is valid and processed, check the line (REVISA_FACTURA part 2) + linked_line = db.query(LineItem).filter( + LineItem.invoice_id == linked_inv.id, + LineItem.line_number == fa_data.search_line, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id + ).first() + + if not linked_line: + errors.add_error( + field=f"line[{line_number}].fa_data.search_line", + message="La Partida de la Factura No Existe Capture o seleccione una que si exista", + code="LINKED_LINE_NOT_FOUND", + solution=["Verificar el número de renglón en la factura de importación."] + ) + else: + # Validation for search_type == "Clase" (Parity with Clarion Valida.Validaciones) + if fa_data.search_type == "Clase" and linked_line.class_id != line.class_id: + # Load class codes for a better error message if necessary + current_class = db.query(Class).filter(Class.id == line.class_id).first() + import_class = db.query(Class).filter(Class.id == linked_line.class_id).first() + + errors.add_error( + field=f"line[{line_number}].class_id", + message=f"Es necesario que la clase: {current_class.class_code if current_class else line.class_id} sea igual a la clase: {import_class.class_code if import_class else linked_line.class_id} de la factura de importación seleccionada.", + code="CLASS_MISMATCH_FOR_SEARCH_TYPE_CLASE", + solution=["Asegurarse de que el activo que se exporta pertenezca a la misma familia/clase que el que se importó."] + ) + + # REVISA_CANTIDADES_A_DESC_TEM logic + # 1. Get current balance from ledger + from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS + from sqlalchemy import case, select + + sign_expr = case( + (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal("-1")), + else_=Decimal("1"), + ) + available_balance = db.execute( + select(func.sum(sign_expr * BalanceMovement.quantity)).where( + BalanceMovement.import_item_line_id == linked_line.id + ) + ).scalar() or Decimal("0") + + # 2. Get pending discharges in this same invoice (sibling lines) + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + pending_sum = db.query(func.sum(LineQuantity.quantity)).join( + LineItem, LineItem.id == LineQuantity.id + ).join( + FaLineItem, FaLineItem.id == LineItem.id + ).filter( + LineItem.invoice_id == line.invoice_id, + FaLineItem.search_invoice == fa_data.search_invoice, + FaLineItem.search_line == fa_data.search_line, + FaLineItem.movement_type_import == fa_data.movement_type_import, + FaLineItem.discharge == True + ).scalar() or Decimal("0") + + current_qty = line.quantity.quantity or Decimal("0") + remaining = available_balance - pending_sum - current_qty + + # Note: Hard validation removed here to allow 'Multiple Source Discharge' + # or 'Automatic Deficit Handling' logic to function during full invoice processing. + # Current balance for information: {available_balance}, short: {remaining if remaining < 0 else 0} + pass + + if ( fa_data.is_subitem and fa_data.contains_subitems ) and not fa_data.subitem_number: diff --git a/backend/api/v1/modules/a76/items/exports/validators/update.py b/backend/api/v1/modules/a76/items/exports/validators/update.py index 3d464082..758c71de 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/update.py +++ b/backend/api/v1/modules/a76/items/exports/validators/update.py @@ -1,4 +1,5 @@ from decimal import Decimal +from sqlalchemy import func, exists from sqlalchemy.orm import Session from core.exceptions import ErrorCollector @@ -8,6 +9,7 @@ from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.models import ( USTariffFraction, ) +from api.v1.modules.a76.classes.models import Class from .common import validate_common @@ -189,7 +191,7 @@ def validate_update( existing_line.description.description_spanish ) - if line.description.description_english is None: + if not line.description.description_english: line.description.description_english = ( existing_line.description.description_english ) @@ -233,6 +235,119 @@ def validate_update( if fa_data.search_line is None: fa_data.search_line = existing_fa_data.search_line + # REVISA_FACTURA logic if both invoice and line are present (even after partial update) + if fa_data.discharge is True and fa_data.search_invoice and fa_data.search_line: + from sqlalchemy import exists + linked_inv = db.query(InvoiceHeader).filter( + InvoiceHeader.invoice_number == fa_data.search_invoice, + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.operation_type == "imp" + ).first() + + if not linked_inv: + errors.add_error( + field=f"line[{line_number}].fa_data.search_invoice", + message="La Factura No Existe Capture o seleccione una que si exista", + code="LINKED_INVOICE_NOT_FOUND", + solution=["Verificar el número de factura de importación."] + ) + elif linked_inv.status != "processed": + errors.add_error( + field=f"line[{line_number}].fa_data.search_invoice", + message="La Factura no esta Actualizada, Capture o seleccione una que si este Actualizada", + code="LINKED_INVOICE_NOT_PROCESSED", + solution=["Actualizar/Procesar la factura de importación antes de descargarla."] + ) + else: + # Invoice is valid and processed, check the line + linked_line_exists = db.query(exists().where( + (LineItem.invoice_id == linked_inv.id) & + (LineItem.line_number == fa_data.search_line) & + (LineItem.tenant_id == tenant_id) & + (LineItem.company_id == company_id) + )).scalar() + + if not linked_line_exists: + errors.add_error( + field=f"line[{line_number}].fa_data.search_line", + message="La Partida de la Factura No Existe Capture o seleccione una que si exista", + code="LINKED_LINE_NOT_FOUND", + solution=["Verificar el número de renglón en la factura de importación."] + ) + else: + # Validation for search_type == "Clase" (Parity with Clarion Valida.Validaciones) + # We need to get the actual IDs to compare + linked_import_line = db.query(LineItem).filter( + LineItem.invoice_id == linked_inv.id, + LineItem.line_number == fa_data.search_line, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id + ).first() + + if linked_import_line: + current_class_id = line.class_id if line.class_id else existing_line.class_id + if fa_data.search_type == "Clase" and linked_import_line.class_id != current_class_id: + current_class = db.query(Class).filter(Class.id == current_class_id).first() + import_class = db.query(Class).filter(Class.id == linked_import_line.class_id).first() + + errors.add_error( + field=f"line[{line_number}].class_id", + message=f"Es necesario que la clase: {current_class.class_code if current_class else current_class_id} sea igual a la clase: {import_class.class_code if import_class else linked_import_line.class_id} de la factura de importación seleccionada.", + code="CLASS_MISMATCH_FOR_SEARCH_TYPE_CLASE", + solution=["Asegurarse de que el activo que se exporta pertenezca a la misma familia/clase que el que se importó."] + ) + + # REVISA_CANTIDADES_A_DESC_TEM logic for Update + + # 0. Get the internal ID of the linked import line + linked_import_line = db.query(LineItem).filter( + LineItem.invoice_id == linked_inv.id, + LineItem.line_number == fa_data.search_line, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id + ).first() + + if linked_import_line: + # 1. Get current balance from ledger + from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS + from sqlalchemy import case, select + + sign_expr = case( + (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal("-1")), + else_=Decimal("1"), + ) + available_balance = db.execute( + select(func.sum(sign_expr * BalanceMovement.quantity)).where( + BalanceMovement.import_item_line_id == linked_import_line.id + ) + ).scalar() or Decimal("0") + + # 2. Get pending discharges in this same invoice (sibling lines) + # EXCLUDING the current line we are updating + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + pending_sum = db.query(func.sum(LineQuantity.quantity)).join( + LineItem, LineItem.id == LineQuantity.id + ).join( + FaLineItem, FaLineItem.id == LineItem.id + ).filter( + LineItem.invoice_id == line.invoice_id, + LineItem.id != existing_line.id, # IMPORTANT: Exclude self + FaLineItem.search_invoice == fa_data.search_invoice, + FaLineItem.search_line == fa_data.search_line, + FaLineItem.movement_type_import == fa_data.movement_type_import, + FaLineItem.discharge == True + ).scalar() or Decimal("0") + + current_qty = line.quantity.quantity if line.quantity.quantity is not None else existing_line.quantity.quantity + remaining = available_balance - pending_sum - current_qty + + # Note: Hard validation removed here to allow 'Multiple Source Discharge' + # or 'Automatic Deficit Handling' logic to function during full invoice processing. + # Current balance for information: {available_balance}, short: {remaining if remaining < 0 else 0} + pass + + # Subpartidas (EsSubPartida / SubPartida) if fa_data.is_subitem is None: fa_data.is_subitem = existing_fa_data.is_subitem diff --git a/backend/api/v1/modules/a76/items/imports/validators/update.py b/backend/api/v1/modules/a76/items/imports/validators/update.py index f6dbbdc6..ad18716d 100644 --- a/backend/api/v1/modules/a76/items/imports/validators/update.py +++ b/backend/api/v1/modules/a76/items/imports/validators/update.py @@ -183,12 +183,12 @@ def validate_update( line.order = existing_line.order # Descripciones - if line.description.description_spanish is None: + if not line.description.description_spanish: line.description.description_spanish = ( existing_line.description.description_spanish ) - if line.description.description_english is None: + if not line.description.description_english: line.description.description_english = ( existing_line.description.description_english ) diff --git a/backend/api/v1/modules/a76/items/models.py b/backend/api/v1/modules/a76/items/models.py index 6628b672..0fe72550 100644 --- a/backend/api/v1/modules/a76/items/models.py +++ b/backend/api/v1/modules/a76/items/models.py @@ -223,6 +223,11 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): back_populates="line", cascade="all, delete-orphan", ) + series: Mapped[List["Serie"]] = relationship( + "Serie", + cascade="all, delete-orphan", + ) + part_info: Mapped[Optional["Part"]] = relationship( "Part", foreign_keys=[part_number_id], diff --git a/backend/api/v1/modules/a76/items/routes.py b/backend/api/v1/modules/a76/items/routes.py index a1b58388..540d4bc6 100644 --- a/backend/api/v1/modules/a76/items/routes.py +++ b/backend/api/v1/modules/a76/items/routes.py @@ -167,6 +167,25 @@ async def delete_item( return None +@router.delete("/{item_id}/series", status_code=status.HTTP_200_OK) +async def delete_item_series( + item_id: int = Path(..., description="Item ID"), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Delete all serial numbers for a specific item. + Equivalent to Clarion BORRAR_SERIES_EXPO. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + service = ItemService() + count = service.delete_item_series(db, item_id, tenant_id, company_id) + + return {"message": f"Successfully deleted {count} series", "count": count} + + # ============================================================================ # ADDITIONAL ENDPOINTS FOR INVOICE # ============================================================================ @@ -209,6 +228,10 @@ async def get_items_with_balance( "before this date are subtracted (CALCULA_SALDO_FECHA_EXPO logic)." ), ), + current_export_invoice_id: Optional[int] = Query( + None, + description="ID of the current export invoice being edited to subtract its pending quantities from balance." + ), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -222,12 +245,15 @@ async def get_items_with_balance( - available_balance : net balance still available for export discharge - has_balance : true when available_balance > 0 + If current_export_invoice_id is provided, quantities already assigned to + this specified invoice will be subtracted from available_balance. + Use ``as_of_date`` to restrict consumption movements to a specific date (pass the export invoice date so that future discharges are not counted). """ tenant_id = validate_access_to_resource(db, company_id, current_user) service = ItemService() - return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date) + return service.get_lines_with_balance(db, invoice_id, tenant_id, company_id, as_of_date, current_export_invoice_id) # ============================================================================ diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index c20eb94f..36c5190a 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -207,6 +207,7 @@ class ItemService: if not serie_dict: continue serie_dict.update({ + "line_item_id": line.id, "tenant_id": tenant_id, "company_id": company_id }) @@ -234,6 +235,7 @@ class ItemService: if not id_dict: continue id_dict.update({ + "item_line_id": line.id, "tenant_id": tenant_id, "company_id": company_id }) @@ -571,7 +573,7 @@ class ItemService: logger.error(f"Error creating item: {e}") raise HTTPException( status_code=400, - detail="LineItem creation failed - integrity constraint violated", + detail=f"LineItem creation failed - integrity constraint violated: {e.orig}", ) except Exception as e: db.rollback() @@ -800,6 +802,32 @@ class ItemService: logger.error(f"Unexpected error updating item: {e}") raise HTTPException(status_code=500, detail=f"Error updating item: {str(e)}") + @staticmethod + def delete_item_series( + db: Session, + item_id: int, + tenant_id: int, + company_id: int, + current_user_name: Optional[str] = None + ) -> int: + """ + Deletes all serial numbers for a specific item. + Equivalent to Clarion BORRAR_SERIES_EXPO. + """ + # Ensure item exists and belongs to the company + item = ItemService.get_by_id(db, item_id, tenant_id, company_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + # Delete series + deleted_count = db.query(Serie).filter(Serie.line_item_id == item_id).delete(synchronize_session='fetch') + + # Log to bitácora (if system supports it) + # GBitacora('BORRAR TODAS LAS SERIE EXPO ', item.invoice_number) + + db.commit() + return deleted_count + @staticmethod def delete( db: Session, @@ -824,6 +852,29 @@ class ItemService: status_code=404, detail="Invoice not found or could not be locked" ) + # Manual cascade cleanup for Anexo 24 references + from api.v1.modules.a24.balance_movements.models import BalanceMovement + from api.v1.modules.a24.discharges.models import DischargeDetail + + # Check if this item is used in any discharges + if db_item.invoice and db_item.invoice.operation_type == "imp": + # Import item: check if it has been consumed + consumptions = db.query(BalanceMovement).filter( + BalanceMovement.import_item_line_id == item_id, + BalanceMovement.movement_type != "entry" + ).count() + if consumptions > 0: + raise HTTPException( + status_code=400, + detail="No se puede borrar la partida de importación porque ya ha sido descargada/consumida parcial o totalmente." + ) + # It's safe to delete its ENTRY movements + db.query(BalanceMovement).filter(BalanceMovement.import_item_line_id == item_id).delete() + else: + # Export item: delete its derived consumptions and discharge details + db.query(DischargeDetail).filter(DischargeDetail.export_item_line_id == item_id).delete() + db.query(BalanceMovement).filter(BalanceMovement.source_item_line_id == item_id).delete() + db.delete(db_item) db.flush() ItemService._renumber_all_invoice_lines(db, invoice_id) @@ -832,8 +883,8 @@ class ItemService: except Exception as e: db.rollback() - logger.error(f"Error deleting item: {e}") - raise HTTPException(status_code=500, detail="Error deleting item") + logger.error(f"Error deleting item: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) @staticmethod def get_lines_with_balance( @@ -842,20 +893,20 @@ class ItemService: tenant_id: int, company_id: int, as_of_date: Optional[datetime.date] = None, + current_export_invoice_id: Optional[int] = None, ) -> List[dict]: """ Returns every line of an import invoice together with its current available balance calculated from the a24.balance_movement ledger. - Lines with balance <= 0 are included but marked as unavailable so - the frontend can grey them out / disable them. + If current_export_invoice_id is provided, it also subtracts quantities + already allocated in that export invoice to provide a "real-time" + remaining balance for the user during capture. Parameters ---------- - as_of_date : optional cut-off date. Only negative movements - (consumptions, etc.) on or before this date are counted, - mirroring the CALCULA_SALDO_FECHA_EXPO Clarion logic. - If None, all movements are counted (no date restriction). + as_of_date : optional cut-off date. + current_export_invoice_id : current export invoice being edited. """ lines: List[LineItem] = ( db.query(LineItem) @@ -877,14 +928,42 @@ class ItemService: .all() ) + # 1. Get official balance from ledger result = [] used_map = ItemService._used_quantities_by_procedure( db=db, import_line_ids=[line.id for line in lines], as_of_date=as_of_date, ) + + # 2. Get locally reserved quantities in the current export invoice (if any) + reserved_map = {} + if current_export_invoice_id and lines: + import_inv = lines[0].invoice + if import_inv: + reserved_rows = ( + db.query(FaLineItem.search_line, func.sum(LineQuantity.quantity)) + .join(LineItem, LineItem.id == FaLineItem.id) + .join(LineQuantity, LineQuantity.id == LineItem.id) + .filter( + LineItem.invoice_id == current_export_invoice_id, + FaLineItem.search_invoice == import_inv.invoice_number, + FaLineItem.movement_type_import == import_inv.invoice_type, # Match TEM/DEF + FaLineItem.discharge == True + ) + .group_by(FaLineItem.search_line) + .all() + ) + reserved_map = {int(row[0]): Decimal(str(row[1] or 0)) for row in reserved_rows} + for line in lines: + # Official ledger balance available_balance = ItemService._compute_balance(db, line.id, as_of_date) + + # Subtract what's already assigned in THIS invoice + reserved = reserved_map.get(line.line_number, Decimal(0)) + active_balance = available_balance - reserved + qty = line.quantity desc = line.description fa = line.fa_data @@ -925,8 +1004,11 @@ class ItemService: "quantity_used_temp": float(qty_used_temp), "quantity_used_def": float(qty_used_def), # Balance - "available_balance": float(available_balance), - "has_balance": available_balance > Decimal(0), + "available_balance": float(active_balance), + "has_balance": active_balance > Decimal(0), + # Weights for proportional calculation + "net_weight": float(qty.net_weight) if qty and qty.net_weight is not None else 0.0, + "gross_weight": float(qty.gross_weight) if qty and qty.gross_weight is not None else 0.0, # FA / subitem info "is_subitem": fa.is_subitem if fa else None, "contains_subitems": fa.contains_subitems if fa else None, @@ -934,6 +1016,39 @@ class ItemService: }) return result + @staticmethod + def get_pending_discharge_sum( + db: Session, + invoice_id: int, + search_invoice: str, + search_line: int, + movement_type_import: Optional[str] = None, + exclude_line_id: Optional[int] = None + ) -> Decimal: + """ + CUENTA_CANTIDADES_A_DESC equivalent. + Sums quantity from other lines in the same invoice targeting the same import source. + """ + query = ( + select(func.sum(LineQuantity.quantity)) + .join(LineItem, LineItem.id == LineQuantity.id) + .join(FaLineItem, FaLineItem.id == LineItem.id) + .where( + LineItem.invoice_id == invoice_id, + FaLineItem.search_invoice == search_invoice, + FaLineItem.search_line == search_line, + FaLineItem.discharge == True + ) + ) + if movement_type_import: + query = query.where(FaLineItem.movement_type_import == movement_type_import) + + if exclude_line_id: + query = query.where(LineItem.id != exclude_line_id) + + result = db.execute(query).scalar() + return Decimal(str(result or 0)) + @staticmethod def _compute_balance( db: Session, diff --git a/backend/api/v1/modules/a76/parts/service.py b/backend/api/v1/modules/a76/parts/service.py index ecde9597..16b06872 100644 --- a/backend/api/v1/modules/a76/parts/service.py +++ b/backend/api/v1/modules/a76/parts/service.py @@ -74,12 +74,14 @@ class PartService: query = query.options(load_inv_opt, load_fa_opt) if filters: + logger.info(f"Applying filters to Part list: {filters}") if filters.get("q"): search = f"%{filters['q']}%" query = query.filter( or_( Part.part_number.ilike(search), Part.description_spanish.ilike(search), + Part.description_english.ilike(search), Part.commercial_part_number.ilike(search) ) ) diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index 8423403d..6d6e395a 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -330,12 +330,13 @@ async def general_exception_handler( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ "error": "INTERNAL_SERVER_ERROR", - "message": "Error interno del servidor", + "message": f"Error interno del servidor: {str(exc)}", "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, }, ) for k, v in _cors_headers(request).items(): response.headers[k] = v + return response diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 9fabab3e..5d6acbd0 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -33,6 +33,7 @@ export interface LineFinancials { unit_cost_mxn?: number; unit_cost_capture?: number; unit_cost_commercial_usd?: number; + unit_cost_mc?: number; // Values value_mc?: number; @@ -42,6 +43,9 @@ export interface LineFinancials { value_returned_mxn?: number; customs_value_usd?: number; customs_value_mxn?: number; + vat_mxn?: number; + vat_usd?: number; + vat_mc?: number; } export interface LineQuantities { @@ -137,6 +141,13 @@ export interface FaLineItem { own_equipment?: boolean; omit_annex31?: boolean; + // Source data for proportional calculations (CALCULO_PESOS) and inventory balance validation + source_quantity?: number; + source_balance?: number; + source_net_weight?: number; + source_gross_weight?: number; + source_packages?: number; + // Timestamps created_at?: string; updated_at?: string; @@ -290,9 +301,6 @@ export const itemsApi = { return api.put(`/v1/a76/items/${itemId}/?${params.toString()}`, data); }, - /** - * Elimina un item - */ delete: (itemId: number, companyId: number) => { const params = new URLSearchParams({ company_id: companyId.toString() @@ -300,6 +308,18 @@ export const itemsApi = { return api.delete(`/v1/a76/items/${itemId}/?${params.toString()}`); }, + /** + * Elimina todas las series de un item + */ + deleteSeries: (itemId: number, companyId: number) => { + const params = new URLSearchParams({ + company_id: companyId.toString() + }); + return api.delete<{ message: string; count: number }>( + `/v1/a76/items/${itemId}/series?${params.toString()}` + ); + }, + /** * Lista las líneas de una factura de importación con su saldo disponible. * Solo las líneas con has_balance = true tienen mercancía disponible para descarga. @@ -313,10 +333,12 @@ export const itemsApi = { listByInvoiceWithBalance: ( invoiceId: number, companyId: number, - asOfDate?: string + asOfDate?: string, + currentExportInvoiceId?: number ) => { const params = new URLSearchParams({ company_id: companyId.toString() }); if (asOfDate) params.append('as_of_date', asOfDate); + if (currentExportInvoiceId) params.append('current_export_invoice_id', currentExportInvoiceId.toString()); return api.get( `/v1/a76/items/invoice/${invoiceId}/items-with-balance?${params.toString()}` ); @@ -339,6 +361,9 @@ export interface ImportLineWithBalance { quantity?: number; quantity_used_temp?: number; quantity_used_def?: number; + // Weights + net_weight?: number; + gross_weight?: number; // Balance available_balance: number; has_balance: boolean; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte index 2c8cff0a..ab523875 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte @@ -2,7 +2,7 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; - import { Search, Loader2 } from 'lucide-svelte'; + import { Search, Loader2, FileText, Calendar, Hash } from 'lucide-svelte'; import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices'; import { companyStore } from '$lib/stores/company.svelte'; import { toast } from 'svelte-sonner'; @@ -11,6 +11,7 @@ open: boolean; regimen?: string; operationType?: 'imp' | 'exp'; + status?: string; onSelect: (invoice: Invoice) => void; } @@ -21,18 +22,27 @@ onSelect }: Props = $props(); + let status = $derived(operationType === 'imp' ? 'processed' : undefined); + let invoices = $state([]); let loading = $state(false); let searchTerm = $state(''); async function searchInvoices() { - if (!companyStore.activeCompany) return; + const activeCompanyId = companyStore?.activeCompany?.id; + if (!activeCompanyId) { + toast.error('No se ha seleccionado una empresa activa'); + return; + } + loading = true; try { const filters: any = { operation_type: operationType, - invoice_number: searchTerm || undefined + invoice_number: searchTerm || undefined, + status: status || undefined }; + if (operationType === 'imp' && regimen) { if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') { filters.invoice_type = 'TEM'; @@ -41,7 +51,10 @@ } } - const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters); + console.log('🔍 [Modal] Buscando facturas...', { activeCompanyId, filters }); + + const res = await invoicesApi.list(activeCompanyId, 1, 50, filters); + console.log('✅ [Modal] Respuesta recibida:', res); if (res.data) { invoices = res.data.items || []; } @@ -65,85 +78,109 @@ }); - - - - - {operationType === 'exp' ? 'Seleccionar Factura de Exportación' : `Seleccionar Factura (${regimen})`} - - + + + +
+
+ +
+ + {operationType === 'exp' ? 'Facturas de Exportación' : `Facturas de Importación (${regimen})`} + +
+ {#if operationType === 'exp'} - Busca y selecciona una factura del catálogo de exportación. + Selecciona una factura del catálogo para vincularla a la partida. {:else} - Busca y selecciona una factura del catálogo de importación para el régimen {regimen}. + Selecciona una factura de importación procesada para el régimen {regimen}. {/if}
-
+
-
- +
+ e.key === 'Enter' && searchInvoices()} />
-
+
-
- - - - - - - - - {#if invoices.length === 0} - - - - {:else} - {#each invoices as invoice} - handleSelect(invoice)} - > - - - - {/each} - {/if} - -
Número de FacturaPedimento
- {#if loading} - Buscando facturas... - {:else} - No se encontraron resultados - {/if} -
- {invoice.invoice_number} - - {invoice.compliance_mx?.pedimento_r1 || - invoice.compliance_mx?.pedimento_id || - '-'} -
+
+
+ {#if loading && invoices.length === 0} +
+ +

Buscando facturas disponibles...

+
+ {:else if invoices.length === 0} +
+ +

No se encontraron facturas

+

Intenta con otro número de factura o filtro

+
+ {:else} + {#each invoices as invoice (invoice.id)} + +
+ + {/each} + {/if}
+ + +

+ Total: {invoices.length} facturas encontradas +

+ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte index 1e74393d..71a07c9e 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/class-dialog.svelte @@ -3,9 +3,10 @@ import * as Table from '$lib/components/ui/table'; import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; - import { Search, Loader2 } from 'lucide-svelte'; + import { Search, Loader2, Info } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; + import { onMount } from 'svelte'; let { open = $bindable(false), @@ -16,160 +17,217 @@ } = $props(); let searchQuery = $state(''); + let debouncedSearch = $state(''); let isSearching = $state(false); + let isLoadingMore = $state(false); let classes = $state([]); - let displayedClasses = $state([]); let currentPage = $state(1); - let itemsPerPage = 10; + let hasMore = $state(true); + let totalItems = $state(0); + const itemsPerPage = 25; - const filteredClasses = $derived( - searchQuery - ? classes.filter( - (c) => - c.class_code?.toLowerCase().includes(searchQuery.toLowerCase()) || - c.description_es?.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : classes - ); - - $effect(() => { - if (open) { - searchClasses(); - } - }); - - $effect(() => { - currentPage = 1; - loadMoreClasses(); - }); - - async function searchClasses() { + async function fetchClasses(page: number = 1, search: string = '') { const activeCompanyId = companyStore?.activeCompany?.id; - if (!activeCompanyId) { - toast.error('No hay compañía activa'); - return; - } + if (!activeCompanyId) return; + + if (page === 1) isSearching = true; + else isLoadingMore = true; - isSearching = true; try { - const response = await fetch( - `/api-sveltekit/classes?company_id=${activeCompanyId}&limit=100`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - } - ); + // Construct query params + const params = new URLSearchParams({ + company_id: activeCompanyId.toString(), + page: page.toString(), + page_size: itemsPerPage.toString(), + sort_by: 'class_code', + sort_order: 'asc' + }); - if (!response.ok) { - throw new Error('Error al buscar clases'); + if (search) { + // Most TenantCRUDRoutes support 'q' for general search or field-specific filters + params.append('q', search); } + const response = await fetch(`/api-sveltekit/classes?${params.toString()}`); + + if (!response.ok) throw new Error('Error al buscar clases'); + const data = await response.json(); - classes = data.items || []; - loadMoreClasses(); + const newItems = data.items || []; + + if (page === 1) { + classes = newItems; + } else { + classes = [...classes, ...newItems]; + } + + totalItems = data.total || 0; + hasMore = newItems.length === itemsPerPage; + currentPage = page; } catch (error) { - console.error('Error searching classes:', error); - toast.error('Error al buscar clases'); - classes = []; + console.error('Error fetching classes:', error); + toast.error('Error al cargar clases'); } finally { isSearching = false; + isLoadingMore = false; } } - function loadMoreClasses() { - const start = 0; - const end = currentPage * itemsPerPage; - displayedClasses = filteredClasses.slice(start, end); - } - - function handleScroll(e: Event) { - const target = e.target as HTMLDivElement; - const threshold = 100; - const scrolledToBottom = - target.scrollHeight - target.scrollTop - target.clientHeight < threshold; - - if (scrolledToBottom && displayedClasses.length < filteredClasses.length) { - currentPage++; - loadMoreClasses(); - } - } + // Debounce effect + $effect(() => { + // Accedemos a searchQuery para que el efecto dependa de él + const query = searchQuery; + + const timeout = setTimeout(() => { + if (debouncedSearch !== query) { + debouncedSearch = query; + currentPage = 1; + fetchClasses(1, query); + } + }, 400); + + return () => clearTimeout(timeout); + }); function handleSelect(classItem: any) { - if (onSelect) { - onSelect(classItem); - } + if (onSelect) onSelect(classItem); open = false; } + + // Intersection Observer for Infinite Scroll + let observerNode: HTMLElement | null = $state(null); + + $effect(() => { + if (!observerNode || !hasMore || isSearching || isLoadingMore) return; + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + fetchClasses(currentPage + 1, debouncedSearch); + } + }, { threshold: 0.1 }); + + observer.observe(observerNode); + return () => observer.disconnect(); + }); + + // Reset state when opening + $effect(() => { + if (open) { + currentPage = 1; + searchQuery = ''; + debouncedSearch = ''; + fetchClasses(1, ''); + } + }); - - - Seleccionar Clase - Busca y selecciona una clase para la partida + + + Catálogo de Clases + + Selecciona una clase para asociarla a la partida. Los datos se cargarán automáticamente. + -
-
- +
+
+ + {#if isSearching} +
+ +
+ {/if}
-
- {#if isSearching} -
- -
- {:else} +
+
- - - Código - Descripción - U.M. - + + + Código + Descripción Española + U.M. + - {#if displayedClasses.length === 0} - - - No se encontraron clases + {#each classes as classItem (classItem.id)} + handleSelect(classItem)} + > + + {classItem.class_code} + + + {classItem.description_es || classItem.description_en || '-'} + + + + {classItem.unit_of_measure || '-'} + + + +
+ +
{:else} - {#each displayedClasses as classItem} - handleSelect(classItem)} - > - {classItem.class_code} - {classItem.description_es || classItem.description_en || '-'} - {classItem.unit_of_measure || '-'} - - + {#if !isSearching} + + +
+
+ +
+

No encontramos resultados

+

Intenta con otros términos de búsqueda

+
- {/each} + {/if} + {/each} + + + {#if hasMore} + + +
+ {#if isLoadingMore} +
+ + Cargando más resultados... +
+ {/if} +
+
+
{/if}
- {/if} +
- - + +
+ + Mostrando {classes.length} de {totalItems} clases +
+
+ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte index f6505bab..5c4fbda9 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-configuration.svelte @@ -9,12 +9,15 @@ let { lineItem = $bindable(), - descriptions = $bindable() + descriptions = $bindable(), + disabled = false }: { lineItem: Partial; descriptions: LineDescriptions; + disabled?: boolean; } = $props(); + let showPartDialog = $state(false); // Initialize fa_data for fixed asset system @@ -72,7 +75,8 @@
Is - + +
@@ -91,8 +95,10 @@ +
@@ -112,8 +118,10 @@ id="subitem_number" type="number" value={subitemNumber} + disabled={disabled} oninput={(e) => setSubitemNumber(e.currentTarget.valueAsNumber || 0)} class="h-7 text-xs" + placeholder="Enter main item number" />
@@ -130,16 +138,20 @@ type="text" value={(lineItem as any).part_number_display || ''} readonly + disabled={disabled} class="h-7 flex-1 cursor-pointer bg-muted text-xs" placeholder="Selecciona número de parte" - onclick={() => (showPartDialog = true)} + onclick={() => !disabled && (showPartDialog = true)} + />
@@ -157,8 +169,10 @@ +
@@ -166,8 +180,10 @@ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 6afc5f7a..b34b4c1d 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -8,7 +8,7 @@ import { Label } from '$lib/components/ui/label'; import { RadioGroup, RadioGroupItem } from '$lib/components/ui/radio-group'; import { Separator } from '$lib/components/ui/separator'; - import { Loader2, Package, Save, X, FileText, Folder } from 'lucide-svelte'; + import { Loader2, Package, Save, X, FileText, Folder, Calendar } from 'lucide-svelte'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import { invoicesApi } from '$lib/api/dashboard/a76/invoices'; import { itemsApi, type Item, type ImportLineWithBalance } from '$lib/api/dashboard/a76/items'; @@ -69,7 +69,7 @@ editingItem.fa_data.omit_annex31 = false; } if (editingItem.fa_data.discharge === undefined) { - editingItem.fa_data.discharge = true; + editingItem.fa_data.discharge = false; } } }); @@ -84,6 +84,7 @@ let importInvoiceLines = $state([]); let exportInvoiceLines = $state([]); let loadingImportLines = $state(false); + let loadingImportDetails = $state(false); let loadingExportLines = $state(false); async function loadImportLines(invoiceId: number) { @@ -96,7 +97,12 @@ const asOfDate = invoice?.invoice_date ? invoice.invoice_date.split('T')[0] : undefined; - const res = await itemsApi.listByInvoiceWithBalance(invoiceId, companyId, asOfDate); + const res = await itemsApi.listByInvoiceWithBalance( + invoiceId, + companyId, + asOfDate, + invoice?.id + ); importInvoiceLines = res.data ?? []; } catch { importInvoiceLines = []; @@ -137,6 +143,32 @@ } const visibility = $derived.by(() => getVisibility(invoiceType ?? invoice?.invoice_type, operationType ?? invoice?.operation_type)); + + // Proportional weight calculation (CALCULO_PESOS) + function recalculateWeights() { + if (!editingItem.quantity || !editingItem.fa_data?.discharge) return; + + const qty = Number(editingItem.quantity.quantity || 0); + const sourceQty = Number(editingItem.fa_data.source_quantity || 0); + const sourceNet = Number(editingItem.fa_data.source_net_weight || 0); + const sourceGross = Number(editingItem.fa_data.source_gross_weight || 0); + const sourcePackages = Number(editingItem.fa_data.source_packages || 0); + + if (sourceQty > 0) { + editingItem.quantity.net_weight = Number((qty * (sourceNet / sourceQty)).toFixed(8)); + editingItem.quantity.gross_weight = Number((qty * (sourceGross / sourceQty)).toFixed(8)); + editingItem.quantity.package_quantity = Math.floor(qty * (sourcePackages / sourceQty)); + } + } + + // Watch for quantity changes to recalculate proportional weights + $effect(() => { + const qty = editingItem.quantity?.quantity; + if (qty !== undefined && editingItem.fa_data?.discharge) { + recalculateWeights(); + } + }); + /** Show link-to-import block for import (CR tracking) or for export when showExportLinkToImportBlock. */ const showLinkToImportBlock = $derived.by(() => { const normalizedOperationType = operationType ?? invoice?.operation_type; @@ -216,6 +248,9 @@ } return visibility.showCrTrackingHeader; }); + + const isReadOnly = $derived(invoice?.status === 'processed'); + const isExport = $derived.by(() => { const op = operationType ?? invoice?.operation_type; return op === 1 || op === 'exp'; @@ -480,7 +515,7 @@

Datos Principales

-
+
{#if editingItem.quantity && editingItem.financial && editingItem.customs} {/if} +
@@ -504,6 +541,7 @@ {/if}
@@ -531,6 +569,7 @@ bind:customs={editingItem.customs} bind:quantities={editingItem.quantity} invoice={invoice} + disabled={isReadOnly} /> {/if} {#if editingItem.financial && editingItem.quantity} @@ -538,9 +577,12 @@ bind:financials={editingItem.financial} bind:quantities={editingItem.quantity} lineItem={editingItem} - {invoice} + invoice={invoice} + disabled={isReadOnly} /> {/if} + +
@@ -550,6 +592,7 @@ bind:lineItem={editingItem} bind:descriptions={editingItem.description} visibility={visibility} + disabled={isReadOnly} /> {/if} @@ -561,13 +604,19 @@ bind:series={editingItem.series} lineItem={editingItem} {invoice} + disabled={isReadOnly} /> {/if} {#if visibility.showLabelingTab} - + {/if} @@ -578,6 +627,7 @@ invoiceConsecutive={invoice?.id} invoiceNumber={invoice?.invoice_number ?? ''} {visibility} + disabled={isReadOnly} /> {/if} @@ -595,17 +645,20 @@
- + + {#if !isReadOnly} + + {/if}
@@ -613,10 +666,10 @@ {/each} -
-
+
+
- - - - - Seleccionar línea de importación -

- Solo se muestran líneas con saldo disponible -

+ + + + +
+
+ +
+ Partidas de Importación +
+ + Selecciona una línea con saldo disponible para realizar la descarga. +
- -
-
- {#if importInvoiceLines.every(l => !l.has_balance)} -

- No hay líneas con saldo disponible en esta factura. -

+ +
+ {#if loadingImportLines} +
+ +

Cargando partidas de la factura...

+
+ {:else if importInvoiceLines.every(l => !l.has_balance)} +
+ +

Sin saldo disponible

+

No hay líneas con saldo en esta factura para descargar.

+
{:else} - - - - - - - - - - - - - - - - - - - - {#each importInvoiceLines as lineItem} - {#if lineItem.has_balance} - { +
+ {#each importInvoiceLines as lineItem} + {#if lineItem.has_balance} +
- - - - - - - - - - - - - {/if} - {/each} - -
LíneaFacturaFechaNum. ParteClaseDescripciónCant. Imp.Ret. Temp.Ret. Def.Saldo Disp.EstatusSub.
{lineItem.line_number}{lineItem.invoice_number ?? '-'} - {lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : '-'} - {lineItem.part_number ?? '-'}{lineItem.class_code ?? '-'} - {lineItem.description_spanish ?? '-'} - - {lineItem.quantity != null ? lineItem.quantity.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} - {lineItem.unit_of_measure_code ?? ''} - - {lineItem.quantity_used_temp != null ? lineItem.quantity_used_temp.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} - - {lineItem.quantity_used_def != null ? lineItem.quantity_used_def.toLocaleString('es-MX', { maximumFractionDigits: 4 }) : '-'} - - {lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })} - {lineItem.unit_of_measure_code ?? ''} - + } catch (err) { + console.error('Error auto-filling import info:', err); + } finally { + loadingImportDetails = false; + } + }} + > + +
+
+
+ Línea {lineItem.line_number} +
+
+ Factura: {lineItem.invoice_number ?? '-'} +
+
+
{#if lineItem.invoice_status === 'processed'} - + Procesada - {:else if lineItem.invoice_status === 'reversed'} - - Revertida - - {:else} - - {lineItem.invoice_status ?? 'Pendiente'} - {/if} -
{#if lineItem.is_subitem} - - Sub + + Subpartida - {:else if lineItem.contains_subitems} - - {lineItem.subitem_count ?? 0} sub - - {:else} - {/if} -
+
+
+ + +
+ +
+

Número de Parte / Clase

+

+ {lineItem.part_number ?? '-'} +

+

+ {lineItem.class_code ?? 'Sin Clase'} +

+
+ + +
+

Descripción

+

+ {lineItem.description_spanish || 'Sin descripción'} +

+
+ + +
+
+ Cant. Imp: + + {lineItem.quantity?.toLocaleString() || '0'} {lineItem.unit_of_measure_code || ''} + +
+
+ Desc. Temp: + + -{lineItem.quantity_used_temp?.toLocaleString() || '0'} + +
+
+ + +
+

Saldo Disponible

+
+ + {lineItem.available_balance.toLocaleString('es-MX', { maximumFractionDigits: 4 })} + + + {lineItem.unit_of_measure_code ?? ''} + +
+
+
+ +
+
+ + Fecha: {lineItem.invoice_date ? lineItem.invoice_date.slice(0, 10) : 'N/A'} +
+
+ Hacer descarga → +
+
+ + {/if} + {/each} +
{/if} - + + +

+ Mostrando {importInvoiceLines.filter(l => l.has_balance).length} partidas con saldo +

+ +
\ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte index f279e7cb..04e6fcaf 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/main-data.svelte @@ -16,15 +16,20 @@ quantities = $bindable(), financials = $bindable(), customs = $bindable(), - invoice + invoice, + disabled = false }: { lineItem: Partial; quantities: LineQuantities; financials: LineFinancials; customs: LineCustoms; invoice: Invoice | null; + disabled?: boolean; } = $props(); + + const activeCompanyId = $derived(companyStore?.activeCompany?.id); + let showClassDialog = $state(false); let showUnitDialog = $state(false); let showCountryDialog = $state(false); @@ -47,6 +52,8 @@ return frac; }); + let isDischargeActive = $derived(lineItem.fa_data?.discharge === true); + // Track previous class_id to detect changes let previousClassId = $state(undefined); @@ -132,8 +139,17 @@ $effect(() => { const currentClassId = lineItem.class_id; const activeCompanyId = companyStore?.activeCompany?.id; + + // Initial load protection: If previousClassId is undefined, this is the first run. + // We set previousClassId to the current value without fetching catalog defaults + // if we are opening an existing record that already has a class. + if (previousClassId === undefined && currentClassId) { + previousClassId = currentClassId; + return; + } - // Only fetch if class_id changed, is valid, and we have a company + // Only fetch and apply defaults if class_id changed from a previous value, + // is valid, and we have a company. if (currentClassId && currentClassId !== previousClassId && activeCompanyId) { previousClassId = currentClassId; @@ -188,40 +204,124 @@ // Auto-fetch historical tariff rate when fraction, country, type, and date are available $effect(() => { const fraction = customs.fraction?.replace(/\./g, '') || ''; - const nico = fraction.substring(8, 10); - const fractionType = customs.fraction_type; + const nico = fraction.length >= 10 ? fraction.substring(8, 10) : '00'; + const tariffType = customs.fraction_type || 'GENERAL'; const invoiceDate = invoice?.invoice_date; + + // Map invoice movement type to direction string + const direction = invoice?.operation_type === 'imp' ? 'import' : 'export'; // Only fetch if all required fields are present and fraction has at least 8 chars - if (fraction && fraction.length >= 8 && nico && fractionType && invoiceDate) { + if (activeCompanyId && fraction && fraction.length >= 8 && tariffType && invoiceDate) { const historicalFraction = fraction.substring(0, 8); + const isRegimeChange = invoice?.compliance_mx?.is_regime_change ? 'true' : 'false'; + + // Format date to YYYY-MM-DD + let formattedDate = ''; + if (invoiceDate) { + const dateObj = typeof invoiceDate === 'string' ? new Date(invoiceDate) : invoiceDate; + formattedDate = dateObj.toISOString().split('T')[0]; + } + + if (!formattedDate) return; + const params = new URLSearchParams({ + company_id: activeCompanyId.toString(), historical_fraction: historicalFraction, nico: nico, - fraction_type: fractionType, - invoice_date: invoiceDate + direction: direction, + tariff_type: tariffType, + invoice_date: formattedDate, + is_regime_change: isRegimeChange }); fetch(`/api-sveltekit/historical-tariff-fractions/rate?${params}`) - .then(response => { + .then(async response => { if (response.ok) { return response.json(); } - throw new Error('Failed to fetch tariff rate'); + // If 422 or other error, try to extract the specific detail from FastAPI + let errorMsg = `HTTP ${response.status}`; + try { + const errData = await response.json(); + if (errData.detail) { + errorMsg = typeof errData.detail === 'string' ? errData.detail : JSON.stringify(errData.detail); + } else if (errData.details || errData.error) { + errorMsg = errData.details || errData.error; + } + } catch (e) { + errorMsg = await response.text().catch(() => `Error ${response.status}`); + } + + console.warn('Historical tariff lookup failed at backend:', errorMsg); + return { found: false, rate: 0 }; }) .then(data => { - if (data.found && data.rate !== null) { - customs.rate = data.rate; + if (data && data.found && data.rate !== null) { + customs.rate = String(data.rate).substring(0, 10); } else { customs.rate = '0'; } }) .catch(error => { - console.error('Error fetching historical tariff rate:', error); - // Keep current value on error + console.warn('Historical tariff lookup network error:', error); }); } }); + + /** + * Centralized calculation logic based on the Clarion routine + * @param source - Which field triggered the update + */ + function recalculateFinancials(source: 'total' | 'unit_cost' | 'quantity') { + const qty = Number(quantities.quantity || 0); + const exchangeRate = Number(invoice?.financials?.exchange_rate || 1); + const exchangeRateMM = Number(invoice?.financials?.exchange_rate_mm || 1); + const ivaFactor = Number(invoice?.financials?.iva_factor || 0); + const currencyType = invoice?.financials?.currency_type || 'USD'; + + // 1. Synchronize Unit Cost and Total + if (source === 'total') { + if (qty > 0) { + financials.unit_cost_capture = Number(financials.value_mc || 0) / qty; + } + } else { + // source is unit_cost or quantity + financials.value_mc = Number(financials.unit_cost_capture || 0) * qty; + } + + const unitCostCapture = Number(financials.unit_cost_capture || 0); + const valueCapture = Number(financials.value_mc || 0); + + // 2. Perform Triangulation based on Currency Type + if (currencyType === 'ME' || currencyType === 'FOREIGN' || currencyType === 'USD') { + financials.unit_cost_usd = unitCostCapture; + financials.unit_cost_mxn = unitCostCapture * exchangeRate; + financials.value_usd = valueCapture; + financials.value_mxn = valueCapture * exchangeRate; + } else if (currencyType === 'MN' || currencyType === 'LOCAL' || currencyType === 'MXN') { + financials.unit_cost_mxn = unitCostCapture; + financials.unit_cost_usd = exchangeRate > 0 ? unitCostCapture / exchangeRate : 0; + financials.value_mxn = valueCapture; + financials.value_usd = exchangeRate > 0 ? valueCapture / exchangeRate : 0; + } else if (currencyType === 'MC') { + financials.unit_cost_mc = unitCostCapture; + // Clarion logic for MC: + // 1. Convert Capture to USD using exchangeRateMM (TipoCambioMM) + financials.unit_cost_usd = unitCostCapture * exchangeRateMM; + // 2. Convert resulting USD to MXN using exchangeRate (TipoCambio) + financials.unit_cost_mxn = financials.unit_cost_usd * exchangeRate; + + financials.value_mc = valueCapture; + financials.value_usd = financials.unit_cost_usd * qty; + financials.value_mxn = financials.unit_cost_mxn * qty; + } + + // 3. VAT Calculation + financials.vat_mxn = (Number(financials.value_mxn || 0) * ivaFactor) / 100; + financials.vat_usd = (Number(financials.value_usd || 0) * ivaFactor) / 100; + financials.vat_mc = (Number(financials.value_mc || 0) * ivaFactor) / 100; + } @@ -242,16 +342,19 @@ type="text" value={(lineItem as any).class_code || ''} readonly + disabled={disabled || isDischargeActive} class="h-8 text-xs flex-1 bg-muted cursor-pointer" placeholder="Selecciona una clase" - onclick={() => (showClassDialog = true)} + onclick={() => !disabled && !isDischargeActive && (showClassDialog = true)} /> @@ -263,7 +366,22 @@
- + recalculateFinancials('quantity')} + class="h-8 text-xs text-right" + /> + + {#if isDischargeActive && lineItem.fa_data?.source_balance !== undefined && Number(quantities.quantity) > Number(lineItem.fa_data.source_balance)} +

+ ⚠️ Excede saldo disponible ({lineItem.fa_data.source_balance}) +

+ {/if}
@@ -274,18 +392,21 @@ type="text" value={(lineItem as any).unit_code || ''} readonly + disabled={disabled || isDischargeActive} class="h-8 text-xs flex-1 bg-muted cursor-pointer" placeholder="Selecciona U.M." - onclick={() => (showUnitDialog = true)} + onclick={() => !disabled && !isDischargeActive && (showUnitDialog = true)} /> +
@@ -293,8 +414,36 @@
- - USD + recalculateFinancials('unit_cost')} + class="h-8 text-xs text-right flex-1" + /> + + {invoice?.financials?.currency_type || 'USD'} +
+
+ +
+ +
+ recalculateFinancials('total')} + class="h-8 text-xs text-right flex-1" + /> + + {invoice?.financials?.currency_type || 'USD'}
@@ -305,18 +454,21 @@ id="fraccion" value={fractionDisplay()} readonly + disabled={disabled} class="h-8 text-xs text-center flex-1 bg-muted cursor-pointer" placeholder="Selecciona fracción" - onclick={() => (showFractionDialog = true)} + onclick={() => !disabled && (showFractionDialog = true)} /> + @@ -328,30 +480,34 @@ id="pais_origen" value={customs.origin_country || ''} readonly + disabled={disabled} class="h-8 text-xs flex-1 bg-muted cursor-pointer" placeholder="Selecciona país" - onclick={() => (showCountryDialog = true)} + onclick={() => !disabled && (showCountryDialog = true)} /> +
- +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte index 61c68f09..32d494c3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte @@ -3,7 +3,8 @@ import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; import { Folder } from 'lucide-svelte'; - import type { Item, LineItem, LineDescriptions, LineCustoms, LineQuantities } from '$lib/api/dashboard/a76/items'; + import type { Item, LineDescriptions, LineCustoms, LineQuantities } from '$lib/api/dashboard/a76/items'; + import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import PackageDialog from './package-dialog.svelte'; @@ -13,16 +14,20 @@ descriptions = $bindable(), customs = $bindable(), quantities = $bindable(), - invoice + invoice, + disabled = false }: { item: Partial; - lineItem: LineItem; + lineItem: any; descriptions: LineDescriptions; customs: LineCustoms; quantities: LineQuantities; invoice: Invoice | null; + disabled?: boolean; } = $props(); + + let packageDialogOpen = $state(false); let package_key = $state(''); let package_weight_unit = $state(0); @@ -117,8 +122,9 @@
- +
+
@@ -127,17 +133,22 @@ bind:value={package_key} class="h-7 text-xs flex-1" readonly + disabled={disabled} placeholder="Seleccionar..." + onclick={() => !disabled && (packageDialogOpen = true)} /> + +
@@ -159,12 +170,14 @@
- +
+
- +
+
{weightUnitLabel} @@ -175,19 +188,22 @@
- +
+
- +
+
- +
+
Advalorem: {customs.advalorem_american || '0.00'} @@ -197,16 +213,19 @@
- +
+
- +
+
- +
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte index bcb53791..c60d96a6 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/part-number-dialog.svelte @@ -3,7 +3,7 @@ import * as Table from '$lib/components/ui/table'; import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; - import { Search, Loader2 } from 'lucide-svelte'; + import { Search, Loader2, Info, Package } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; @@ -16,160 +16,227 @@ } = $props(); let searchQuery = $state(''); + let debouncedSearch = $state(''); let isSearching = $state(false); + let isLoadingMore = $state(false); let parts = $state([]); - let displayedParts = $state([]); let currentPage = $state(1); - let itemsPerPage = 10; + let hasMore = $state(true); + let totalItems = $state(0); + const itemsPerPage = 25; - const filteredParts = $derived( - searchQuery - ? parts.filter(p => - p.part_number?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.description_spanish?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.description_english?.toLowerCase().includes(searchQuery.toLowerCase()) - ) - : parts - ); - - $effect(() => { - if (open) { - searchParts(); - } - }); - - $effect(() => { - currentPage = 1; - loadMoreParts(); - }); - - async function searchParts() { + async function fetchParts(page: number = 1, search: string = '') { const activeCompanyId = companyStore?.activeCompany?.id; - if (!activeCompanyId) { - toast.error('No hay compañía activa'); - return; - } + if (!activeCompanyId) return; + + if (page === 1) isSearching = true; + else isLoadingMore = true; - isSearching = true; try { - const response = await fetch( - `/api-sveltekit/parts?company_id=${activeCompanyId}&limit=100`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - } - } - ); + const params = new URLSearchParams({ + company_id: activeCompanyId.toString(), + page: page.toString(), + page_size: itemsPerPage.toString(), + sort_by: 'part_number', + sort_order: 'asc' + }); - if (!response.ok) { - throw new Error('Error al buscar números de parte'); + if (search) { + params.append('q', search); } + const response = await fetch(`/api-sveltekit/parts?${params.toString()}`); + if (!response.ok) throw new Error('Error al buscar números de parte'); + const data = await response.json(); - parts = data.items || []; - loadMoreParts(); + const newItems = data.items || []; + + if (page === 1) { + parts = newItems; + } else { + parts = [...parts, ...newItems]; + } + + totalItems = data.total || 0; + hasMore = newItems.length === itemsPerPage; + currentPage = page; } catch (error) { - console.error('Error searching parts:', error); - toast.error('Error al buscar números de parte'); - parts = []; + console.error('Error fetching parts:', error); + toast.error('Error al cargar números de parte'); } finally { isSearching = false; + isLoadingMore = false; } } - function loadMoreParts() { - const start = 0; - const end = currentPage * itemsPerPage; - displayedParts = filteredParts.slice(start, end); - } - - function handleScroll(e: Event) { - const target = e.target as HTMLDivElement; - const threshold = 100; - const scrolledToBottom = target.scrollHeight - target.scrollTop - target.clientHeight < threshold; + // Debounce effect + $effect(() => { + // Accedemos a searchQuery para que el efecto dependa de él + const query = searchQuery; - if (scrolledToBottom && displayedParts.length < filteredParts.length) { - currentPage++; - loadMoreParts(); - } - } + const timeout = setTimeout(() => { + if (debouncedSearch !== query) { + debouncedSearch = query; + currentPage = 1; + fetchParts(1, query); + } + }, 400); + + return () => clearTimeout(timeout); + }); function handleSelect(part: any) { - if (onSelect) { - onSelect(part); - } + if (onSelect) onSelect(part); open = false; } + + // Intersection Observer for Infinite Scroll + let observerNode: HTMLElement | null = $state(null); + + $effect(() => { + if (!observerNode || !hasMore || isSearching || isLoadingMore) return; + + const observer = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting) { + fetchParts(currentPage + 1, debouncedSearch); + } + }, { threshold: 0.1 }); + + observer.observe(observerNode); + return () => observer.disconnect(); + }); + + // Reset state when opening + $effect(() => { + if (open) { + currentPage = 1; + searchQuery = ''; + debouncedSearch = ''; + fetchParts(1, ''); + } + }); - - - Seleccionar Número de Parte - - Busca y selecciona un número de parte para la partida + + +
+
+ +
+ Números de Parte +
+ + Busca y selecciona un número de parte del inventario maestro.
-
-
- +
+
+ + {#if isSearching} +
+ +
+ {/if}
-
- {#if isSearching} -
- -
- {:else} +
+
- - - Número de Parte - Descripción (ES) - Descripción (EN) - Clase - + + + Número de Parte + Descripción (ES) + Descripción (EN) + Clase + - {#if displayedParts.length === 0} - - - No se encontraron números de parte + {#each parts as part (part.id)} + handleSelect(part)} + > + + {part.part_number} + + +
+ {part.description_spanish || '-'} +
+
+ +
+ {part.description_english || '-'} +
+
+ + + {part.part_class || '-'} + + + +
+ +
{:else} - {#each displayedParts as part} - handleSelect(part)}> - {part.part_number} - {part.description_spanish || '-'} - {part.description_english || '-'} - {part.part_class || '-'} - - + {#if !isSearching} + + +
+
+ +
+

No hay resultados para esta búsqueda

+

Verifica el número de parte o la descripción

+
- {/each} + {/if} + {/each} + + + {#if hasMore} + + +
+ {#if isLoadingMore} +
+ + Cargando más números de parte... +
+ {/if} +
+
+
{/if}
- {/if} +
- -

- Mostrando {displayedParts.length} de {filteredParts.length} resultados -

+ +
+ + Mostrando {parts.length} de {totalItems} registros +
+
+ +
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte index 60317a0c..dc0479bf 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte @@ -8,14 +8,17 @@ financials = $bindable(), quantities = $bindable(), lineItem, - invoice + invoice, + disabled = false }: { financials: LineFinancials; quantities: LineQuantities; lineItem?: Partial; invoice?: Invoice | null; + disabled?: boolean; } = $props(); + // Helper function to safely format numbers function formatNumber(value: any, decimals: number = 8): string { const num = Number(value); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte index d071bc47..b48c1ab8 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte @@ -13,13 +13,16 @@ let { lineItem = $bindable(), descriptions = $bindable(), - visibility + visibility, + disabled = false }: { lineItem: Partial; descriptions: LineDescriptions; visibility: InvoiceItemVisibility; + disabled?: boolean; } = $props(); + let taxPaidValue = $derived(lineItem.tax_payment ? 'si' : 'no'); function setTaxPaid(val: string) { lineItem.tax_payment = val === 'si'; @@ -108,7 +111,9 @@ +
@@ -123,17 +128,19 @@
- +
+
@@ -145,12 +152,13 @@
- +
- +
+
{/if} @@ -159,17 +167,18 @@
FDA / FCC
- +
- +
- +
+
{/if} @@ -182,7 +191,9 @@ +
@@ -195,10 +206,11 @@
- + - +
+
{/if} @@ -209,22 +221,24 @@
- +
- +
+
{/if} @@ -238,11 +252,13 @@ +
@@ -257,21 +273,23 @@ {#if visibility.showContinuationMilitary}
- +
+ {/if} - {#if visibility.showContinuationOwnOmitAnnex} + {#if visibility.showContinuationOwnOmitAnnex && lineItem.fa_data}
- +
- +
+
{/if} @@ -280,12 +298,13 @@
- +
- +
+
{/if}
@@ -298,28 +317,30 @@
- +
- +
- +
+
{/if} {#if visibility.showContinuationConsiderA31}
- +
+ {/if} {#if visibility.showContinuationExtraDescription} @@ -328,10 +349,12 @@
+ {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte index 9600aa83..0f88ad96 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-identifiers.svelte @@ -14,14 +14,17 @@ lineItem = $bindable(), invoiceConsecutive = undefined, invoiceNumber = '', - visibility = { showMexicanIdEnhanced: false } + visibility = { showMexicanIdEnhanced: false }, + disabled = false }: { lineItem: Partial, invoiceConsecutive?: number, invoiceNumber?: string, - visibility?: any + visibility?: any, + disabled?: boolean } = $props(); + // Initialize identifiers if not present if (!lineItem.identifiers) { lineItem.identifiers = []; @@ -148,12 +151,13 @@
-
+
@@ -162,9 +166,12 @@ Num. Factura Línea Imagen - Acciones + {#if !disabled} + Acciones + {/if} + {#if lineItem.series && lineItem.series.length > 0} {#each lineItem.series as asset, index} @@ -181,17 +188,21 @@ - {/if} - -
- - -
-
+ {#if !disabled} + + +
+ + +
+
+ {/if} + {/each} {:else} @@ -212,12 +223,13 @@
-
+
@@ -226,9 +238,12 @@ Compl. 1 Compl. 2 Compl. 3 - Acciones + {#if !disabled} + Acciones + {/if} + {#if lineItem.identifiers && lineItem.identifiers.length > 0} {#each lineItem.identifiers as idDetail, index} @@ -237,17 +252,20 @@ {idDetail.complement1 || '-'} {idDetail.complement2 || '-'} {idDetail.complement3 || '-'} - -
- - -
-
+ {#if !disabled} + +
+ + +
+
+ {/if} + {/each} {:else} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte index bc9d3eb9..2e475913 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-labeling.svelte @@ -10,13 +10,16 @@ let { lineItem = $bindable(), descriptions = $bindable(), - visibility + visibility, + disabled = false }: { lineItem: Partial, descriptions: LineDescriptions, - visibility: any + visibility: any, + disabled?: boolean } = $props(); + // Ensure series is an array if (!lineItem.series) { lineItem.series = []; @@ -90,12 +93,13 @@
- +
- +
+
{/if} @@ -108,9 +112,11 @@ id="cantidad_importar" type="number" bind:value={lineItem.quantity!.quantity} + disabled={disabled} class="h-7 text-xs" />
+ {/if} {#if visibility.showLabelingValuationValue}
@@ -120,9 +126,11 @@ type="number" step="0.00000001" bind:value={lineItem.valuation_determined_value} + disabled={disabled} class="h-7 text-xs" />
+ {/if} {/if}
@@ -135,6 +143,7 @@ @@ -142,12 +151,14 @@ size="icon" variant="outline" class="h-7 w-7" - onclick={() => valuationSelectorOpen = true} + disabled={disabled} + onclick={() => (valuationSelectorOpen = true)} >
+ {/if} {#if visibility.showUsageReason} @@ -156,10 +167,12 @@ + {/if} {/if} @@ -170,10 +183,12 @@ + {/if} {/if} @@ -184,11 +199,12 @@ Assets / Series
-
+
@@ -197,9 +213,12 @@ Asset Num Factura Línea - Acc + {#if !disabled} + Acc + {/if} + {#each lineItem.series || [] as asset, i} @@ -207,17 +226,20 @@ {asset.number_id || '-'} {asset.import_invoice || '-'} {asset.import_line || '-'} - -
- - -
-
+ {#if !disabled} + +
+ + +
+
+ {/if}
+ {:else} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte index 9e93e649..5ede683c 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-series.svelte @@ -4,22 +4,27 @@ import { Checkbox } from '$lib/components/ui/checkbox'; import { Label } from '$lib/components/ui/label'; import { Button } from '$lib/components/ui/button'; - import { Plus, Pencil, Trash2, CheckCircle2 } from 'lucide-svelte'; - import type { Item, LineDescriptions, Serie } from '$lib/api/dashboard/a76/items'; + import { Plus, Pencil, Trash2, CheckCircle2, AlertTriangle } from 'lucide-svelte'; + import { itemsApi, type Item, type LineDescriptions, type Serie } from '$lib/api/dashboard/a76/items'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; + import { companyStore } from '$lib/stores/company.svelte'; + import { toast } from 'svelte-sonner'; let { descriptions = $bindable(), series = $bindable(), lineItem, - invoice + invoice, + disabled = false }: { descriptions: LineDescriptions; series: Serie[] | Serie; lineItem: Partial; invoice: Invoice | null; + disabled?: boolean; } = $props(); + // Normalize to array for display and mutations const seriesList = $derived( Array.isArray(series) ? series : series != null ? [series] : [] @@ -70,6 +75,28 @@ } } + async function clearAllSeries() { + if (seriesList.length === 0) return; + + const confirmed = confirm(`¿Estás seguro de que deseas borrar TODAS las series de esta partida? Esta acción no se puede deshacer.`); + if (!confirmed) return; + + try { + // Si la partida ya existe en la DB, llamamos al endpoint de borrado físico + if (lineItem.id && companyStore.activeCompany?.id) { + await itemsApi.deleteSeries(lineItem.id, companyStore.activeCompany.id); + toast.success('Series eliminadas correctamente y registrado en bitácora.'); + } + + // Limpiar el estado local + series = []; + selectedSeriesIndex = null; + } catch (err) { + console.error('Error clearing series:', err); + toast.error('Error al intentar borrar las series.'); + } + } + // Current serie being edited (reference into the array) const currentSerie = $derived( selectedSeriesIndex !== null && seriesList[selectedSeriesIndex] != null @@ -149,21 +176,39 @@ { if (descriptions) descriptions.has_serial = v; }} /> + +
+
+ + +
- @@ -191,9 +236,10 @@ hasSerial && selectForEdit(i)} + : ''} {!hasSerial || disabled ? 'opacity-70' : ''}" + onclick={() => hasSerial && !disabled && selectForEdit(i)} > + {serie.row ?? i + 1} {serie.serial_numbers || '-'} @@ -211,12 +257,13 @@ variant="ghost" size="icon" class="h-7 w-7 text-blue-600" - disabled={!hasSerial} + disabled={disabled || !hasSerial} onclick={(e) => { e.stopPropagation(); - if (hasSerial) selectForEdit(i); + if (hasSerial && !disabled) selectForEdit(i); }} > + @@ -260,12 +308,19 @@ class="h-7 text-xs bg-emerald-600 hover:bg-emerald-700 text-white gap-1" onclick={clearSelection} > - - Aceptar / Listo - - + {#if !disabled} + + {/if} +
@@ -296,9 +351,10 @@ type="number" min={1} step={1} - disabled={!hasSerial} + disabled={disabled || !hasSerial} onblur={clampRowToInteger} /> +
@@ -308,9 +364,10 @@ class="h-8 text-sm focus:ring-primary" maxlength={50} placeholder="Número de serie..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'serial_numbers')} /> +
@@ -320,9 +377,10 @@ class="h-8 text-sm focus:ring-primary" maxlength={50} placeholder="Modelo..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'model')} /> +
@@ -341,9 +399,10 @@ class="h-8 text-sm focus:ring-primary" maxlength={50} placeholder="Sub modelo..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'sub_model')} /> +
@@ -353,23 +412,27 @@ class="h-8 text-sm focus:ring-primary" maxlength={25} placeholder="Número ID..." - disabled={!hasSerial} + disabled={disabled || !hasSerial} oninput={(e) => handleSerieInput(e, 'number_id')} /> +
-
- -
+ {#if !disabled} +
+ +
+ {/if} + {/if} diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index dd6eb2b2..65ad9610 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -1,5 +1,6 @@