Merge pull request 'task/archivos_winsaai' (#184) from task/archivos_winsaai into development

Reviewed-on: ADUANASOFT/anexo76#184
This commit is contained in:
2026-03-05 14:19:19 +00:00
21 changed files with 1440 additions and 18 deletions

View File

@@ -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"}

View File

@@ -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] = []

View File

@@ -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"

View File

@@ -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()

View File

@@ -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")

View File

@@ -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] = []

View File

@@ -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|<consolidated header>
503|<guides per invoice>
505|<invoice header per invoice>
551|<item per invoice> (repeated)
558|<observations per item>
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()
)

View File

@@ -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()

View File

@@ -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"])

View File

@@ -45,6 +45,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
@@ -154,6 +155,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"])

View File

@@ -45,6 +45,9 @@ 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",
"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"
] # Ruta al módulo donde están las tareas
)

View File

@@ -62,7 +62,7 @@
"electronic_notices": "Avisos electrónicos",
"back_flush": "Back Flush",
"crossing_notice": "Aviso de cruce"
},
},
"fractions": {
"title": "Fracciones",
"sitar": "Fracciones Sitar",

View File

@@ -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<WinsaaiResponse> => {
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<any> => {
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<Response> => {
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<WinsaaiResponse> => {
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<any> => {
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<Response> => {
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}` }
});
}
}
};

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import { Dialog as DialogPrimitive } from 'bits-ui';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import Overlay from './dialog-overlay.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -19,13 +19,13 @@
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Portal {...portalProps}>
<Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...restProps}
@@ -33,11 +33,11 @@
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
class="absolute end-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</Dialog.Portal>
</DialogPrimitive.Portal>

View File

@@ -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,

View File

@@ -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";

View File

@@ -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<string | null>(null);
let currentStatusFunction = $state<((taskId: string) => Promise<any>) | 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}
/>
<AlertDialog.Root bind:open={isWinsaiiConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Sistema de Control de Aduanas e Inventarios</AlertDialog.Title>
<AlertDialog.Description>
A la Factura <strong>{selectedInvoice?.invoice_number}</strong> de tipo
<strong>{selectedInvoice?.document_type}</strong> se le ha asignado el proceso Generación del
Archivo WINSAAI. ¿Desea Continuar o Cancelar?
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
<AlertDialog.Action onclick={confirmWinsaiiGeneration}>Continuar</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<!-- Footer fijo con botones de acción -->
<div
class="fixed right-0 bottom-0 left-0 z-[5] ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
@@ -873,6 +939,16 @@
Packing List
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && handleInterfaceAgenteAduanal(selectedInvoice)}
disabled={!selectedInvoice}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
</Button>
<Button
variant="outline"
size="sm"

View File

@@ -11,11 +11,15 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
import { Edit } from 'lucide-svelte';
import { Edit, Send, Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
import { obtenerAtajosListaPedimento } from '$lib/config/shortcuts/dashboard/pedimentos/list';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { goto } from '$app/navigation';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { toast } from 'svelte-sonner';
import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
@@ -82,6 +86,16 @@
let selectedId = $state<number | null>(null);
let hasSelection = $derived(selectedId !== null);
let showDeleteDialog = $state(false);
let isWinsaiiConfirmOpen = $state(false);
let isWinsaiiByClass = $state(false);
// Estado para diálogos de progreso
let showProgressDialog = $state(false);
let progressDialogTitle = $state('Procesando...');
let currentTaskId = $state<string | null>(null);
let currentStatusFunction = $state<any>(null);
let selectedPedimento = $derived(allItems.find((p) => p.id === selectedId) || null);
function handleRowClick(pedimento: Pedimento) {
// Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar
@@ -130,6 +144,81 @@
}
}
function handleInterfaceAgenteAduanal() {
if (!companyStore.activeCompany) {
toast.error('No hay empresa seleccionada');
return;
}
if (!selectedPedimento) {
toast.error('Por favor selecciona un pedimento');
return;
}
// Abrir confirmación
isWinsaiiConfirmOpen = true;
}
async function confirmWinsaiiGeneration() {
if (!selectedPedimento) return;
isWinsaiiConfirmOpen = false;
try {
const res = await reportsWinsaaiApi.pedimentos.triggerGeneration(
[selectedPedimento.id],
true, // isTemporal
isWinsaiiByClass
);
if (res.task_id) {
currentTaskId = res.task_id;
currentStatusFunction = reportsWinsaaiApi.pedimentos.getTaskStatus;
progressDialogTitle = 'Generando Reporte WINSAAI Pedimentos';
showProgressDialog = true;
}
} catch (error) {
console.error(error);
toast.error('No se pudo iniciar la generación de Interface Agente Aduanal');
}
}
function onPdfComplete(result: any) {
try {
if (result.status === 'success') {
const blob = base64ToBlob(result.content, result.media_type);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = result.file_name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Archivo descargado exitosamente');
} else {
toast.error('Hubo un error en la generación: ' + (result.message || 'Desconocido'));
}
} catch (e) {
console.error('Error al procesar descarga:', e);
toast.error('Error al procesar el archivo descargado');
} finally {
setTimeout(() => {
showProgressDialog = false;
currentTaskId = null;
}, 1000);
}
}
function base64ToBlob(base64: string, type: string) {
const binStr = atob(base64);
const len = binStr.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) {
arr[i] = binStr.charCodeAt(i);
}
return new Blob([arr], { type: type });
}
// Keyboard Shortcuts
useShortcuts(
'Pedimentos',
@@ -455,6 +544,15 @@
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleInterfaceAgenteAduanal}
disabled={!hasSelection}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
</Button>
</div>
</div>
</div>
@@ -475,3 +573,38 @@
</div>
</Dialog.Content>
</Dialog.Root>
<AlertDialog.Root bind:open={isWinsaiiConfirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>¿Generar Interface Agente Aduanal?</AlertDialog.Title>
<AlertDialog.Description>
Se generará el reporte WINSAAI para el pedimento seleccionado.
<div class="mt-4 flex items-center space-x-2">
<Checkbox id="byClass" bind:checked={isWinsaiiByClass} />
<Label
for="byClass"
class="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Agrupar por clase (SCAF)
</Label>
</div>
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => (isWinsaiiConfirmOpen = false)}>Cancelar</Button>
<Button onclick={confirmWinsaiiGeneration}>Generar</Button>
</div>
</AlertDialog.Content>
</AlertDialog.Root>
{#if showProgressDialog && currentTaskId}
<PdfProgressDialog
bind:open={showProgressDialog}
taskId={currentTaskId}
title={progressDialogTitle}
getStatus={currentStatusFunction}
onClose={() => (showProgressDialog = false)}
onComplete={onPdfComplete}
/>
{/if}