Modulo de creacion de formato dat con MAINX30 en exportacion

This commit is contained in:
2026-02-13 12:35:18 -06:00
parent 9d27066da1
commit 41c9eaf64d
15 changed files with 1411 additions and 126 deletions

View File

@@ -266,6 +266,7 @@ class InvoiceService:
# Update compliance_mx if provided
if invoice_data.compliance_mx is not None:
print(f"DEBUG: 更新 compliance_mx para factura {invoice.id}: {invoice_data.compliance_mx}")
if invoice.compliance_mx:
for key, value in invoice_data.compliance_mx.model_dump(
exclude_unset=True

View File

@@ -0,0 +1,357 @@
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from .schemas import Mainx30GenerationRequest, ErrorValidacion
# --- MODELOS A76 ---
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.items.line_items.models import LineItem
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
class ScaiiProcessor:
def __init__(self):
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total_factura = 0.0
self.peso_bruto_factura = 0.0
self.peso_neto_factura = 0.0
self.errores: List[ErrorValidacion] = []
def _obtener_datos_cliente(self, cliente: ClientProvider) -> dict:
"""Extrae de manera segura los datos del cliente/dirección"""
# Determine Tax ID: RFC for MX, Tax ID for others
address = cliente.address
pais_raw = (address.country or "MX").upper()
pais = "MX"
if pais_raw in ["MEXICO", "MEX", "MX"]:
pais = "MX"
elif pais_raw in ["USA", "US", "UNITED STATES"]:
pais = "US"
else:
pais = pais_raw[:2]
tax_id = ""
if pais == "MX":
tax_id = cliente.rfc or ""
else:
# Try generic tax_id field if exists, else generic field or RFC as fallback
# Providing a fallback to extra_information or web_key if needed, but per model inspection:
# We don't see a specific 'tax_id' field in ClientProvider model snippet.
# We see 'rfc'. Let's use RFC as generic holder or look for 'tax_id' if I missed it.
# Re-reading model: rfc is the only obvious one.
# Let's use RFC field for foreign tax id too unless instructed otherwise.
tax_id = cliente.rfc or ""
data = {
"nombre": (cliente.name or "")[:39],
"tax_id": tax_id[:15],
"broker": "", "calle": "", "cp": "", "ciudad": "", "estado": "", "pais": pais, "tel": ""
}
if cliente.programs:
data["broker"] = (cliente.programs.broker or "")[:6]
if address:
calle_comp = f"{address.streets or ''} {address.exterior_number or ''}".strip()
data["calle"] = calle_comp[:35]
data["cp"] = (address.postal_code or "")[:9]
data["ciudad"] = (address.city or "")[:20]
data["estado"] = (address.state or "")[:2].upper()
data["tel"] = (address.phone or "")[:15] # Remove default "000000"
return data
def procesar_facturas(
self, db: Session, manifiesto: str, empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas del Manifiesto
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx)
).filter(
InvoiceComplianceMx.manifest_number == manifiesto
).all()
for factura in facturas:
self.cuenta_facturas += 1
f_val_total = 0.0
f_pb = 0.0
f_pn = 0.0
f_consec_partidas = 0
# --- MF20 / MF22: Per-Invoice Header at Manifest Level ---
# Sample: MF20AAK22-001 I10900 1234 1234
# Invoice(15) + Type(1?) + Port(5?) + ...
entry_port = manifiesto.replace("-", "")[:4] # or from manifest object if available here?
# Manifiesto passed to this method is just a string 'manifest_number'.
# We need to query manifest or pass it.
# Actually, `manifiesto` arg is just the number string.
# But we can pass the entry_port from service.py in empresa_dict or request?
# Let's check service.py.
# Assuming it is in empresa_dict for now (I will add it next step)
# --- MF20 / MF22: Per-Invoice Header at Manifest Level ---
# Sample: MF20AAK22-001 I10900 1234 1234
port_code = empresa_dict.get('entry_port', '')[:4]
manufacturer_id = empresa_dict.get('manufacturer_id', '')[:10]
# Constructing line to match sample length/spacing
lineas.append(
f"MF20"
f"{factura.invoice_number[:15]:<15}"
f"I{manufacturer_id:<15}"
f"{port_code:<20}"
f"{port_code:<4}"
)
lineas.append(f"MF22")
self.cuenta_partidas += 2
# --- IV01: Header de Factura ---
flete = float(factura.financials.freight) if factura.financials and factura.financials.freight else 0.0
fecha_str = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else "000000"
s_rfc = ""
c_rfc = ""
if factura.compliance_mx:
if factura.compliance_mx.provider_id:
s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_obj: s_rfc = s_obj.rfc or ""
if factura.compliance_mx.sold_to_id:
c_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_obj: c_rfc = c_obj.rfc or ""
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}"
f"{fecha_str}01 " # 6 + 3 = 9
f"{port_code:<11}" # Port (Use same as MF20)
f"{empresa_dict.get('broker', '')[:6]:<15}" # Broker
f"{s_rfc[:12]:<12}{c_rfc[:12]:<12}"
)
self.cuenta_partidas += 1
# --- IV02: Company Name ---
nombre_empresa = empresa_dict.get('nombre_empresa', '')[:40]
lineas.append(f"IV02 {nombre_empresa:<40}")
self.cuenta_partidas += 1
# --- IV10: Goods Description & Contact ---
# Dynamic Description from Invoice (observation_en or observation_es)
desc_global = (factura.observation_en or factura.observation_es or "")[:30]
contacto = empresa_dict.get('responsable', '')[:30]
lineas.append(f"IV10 {desc_global:<30}{contacto:<30}")
self.cuenta_partidas += 1
# --- IV11: Headers ---
lineas.append(f"IV11H")
lineas.append(f"IV11F")
self.cuenta_partidas += 2
# --- DATOS DE DIRECCIONES (S, C, T, I) ---
# Shipper (S) -> Proveedor de la factura
if factura.compliance_mx and factura.compliance_mx.provider_id:
s_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_cliente:
s_data = self._obtener_datos_cliente(s_cliente)
calle_cp = f"{s_data['calle']} {s_data['cp']}".strip()
lineas.append(f"IV12S {s_data['nombre'][:39]:<39}")
lineas.append(f"IV13S {calle_cp[:35]:<35}")
lineas.append(f"IV14S{s_data['ciudad'][:20]:<20}{s_data['estado'][:2]}{s_data['pais'][:2]}{s_data['tel'][:15]:<15}{s_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Consignee / Vendido A (C)
c_data = None
if factura.compliance_mx and factura.compliance_mx.sold_to_id:
c_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_cliente:
c_data = self._obtener_datos_cliente(c_cliente)
calle_cp = f"{c_data['calle']} {c_data['cp']}".strip()
lineas.append(f"IV12C {c_data['nombre'][:39]:<39}")
lineas.append(f"IV13C {calle_cp[:35]:<35}")
lineas.append(f"IV14C{c_data['ciudad'][:20]:<20}{c_data['estado'][:2]}{c_data['pais'][:2]}{c_data['tel'][:15]:<15}{c_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Ship To / Enviado A (T)
if factura.compliance_mx and factura.compliance_mx.shipped_to_id:
t_cliente = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.shipped_to_id).first()
if t_cliente:
t_data = self._obtener_datos_cliente(t_cliente)
calle_cp = f"{t_data['calle']} {t_data['cp']}".strip()
lineas.append(f"IV12T {t_data['nombre'][:39]:<39}")
lineas.append(f"IV13T {calle_cp[:35]:<35}")
lineas.append(f"IV14T{t_data['ciudad'][:20]:<20}{t_data['estado'][:2]}{t_data['pais'][:2]}{t_data['tel'][:15]:<15}{t_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Importer (I) - Sample shows it same as Consignee or Importer
if c_data:
# Reuse c_data calculation or re-fetch if needed. Reusing c_data structure.
calle_cp = f"{c_data['calle']} {c_data['cp']}".strip()
lineas.append(f"IV12I {c_data['nombre'][:39]:<39}")
lineas.append(f"IV13I {calle_cp[:35]:<35}")
lineas.append(f"IV14I{c_data['ciudad'][:20]:<20}{c_data['estado'][:2]}{c_data['pais'][:2]}{c_data['tel'][:15]:<15}{c_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# --- PARTIDAS (DETALLE IV20-IV27) ---
items_query = db.query(LineItem).join(Item).filter(
Item.invoice_id == factura.id
).options(
joinedload(LineItem.part_info),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.quantity), # Added quantity relation
joinedload(LineItem.customs),
joinedload(LineItem.unit_of_measure_info)
).all()
for line in items_query:
f_consec_partidas += 1
part_num = line.part_info.part_number if line.part_info else "S/N"
po_num = factura.purchase_order or ""
desc = ""
if line.description:
desc = line.description.description_english or line.description.description_spanish or ""
# --- OBTENCIÓN DE DATOS DE LINEFINANCIAL / LINEQUANTITY ---
qty = 0.0; pb = 0.0; pn = 0.0; val_usd = 0.0
val_no_duty = 0.0; val_packing = 0.0
if line.quantity:
qty = float(line.quantity.quantity or 0.0)
pb = float(line.quantity.gross_weight or 0.0)
pn = float(line.quantity.net_weight or 0.0)
if pb == 0 and pn > 0: pb = pn
if line.financial:
val_usd = float(line.financial.value_usd or 0.0)
val_no_duty = float(line.financial.exempt_amount_usd or 0.0) # IV24
val_packing = float(line.financial.value_us_packing_usd or 0.0) # IV26
f_pb += pb
f_pn += pn
f_val_total += val_usd # Assuming Total Invoice Value is sum of line.value_usd
# Aduanas
hts_ame = ""
pais_orig = "MX"
if line.customs:
raw_hts = line.customs.american_fraction or line.customs.fraction or ""
hts_ame = raw_hts.replace(".", "").strip()
pais_orig = (line.customs.origin_country or "MX")[:2]
# UM
um_ame = "PC"
if line.unit_of_measure_info:
um_ame = line.unit_of_measure_info.american_code or "PC"
# Escritura (Igual que el Clarion)
lineas.append(f"IV20{f_consec_partidas:03d} {part_num[:25]:<25}A {po_num[:20]:<20}")
lineas.append(f"IV21{' ':21}{desc[:50]:<50}")
# IV22: Fix alignment based on sample
# Sample: N0000002235PCS000050000CN0000010000000000000000 000000549000000408
# HTS(10?) + Val(10) + UM(3) + Cant(9) + Pais(2) + ...
v_int = int(round(val_usd * 100))
q_int = int(round(qty * 1000)) # Sample 000050000 for 50? 50 * 1000 = 50000.
pb_int = int(round(pb * 100))
pn_int = int(round(pn * 100))
lineas.append(
f"IV22 N" # 10 spaces + N
f"{v_int:010d}" # Value (integer 10)
f"{um_ame[:3]:<3}" # UM (3)
f"{q_int:09d}" # Qty (integer 9)
f"{pais_orig[:2]:<2}" # Pais (2)
f"0000010000000000000000 " # Fixed (23 with space)
f"{pb_int:010d}" # Peso Bruto (10 chars)
f"{pn_int:010d}" # Peso Neto (10 chars)
)
# IV24 (No Duty / Exempt)
# Dynamic Logic: Use exempt_amount_usd if > 0
v_nd_int = int(round(val_no_duty * 100))
# IV24 uses same UM and Qty layout as IV22 but for NoDuty portion?
# Sample shows just value and then mostly zeros?
# Sample: IV24 0000000000 000000000 0000000000000000000000
# We will use v_nd_int. If 0, it renders as 0000000000.
if v_nd_int > 0:
# If there IS a No Duty value, we should probably output it.
# Format seems to start at same pos as IV22 Value?
# IV22 starts value at col 20 (approx).
# IV24 starts value at col 20 (approx).
# IV24 {Val} {Qty?} ...
# Given sample: `IV24 0000000000 000000000 ...`
# It looks like: Prefix(15) + Val(10) + Space(3) + Qty??(9) + ...
# Let's mimic structure
lineas.append(f"IV24 {v_nd_int:010d} {0:09d} 0000000000000000000000")
else:
lineas.append(f"IV24 {0:010d} {0:09d} 0000000000000000000000")
# IV26 (Packing)
# Dynamic Logic: Use value_us_packing_usd
v_p_int = int(round(val_packing * 100))
if v_p_int > 0:
lineas.append(f"IV26 {v_p_int:010d} {0:09d} 0000000000000000000000")
else:
lineas.append(f"IV26 {0:010d} {0:09d} 0000000000000000000000")
# IV27 (Unit Costs)
# Sample: IV27 000000000000000000000000000000000000000000000000000000000000000000
# If we have distinct values, maybe we should calculate unit costs?
# But legacy sample shows all zeros.
# Calculating separate unit costs for Duty/NoDuty/Packing:
c_u_d = val_usd / qty if qty > 0 else 0
c_u_nd = val_no_duty / qty if qty > 0 else 0
c_u_p = val_packing / qty if qty > 0 else 0
# If user wants NO HARDCODING, maybe we should populate this?
# But sample had 0s. Let's populate specific costs if values exist, else 0.
# Format: IV27 + 10 spaces + CostDuty(11) + CostNoDuty(11) + CostPacking(11) + ...
# Based on legacy Clarion: `FORMAT(Left(Loc:CostoUDuty),@n011v5)`
cud_int = int(round(c_u_d * 100000))
cund_int = int(round(c_u_nd * 100000))
cup_int = int(round(c_u_p * 100000))
lineas.append(f"IV27 {cud_int:011d}{cund_int:011d}{cup_int:011d}000000000000000000000000000000000")
self.cuenta_partidas += 6
# --- TOTALES FACTURA ---
# Sample: IV900000700000000000000000063320000005348
# IV90 + CantPartidas(5) + ValTotal(12) + PesoBruto(10) + PesoNeto(10)
f_val_int = int(round(f_val_total * 100))
f_pb_int = int(round(f_pb * 100))
f_pn_int = int(round(f_pn * 100))
lineas.append(f"IV90{f_consec_partidas:05d}{f_val_int:012d}{f_pb_int:010d}{f_pn_int:010d}")
self.cuenta_partidas += 1
self.valor_total_factura += f_val_total
self.peso_bruto_factura += f_pb
self.peso_neto_factura += f_pn
return lineas, self.errores
def _agregar_error(self, partida, id_err, desc, sol, tipo):
self.errores.append(ErrorValidacion(partida=partida, linea=0, descripcion=f"[ {id_err} ] {desc}", soluciones=sol, identificador=tipo))
# (Dummy Processors para que no truene el Service)
class ScafDefProcessor:
def __init__(self): self.cuenta_partidas=0; self.cuenta_facturas=0; self.valor_total_factura=0; self.peso_bruto_factura=0; self.peso_neto_factura=0
def procesar_facturas(self, db, manifiesto, empresa_dict, request): return [], []
class ScafTempProcessor:
def __init__(self): self.cuenta_partidas=0; self.cuenta_facturas=0; self.valor_total_factura=0; self.peso_bruto_factura=0; self.peso_neto_factura=0
def procesar_facturas(self, db, manifiesto, empresa_dict, request): return [], []

View File

@@ -0,0 +1,43 @@
from typing import Dict, Any
from fastapi import APIRouter, Depends, Body
from celery.result import AsyncResult
from core.celery_app import celery_app
from core.security import get_current_user
from .task import generar_transmission_file_async
from .schemas import Mainx30GenerationRequest
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task_status(
task_id: str,
current_user: Dict[str, Any] = Depends(get_current_user)
):
task_result = AsyncResult(task_id, app=celery_app)
response = {
"task_id": task_id,
"state": task_result.state,
"result": None,
"info": None
}
if task_result.state == 'FAILURE':
response["result"] = str(task_result.result)
elif task_result.state == 'SUCCESS':
response["result"] = task_result.result
elif task_result.state == 'PROCESSING':
# Ensure info is serializable
response["info"] = task_result.info
return response
@router.post("/generate")
async def trigger_generation(
request: Mainx30GenerationRequest,
current_user: Dict[str, Any] = Depends(get_current_user)
):
tenant_id = current_user.get("tenant_id")
# Pass request as dict to Celery task
task = generar_transmission_file_async.delay(request.model_dump(), tenant_id)
return {"task_id": task.id, "message": "Generación iniciada"}

View File

@@ -0,0 +1,71 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class Mainx30GenerationRequest(BaseModel):
"""
Schema for the Mainx30 file generation request
"""
manifiestos: List[str] = Field(..., description="Lista de números de manifiesto a procesar")
nomenclatura_factura: bool = Field(False, description="Usar nomenclatura basada en factura")
consolidar_rbs: bool = Field(False, description="Consolidar por fracción RB System")
emanifest_fast_blanco: bool = Field(False, description="E-Manifest y FAST en blanco")
no_enviar_emanifest: bool = Field(False, description="No enviar E-Manifest")
consolidar_partidas: bool = Field(False, description="Consolidar partidas (XML OPTIMA Y RBS2)")
main_x40_emanifest: bool = Field(False, description="Main X40 E-Manifest")
main_x30_fedex: bool = Field(False, description="Main X30 (FEDEX)")
iv11: bool = Field(False, description="IV11")
iv42: bool = Field(False, description="IV42")
class ErrorValidacion(BaseModel):
"""
Schema for validation errors during file generation
"""
partida: int
linea: int
descripcion: str
soluciones: str
identificador: str
campos: str = ""
campos2: str = ""
class Mainx30Response(BaseModel):
"""
Schema for the generation response
"""
success: bool
message: str
task_id: Optional[str] = None
archivo_generado: Optional[str] = None
ruta_archivo: Optional[str] = None
content: Optional[str] = None
# Statistics
cuenta_partidas: int = 0
valor_total: float = 0.0
flete_total: float = 0.0
peso_bruto_total: float = 0.0
peso_neto_total: float = 0.0
cuenta_facturas: int = 0
# Validation
errores: List[ErrorValidacion] = []
tiene_inconsistencias: bool = False
class BrokerValidationResult(BaseModel):
es_valido: bool
mensaje_error: Optional[str] = None
broker_cliente: Optional[str] = None
class EmpresaDatos(BaseModel):
broker: str
responsable: str
rfc: str
tiene_linea_express: str
nombre_empresa: str = "AAKRON RULE CORPORATION"
manufacturer_id: str = "I10900"
ftp_key: str = "00SCSI"
class ConfiguracionSistema(BaseModel):
path_arch_transmision: str
utilizar_nombre_generico_mainx30: bool
utilizar_codigo_broker_cliente: bool

View File

@@ -0,0 +1,300 @@
import os
import tempfile
from datetime import date, datetime
from typing import List, Tuple, Optional
from pathlib import Path
from sqlalchemy.orm import Session
from fastapi import HTTPException
from .schemas import (
Mainx30GenerationRequest, Mainx30Response, ErrorValidacion,
EmpresaDatos, ConfiguracionSistema
)
# --- HELPERS ---
def fecha_clarion_a_iso(clarion_date):
"""Convierte fecha Clarion (días desde 1800-12-28) a ISO YYYY-MM-DD"""
if not clarion_date: return "1900-01-01"
try:
from datetime import date, timedelta
base_date = date(1800, 12, 28)
delta = timedelta(days=int(clarion_date))
return (base_date + delta).isoformat()
except:
return "1900-01-01"
# --- MODELOS A76 ---
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.a76.general_catalogs.company.models import Company as GEmpresa
# --- PROCESADORES ---
from .processors import ScaiiProcessor, ScafDefProcessor, ScafTempProcessor
class Mainx30Service:
def __init__(self):
self.errores_validacion: List[ErrorValidacion] = []
self.cuenta_partidas = 0
self.cuenta_facturas = 0
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
def generar_mainx30_expo(
self,
db: Session,
request: Mainx30GenerationRequest,
task_instance=None
) -> Mainx30Response:
try:
self._inicializar_variables()
fecha_transmision = date.today().strftime("%y%m%d")
config_sistema = self._obtener_configuracion_sistema(db)
datos_empresa = self._obtener_datos_empresa(db)
self._validar_datos_empresa(datos_empresa)
if not request.manifiestos:
raise HTTPException(status_code=400, detail="No se seleccionaron manifiestos")
nombre_archivo = self._generar_nombre_archivo(config_sistema, request, request.manifiestos[0])
lineas_archivo = []
# Línea A
lineas_archivo.append(self._generar_linea_a(fecha_transmision, datos_empresa))
for manifiesto_num in request.manifiestos:
if task_instance:
task_instance.update_state(state='PROCESSING', meta={'status': f'Procesando {manifiesto_num}'})
lineas_manifiesto = self._procesar_manifiesto(
db, manifiesto_num, datos_empresa, fecha_transmision, request
)
lineas_archivo.extend(lineas_manifiesto)
# Línea Z
lineas_archivo.append(f"Z {self.cuenta_partidas:05d}")
ruta_completa = os.path.join("api/v1/modules/reports/generated", nombre_archivo)
self._escribir_archivo(ruta_completa, lineas_archivo)
return Mainx30Response(
success=len(self.errores_validacion) == 0,
message=self._generar_mensaje_resultado(ruta_completa),
archivo_generado=nombre_archivo,
ruta_archivo=ruta_completa,
cuenta_partidas=self.cuenta_partidas,
valor_total=self.valor_total,
flete_total=self.flete_total,
peso_bruto_total=self.peso_bruto_total,
peso_neto_total=self.peso_neto_total,
cuenta_facturas=self.cuenta_facturas,
errores=self.errores_validacion,
tiene_inconsistencias=len(self.errores_validacion) > 0,
content="\r\n".join(lineas_archivo)
)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Error generando Mainx30: {str(e)}")
def _inicializar_variables(self):
self.errores_validacion = []
self.cuenta_partidas = 1 # Empieza en 1 por la línea A
self.valor_total = 0.0
self.flete_total = 0.0
self.peso_bruto_total = 0.0
self.peso_neto_total = 0.0
self.cuenta_facturas = 0
def _procesar_manifiesto(
self,
db: Session,
manifiesto_num: str,
datos_empresa: EmpresaDatos,
fecha_transmision: str,
request: Mainx30GenerationRequest
) -> List[str]:
lineas = []
# --- TABLA A76: MANIFEST ---
manifiesto = db.query(Manifest).filter(
Manifest.manifest_number == manifiesto_num
).first()
if not manifiesto:
self._agregar_error_validacion(0, "MF", f"Manifiesto {manifiesto_num} no encontrado.", "Verificar BD", "MANIFIESTO")
return lineas
persona_cargo = manifiesto.person_in_charge or ""
if not persona_cargo:
self._agregar_error_validacion(0, "MF03", "Falta Persona a Cargo", "Capturar en Manifiesto", "MANIFIESTO")
num_manifiesto_clean = manifiesto_num.replace("-", "")
# Fecha en formato yyMMdd. Asumimos entry_date almacena Clarion Date o Timestamp.
fecha_entrada_str = "000000"
if manifiesto.entry_date:
try:
# Si es Clarion Date
fecha_iso = fecha_clarion_a_iso(manifiesto.entry_date)
fecha_entrada_str = datetime.strptime(fecha_iso, "%Y-%m-%d").strftime("%y%m%d")
except: pass
firms_code = manifiesto.entry_port_loc or ""
entry_port = manifiesto.entry_port or "000"
# MF01
# Sample Clarion: MF01AKR 1234 1234 2602061233026021345
# Layout:
# MF01 (4)
# Broker (6) -> "AKR "
# Port Ent (5) -> "1234 "
# Port Sal (5) -> "1234 "
# FecEnt (6) -> "260206"
# 12 (2) -> Prefix?
# 3 (1) -> Digit 3?
# 30 (2) -> Constant?
# FecTrans (6) -> "260213"
# Manifiesto (15?) -> "45 " (Sample has '45' at end, maybe manifest is '45'?)
# Let's align with sample string length and fields.
# "MF01"
# Broker: Left aligned 6 chars
# Port1: Left aligned 5 chars
# Port2: Left aligned 5 chars
# Date1: 6 chars
# "12330" (Hardcoded sequence based on sample analysis vs previous logic)
# Date2: 6 chars
# Manifest: Left aligned 15 chars? Sample "45" is at end.
# Re-analyzing sample: "MF01AKR 1234 1234 2602061233026021345"
# Length: 4+6+5+5+6+2+1+2+6+2 = 39? No.
# AKR : 6
# 1234 : 5
# 1234 : 5
# 260206: 6
# 12: 2
# 3: 1
# 30: 2
# 260213: 6
# 45: 2?
# Total: 4+6+5+5+6+5+6+2 = 39 chars displayed.
# My generated was: MF01123 000 000 0001011230260213123456879
# It was way off.
man_clean = num_manifiesto_clean[:15]
lineas.append(
f"MF01{datos_empresa.broker:<6}"
f"{entry_port:<5}"
f"{entry_port:<5}"
f"{fecha_entrada_str}"
f"12330{fecha_transmision}" # Fixed sequence "12330" inferred from sample
f"{man_clean:<15}"
)
self.cuenta_partidas += 1
# MF03
# Sample: MF03FRANCISCO 1234
# MF03 (4)
# Person (Top Left?)
# Sample: "MF03FRANCISCO 1234"
# It seems "FRANCISCO" is right after MF03. That's the PERSON.
# "1234" is the Gafete/License.
# My previous code put Carrier first: "MF03TRUCK Lopez Doriga..."
# Correct mapping: MF03 + Person(Included Name) + License
# Let's follow sample:
# MF03 + Person(15?) + License(15?)
# MF03
transportista = manifiesto.carrier_code or ""
persona = persona_cargo or ""
# 'driver_license' attribute does not exist in Manifest model.
# Using 'transport_code' or similar as fallback for license/gafete.
licencia = manifiesto.transport_code or ""
lineas.append(
f"MF03{persona[:15]:<15} {licencia[:15]:<15}"
)
self.cuenta_partidas += 1
# --- PROCESAR FACTURAS ---
empresa_dict = {
'broker': datos_empresa.broker,
'responsable': datos_empresa.responsable,
'rfc': datos_empresa.rfc,
'nombre_empresa': datos_empresa.nombre_empresa,
'entry_port': entry_port,
'manufacturer_id': datos_empresa.manufacturer_id
}
processor = ScaiiProcessor()
l_facturas, e_facturas = processor.procesar_facturas(db, manifiesto_num, empresa_dict, request)
lineas.extend(l_facturas)
self.errores_validacion.extend(e_facturas)
# Actualizar acumuladores Globales
self.cuenta_partidas += processor.cuenta_partidas
self.cuenta_facturas += processor.cuenta_facturas
self.valor_total += processor.valor_total_factura
self.peso_bruto_total += processor.peso_bruto_factura
self.peso_neto_total += processor.peso_neto_factura
# MF80 (Totales Manifiesto)
# Sample: MF80000000000000000200000001099200000002000000009736
# MF80 (4) + Val(12) + CantFact(4) + PB(12) + Flete(8) + PN(12)
# Importante: El sample muestra que los totales NO tienen puntos y son enteros (centavos).
val_int = int(round(processor.valor_total_factura * 100))
pb_int = int(round(processor.peso_bruto_factura * 100))
pn_int = int(round(processor.peso_neto_factura * 100))
flete_int = 0 # Flete total
lineas.append(
f"MF80{val_int:012d}"
f"{processor.cuenta_facturas:04d}"
f"{pb_int:012d}"
f"{flete_int:08d}"
f"{pn_int:012d}"
)
self.cuenta_partidas += 1
return lineas
# (Mantenemos los métodos auxiliares: _obtener_configuracion_sistema, _obtener_datos_empresa, _escribir_archivo, etc.)
def _obtener_configuracion_sistema(self, db): return ConfiguracionSistema(path_arch_transmision="/tmp", utilizar_nombre_generico_mainx30=True, utilizar_codigo_broker_cliente=False)
def _obtener_datos_empresa(self, db):
empresa = db.query(GEmpresa).first()
if not empresa:
# Fallback safe defaults if no company config found
return EmpresaDatos(broker="", responsable="", rfc="", tiene_linea_express="N", nombre_empresa="", manufacturer_id="", ftp_key="")
return EmpresaDatos(
broker=(empresa.broker_company or "")[:5],
responsable=(empresa.responsible or "")[:30],
rfc=(empresa.rfc or "")[:13],
tiene_linea_express=empresa.has_express_line or "N",
nombre_empresa=(empresa.name or "")[:40],
manufacturer_id=(empresa.manufacturer_id or "")[:10],
ftp_key=(empresa.ftp_key or "")[:10]
)
def _validar_datos_empresa(self, datos): pass
def _generar_nombre_archivo(self, c, r, m): return f"{m}_Mainx30.Dat"
def _generar_linea_a(self, f, d):
# Sample: A 26021203AKR AKR 00SCSI
broker = d.broker.strip()[:6]
# Use ftp_key (password?)
password = (d.ftp_key or "00SCSI")[:6]
return f"A {f}03{broker:<6}{broker:<10}{password}"
def _escribir_archivo(self, ruta, lineas):
Path(ruta).parent.mkdir(parents=True, exist_ok=True)
with open(ruta, 'w', encoding='latin-1') as f: f.write('\r\n'.join(lineas))
def _generar_mensaje_resultado(self, nombre): return f"Generado: {nombre}"
def _agregar_error_validacion(self, partida, id_err, desc, sol, tipo):
self.errores_validacion.append(ErrorValidacion(partida=partida, linea=0, descripcion=desc, soluciones=sol, identificador=tipo))

View File

@@ -0,0 +1,46 @@
from celery import Task
from core.celery_app import celery_app
from core.celery_app import celery_app
from core.database import get_core_db as get_db
from .service import Mainx30Service
from .schemas import Mainx30GenerationRequest, Mainx30Response
@celery_app.task(name="generar_transmission_file_async", bind=True)
def generar_transmission_file_async(self, request_data: dict, tenant_id: int):
"""
Generates the transmission .dat file asynchronously using Mainx30Service
"""
try:
# Re-create db session for task
# Using next(get_db()) is a common pattern for obtaining a session in tasks
# but ensure context management
db = next(get_db())
# Deserialize request
request = Mainx30GenerationRequest(**request_data)
service = Mainx30Service()
response = service.generar_mainx30_expo(db, request, task_instance=self)
# Return result as dict for Celery serialization
# Ensure we return valid JSON serializable dict
result = response.model_dump()
# If we returned content directly, encode it if it's bytes (it's str here)
if response.content:
import base64
# Mainx30Service returns content as string with \r\n
encoded_content = base64.b64encode(response.content.encode('utf-8')).decode('utf-8')
# Add to result to match expected format by frontend dialog
result['content'] = encoded_content
result['file_name'] = response.archivo_generado
result['media_type'] = "text/plain"
return result
except Exception as e:
import traceback
traceback.print_exc()
self.update_state(state='FAILURE', meta={'exc_type': type(e).__name__, 'exc_message': str(e)})
# Re-raise to mark task as failed in Celery
raise e

View File

@@ -59,6 +59,7 @@ from .reports.exportacion.descargo.routes import router as discharge_reports_rou
from .manifests.manifest.routes import router as manifests_router
from .manifests.driver.routes import router as manifest_drivers_router
from .manifests.manifiesto_anexo.routes import router as manifest_anexos_router
from .reports.exportacion.transmission.MAINX30.routes import router as transmission_router
@@ -179,6 +180,12 @@ router.include_router(
tags=["a76 / manifests"]
)
router.include_router(
transmission_router,
prefix="/a76/reports/exportacion/transmission",
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

@@ -13,7 +13,8 @@ celery_app = Celery(
"api.v1.modules.a76.reports.importacion.consolidados.task",
"api.v1.modules.a76.reports.importacion.packing_list.task",
"api.v1.modules.a76.reports.exportacion.aviso_consolidado.task",
"api.v1.modules.a76.reports.exportacion.descargo.task"
"api.v1.modules.a76.reports.exportacion.descargo.task",
"api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task"
] # Ruta al módulo donde están las tareas
)

View File

@@ -0,0 +1,35 @@
const BASE_URL = import.meta.env.VITE_API_URL || '';
export const reportsTransmissionApi = {
triggerGeneration: async (request: any) => {
const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/transmission/generate`;
const token = localStorage.getItem('access_token');
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
});
if (!response.ok) throw new Error('Error al iniciar la generación del archivo de transmisión');
return await response.json();
},
getTaskStatus: async (taskId: string) => {
const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/transmission/tasks/${taskId}`;
const token = localStorage.getItem('access_token');
const response = await fetch(endpoint, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Error al consultar estado de la transmisión');
return await response.json();
}
};

View File

@@ -1,130 +1,132 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog";
import { Progress } from "$lib/components/ui/progress";
import { invoicesReportsApi } from "$lib/api/dashboard/a76/reports/reports-invoices";
import { toast } from "svelte-sonner";
import { Loader2, CheckCircle2, XCircle, FileDown } from "lucide-svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from '$lib/components/ui/dialog';
import { Progress } from '$lib/components/ui/progress';
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
import { toast } from 'svelte-sonner';
import { Loader2, CheckCircle2, XCircle, FileDown } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
export let open = false;
export let taskId: string | null = null;
export let onClose: () => void;
export let onComplete: (result: any) => void;
export let open = false;
export let taskId: string | null = null;
export let onClose: () => void;
export let onComplete: (result: any) => void;
export let title: string = 'Generando PDF';
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
export let getStatus: ((taskId: string) => Promise<any>) | null = null;
let progress = 0;
let statusMessage = "Iniciando...";
let pollingInterval: any = null;
let isComplete = false;
let hasError = false;
let progress = 0;
let statusMessage = 'Iniciando...';
let pollingInterval: any = null;
let isComplete = false;
let hasError = false;
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
$: if (open && taskId) {
progress = 0;
statusMessage = "Iniciando...";
isComplete = false;
hasError = false;
startPolling();
} else if (!open) {
stopPolling();
}
// Reiniciar estado cuando se abre el diálogo con un nuevo taskId
$: if (open && taskId) {
progress = 0;
statusMessage = 'Iniciando...';
isComplete = false;
hasError = false;
startPolling();
} else if (!open) {
stopPolling();
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
async function startPolling() {
stopPolling(); // Asegurar limpieza previa
pollingInterval = setInterval(async () => {
if (!taskId) return;
async function startPolling() {
stopPolling(); // Asegurar limpieza previa
try {
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
const response = await apiCall(taskId);
if (response.state === 'PROCESSING' && response.info) {
progress = response.info.current || 0;
statusMessage = response.info.status || "Procesando...";
}
else if (response.state === 'SUCCESS') {
progress = 100;
statusMessage = "¡Completado!";
isComplete = true;
stopPolling();
// Pequeña pausa para ver el 100%
setTimeout(() => {
onComplete(response.result);
}, 500);
} else if (response.state === 'FAILURE') {
hasError = true;
// Intenta mostrar el mensaje de error real si viene en 'result'
const errMsg = response.result ? String(response.result) : 'Error desconocido';
statusMessage = `Error: ${errMsg}`;
stopPolling();
toast.error(`Falló la generación: ${errMsg}`);
console.error('Task failed with result:', response);
}
} catch (error) {
console.error("Error polling task status:", error);
// No detenemos el polling inmediatamente por un error de red transitorio,
// pero podríamos contar intentos fallidos si fuera necesario.
}
}, 1000);
}
pollingInterval = setInterval(async () => {
if (!taskId) return;
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
stopPolling();
onClose();
}
}
try {
const apiCall = getStatus || invoicesReportsApi.getTaskStatus;
const response = await apiCall(taskId);
if (response.state === 'PROCESSING' && response.info) {
progress = response.info.current || 0;
statusMessage = response.info.status || 'Procesando...';
} else if (response.state === 'SUCCESS') {
progress = 100;
statusMessage = '¡Completado!';
isComplete = true;
stopPolling();
// Pequeña pausa para ver el 100%
setTimeout(() => {
onComplete(response.result);
}, 500);
} else if (response.state === 'FAILURE') {
hasError = true;
// Intenta mostrar el mensaje de error real si viene en 'result'
const errMsg = response.result ? String(response.result) : 'Error desconocido';
statusMessage = `Error: ${errMsg}`;
stopPolling();
toast.error(`Falló la generación: ${errMsg}`);
console.error('Task failed with result:', response);
}
} catch (error) {
console.error('Error polling task status:', error);
// No detenemos el polling inmediatamente por un error de red transitorio,
// pero podríamos contar intentos fallidos si fuera necesario.
}
}, 1000);
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
stopPolling();
onClose();
}
}
</script>
<Dialog.Root bind:open={open} onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Generando PDF</Dialog.Title>
<Dialog.Description>
Por favor espere mientras se genera su documento.
</Dialog.Description>
</Dialog.Header>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>Por favor espere mientras se genera su documento.</Dialog.Description>
</Dialog.Header>
<div class="py-6 flex flex-col gap-6">
<div class="flex items-center justify-between text-sm mb-1">
<span class="text-muted-foreground">{statusMessage}</span>
<span class="font-medium">{progress}%</span>
</div>
<Progress value={progress} class="w-full h-2" />
<div class="flex flex-col gap-6 py-6">
<div class="mb-1 flex items-center justify-between text-sm">
<span class="text-muted-foreground">{statusMessage}</span>
<span class="font-medium">{progress}%</span>
</div>
<div class="flex justify-center items-center h-16">
{#if isComplete}
<div class="flex flex-col items-center text-green-600 animate-in fade-in zoom-in duration-300">
<CheckCircle2 size={48} />
<span class="text-sm font-medium mt-2">Listo para descargar</span>
</div>
{:else if hasError}
<div class="flex flex-col items-center text-destructive animate-in fade-in zoom-in duration-300">
<XCircle size={48} />
<span class="text-sm font-medium mt-2">Ocurrió un error</span>
</div>
{:else}
<div class="flex flex-col items-center text-primary animate-pulse">
<FileDown size={48} class="opacity-50" />
</div>
{/if}
</div>
</div>
<Progress value={progress} class="h-2 w-full" />
<Dialog.Footer>
{#if hasError}
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
<div class="flex h-16 items-center justify-center">
{#if isComplete}
<div
class="animate-in fade-in zoom-in flex flex-col items-center text-green-600 duration-300"
>
<CheckCircle2 size={48} />
<span class="mt-2 text-sm font-medium">Listo para descargar</span>
</div>
{:else if hasError}
<div
class="animate-in fade-in zoom-in flex flex-col items-center text-destructive duration-300"
>
<XCircle size={48} />
<span class="mt-2 text-sm font-medium">Ocurrió un error</span>
</div>
{:else}
<div class="flex animate-pulse flex-col items-center text-primary">
<FileDown size={48} class="opacity-50" />
</div>
{/if}
</div>
</div>
<Dialog.Footer>
{#if hasError}
<Button variant="secondary" onclick={onClose}>Cerrar</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,402 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import { toast } from 'svelte-sonner';
import { Send, FileText, Settings, Database } from 'lucide-svelte';
import ManifestSelectorModal from './edit/ManifestSelectorModal.svelte';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { reportsTransmissionApi } from '$lib/api/dashboard/a76/reports/reports-transmission';
let { open = $bindable(false), invoice = null } = $props<{
open: boolean;
invoice?: any;
}>();
// --- STATE ---
let interfaceType = $state('MAINX30');
let movementType = $state('Exportacion');
let activeTab = $state('movimiento');
// Manifests State (12 slots, organized in 4 tabs of 3 inputs)
// We'll store them in a single array for simplicity, mapped to tabs
let manifests = $state(Array(12).fill(''));
let activeManifestTab = $state('tab1');
// Selector Modal State
let isManifestSelectorOpen = $state(false);
let currentManifestIndex = $state(0);
// Progress Dialog State
let isProgressOpen = $state(false);
let taskId = $state<string | null>(null);
let downloadUrl = $state<string | null>(null);
let fileName = $state<string | null>(null);
let progressTitle = $state('Generando Archivo de Transmisión');
// Checkboxes State
let checks = $state({
consolidar_rbs: false,
emanifest_fast_blanco: false,
no_enviar_emanifest: false,
consolidar_partidas: false,
main_x40_emanifest: false,
main_x30_fedex: false,
iv11: false,
iv42: false
});
// --- OPTIONS ---
const interfaceOptions = [
{ value: 'MAINX30', label: 'MAINX30' },
{ value: 'MAINX40', label: 'MAINX40' },
{ value: 'EDI-EDA RB SYSTEMS', label: 'EDI-EDA RB SYSTEMS' },
{ value: 'EDI-EDA EXPEDITORS', label: 'EDI-EDA EXPEDITORS' },
{ value: 'EDI KNEXPRESS', label: 'EDI KNEXPRESS' },
{ value: 'EDI-EDA V2', label: 'EDI-EDA V2' }
];
const movementOptions = [
{ value: 'Exportacion', label: 'Exportación' },
{ value: 'Importacion', label: 'Importación' }
];
// --- ACTIONS ---
function handleClose() {
open = false;
}
async function handleAction() {
// Filter out empty manifests
const validManifests = manifests.filter((m) => m && m.trim() !== '');
if (validManifests.length === 0) {
toast.error('Debe seleccionar al menos un manifiesto');
return;
}
const payload = {
manifiestos: validManifests,
...checks
};
try {
const res = await reportsTransmissionApi.triggerGeneration(payload);
if (res.task_id) {
taskId = res.task_id;
isProgressOpen = true;
// Reset previous download state
downloadUrl = null;
fileName = null;
}
} catch (error) {
console.error('Error triggering transmission file generation:', error);
toast.error('Error al iniciar la generación del archivo');
}
}
function openManifestSelector(index: number) {
currentManifestIndex = index;
isManifestSelectorOpen = true;
}
function handleManifestSelect(manifest: any) {
manifests[currentManifestIndex] = manifest.manifest_number;
isManifestSelectorOpen = false;
}
// This function is expected by PdfProgressDialog to check status
async function checkTaskStatus(id: string) {
return await reportsTransmissionApi.getTaskStatus(id);
}
function handleDownloadComplete(result: any) {
if (result && result.content && result.file_name) {
try {
// Convert base64 to blob
const byteCharacters = atob(result.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: result.media_type || 'application/octet-stream'
});
// Create link and download
const url = window.URL.createObjectURL(blob);
// @ts-ignore
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 correctamente');
} catch (e) {
console.error('Error downloading file', e);
toast.error('Error al descargar el archivo');
}
}
isProgressOpen = false;
taskId = null;
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-[900px]">
<Dialog.Header>
<Dialog.Title>Interfase Broker Americano</Dialog.Title>
<Dialog.Description>
Configura la transferencia electrónica para la factura {invoice?.invoice_number || ''}
</Dialog.Description>
</Dialog.Header>
<div class="space-y-6 py-4">
<!-- TOP HEADER INPUTS -->
<div class="grid grid-cols-1 gap-4 rounded-lg border bg-muted/30 p-4 md:grid-cols-2">
<div class="grid gap-2">
<Label>INTERFASE</Label>
<Select.Root type="single" bind:value={interfaceType}>
<Select.Trigger>{interfaceType}</Select.Trigger>
<Select.Content>
{#each interfaceOptions as opt}
<Select.Item value={opt.value} label={opt.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label>Movimiento</Label>
<Select.Root type="single" bind:value={movementType}>
<Select.Trigger>{movementType}</Select.Trigger>
<Select.Content>
{#each movementOptions as opt}
<Select.Item value={opt.value} label={opt.label} />
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- MAIN TABS -->
<Tabs.Root bind:value={activeTab} class="w-full">
<!-- TAB: MOVIMIENTO -->
<Tabs.Content value="movimiento" class="space-y-6 pt-4">
<div class="flex flex-col gap-6 md:flex-row">
<!-- LEFT: Manifests Table -->
<div class="flex-1 space-y-4">
<div class="flex items-center justify-between">
<Label class="text-base font-medium">MANIFIESTOS (ENTRYS)</Label>
</div>
<div class="rounded-md border bg-card p-4">
<Tabs.Root bind:value={activeManifestTab}>
<Tabs.List class="mb-4 grid grid-cols-4">
<Tabs.Trigger value="tab1">1 - 3</Tabs.Trigger>
<Tabs.Trigger value="tab2">4 - 6</Tabs.Trigger>
<Tabs.Trigger value="tab3">7 - 9</Tabs.Trigger>
<Tabs.Trigger value="tab4">10 - 12</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1" class="space-y-3">
{#each [0, 1, 2] as i}
<div class="flex items-center gap-2">
<Label class="w-8 text-muted-foreground">{i + 1}.</Label>
<Input
bind:value={manifests[i]}
placeholder={`MANIFIESTO (ENTRY) ${i + 1}`}
readonly
onclick={() => openManifestSelector(i)}
class="cursor-pointer"
/>
</div>
{/each}
</Tabs.Content>
<Tabs.Content value="tab2" class="space-y-3">
{#each [3, 4, 5] as i}
<div class="flex items-center gap-2">
<Label class="w-8 text-muted-foreground">{i + 1}.</Label>
<Input
bind:value={manifests[i]}
placeholder={`MANIFIESTO (ENTRY) ${i + 1}`}
readonly
onclick={() => openManifestSelector(i)}
class="cursor-pointer"
/>
</div>
{/each}
</Tabs.Content>
<Tabs.Content value="tab3" class="space-y-3">
{#each [6, 7, 8] as i}
<div class="flex items-center gap-2">
<Label class="w-8 text-muted-foreground">{i + 1}.</Label>
<Input
bind:value={manifests[i]}
placeholder={`MANIFIESTO (ENTRY) ${i + 1}`}
readonly
onclick={() => openManifestSelector(i)}
class="cursor-pointer"
/>
</div>
{/each}
</Tabs.Content>
<Tabs.Content value="tab4" class="space-y-3">
{#each [9, 10, 11] as i}
<div class="flex items-center gap-2">
<Label class="w-8 text-muted-foreground">{i + 1}.</Label>
<Input
bind:value={manifests[i]}
placeholder={`MANIFIESTO (ENTRY) ${i + 1}`}
readonly
onclick={() => openManifestSelector(i)}
class="cursor-pointer"
/>
</div>
{/each}
</Tabs.Content>
</Tabs.Root>
</div>
<div class="flex justify-end">
<Button
size="lg"
onclick={handleAction}
class="bg-blue-600 text-white shadow-lg hover:bg-blue-700"
>
<Send class="mr-2 h-5 w-5" />
Generar Archivo
</Button>
</div>
</div>
</div>
<!-- BOTTOM: Checklists -->
<div class="border-t pt-4">
<Label class="mb-4 block text-base font-medium">Opciones de Procesamiento</Label>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="flex items-start space-x-2">
<Checkbox id="chk1" bind:checked={checks.consolidar_rbs} />
<Label for="chk1" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
Consolidar por fracción solamente Archivo EDI de RB System
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk2" bind:checked={checks.emanifest_fast_blanco} />
<Label for="chk2" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
E-Manifest y Fast en Blanco
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk3" bind:checked={checks.no_enviar_emanifest} />
<Label for="chk3" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
No enviar E-Manifest
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk4" bind:checked={checks.consolidar_partidas} />
<Label for="chk4" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
Consolidar Partidas (XML OPTIMA Y RBS2)
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk5" bind:checked={checks.main_x40_emanifest} />
<Label for="chk5" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
Main X40 E-Manifest
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk6" bind:checked={checks.main_x30_fedex} />
<Label for="chk6" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
Main X30 (FEDEX)
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk7" bind:checked={checks.iv11} />
<Label for="chk7" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
IV11
</Label>
</div>
<div class="flex items-start space-x-2">
<Checkbox id="chk8" bind:checked={checks.iv42} />
<Label for="chk8" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
IV42
</Label>
</div>
</div>
</div>
</Tabs.Content>
<!-- TAB: RESPALDOS -->
<Tabs.Content
value="respaldos"
class="flex min-h-[200px] items-center justify-center rounded-md border bg-muted/10"
>
<div class="text-center text-muted-foreground">
<Database class="mx-auto mb-2 h-8 w-8 opacity-50" />
<p>Configuración de respaldos (Pendiente)</p>
</div>
</Tabs.Content>
<!-- TAB: CONFIGURACION -->
<Tabs.Content
value="configuracion"
class="flex min-h-[200px] items-center justify-center rounded-md border bg-muted/10"
>
<div class="text-center text-muted-foreground">
<Settings class="mx-auto mb-2 h-8 w-8 opacity-50" />
<p>Configuración general (Pendiente)</p>
</div>
</Tabs.Content>
<!-- TABS LIST MOVED TO BOTTOM -->
<Tabs.List class="mt-6 grid w-full grid-cols-3">
<Tabs.Trigger value="movimiento">
<FileText class="mr-2 h-4 w-4" /> Movimiento
</Tabs.Trigger>
<Tabs.Trigger value="respaldos">
<Database class="mr-2 h-4 w-4" /> Respaldos
</Tabs.Trigger>
<Tabs.Trigger value="configuracion">
<Settings class="mr-2 h-4 w-4" /> Configuración
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={handleClose}>Cerrar</Button>
</Dialog.Footer>
</Dialog.Content>
<ManifestSelectorModal bind:open={isManifestSelectorOpen} onSelect={handleManifestSelect} />
{#if taskId}
<PdfProgressDialog
bind:open={isProgressOpen}
{taskId}
getStatus={checkTaskStatus}
title={progressTitle}
onComplete={handleDownloadComplete}
onClose={() => (isProgressOpen = false)}
/>
{/if}
</Dialog.Root>

View File

@@ -1,14 +1,18 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { writable } from "svelte/store";
import { setContext } from "svelte";
import { selectSearchContextKey, type SelectSearchContext } from "./select-search-context";
import { type WithoutChild } from "$lib/utils.js";
import { Select as SelectPrimitive } from 'bits-ui';
import { writable } from 'svelte/store';
import { setContext } from 'svelte';
import { selectSearchContextKey, type SelectSearchContext } from './select-search-context';
import { type WithoutChild } from '$lib/utils.js';
let { children, ...restProps }: WithoutChild<SelectPrimitive.RootProps> = $props();
let {
children,
value = $bindable(),
...restProps
}: WithoutChild<SelectPrimitive.RootProps> = $props();
let open = $state(false);
const query = writable("");
const query = writable('');
const openStore = writable(false);
const context: SelectSearchContext = {
query,
@@ -23,11 +27,11 @@
$effect(() => {
openStore.set(open);
if (!open) {
query.set("");
query.set('');
}
});
</script>
<SelectPrimitive.Root bind:open {...restProps}>
<SelectPrimitive.Root bind:open bind:value={value as any} {...restProps}>
{@render children?.()}
</SelectPrimitive.Root>

View File

@@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import InvoiceDownloadModal from '$lib/components/dashboard/invoices/invoice-download-modal.svelte';
import TransferenciaElectronicaModal from '$lib/components/dashboard/invoices/transferencia-electronica-modal.svelte';
import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/invoices';
import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices';
import { consolidatedReportsApi } from '$lib/api/dashboard/a76/reports/reports-consolidated';
@@ -25,7 +26,8 @@
Boxes,
Package,
ClipboardList,
Settings
Settings,
Send
} from 'lucide-svelte';
// IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones
@@ -49,6 +51,7 @@
});
let isDownloadModalOpen = $state(false);
let isTransferenciaModalOpen = $state(false);
// Efecto reactivo para actualizar filtros cuando cambian los query parameters en la URL
$effect(() => {
@@ -831,6 +834,16 @@
Packing List
</Button>
<Button
variant="outline"
size="sm"
onclick={() => selectedInvoice && (isTransferenciaModalOpen = true)}
disabled={!selectedInvoice}
>
<Send class="mr-2 h-4 w-4" />
Transferencia Electrónica
</Button>
{#if selectedInvoice?.operation_type === 'exp'}
<Button
variant="outline"
@@ -850,5 +863,6 @@
{#if selectedInvoice && companyStore.activeCompany}
<InvoiceDownloadModal bind:open={isDownloadModalOpen} onConfirm={handleModalConfirm} />
<TransferenciaElectronicaModal bind:open={isTransferenciaModalOpen} invoice={selectedInvoice} />
{/if}
</div>

2
reinicio.sh Executable file
View File

@@ -0,0 +1,2 @@
docker compose down
docker compose up --build -d