From d72edda61c1399465b3654a5a01d55e4a092b49b Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 3 Mar 2026 09:08:49 -0600 Subject: [PATCH 1/5] Generacion de archivos winsaai --- .../reports/importacion/winsaai/__init__.py | 0 .../importacion/winsaai/invoices/__init__.py | 0 .../importacion/winsaai/invoices/routes.py | 78 +++ .../importacion/winsaai/invoices/schemas.py | 20 + .../importacion/winsaai/invoices/service.py | 340 +++++++++++++ .../importacion/winsaai/invoices/task.py | 62 +++ .../winsaai/pedimentos/__init__.py | 0 .../importacion/winsaai/pedimentos/routes.py | 75 +++ .../importacion/winsaai/pedimentos/schemas.py | 20 + .../importacion/winsaai/pedimentos/service.py | 460 ++++++++++++++++++ .../importacion/winsaai/pedimentos/task.py | 53 ++ .../a76/reports/importacion/winsaai/router.py | 9 + backend/api/v1/modules/a76/router.py | 7 + backend/core/celery_app.py | 6 +- frontend/messages/es.json | 2 +- .../dashboard/a76/reports/reports-winsaai.ts | 86 ++++ .../ui/dialog/dialog-content.svelte | 20 +- .../src/lib/components/ui/dialog/index.ts | 6 +- frontend/src/lib/components/ui/sheet/index.ts | 4 +- .../routes/dashboard/invoices/+page.svelte | 76 +++ .../routes/dashboard/pedimentos/+page.svelte | 137 +++++- 21 files changed, 1441 insertions(+), 20 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py create mode 100644 backend/api/v1/modules/a76/reports/importacion/winsaai/router.py create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/__init__.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/__init__.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py new file mode 100644 index 00000000..5d2062ef --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/routes.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, Depends, BackgroundTasks +from sqlalchemy.orm import Session +from core.database import get_core_db +from .schemas import WinsaaiGenerationRequest, WinsaaiResponse +from .task import generate_winsaai_task +from core.celery_app import celery_app +from celery.result import AsyncResult +from typing import Dict, Any +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["WINSAAI"]) + +@router.post("/generate", response_model=WinsaaiResponse) +async def trigger_winsaai_generation( + request: WinsaaiGenerationRequest, + db: Session = Depends(get_core_db) +): + task = generate_winsaai_task.delay(request.invoice_ids, request.is_temporal) + return WinsaaiResponse( + task_id=task.id, + status="PENDING", + message="WINSAAI generation task started" + ) + +@router.get("/status/{task_id}") +async def get_task_status(task_id: str): + logger.info(f"Polling status for WINSAAI task {task_id}") + task_result = AsyncResult(task_id, app=celery_app) + + try: + state = task_result.state + info = task_result.info + logger.debug(f"Task {task_id} state: {state}, info: {info}") + except Exception as e: + logger.error(f"Error polling task {task_id}: {str(e)}") + return {"task_id": task_id, "state": "FAILURE", "result": str(e), "info": None} + + response = { + "task_id": task_id, + "state": state, + "result": None, + "info": None + } + + if state == 'FAILURE': + # Safely try to get result, but handle cases where it can't be deserialized + try: + # result = task_result.result # This might trigger the ValueError + # Better: if it's FAILURE, just report it as failed and maybe look into info/meta + response["result"] = "Task failed. Check worker logs for details." + if isinstance(info, dict) and 'error' in info: + response["result"] = info['error'] + except Exception: + response["result"] = "Task failed (error reading exception details)" + elif state == 'SUCCESS': + response["result"] = task_result.result + elif state in ['PROCESSING', 'PROGRESS']: + response["info"] = info + + return response + +@router.get("/download/{task_id}") +async def download_winsaai_file(task_id: str): + res = AsyncResult(task_id) + if not res.ready(): + return {"error": "Task not ready"} + + result = res.result + if isinstance(result, dict) and "filepath" in result: + from fastapi.responses import FileResponse + return FileResponse( + path=result["filepath"], + filename=result["filename"], + media_type="text/plain" + ) + return {"error": "File not found"} diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py new file mode 100644 index 00000000..a41d017f --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/schemas.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +class WinsaaiGenerationRequest(BaseModel): + invoice_ids: List[int] = Field(..., description="List of invoice IDs to process") + is_temporal: bool = Field(True, description="Whether to generate as temporal (SCAII) or definitive") + +class ErrorValidacion(BaseModel): + partida: Optional[int] = None + linea: int + descripcion: str + soluciones: Optional[str] = None + identificador: Optional[str] = None + +class WinsaaiResponse(BaseModel): + task_id: str + status: str = "PENDING" + message: str = "WINSAAI generation task started" + validation_errors: List[ErrorValidacion] = [] diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py new file mode 100644 index 00000000..c00cafce --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/service.py @@ -0,0 +1,340 @@ +""" +Invoice WINSAAI Service – Legacy Format +Generates the legacy WINSAAI .txt file that matches the output of the +original Clarion system, used with the WINSAAI utility. + +Format reference (from real system example): + 501|1|REGIME|PED_NUM|ADUANA|IGI|IVA|DTA|PREVAL|OTROS|WEIGHT|BULTOS|7|7|7||... + 503||GUIA|| + 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VALOR_ME|VALOR_MN|...|CANT_LINES|SOLD_TO_SHORT|SOLD_TO_NAME| + 551|FRACCION|DESC|VAL_MN|VAL_ME|CANT|UM|CANT_TAR|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||... + 558|| + 999| +""" + +import re +from typing import List, Optional +from sqlalchemy.orm import Session, joinedload + +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics, +) +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_financials.models import LineFinancial +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.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.parts.models import Part +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.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.pedmientos.models.pedimento_contributions import PedimentoContributions + + +# ───────────────────────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _s(val) -> str: + """Safe string conversion – return '' for None.""" + if val is None: + return "" + return str(val).strip() + + +def _n(val, decimals: int = 0) -> str: + """Format numeric value without leading zeros, with optional decimal places.""" + if val is None: + return "0" + try: + f = float(val) + if decimals: + return f"{f:.{decimals}f}" + else: + # Integer representation, no decimals + return str(int(f)) + except (TypeError, ValueError): + return "0" + + +def _fmt_date_ymd(dt) -> str: + """Format as YYYYMMDD (legacy WINSAAI style).""" + if not dt: + return "" + return dt.strftime("%Y%m%d") + + +def _clean(text: str) -> str: + """Remove pipes and line breaks that would corrupt the pipe-delimited format.""" + if not text: + return "" + text = re.sub(r'[\r\n]+', ' ', text) + text = text.replace('|', '/') + return text.strip() + + +def _sanitize_filename(name: str) -> str: + return re.sub(r'[/\\:*?"<>|]', '', name) + + +# ───────────────────────────────────────────────────────────────────────────── +# SERVICE +# ───────────────────────────────────────────────────────────────────────────── + +class WinsaaiService: + def __init__(self, db: Session, company_id: int, tenant_id: int): + self.db = db + self.company_id = company_id + self.tenant_id = tenant_id + + # ── ENTRY POINT ────────────────────────────────────────────────────────── + + def generate_winsaai_data(self, invoice_ids: List[int], is_temporal: bool) -> List[str]: + all_lines: List[str] = [] + for inv_id in invoice_ids: + invoice = ( + self.db.query(InvoiceHeader) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + joinedload(InvoiceHeader.details), + ) + .filter(InvoiceHeader.id == inv_id) + .first() + ) + if not invoice: + continue + all_lines.extend(self._gen_legacy(invoice)) + # Terminator + all_lines.append("999|") + return all_lines + + # ── MAIN GENERATOR ─────────────────────────────────────────────────────── + + def _gen_legacy(self, inv: InvoiceHeader) -> List[str]: + lines: List[str] = [] + + comp: Optional[InvoiceComplianceMx] = inv.compliance_mx + fin: Optional[InvoiceFinancials] = inv.financials + log: Optional[InvoiceLogistics] = inv.logistics + + # ── Pedimento ──────────────────────────────────────────────────────── + ped: Optional[Pedimentos] = None + if comp and comp.pedimento_id: + ped = self.db.query(Pedimentos).filter(Pedimentos.id == comp.pedimento_id).first() + + ped_full = (ped.pedimento_number or "") if ped else "" + # Clarion: SUB(Pedimento,4,4) → chars 3-7 = license/patente + # SUB(Pedimento,9,7) → after hyphen = 7-digit number + # Legacy example uses just the number portion (the integer part) + # We'll parse: aduana(3) + año(2) + patente(4) + num(7) or use raw sub-strings + ped_aduana = _s(ped.customs_office) if ped else "" + ped_regime = _s(ped.regime) if ped else "" + + # Try to extract 7-digit number from pedimento_number + # Formats: "010 IN 3130441" or "01023130441" + ped_num_raw = "".join(filter(str.isdigit, ped_full)) + if len(ped_num_raw) >= 7: + ped_num_short = ped_num_raw[-7:] # last 7 digits (the folio) + else: + ped_num_short = ped_num_raw + + # ── Partners ───────────────────────────────────────────────────────── + sold_to = self._get_partner(comp.sold_to_id) if comp and comp.sold_to_id else None + provider = self._get_partner(comp.provider_id) if comp and comp.provider_id else None + + # ── Financial totals ───────────────────────────────────────────────── + tc = float(fin.exchange_rate or 1) if fin else 1.0 + currency = _s(fin.currency_type) if fin else "USD" + if currency.upper() in ("MXP", "MXN"): + currency = "MXP" + else: + currency = "USD" + + valor_me_hdr = float(fin.value_me or 0) if fin else 0.0 + freight = float(fin.freight or 0) if fin else 0.0 + insurance = float(fin.insurance or 0) if fin else 0.0 + gross_wt_hdr = float(fin.gross_weight or 0) if fin else 0.0 + bundles = int(fin.bundle_count or 0) if fin else 0 + incoterm = _s(log.incoterm) if log else "" + + # ── Items (needed early for summing) ───────────────────────────────── + items = ( + self.db.query(LineItem) + .filter(LineItem.invoice_id == inv.id) + .order_by(LineItem.line_number) + .all() + ) + + # Sum values from line items when header totals are missing/zero + sum_val_me = 0.0 + sum_val_mn = 0.0 + sum_gross = 0.0 # goes into 501 (peso bruto total) + sum_bultos = 0 + for _item in items: + _fin = _item.financial + _qty = _item.quantity + if _fin: + sum_val_me += float(_fin.value_usd or 0) + sum_val_mn += float(_fin.value_mxn or 0) + if _qty: + sum_gross += float(_qty.gross_weight or _qty.net_weight or 0) + sum_bultos += int(_qty.package_quantity or 0) + + # Use header value if present, otherwise sum from items + valor_me = valor_me_hdr if valor_me_hdr > 0 else sum_val_me + valor_mn = (valor_me * tc) if sum_val_mn == 0 else sum_val_mn + gross_wt = gross_wt_hdr if gross_wt_hdr > 0 else sum_gross + if bundles == 0 and sum_bultos > 0: + bundles = sum_bultos + + # ── Contributions (DTA, Prevalidación, IGI, IVA) ───────────────────── + igi_total = 0.0 + iva_total = 0.0 + dta_total = 0.0 + preval_total = 0.0 + if ped: + contribs = ( + self.db.query(PedimentoContributions) + .filter(PedimentoContributions.pedimento_id == ped.id) + .all() + ) + for c in contribs: + code = (c.contribucion or "").upper() + abrev = (c.abreviacion or "").upper() + amount = float(c.importe or 0) + if "IGI" in code or abrev == "IGI": + igi_total += amount + elif "IVA" in code or abrev == "IVA": + iva_total += amount + elif "DTA" in code or abrev == "DTA": + dta_total += amount + elif "PREVAL" in code or "15" in code: + preval_total += amount + + + + # ── REGISTRO 501 ────────────────────────────────────────────────────── + # Legacy: 501|1|REGIME|PED_NUM|ADUANA|IGI|IVA|DTA|PREVAL|0|GROSS_WT|BUNDLES|7|7|7||... + # Example: 501|1|IN|3130441|ENTRADA|0|0|0|0|0|4156.960|0|7|7|7||||160|1764|... + lines.append( + f"501|1|{ped_regime}|{ped_num_short}|{ped_aduana}|" + f"{_n(igi_total, 3)}|{_n(iva_total, 3)}|{_n(dta_total, 3)}|{_n(preval_total, 3)}|0|" + f"{_n(gross_wt, 3)}|{bundles}|7|7|7||||" + f"{_n(freight, 3)}|{_n(insurance, 3)}|1|||||||||||||||" + ) + + # ── REGISTRO 503 ────────────────────────────────────────────────────── + niu = _s(comp.niu_number) if comp else "" + guide = _s(comp.guide_type_to_identify) if comp else "" + lines.append(f"503|{niu}|{guide}||") + + # ── REGISTRO 505 ────────────────────────────────────────────────────── + # Legacy: 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VALOR_ME|VALOR_MN||||||||||CANT_LINES|SOLD_SHORT|SOLD_NAME| + # Example: 505|1111|20230525|DAP|USD|69760.00|69760.00||||||||||3|ENTRADA|ENTRADA GROUP...| + sold_short = _s(sold_to.short_name) if sold_to else "" + sold_name = _clean(_s(sold_to.name)) if sold_to else "" + invoicenum = _s(inv.invoice_number) + fecha_fac = _fmt_date_ymd(inv.invoice_date) + + lines.append( + f"505|{invoicenum}|{fecha_fac}|{incoterm}|{currency}|" + f"{_n(valor_me, 2)}|{_n(valor_mn, 2)}||||||||||" + f"{len(items)}|{sold_short}|{sold_name}|" + ) + + # ── REGISTROS 551 + 558 por partida ────────────────────────────────── + # Legacy 551 format (from example): + # 551|FRACCION|DESC|VAL_MN(int)|VAL_ME(dec)|CANT(3dec)|UM_ADUANA|CANT_TAR(3dec)|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||||||FPAGO||||||||99| + linea_num = 0 + for item in items: + linea_num += 1 + item_qty: Optional[LineQuantity] = item.quantity + item_fin: Optional[LineFinancial] = item.financial + item_cust: Optional[LineCustom] = item.customs + item_desc: Optional[LineDescription] = item.description + + # Fraccion + fraccion = "" + if item_cust and item_cust.fraction: + fraccion = item_cust.fraction[:8] + + # Descripción + part = None + if item.part_number_id: + part = self.db.query(Part).filter(Part.id == item.part_number_id).first() + desc = "" + if part: + desc = _clean(part.description_spanish or part.description or "") + if not desc and item_desc: + desc = _clean(item_desc.description_spanish or item_desc.description1 or "") + + # Valores + val_me = float(item_fin.value_usd or 0) if item_fin else 0.0 + val_mn = float(item_fin.value_mxn or val_me * tc) if item_fin else val_me * tc + + # Cantidades + cant = float(item_qty.quantity or 0) if item_qty else 0.0 + + # UM aduanas (Clave_AMex) + um_aduana = "" + if item.unit_of_measure: + uom = self.db.query(UnitOfMeasure).filter(UnitOfMeasure.id == item.unit_of_measure).first() + if uom: + um_aduana = _s(uom.customs_code) + + # Cantidad tarifa + cant_tar = float(item_qty.quantity_uma or cant) if item_qty else cant + + # Forma de pago (del item) + forma_pago = _s(item.payment_method) + + # País origen y país importador + pais_orig = _s(item_cust.origin_country) if item_cust else "" + pais_imp = "" + if sold_to and sold_to.address: + pais_imp = _s(sold_to.address.country) + + # Peso neto + peso = float(item_qty.net_weight or 0) if item_qty else 0.0 + + # MetValor + met_valor = _s(item.valuation_method) or _s(comp.value_method if comp else "") + + # Legacy 551 field layout (counting from example): + # 551|frac|desc|val_mn|val_me|cant|um|cant_tar|0|0|fpago|||p_orig|p_imp|||||0||peso|||moneda|factura||0|peso|peso|met_valor|linea|||||||fpago||||||||99| + lines.append( + f"551|{fraccion}|{desc}|{_n(val_mn, 0)}|{_n(val_me, 2)}|" + f"{_n(cant, 3)}|{um_aduana}|{_n(cant_tar, 3)}|0|0|{forma_pago}|||" + f"{pais_orig}|{pais_imp}|||||0||{_n(peso, 3)}|||" + f"{currency}|{invoicenum}||0|{_n(peso, 3)}|{_n(peso, 3)}|" + f"{met_valor}|{linea_num}|||||||{forma_pago}||||||||99|" + ) + + # ── REGISTRO 558 ────────────────────────────────────────────────── + # Legacy: always emit an empty 558 after each 551 + # If there is additional info, emit it, otherwise just empty: 558|| + info = "" + if item_desc and hasattr(item_desc, 'extra_description'): + info = _clean(item_desc.extra_description or "") + lines.append(f"558|{info}|") + + return lines + + # ── HELPERS ────────────────────────────────────────────────────────────── + + def _get_partner(self, partner_id: int) -> Optional[ClientProvider]: + if not partner_id: + return None + return ( + self.db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter(ClientProvider.id == partner_id) + .first() + ) + + def get_filename(self, invoice: InvoiceHeader) -> str: + inv_num = _sanitize_filename(invoice.invoice_number or "") + return f"{inv_num}.txt" diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py new file mode 100644 index 00000000..d21daac9 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/invoices/task.py @@ -0,0 +1,62 @@ +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .service import WinsaaiService +import os +import tempfile + +import logging +import sys + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True) +def generate_winsaai_task(self, invoice_ids: list, is_temporal: bool): + logger.info(f"Starting WINSAAI task {self.request.id} for {invoice_ids}") + db = CoreSessionLocal() + try: + self.update_state(state='PROCESSING', meta={'current': 10, 'status': 'Cargando servicio...'}) + from api.v1.modules.a76.invoices.models import InvoiceHeader as _InvH + _first = db.query(_InvH).filter(_InvH.id == invoice_ids[0]).first() if invoice_ids else None + _company_id = _first.company_id if _first else 0 + _tenant_id = _first.tenant_id if _first else 0 + service = WinsaaiService(db, _company_id, _tenant_id) + + self.update_state(state='PROCESSING', meta={'current': 30, 'status': 'Generando datos...'}) + lines = service.generate_winsaai_data(invoice_ids, is_temporal) + + self.update_state(state='PROCESSING', meta={'current': 70, 'status': 'Guardando archivo...'}) + + # Determine filename + from api.v1.modules.a76.invoices.models import InvoiceHeader + first_invoice = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_ids[0]).first() if invoice_ids else None + filename = f"{first_invoice.invoice_number}.txt" if first_invoice else f"winsaai_{self.request.id}.txt" + + temp_dir = tempfile.gettempdir() + filepath = os.path.join(temp_dir, filename) + + with open(filepath, "w", encoding="latin-1") as f: + for line in lines: + f.write(line + "\n") + + self.update_state(state='PROCESSING', meta={'current': 90, 'status': 'Finalizando...'}) + + import base64 + with open(filepath, "rb") as f: + encoded_content = base64.b64encode(f.read()).decode('utf-8') + + logger.info(f"WINSAAI task {self.request.id} completed successfully") + return { + "status": "success", + "content": encoded_content, + "filepath": filepath, + "filename": filename, + "file_name": filename, + "media_type": "text/plain", + "validation_errors": [] + } + except Exception as e: + logger.error(f"Error in generate_winsaai_task: {str(e)}", exc_info=True) + # No actualizar estado a FAILURE manualmente, dejar que Celery lo haga al re-lanzar + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/__init__.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py new file mode 100644 index 00000000..995177d2 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/routes.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from core.database import get_core_db +from .schemas import PedimentosWinsaaiGenerationRequest, WinsaaiResponse +from .task import generate_pedimentos_winsaai_task +from core.celery_app import celery_app +from celery.result import AsyncResult +from typing import Dict, Any +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["WINSAAI-Pedimentos"]) + +@router.post("/generate", response_model=WinsaaiResponse) +async def trigger_pedimentos_generation( + request: PedimentosWinsaaiGenerationRequest, + db: Session = Depends(get_core_db) +): + task = generate_pedimentos_winsaai_task.delay( + request.pedimento_ids, + request.is_temporal, + request.is_by_class + ) + return WinsaaiResponse( + task_id=task.id, + status="PENDING", + message="WINSAAI pedimentos generation task started" + ) + +@router.get("/status/{task_id}") +async def get_task_status(task_id: str): + logger.info(f"Polling status for WINSAAI pedimento task {task_id}") + task_result = AsyncResult(task_id, app=celery_app) + + try: + state = task_result.state + info = task_result.info + except Exception as e: + logger.error(f"Error polling task {task_id}: {str(e)}") + return {"task_id": task_id, "state": "FAILURE", "result": str(e), "info": None} + + response = { + "task_id": task_id, + "state": state, + "result": None, + "info": None + } + + if state == 'FAILURE': + response["result"] = "Task failed. Check worker logs for details." + if isinstance(info, dict) and 'error' in info: + response["result"] = info['error'] + elif state == 'SUCCESS': + response["result"] = task_result.result + elif state in ['PROCESSING', 'PROGRESS']: + response["info"] = info + + return response + +@router.get("/download/{task_id}") +async def download_file(task_id: str): + res = AsyncResult(task_id) + if not res.ready(): + raise HTTPException(status_code=400, detail="Task not ready") + + result = res.result + if isinstance(result, dict) and "filepath" in result: + from fastapi.responses import FileResponse + return FileResponse( + path=result["filepath"], + filename=result["filename"], + media_type="text/plain" + ) + raise HTTPException(status_code=404, detail="File not found") diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py new file mode 100644 index 00000000..8bbbb72e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/schemas.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import List, Optional + +class PedimentosWinsaaiGenerationRequest(BaseModel): + pedimento_ids: List[int] = Field(..., description="List of Pedimento IDs to process") + is_temporal: bool = Field(True, description="Whether to generate as temporal (SCAII) or definitive") + is_by_class: bool = Field(False, description="Whether to group items by Class (SCAF)") + +class ErrorValidacion(BaseModel): + partida: Optional[int] = None + linea: int + descripcion: str + soluciones: Optional[str] = None + identificador: Optional[str] = None + +class WinsaaiResponse(BaseModel): + task_id: str + status: str = "PENDING" + message: str = "WINSAAI generation task started" + validation_errors: List[ErrorValidacion] = [] diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py new file mode 100644 index 00000000..84d0f8eb --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/service.py @@ -0,0 +1,460 @@ +""" +Pedimentos WINSAAI Service – Legacy Format +Generates the legacy WINSAAI .txt file for a pedimento, consolidating +all linked invoices and their line items into a single file. + +Format: + 501| + 503| + 505| + 551| (repeated) + 558| + 999| +""" + +import re +from typing import List, Optional +from decimal import Decimal +from sqlalchemy.orm import Session, joinedload +from fastapi import HTTPException + +from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, InvoiceComplianceMx, InvoiceFinancials, InvoiceLogistics +) +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_financials.models import LineFinancial +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.general_catalogs.company.models import Company +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.classes.models import Class +from .schemas import ErrorValidacion + + +# ───────────────────────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────────────────────── + +def _s(val) -> str: + if val is None: + return "" + return str(val).strip() + + +def _n(val, decimals: int = 0) -> str: + if val is None: + return "0" + try: + f = float(val) + if decimals: + return f"{f:.{decimals}f}" + return str(int(f)) + except (TypeError, ValueError): + return "0" + + +def _fmt_date_ymd(dt) -> str: + if not dt: + return "" + return dt.strftime("%Y%m%d") + + +def _clean(text: str) -> str: + if not text: + return "" + text = re.sub(r'[\r\n]+', ' ', text) + text = text.replace('|', '/') + return text.strip() + + +# ───────────────────────────────────────────────────────────────────────────── +# SERVICE +# ───────────────────────────────────────────────────────────────────────────── + +class PedimentosWinsaaiService: + def __init__(self, db: Session): + self.db = db + self.validation_errors: List[ErrorValidacion] = [] + self.line_count = 0 + + # ── ENTRY POINT ────────────────────────────────────────────────────────── + + def generate_pedimentos_data( + self, + pedimento_ids: List[int], + is_temporal: bool = True, + is_by_class: bool = False, + ) -> List[str]: + lines: List[str] = [] + self.line_count = 0 + self.validation_errors = [] + + company = self.db.query(Company).first() + if not company: + raise HTTPException(status_code=404, detail="Company information not found") + + for p_id in pedimento_ids: + pedimento = self.db.query(Pedimentos).filter(Pedimentos.id == p_id).first() + if not pedimento: + continue + + # All invoices linked to this pedimento + invoices = ( + self.db.query(InvoiceHeader) + .join(InvoiceHeader.compliance_mx) + .filter(InvoiceComplianceMx.pedimento_id == p_id) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + ) + .all() + ) + + if not invoices: + self.validation_errors.append(ErrorValidacion( + linea=self.line_count, + descripcion=f"Pedimento {pedimento.pedimento_number} no tiene facturas vinculadas", + identificador=pedimento.pedimento_number, + )) + continue + + # ── Consolidate totals across all invoices for 501 ──────────────── + total_peso = 0.0 + total_bultos = 0 + total_flete = 0.0 + total_seguro = 0.0 + total_embalaje = 0.0 + total_otros = 0.0 + + for inv in invoices: + fin = inv.financials + if fin: + total_flete += float(fin.freight or 0) + total_seguro += float(fin.insurance or 0) + total_embalaje += float(getattr(fin, 'packaging', None) or 0) + total_otros += float(getattr(fin, 'other_increments', None) or 0) + + h_peso = float(fin.gross_weight or 0) + h_bultos = int(fin.bundle_count or 0) + if h_peso > 0: + total_peso += h_peso + else: + rows = ( + self.db.query(LineQuantity.gross_weight) + .join(LineItem) + .filter(LineItem.invoice_id == inv.id) + .all() + ) + total_peso += sum(float(r[0] or 0) for r in rows) + + if h_bultos > 0: + total_bultos += h_bultos + else: + rows = ( + self.db.query(LineQuantity.package_quantity) + .join(LineItem) + .filter(LineItem.invoice_id == inv.id) + .all() + ) + total_bultos += sum(int(r[0] or 0) for r in rows) + + # ── REGISTRO 501 (once per pedimento) ──────────────────────────── + lines.append(self._record_501( + pedimento, invoices[0], + total_peso, total_bultos, + total_flete, total_seguro, total_embalaje, total_otros, + )) + + # ── One 503 + 505 + 551s per invoice ───────────────────────────── + for inv_idx, inv in enumerate(invoices, 1): + # 503 + lines.append(self._record_503(inv)) + + # Sum financial totals for 505 + items = ( + self.db.query(LineItem) + .options( + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.part_info), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.class_info), + joinedload(LineItem.fa_data), + ) + .filter(LineItem.invoice_id == inv.id) + .all() + ) + + sum_me = sum(float(it.financial.value_usd or 0) for it in items if it.financial) + sum_mn = sum(float(it.financial.value_mxn or 0) for it in items if it.financial) + # Fallback to header + if inv.financials: + hdr_me = float(inv.financials.value_me or 0) + if hdr_me > 0: + sum_me = hdr_me + + # 505 + lines.append(self._record_505(inv, sum_mn, sum_me, inv_idx)) + + # Items — optionally grouped by class + items_to_process = self._group_items(items, is_by_class) + + for it_idx, (item, source_items) in enumerate(items_to_process, 1): + tc = float(inv.financials.exchange_rate or 1) if inv.financials else 1.0 + lines.append(self._record_551(item, inv, tc, it_idx)) + + # 554 identifiers + cust = item.customs + if cust and cust.fraction_type: + ftype = cust.fraction_type + if ftype == "TLCS": + lines.append(f"554|TL|{_s(cust.origin_country)}|") + elif ftype == "PROSEC": + lines.append(f"554|PS|{_s(cust.sector)}|") + elif ftype == "ALADI": + lines.append(f"554|AL|{_s(cust.sector)}|") + + # 558 observations + parts: List[str] = [] + if item.description: + if item.description.brand: + parts.append(f"MARCA: {item.description.brand}") + if item.description.model: + parts.append(f"MODELO: {item.description.model}") + if item.description.extra_description: + parts.append(item.description.extra_description) + for src in source_items: + if src.fa_data and src.fa_data.equipment_message: + if src.fa_data.equipment_message not in parts: + parts.append(src.fa_data.equipment_message) + + full_obs = _clean(". ".join(parts)) + if full_obs: + for i in range(0, len(full_obs), 120): + lines.append(f"558|{full_obs[i:i+120]}|") + else: + lines.append("558||") + + self.line_count += len(items_to_process) + + lines.append("999|") + return lines + + # ── RECORD BUILDERS ────────────────────────────────────────────────────── + + def _record_501( + self, + ped: Pedimentos, + rep_inv: InvoiceHeader, + peso: float, bultos: int, + flete: float, seguro: float, embalaje: float, otros: float, + ) -> str: + """ + Legacy: 501|1|REGIME|NUM7|ADUANA|IGI|IVA|DTA|PREVAL|0|PESO|BULTOS|7|7|7||||FLETE|SEGURO|... + Example: 501|1|IN|3130441|ENTRADA|0|0|0|0|0|4156.960|0|7|7|7||||160|1764|... + """ + regime = _s(ped.regime) + aduana = _s(ped.customs_office) + num_raw = "".join(filter(str.isdigit, _s(ped.pedimento_number))) + num7 = num_raw[-7:] if len(num_raw) >= 7 else num_raw + + return ( + f"501|1|{regime}|{num7}|{aduana}|0|0|0|0|0|" + f"{_n(peso, 3)}|{bultos}|7|7|7||||" + f"{_n(flete, 3)}|{_n(seguro, 3)}|1|||||||||||||||||" + ) + + def _record_503(self, inv: InvoiceHeader) -> str: + comp = inv.compliance_mx + niu = _s(comp.niu_number) if comp else "" + guide = _s(comp.guide_type_to_identify) if comp else "" + return f"503|{niu}|{guide}||" + + def _record_505( + self, + inv: InvoiceHeader, + val_mn: float, + val_me: float, + seq: int, + ) -> str: + """ + Legacy: 505|FACTURA|YYYYMMDD|INCOTERM|MONEDA|VAL_ME|VAL_MN|...|COUNT|SHORT|NAME| + """ + comp = inv.compliance_mx + fin = inv.financials + log = inv.logistics + + invoicenum = _s(inv.invoice_number) + fecha = _fmt_date_ymd(inv.invoice_date) + incoterm = _s(log.incoterm) if log else "" + currency = _s(fin.currency_type) if fin else "USD" + currency = "MXP" if currency.upper() in ("MXP", "MXN") else "USD" + tc = float(fin.exchange_rate or 1) if fin else 1.0 + + # val_mn fallback + if val_mn == 0 and val_me > 0: + val_mn = val_me * tc + + sold_to_id = comp.sold_to_id if comp else None + sold_to = self._get_partner(sold_to_id) + sold_short = _s(sold_to.short_name) if sold_to else "" + sold_name = _clean(_s(sold_to.name)) if sold_to else "" + + # Count items for this invoice + item_count = ( + self.db.query(LineItem) + .filter(LineItem.invoice_id == inv.id) + .count() + ) + + return ( + f"505|{invoicenum}|{fecha}|{incoterm}|{currency}|" + f"{_n(val_me, 2)}|{_n(val_mn, 2)}||||||||||" + f"{item_count}|{sold_short}|{sold_name}|" + ) + + def _record_551( + self, + item: LineItem, + inv: InvoiceHeader, + tc: float, + line_num: int, + ) -> str: + """ + Legacy 551: + 551|FRACCION|DESC|VAL_MN|VAL_ME|CANT|UM|CANT_TAR|0|0|FPAGO|||P_ORIG|P_IMP|||||0||PESO|||MONEDA|FACTURA||0|PESO|PESO|MET_VALOR|LINEA|||...99| + """ + fin = item.financial + qty = item.quantity + cust = item.customs + desc = item.description + + # Fracción + fraccion = "" + if cust and cust.fraction: + fraccion = cust.fraction[:8] + + # Descripción + part = None + if item.part_number_id: + part = self.db.query(Part).filter(Part.id == item.part_number_id).first() + description = "" + if part: + description = _clean(part.description_spanish or part.description or "") + if not description and desc: + description = _clean(getattr(desc, 'description_spanish', None) or getattr(desc, 'description1', None) or "") + if not description and item.class_info: + description = _clean(getattr(item.class_info, 'description_es', None) or "") + + # Valores + val_me = float(fin.value_usd or 0) if fin else 0.0 + val_mn = float(fin.value_mxn or val_me * tc) if fin else val_me * tc + + # Cantidades + cant = float(qty.quantity or 0) if qty else 0.0 + + # UM aduanas + um_aduana = "" + if item.unit_of_measure: + uom = self.db.query(UnitOfMeasure).filter(UnitOfMeasure.id == item.unit_of_measure).first() + if uom: + um_aduana = _s(uom.customs_code) + + cant_tar = float(qty.quantity_uma or cant) if qty else cant + + # FormaPago y MetValor + forma_pago = _s(item.payment_method) + met_valor = _s(item.valuation_method) + if not met_valor and inv.compliance_mx: + met_valor = _s(inv.compliance_mx.value_method) + + # País origen e importador + pais_orig = _s(cust.origin_country) if cust else "" + pais_imp = "" + comp = inv.compliance_mx + if comp and comp.sold_to_id: + sold_to = self._get_partner(comp.sold_to_id) + if sold_to and sold_to.address: + pais_imp = _s(sold_to.address.country) + + # Peso neto + peso = float(qty.net_weight or 0) if qty else 0.0 + + # Currency and invoice number + fin_obj = inv.financials + currency = _s(fin_obj.currency_type) if fin_obj else "USD" + currency = "MXP" if currency.upper() in ("MXP", "MXN") else "USD" + invoicenum = _s(inv.invoice_number) + + return ( + f"551|{fraccion}|{description}|{_n(val_mn, 0)}|{_n(val_me, 2)}|" + f"{_n(cant, 3)}|{um_aduana}|{_n(cant_tar, 3)}|0|0|{forma_pago}|||" + f"{pais_orig}|{pais_imp}|||||0||{_n(peso, 3)}|||" + f"{currency}|{invoicenum}||0|{_n(peso, 3)}|{_n(peso, 3)}|" + f"{met_valor}|{line_num}|||||||{forma_pago}||||||||99|" + ) + + # ── HELPERS ────────────────────────────────────────────────────────────── + + def _group_items(self, items, is_by_class: bool): + """Returns list of (item, source_items). Groups by class if is_by_class=True.""" + if not is_by_class: + return [(it, [it]) for it in items] + + groups = {} + for it in items: + fraction = (it.class_info.fraction if it.class_info else getattr(it.customs, 'fraction', '')) or '' + country = getattr(it.customs, 'origin_country', '') or '' + key = (it.class_id, fraction, country, it.valuation_method) + if key not in groups: + groups[key] = { + 'rep': it, + 'val_me': 0.0, 'val_mn': 0.0, + 'qty': 0.0, 'qty_uma': 0.0, + 'gross': 0.0, 'net': 0.0, 'bultos': 0, + 'sources': [], + } + g = groups[key] + g['val_me'] += float(it.financial.value_usd or 0) if it.financial else 0 + g['val_mn'] += float(it.financial.value_mxn or 0) if it.financial else 0 + g['qty'] += float(it.quantity.quantity or 0) if it.quantity else 0 + g['qty_uma'] += float(it.quantity.quantity_uma or 0) if it.quantity else 0 + g['gross'] += float(it.quantity.gross_weight or 0) if it.quantity else 0 + g['net'] += float(it.quantity.net_weight or 0) if it.quantity else 0 + g['bultos'] += int(it.quantity.package_quantity or 0) if it.quantity else 0 + g['sources'].append(it) + + result = [] + for g in groups.values(): + rep = g['rep'] + if rep.financial: + rep.financial.value_usd = g['val_me'] + rep.financial.value_mxn = g['val_mn'] + if rep.quantity: + rep.quantity.quantity = g['qty'] + rep.quantity.quantity_uma = g['qty_uma'] + rep.quantity.gross_weight = g['gross'] + rep.quantity.net_weight = g['net'] + rep.quantity.package_quantity = g['bultos'] + if rep.class_info and rep.customs: + rep.customs.fraction = getattr(rep.class_info, 'fraction', rep.customs.fraction) + result.append((rep, g['sources'])) + return result + + def _get_partner(self, partner_id: Optional[int]) -> Optional[ClientProvider]: + if not partner_id: + return None + return ( + self.db.query(ClientProvider) + .options(joinedload(ClientProvider.address)) + .filter(ClientProvider.id == partner_id) + .first() + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py new file mode 100644 index 00000000..119b93d0 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/pedimentos/task.py @@ -0,0 +1,53 @@ +import logging +from typing import List +import base64 +import os +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from .service import PedimentosWinsaaiService + +logger = logging.getLogger(__name__) + +@celery_app.task(bind=True) +def generate_pedimentos_winsaai_task(self, pedimento_ids: List[int], is_temporal: bool, is_by_class: bool = False): + logger.info(f"Starting Pedimentos WINSAAI task {self.request.id} for pedimentos {pedimento_ids} (by_class={is_by_class})") + db = CoreSessionLocal() + try: + self.update_state(state='PROCESSING', meta={'current': 10, 'status': 'Cargando servicio...'}) + service = PedimentosWinsaaiService(db) + + self.update_state(state='PROCESSING', meta={'current': 30, 'status': 'Generando datos...'}) + lines = service.generate_pedimentos_data(pedimento_ids, is_temporal, is_by_class) + + self.update_state(state='PROCESSING', meta={'current': 70, 'status': 'Guardando archivo...'}) + # Use first pedimento number for filename + from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos + ped = db.query(Pedimentos).filter(Pedimentos.id == pedimento_ids[0]).first() if pedimento_ids else None + if ped: + filename = f"{ped.year}-{ped.license}-{ped.pedimento_number}.txt" + else: + filename = "pedimentos.txt" + filepath = f"/tmp/{filename}" + + # Clarion uses ANSI/UTF-8 usually, but let's stick to UTF-8 for now unless specific requirement + with open(filepath, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + self.update_state(state='PROCESSING', meta={'current': 90, 'status': 'Finalizando...'}) + with open(filepath, "rb") as f: + content_b64 = base64.b64encode(f.read()).decode("utf-8") + + return { + "status": "success", + "content": content_b64, + "filepath": filepath, + "filename": filename, + "file_name": filename, + "media_type": "text/plain", + "validation_errors": [e.model_dump() for e in service.validation_errors] + } + except Exception as e: + logger.error(f"Error in generate_pedimentos_winsaai_task: {str(e)}", exc_info=True) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/importacion/winsaai/router.py b/backend/api/v1/modules/a76/reports/importacion/winsaai/router.py new file mode 100644 index 00000000..409edbfe --- /dev/null +++ b/backend/api/v1/modules/a76/reports/importacion/winsaai/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from .invoices.routes import router as invoices_router + +router = APIRouter() + +router.include_router(invoices_router, prefix="/invoices", tags=["WINSAAI - Invoices"]) + +from .pedimentos.routes import router as pedimentos_router +router.include_router(pedimentos_router, prefix="/pedimentos", tags=["WINSAAI - Pedimentos"]) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 570b247b..b8d0e610 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -43,6 +43,7 @@ from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router from .reports.exportacion.transmission.MAINX30.routes import router as transmission_router from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router +from .reports.importacion.winsaai.router import router as winsaai_router # Router principal @@ -150,6 +151,12 @@ router.include_router( tags=["a76 / reports"] ) +router.include_router( + winsaai_router, + prefix="/a76/reports/importacion/winsaai", + tags=["a76 / reports"] +) + # Registrar router de bitácora from .audit_log.router import router as audit_log_router router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"]) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 35694fea..a682cce7 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -28,8 +28,10 @@ celery_app.conf.update( "api.v1.modules.a76.imports.tasks", "api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task", - "api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task" - "api.v1.modules.core.help_center.tasks" + "api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task", + "api.v1.modules.a76.reports.importacion.winsaai.invoices.task", + "api.v1.modules.a76.reports.importacion.winsaai.pedimentos.task", + "api.v1.modules.core.help_center.tasks", ] # Ruta al módulo donde están las tareas ) diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 413726cd..c92b7dd3 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -62,7 +62,7 @@ "electronic_notices": "Avisos electrónicos", "back_flush": "Back Flush", "crossing_notice": "Aviso de cruce" - }, + }, "fractions": { "title": "Fracciones", "sitar": "Fracciones Sitar", diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts new file mode 100644 index 00000000..bfad4d0f --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-winsaai.ts @@ -0,0 +1,86 @@ +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export interface WinsaaiGenerationRequest { + invoice_ids: number[]; + is_temporal: boolean; +} + +export interface WinsaaiResponse { + task_id: string; + status: string; + message: string; +} + +export const reportsWinsaaiApi = { + invoices: { + triggerGeneration: async (payload: WinsaaiGenerationRequest): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/generate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + if (!response.ok) throw new Error('Error al iniciar la generación WINSAAI de facturas'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/status/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error('Error al consultar estado WINSAAI de facturas'); + return await response.json(); + }, + + downloadFile: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + return await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/invoices/download/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + } + }, + + pedimentos: { + triggerGeneration: async (pedimentoIds: number[], isTemporal: boolean = true, isByClass: boolean = false): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/generate`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + pedimento_ids: pedimentoIds, + is_temporal: isTemporal, + is_by_class: isByClass + }) + }); + if (!response.ok) throw new Error('Error al iniciar la generación WINSAAI de pedimentos'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + const response = await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/status/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error('Error al consultar estado WINSAAI de pedimentos'); + return await response.json(); + }, + + downloadFile: async (taskId: string): Promise => { + const token = localStorage.getItem('access_token'); + return await fetch(`${BASE_URL}/v1/a76/reports/importacion/winsaai/pedimentos/download/${taskId}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + } + } +}; diff --git a/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/frontend/src/lib/components/ui/dialog/dialog-content.svelte index a647d566..f58ca8b8 100644 --- a/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,9 +1,9 @@ - - + + Close {/if} - + diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index 1d37c04e..dce1d9dc 100644 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -1,4 +1,4 @@ -import { Dialog } from "bits-ui"; +import { Dialog as DialogPrimitive } from "bits-ui"; import Title from "./dialog-title.svelte"; import Footer from "./dialog-footer.svelte"; @@ -9,8 +9,8 @@ import Description from "./dialog-description.svelte"; import Trigger from "./dialog-trigger.svelte"; import Close from "./dialog-close.svelte"; -const Root = Dialog.Root; -const Portal = Dialog.Portal; +const Root = DialogPrimitive.Root; +const Portal = DialogPrimitive.Portal; export { Root, diff --git a/frontend/src/lib/components/ui/sheet/index.ts b/frontend/src/lib/components/ui/sheet/index.ts index 94a584fb..2c191acd 100644 --- a/frontend/src/lib/components/ui/sheet/index.ts +++ b/frontend/src/lib/components/ui/sheet/index.ts @@ -1,5 +1,5 @@ -import { Dialog } from "bits-ui"; -const SheetPrimitive = Dialog; +import { Dialog as DialogPrimitive } from "bits-ui"; +const SheetPrimitive = DialogPrimitive; import Trigger from "./sheet-trigger.svelte"; import Close from "./sheet-close.svelte"; import Overlay from "./sheet-overlay.svelte"; diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index e6a1f74c..6adcc58e 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -9,9 +9,12 @@ import { consolidatedReportsApi } from '$lib/api/dashboard/a76/reports/reports-consolidated'; import { dischargeReportsApi } from '$lib/api/dashboard/a76/reports/reports-descargo'; import { avisoConsolidadoReportsApi } from '$lib/api/dashboard/a76/reports/reports-aviso-consolidado'; + import { reportsTransmissionApi } from '$lib/api/dashboard/a76/reports/reports-transmission'; + import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai'; import DataTable from '$lib/components/dashboard/invoices/data-table.svelte'; import { createColumns } from '$lib/components/dashboard/invoices/columns.js'; import * as Card from '$lib/components/ui/card'; + import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; @@ -364,8 +367,10 @@ // Estado para el diálogo de progreso let showProgressDialog = $state(false); + let isWinsaiiConfirmOpen = $state(false); let currentTaskId = $state(null); let currentStatusFunction = $state<((taskId: string) => Promise) | null>(null); + let progressDialogTitle = $state('Generando documento'); // Utilidad para convertir Base64 a Blob function base64ToBlob(base64: string, type: string) { @@ -394,6 +399,7 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = invoicesReportsApi.getTaskStatus; + progressDialogTitle = 'Generando PDF de Factura'; showProgressDialog = true; } catch (error) { console.error(error); @@ -417,6 +423,7 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = consolidatedReportsApi.getTaskStatus; + progressDialogTitle = 'Generando Consolidado'; showProgressDialog = true; } catch (error) { console.error(error); @@ -450,6 +457,7 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = dischargeReportsApi.getTaskStatus; + progressDialogTitle = 'Generando Reporte PEPS'; showProgressDialog = true; } catch (error) { console.error(error); @@ -473,6 +481,7 @@ // 2. Abrir diálogo de progreso currentTaskId = task_id; currentStatusFunction = avisoConsolidadoReportsApi.getTaskStatus; + progressDialogTitle = 'Generando Aviso Consolidado'; showProgressDialog = true; } catch (error) { console.error(error); @@ -497,6 +506,7 @@ currentTaskId = task_id; // Use the specific status function for Packing List currentStatusFunction = invoicesReportsApi.getPackingListTaskStatus; + progressDialogTitle = 'Generando Packing List'; showProgressDialog = true; } catch (error) { console.error(error); @@ -504,6 +514,44 @@ } } + async function handleInterfaceAgenteAduanal(invoice: any) { + if (!companyStore.activeCompany) { + toast.error('No hay empresa seleccionada'); + return; + } + + if (invoice.operation_type !== 'imp') { + toast.info('La interfaz rápida solo está disponible para facturas de Importación'); + return; + } + + // Abrir confirmación + isWinsaiiConfirmOpen = true; + } + + async function confirmWinsaiiGeneration() { + if (!selectedInvoice) return; + isWinsaiiConfirmOpen = false; + + try { + const res = await reportsWinsaaiApi.invoices.triggerGeneration({ + invoice_ids: [selectedInvoice.id], + is_temporal: selectedInvoice.invoice_type === 'TEM' + }); + + if (res.task_id) { + currentTaskId = res.task_id; + // Use the specific status and download functions for WINSAAI + currentStatusFunction = reportsWinsaaiApi.invoices.getTaskStatus; + progressDialogTitle = 'Generando Reporte WINSAAI'; + showProgressDialog = true; + } + } catch (error) { + console.error(error); + toast.error('No se pudo iniciar la generación de Interface Agente Aduanal'); + } + } + function onPdfComplete(result: any) { // Esta función se llama cuando el diálogo reporta SUCCESS try { @@ -806,8 +854,26 @@ getStatus={currentStatusFunction} onComplete={onPdfComplete} onClose={closeProgressDialog} + title={progressDialogTitle} /> + + + + Sistema de Control de Aduanas e Inventarios + + A la Factura {selectedInvoice?.invoice_number} de tipo + {selectedInvoice?.document_type} se le ha asignado el proceso Generación del + Archivo WINSAAI. ¿Desea Continuar o Cancelar? + + + + Cancelar + Continuar + + + +
+ + +
@@ -475,3 +573,38 @@ + + + + + ¿Generar Interface Agente Aduanal? + + Se generará el reporte WINSAAI para el pedimento seleccionado. +
+ + +
+
+
+
+ + +
+
+
+ +{#if showProgressDialog && currentTaskId} + (showProgressDialog = false)} + onComplete={onPdfComplete} + /> +{/if} From d49d2d68c3923d15c084c1b96789acc26621991e Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 3 Mar 2026 09:16:58 -0600 Subject: [PATCH 2/5] Correccion de conflictos en celery --- backend/core/celery_app.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 55ba6651..9b292943 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -29,13 +29,10 @@ celery_app.conf.update( "api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task", -<<<<<<< HEAD "api.v1.modules.a76.reports.importacion.winsaai.invoices.task", "api.v1.modules.a76.reports.importacion.winsaai.pedimentos.task", "api.v1.modules.core.help_center.tasks", -======= "api.v1.modules.core.help_center.tasks" ->>>>>>> 3e0072897d22dc9ee7ebbc3be471aab2aecb46f6 ] # Ruta al módulo donde están las tareas ) From 217ec822e7380516d8b753a03461d1af2caebe8c Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 3 Mar 2026 09:23:32 -0600 Subject: [PATCH 3/5] Chore: Se ignora celerybeat-schedule generado por Docker --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 2db7f63a..cd5adff8 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ backend/app_data/ .pytest_cache/ .coverage htmlcov/ +backend/celerybeat-schedule # Node (para frontend) **/node_modules/ @@ -67,3 +68,4 @@ postgres-data/ backend/uploads/ docker-compose.yml .mypy_cache/ + From 5bbb4ac9b98c025296f6a53b917d96eda209a570 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 3 Mar 2026 08:55:42 -0700 Subject: [PATCH 4/5] feat: Relocate units of measure and historical tariff fractions seeding to company creation and add Celery schedule files to gitignore. --- .gitignore | 5 ++ .../7937209f9718_seed_initial_data.py | 63 +----------------- .../a76/general_catalogs/company/service.py | 65 ++++++++++++++++++- 3 files changed, 71 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index 2db7f63a..8286dea9 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,8 @@ postgres-data/ backend/uploads/ docker-compose.yml .mypy_cache/ + +# Celery +backend/celerybeat-schedule +celerybeat-schedule +celerybeat-schedule.* diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index a6fda94d..e41ecd80 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -73,9 +73,6 @@ from api.v1.modules.a76.general_catalogs.units_of_measure.seed_adua import ( from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.seed import ( seed as tariff_fractions_seed, ) -from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.seed import ( - seed as historical_tariff_fractions_seed, -) from api.v1.modules.public.reference_data.trailer_types.seed import ( seed as trailer_types_seed, ) @@ -429,25 +426,7 @@ def upgrade() -> None: f"INSERT INTO a76.unit_of_measure_oma (code, description) VALUES {val_add_oma} ON CONFLICT ON CONSTRAINT uq_uom_oma_code DO NOTHING;" ) - # TABLA MAESTRA UOM - # TODO: Generar tenant_id y company_id correctos - val_uom = ", ".join( - [ - f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " - f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, 1, 1)" - for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed - ] - ) - op.execute("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;") - op.execute( - f""" - INSERT INTO a76.units_of_measure - (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) - VALUES {val_uom} - ON CONFLICT (code, tenant_id, company_id) DO NOTHING; - """ - ) - op.execute("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;") + # TABLA MAESTRA UOM se genera ahora al crear una empresa # --- SEEDS CORE (Permissions) --- @@ -501,45 +480,7 @@ def upgrade() -> None: """ ) - def format_bool(val): - """Convert boolean string to SQL boolean.""" - if val is None or str(val).strip() == "" or str(val).upper() == "NONE": - return "NULL" - return "TRUE" if str(val).upper() == "TRUE" else "FALSE" - - def format_timestamp(val): - """Format timestamp for PostgreSQL.""" - if val is None or str(val).strip() == "" or str(val).upper() == "NONE": - return "NULL" - # El valor ya viene en formato 'YYYY-MM-DD HH:MM:SS' - return f"'{str(val)}'" - - values_historical_fractions = ", ".join( - [ - f"({format_value(historical_fraction)}, {format_value(nico)},{format_value(unit_measure)}, {format_value(country)}, " - f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, " - f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, " - f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, " - f"{format_bool(by_log)}, {format_timestamp(end_date)}, " - f"1, 1)" # tenant_id=1, company_id=1 - for historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax, export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date in historical_tariff_fractions_seed - ] - ) - - if values_historical_fractions: - op.execute("SET session_replication_role = replica;") - op.execute( - f""" - INSERT INTO a76.historical_tariff_fractions - (historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector, - import_tax_rate, export_tax_rate, publication_date, is_immex, - normal_temporality, services_temporality, certified_temporality, by_log, end_date, - tenant_id, company_id) - VALUES {values_historical_fractions} - ON CONFLICT DO NOTHING; - """ - ) - op.execute("SET session_replication_role = DEFAULT;") + # Historical Fractions se generan ahora al crear una empresa def downgrade() -> None: diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index 06b85f7d..c66602f0 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -12,7 +12,10 @@ from sqlalchemy.orm import Session from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO from .models import Company from ...audit_log.services.service import AuditService +from ..units_of_measure.seed import seed as units_of_measure_seed +from ..fractions.historical_tariff_fractions.seed import seed as historical_tariff_fractions_seed from core.context import get_user_context +from sqlalchemy import text logger = logging.getLogger(__name__) @@ -381,7 +384,10 @@ class CompanyService: if addr_ind2: self.db.add(CompanyAddress(**addr_ind2, address_type='industrial2', company_id=db_company.id)) - # 7. Commit + # 7. Seed company data (tenant/company dependent) + self._seed_company_data(self.db, tenant_id, db_company.id) + + # 8. Commit self.db.commit() self.db.refresh(db_company) @@ -698,6 +704,63 @@ class CompanyService: # Custom methods + def _seed_company_data(self, db: Session, tenant_id: int, company_id: int): + """Seeds tenant/company dependent data for a new company""" + def format_value(val): + if val is None or str(val).strip() == "" or str(val).upper() == "NONE": + return "NULL" + return f"'{str(val).replace(chr(39), chr(39)*2)}'" + + # 1. Units of Measure + val_uom = ", ".join( + [ + f"({format_value(code)}, {format_value(desc)}, {format_value(desc_en)}, " + f"{format_value(customs)}, {format_value(american)}, {format_value(ace)}, {format_value(oma)}, {tenant_id}, {company_id})" + for code, desc, desc_en, customs, american, ace, oma in units_of_measure_seed + ] + ) + + db.execute(text("ALTER TABLE a76.units_of_measure DISABLE TRIGGER ALL;")) + db.execute(text(f"INSERT INTO a76.units_of_measure (code, description, description_en, customs_code, american_code, ace_code, oma_code, tenant_id, company_id) VALUES {val_uom} ON CONFLICT (code, tenant_id, company_id) DO NOTHING;")) + db.execute(text("ALTER TABLE a76.units_of_measure ENABLE TRIGGER ALL;")) + + # 2. Historical Tariff Fractions + def format_bool(val): + if val is None or str(val).strip() == "" or str(val).upper() == "NONE": + return "NULL" + return "TRUE" if str(val).upper() == "TRUE" else "FALSE" + + def format_timestamp(val): + if val is None or str(val).strip() == "" or str(val).upper() == "NONE": + return "NULL" + return f"'{str(val)}'" + + values_historical = ", ".join( + [ + f"({format_value(historical_fraction)}, {format_value(nico)}, {format_value(unit_measure)}, {format_value(country)}, " + f"{format_value(fraction_type)}, {format_value(sector)}, {format_value(import_tax)}, " + f"{format_value(export_tax)}, {format_timestamp(pub_date)}, {format_bool(is_immex)}, " + f"{format_bool(normal_temp)}, {format_bool(services_temp)}, {format_bool(certified_temp)}, " + f"{format_bool(by_log)}, {format_timestamp(end_date)}, " + f"{tenant_id}, {company_id})" + for (historical_fraction, nico, unit_measure, country, fraction_type, sector, import_tax, + export_tax, pub_date, is_immex, normal_temp, services_temp, certified_temp, by_log, end_date) in historical_tariff_fractions_seed + ] + ) + + if values_historical: + db.execute(text("SET session_replication_role = replica;")) + db.execute(text(f""" + INSERT INTO a76.historical_tariff_fractions + (historical_fraction, nico, unit_of_measure_code, country, fraction_type, sector, + import_tax_rate, export_tax_rate, publication_date, is_immex, + normal_temporality, services_temporality, certified_temporality, by_log, end_date, + tenant_id, company_id) + VALUES {values_historical} + ON CONFLICT DO NOTHING; + """)) + db.execute(text("SET session_replication_role = DEFAULT;")) + def get_companies_by_tenant(self, tenant_id: int) -> List[Company]: """Get all companies for a tenant""" return ( From a0e231196e164ca6516f83467437cc0602f9d0e9 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 3 Mar 2026 16:52:17 -0700 Subject: [PATCH 5/5] refactor: update Svelte components to use runes, snippets, and improved reactivity patterns. --- .../a76/general_catalogs/company/service.py | 20 +- docker-compose.yml | 6 - .../customs_brokers/data-table-actions.svelte | 54 +- .../src/lib/components/help/HelpDrawer.svelte | 22 +- .../keyboard/ShortcutsHelpModal.svelte | 31 +- .../lib/components/sidebar/nav-main.svelte | 79 +- .../lib/components/sidebar/nav-user.svelte | 62 +- .../components/sidebar/team-switcher.svelte | 169 ++-- .../ui/sidebar/sidebar-menu-button.svelte | 58 +- .../ui/tooltip/tooltip-content.svelte | 18 +- .../src/routes/dashboard/users/+page.svelte | 927 ++++++++++-------- scripts/init_first_time.sh | 81 +- 12 files changed, 836 insertions(+), 691 deletions(-) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/service.py b/backend/api/v1/modules/a76/general_catalogs/company/service.py index c66602f0..e042cea9 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/service.py @@ -3,6 +3,7 @@ Capa de servicio para lógica de negocio de empresa """ import logging +from datetime import datetime from typing import List, Optional, Tuple, Dict, Any from fastapi import HTTPException @@ -37,7 +38,7 @@ class CompanyService: filters: Optional[Dict[str, Any]] = None, ) -> Tuple[List[Company], int]: """Get all companies for a tenant with pagination""" - query = db.query(Company).filter(Company.tenant_id == tenant_id) + query = db.query(Company).filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) # Apply filters if provided if filters: @@ -65,6 +66,7 @@ class CompanyService: .filter( Company.id == company_id, Company.tenant_id == tenant_id, + Company.deleted_at.is_(None) ) .first() ) @@ -590,17 +592,9 @@ class CompanyService: # ---------------------- try: - # Cascading deletes are handled by relationship settings, but manual is safer here - if company.certification: db.delete(company.certification) - if company.prevalidator: db.delete(company.prevalidator) - if company.electronic_agent: db.delete(company.electronic_agent) - if company.ventanilla_unica: db.delete(company.ventanilla_unica) - if company.cfdi: db.delete(company.cfdi) - for cert in company.digital_certificates: db.delete(cert) - for addr in company.addresses: db.delete(addr) - + company.deleted_at = datetime.utcnow() + db.flush() - db.delete(company) db.commit() # --- Audit Log --- @@ -765,7 +759,7 @@ class CompanyService: """Get all companies for a tenant""" return ( self.db.query(Company) - .filter(Company.tenant_id == tenant_id) + .filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) .order_by(Company.name) .all() ) @@ -774,7 +768,7 @@ class CompanyService: """Check if a company exists for a tenant""" return ( self.db.query(Company) - .filter(Company.tenant_id == tenant_id) + .filter(Company.tenant_id == tenant_id, Company.deleted_at.is_(None)) .first() is not None ) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 5ae2e394..268074e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -268,12 +268,6 @@ services: options: max-size: "10m" max-file: "3" - deploy: - resources: - limits: - memory: 1G - reservations: - memory: 512M # celery celery_worker: diff --git a/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte index d32046fc..5e203e5b 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte @@ -1,16 +1,16 @@ - - + + diff --git a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte index 00f9a73a..826d421c 100644 --- a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte +++ b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte @@ -7,11 +7,11 @@ // Group local shortcuts let localShortcuts = $derived($store.shortcuts); - let globalList: HTMLDivElement; - let localList: HTMLDivElement; - let modalRef: HTMLDivElement; + let globalList = $state(); + let localList = $state(); + let modalRef = $state(); - function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement) { + function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) { if (!target) return; if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); @@ -50,8 +50,9 @@ aria-modal="true" >