Merge pull request 'task/archivos_winsaai' (#184) from task/archivos_winsaai into development
Reviewed-on: ADUANASOFT/anexo76#184
This commit is contained in:
@@ -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"}
|
||||
@@ -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] = []
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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] = []
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"electronic_notices": "Avisos electrónicos",
|
||||
"back_flush": "Back Flush",
|
||||
"crossing_notice": "Aviso de cruce"
|
||||
},
|
||||
},
|
||||
"fractions": {
|
||||
"title": "Fracciones",
|
||||
"sitar": "Fracciones Sitar",
|
||||
|
||||
@@ -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}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user