Merge pull request 'feature/trasnferencia_mainx30' (#154) from feature/trasnferencia_mainx30 into development

Reviewed-on: ADUANASOFT/anexo76#154
This commit is contained in:
2026-02-20 15:32:00 +00:00
26 changed files with 3377 additions and 127 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

@@ -0,0 +1,370 @@
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from datetime import datetime
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.flete_total = 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"""
address = cliente.address
pais_raw = (address.country or "MX").upper() if address else "MX"
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 = cliente.rfc or ""
data = {
"nombre": (cliente.name or "")[:39],
"tax_id": tax_id[:15],
"broker": "", "calle": "", "cp": "", "ciudad": "", "estado": "", "pais": pais, "tel": "",
"manufacturer_id": "", "tipo_ext_nac": "E"
}
# TipoExtNac logic from Clarion: N (National/ManufacturerID) or E (External/TaxID)
# We'll use TaxID as default for definitive if not specified
data["manufacturer_id"] = (cliente.programs.manufacturer_id or "")[:15] if cliente.programs else ""
if cliente.programs:
data["broker"] = (cliente.programs.broker or "")[:6]
if address:
calle_base = address.streets or ""
num_base = address.exterior_number or ""
# Prevent 'None' string
calle_comp = f"{calle_base} {num_base}".strip()
# Clarion expects 20 chars for city_state: 5 CP + 11 City + 4 State
cp_formatted = (address.postal_code or "")[:5]
city_formatted = (address.city or "")[:11]
state_formatted = (address.state or "")[:4]
data["city_state"] = f"{cp_formatted:<5}{city_formatted:<11}{state_formatted:<4}"
data["calle"] = calle_comp[:35]
data["cp"] = (address.postal_code or "")[:9]
data["ciudad"] = (address.city or "").strip()[:20]
data["estado"] = (address.state or "").strip()[:2].upper()
data["tel"] = (address.phone or "")[:15]
return data
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))
class ScafDefProcessor(ScaiiProcessor):
"""Procesador para Importación Definitiva basado en lógica Clarion (SComprasMexID)"""
def __init__(self):
super().__init__()
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas por número
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics)
).filter(
InvoiceHeader.invoice_number.in_(facturas_nums)
).all()
entry_port = request.entry_port or ""
exit_port = request.exit_port or ""
fecha_trans = datetime.now().strftime("%y%m%d")
entry_port_desc = empresa_dict.get('entry_port_desc', 'PUERTO ENTRADA')[:15]
exit_port_desc = empresa_dict.get('exit_port_desc', 'PUERTO SALIDA')[:15]
main_activity = empresa_dict.get('main_activity', 'RAW MATERIAL')[:30]
city_state = empresa_dict.get('city_state', '')[:30]
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
# MF01: Header
mod_trans = factura.logistics.transport_mode if factura.logistics else "30"
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else fecha_trans
lineas.append(
f"MF01{empresa_dict['broker'][:6]:<6}"
f"{exit_port[:5]:<5}"
f"{entry_port[:5]:<5}"
f"{fecha_trans}"
f" {mod_trans[:2]:<2}"
f"{f_fecha}"
f"{factura.invoice_number[:15]:<15}"
)
self.cuenta_partidas += 1
# MF03
conductor = (factura.logistics.driver_name or "")[:23] if factura.logistics else ""
carrier = (factura.logistics.carrier_id or "")[:10]
lineas.append(
f"MF03{carrier:<10}{conductor:<23}{entry_port_desc:<15}{exit_port_desc:<15}"
)
self.cuenta_partidas += 1
# MF20
num_transporte = (factura.logistics.transport_num or "")[:15] if factura.logistics else ""
# Use dynamic city/state from entry_port description if available, otherwise blank
# Clarion format: 5 digit Zip (if relevant) + City State
# We will use the entry_port_desc (e.g. CD. JUAREZ CHIH) and assume a dummy zip '00000' if not parsed
# Or better, just use the entry_port_desc fully aligned
# Using entry_port_desc directly instead of hardcoded default
cruce_desc = entry_port_desc[:20] if entry_port_desc else " "
lineas.append(
f"MF20{factura.invoice_number[:15]:<15}I{num_transporte:<15}00000{cruce_desc:<20}"
f"{exit_port[:5]:<5}{entry_port_desc:<15} "
)
self.cuenta_partidas += 1
# MF22
lineas.append(f"MF22{main_activity:<60}")
self.cuenta_partidas += 1
# IV01
flete = int(round(float(factura.financials.freight or 0))) if factura.financials else 0
self.flete_total += float(factura.financials.freight or 0) if factura.financials else 0.0
s_tax = ""; c_tax = ""
if factura.compliance_mx:
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_tax = (c_obj.rfc or "")[:12]
if factura.compliance_mx.provider_id:
s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_obj: s_tax = (s_obj.rfc or "")[:12]
# IV01 uses 11 spaces then 'C' per Clarion logic
# Adjusted validation for RFCs to avoid crashes or None
s_tax_safe = s_tax if s_tax else " "
c_tax_safe = c_tax if c_tax else " "
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}{f_fecha}78{' ':<12}C"
f"{empresa_dict['broker'][:6]:<6}{flete:08d}{s_tax_safe:<13}{c_tax_safe:<13}"
)
self.cuenta_partidas += 1
# IV02: Vendor/Manufacturer Info
# Clarion: CliVen:ManufacterID, CliVen:Nombre
manufacturer_id = ""
vendor_name = ""
if factura.compliance_mx and factura.compliance_mx.provider_id:
vendor = db.query(ClientProvider).options(
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if vendor:
manufacturer_id = (vendor.programs.manufacturer_id or "")[:16] if vendor.programs else ""
vendor_name = (vendor.name or "")[:39]
lineas.append(f"IV02{manufacturer_id:<16}{vendor_name:<39}")
self.cuenta_partidas += 1
# IV10: Responsible
lineas.append(f"IV10 {main_activity[:30]:<30}{empresa_dict['responsable'][:30]:<30}")
self.cuenta_partidas += 1
# IV11: Obs
lineas.append(f"IV11H")
self.cuenta_partidas += 1
obs_line = f"IV11F{(factura.notes or '')[:70]:<70}" if request.iv11 else "IV11F"
lineas.append(obs_line)
self.cuenta_partidas += 1
# Participants IV12-14 (S, C, T, I)
# Shipper (S)
if factura.compliance_mx and factura.compliance_mx.provider_id:
s_cliente = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_cliente:
s_data = self._obtener_datos_cliente(s_cliente)
broker_impo = s_data['broker'] or empresa_dict['broker']
lineas.append(f"IV12S{broker_impo[:6]:<6}{s_data['nombre'][:39]:<39}")
# Ensure spacing aligns with Clarion example (30 spaces + 35 address + space + CP)
lineas.append(f"IV13S{'':<30}{s_data['calle']:<35} {s_data['cp']:<9}")
# IV14: City(20)+State(2)+Country(2)+Phone(30?? No, Clarion example shows Phone then TaxID)
# Clarion example: IV14S... CITY... STMX... TAXID... TEL...
# Re-aligning based on provided example:
# IV14SAKRON NEUS 16-0919851 00000
# City (20) State (2) Country (2) Space(30) TaxID(15) Tel(5?)
lineas.append(f"IV14S{s_data['ciudad']:<20}{s_data['estado']:<2}{s_data['pais']:<2}{'':<30}{s_data['tax_id']:<15}{s_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Consignee (C), Ship To (T), and Importer (I)
if factura.compliance_mx and factura.compliance_mx.sold_to_id:
c_cliente = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == factura.compliance_mx.sold_to_id).first()
if c_cliente:
c_data = self._obtener_datos_cliente(c_cliente)
broker_impo = c_data['broker'] or empresa_dict['broker']
# Consignee (C)
lineas.append(f"IV12C{broker_impo[:6]:<6}{c_data['nombre'][:39]:<39}")
lineas.append(f"IV13C{'':<30}{c_data['calle']:<35} {c_data['cp']:<9}")
lineas.append(f"IV14C{c_data['ciudad']:<20}{c_data['estado']:<2}{c_data['pais']:<2}{'':<30}{c_data['tax_id']:<15}{c_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Ship To (T) - check if different
t_id = factura.compliance_mx.shipped_to_id
if t_id and t_id != factura.compliance_mx.sold_to_id:
t_cl = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == t_id).first()
if t_cl:
t_data = self._obtener_datos_cliente(t_cl)
b_t = t_data['broker'] or empresa_dict['broker']
lineas.append(f"IV12T{b_t[:6]:<6}{t_data['nombre'][:39]:<39}")
lineas.append(f"IV13T{'':<30}{t_data['calle']:<35} {t_data['cp']:<9}")
lineas.append(f"IV14T{t_data['ciudad']:<20}{t_data['estado']:<2}{t_data['pais']:<2}{'':<30}{t_data['tax_id']:<15}{t_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Importer (I) - Usually same as Consignee in Definitive unless ShippedBy is set
i_id = factura.compliance_mx.shipped_by_id
if i_id and i_id != factura.compliance_mx.sold_to_id and i_id != t_id:
i_cl = db.query(ClientProvider).options(
joinedload(ClientProvider.address),
joinedload(ClientProvider.programs)
).filter(ClientProvider.id == i_id).first()
if i_cl:
i_data = self._obtener_datos_cliente(i_cl)
b_i = i_data['broker'] or empresa_dict['broker']
lineas.append(f"IV12I{b_i[:6]:<6}{i_data['nombre'][:39]:<39}")
lineas.append(f"IV13I{'':<30}{i_data['calle']:<35} {i_data['cp']:<9}")
lineas.append(f"IV14I{i_data['ciudad']:<20}{i_data['estado']:<2}{i_data['pais']:<2}{'':<30}{i_data['tax_id']:<15}{i_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
else:
# Fallback: repeat C as I if not specified (following Clarion pattern)
lineas.append(f"IV12I{broker_impo[:6]:<6}{c_data['nombre'][:39]:<39}")
lineas.append(f"IV13I{'':<30}{c_data['calle']:<35} {c_data['cp']:<9}")
lineas.append(f"IV14I{c_data['ciudad']:<20}{c_data['estado']:<2}{c_data['pais']:<2}{'':<30}{c_data['tax_id']:<15}{c_data['tel'][:5]:<5}")
self.cuenta_partidas += 3
# Items IV20-27
items_headers = db.query(Item).filter(Item.invoice_id == factura.id).all()
item_ids = [ih.id for ih in items_headers]
if item_ids:
items_query = db.query(LineItem).filter(
LineItem.item_id.in_(item_ids)
).options(
joinedload(LineItem.part_info),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
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")[:25]
po = (line.financial.purchase_order or "")[:20] if line.financial else ""
# IV20
lineas.append(f"IV20{f_consec_partidas:03d} {part_num:<25}C {po:<20}")
self.cuenta_partidas += 1
# IV21
desc_ingles = (line.description.description_english or "")[:50] if line.description else ""
lineas.append(f"IV21 {desc_ingles:<50}")
self.cuenta_partidas += 1
# Data for IV22
val_me = float(line.financial.value_usd or 0) if line.financial else 0.0
qty = float(line.quantity.quantity or 0) if line.quantity else 0.0
pb = float(line.quantity.gross_weight or 0) if line.quantity else 0.0
pn = float(line.quantity.net_weight or 0) if line.quantity else 0.0
costo_u = float(line.financial.unit_price_usd or 0) if line.financial else 0.0
um = (line.unit_of_measure_info.american_code or "PCS")[:3] if line.unit_of_measure_info else "PCS"
pais = ((line.customs.origin_country or "MX")[:2]).upper() if line.customs else "MX"
hts = (line.customs.american_fraction or "").replace(".", "")[:10] if line.customs else ""
# Formato Clarion @n...v...
val_int = int(round(val_me * 100)) # @n010v2
qty_int = int(round(qty * 100)) # @n09v2
pb_int = int(round(pb * 100)) # @n09v2
pn_int = int(round(pn * 100)) # @n09v2
costo_int = int(round(costo_u * 100000)) # @n011v5
prog_ind = "S" if line.customs and line.customs.has_origin_certificate else "N"
# IV22
lineas.append(
f"IV22{hts:<10}{prog_ind}"
f"{val_int:010d}{um:<3}{qty_int:09d}{pais:<2}0000010000000000100000 "
f"{pb_int:09d}{pn_int:09d}"
)
self.cuenta_partidas += 1
# IV24, IV26 (Zeros per logic)
lineas.append(f"IV24 0000000000 000000000 0000000000000000000000")
lineas.append(f"IV26 0000000000 000000000 0000000000000000000000")
self.cuenta_partidas += 2
# IV27
lineas.append(f"IV27{hts:<10}{costo_int:011d}0000000000000000000000000000000000000 ")
self.cuenta_partidas += 1
# IV42 (Optional)
if request.iv42:
um4 = um[:4]
lineas.append(f"IV42 0{qty_int:09d}{um4:<4}0000000000 0000000000 0000000000 0000000000 ")
self.cuenta_partidas += 1
f_val_total += val_me
f_pb += pb
f_pn += pn
# IV90: Footer per Invoice
fv_int = int(round(f_val_total * 100))
fpb_int = int(round(f_pb * 100))
fpn_int = int(round(f_pn * 100))
lineas.append(f"IV90{f_consec_partidas:05d}{fv_int:012d}{fpb_int:010d}{fpn_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

View File

@@ -0,0 +1,41 @@
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_definitiva_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':
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")
task = generar_transmission_definitiva_async.delay(request.model_dump(), tenant_id)
return {"task_id": task.id, "message": "Generación Definitiva iniciada"}

View File

@@ -0,0 +1,72 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class Mainx30GenerationRequest(BaseModel):
"""
Schema for the Mainx30 file generation request for Definitive Import
"""
manifiestos: Optional[List[str]] = Field(None, description="Lista de números de manifiesto a procesar")
facturas: Optional[List[str]] = Field(None, description="Lista de números de factura a procesar")
entry_port: Optional[str] = Field(None, description="Puerto de entrada")
exit_port: Optional[str] = Field(None, description="Puerto de salida")
regimen: Optional[str] = Field("Definitiva", description="Regimen de importación (Temporal/Definitiva)")
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 EmpresaDatos(BaseModel):
broker: str
responsable: str
rfc: str
tiene_linea_express: str
nombre_empresa: str = ""
manufacturer_id: str = ""
ftp_key: str = "00SCSI"
main_activity: str = "RAW MATERIAL"
city_state: str = ""
class ConfiguracionSistema(BaseModel):
path_arch_transmision: str
utilizar_nombre_generico_mainx30: bool
utilizar_codigo_broker_cliente: bool

View File

@@ -0,0 +1,175 @@
import os
import tempfile
from datetime import date, datetime
from typing import List, Tuple, Optional, Dict, Any
from pathlib import Path
from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException
from .schemas import (
Mainx30GenerationRequest, Mainx30Response, ErrorValidacion,
EmpresaDatos, ConfiguracionSistema
)
# --- MODELOS A76 ---
from api.v1.modules.a76.general_catalogs.company.models import Company as GEmpresa
from api.v1.modules.a76.general_catalogs.ports.models import Port
# --- PROCESADORES ---
from .processors import ScafDefProcessor
class Mainx30DefinitiveService:
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(
self,
db: Session,
request: Mainx30GenerationRequest,
task_instance=None
) -> Mainx30Response:
try:
self._inicializar_variables()
# 1. Obtener Datos de Empresa
datos_empresa = self._obtener_datos_company(db)
emp_dict = datos_empresa.model_dump()
# 2. Obtener Descripciones de Puertos
if request.entry_port:
p_ent = db.query(Port).filter(Port.port_code == request.entry_port).first()
if p_ent: emp_dict['entry_port_desc'] = p_ent.description or p_ent.location_description or ""
if request.exit_port:
p_sal = db.query(Port).filter(Port.port_code == request.exit_port).first()
if p_sal: emp_dict['exit_port_desc'] = p_sal.description or p_sal.location_description or ""
# 3. Fecha de Transmisión (YYMMDD)
fecha_transmision = datetime.now().strftime("%y%m%d")
# 4. Determinar Procesador (Always ScafDefProcessor for this service)
processor = ScafDefProcessor()
# Both 'Definitiva' and 'DEFINITIVO SCAF' use the same heavy processor
# 5. Procesar Facturas
if not request.facturas:
raise HTTPException(status_code=400, detail="No se proporcionaron facturas para procesar.")
l_facturas, e_facturas = processor.procesar_facturas(db, request.facturas, emp_dict, request)
self.errores_validacion.extend(e_facturas)
# 6. Construir Líneas del Archivo
lineas = []
# Línea A
broker = (datos_empresa.broker or "")[:6]
# Clarion uses Broker twice in 'A' record for Definitive
ftp_key = (datos_empresa.ftp_key or "00SCSI")[:6]
lineas.append(f"A {fecha_transmision}03{broker:<6}{broker:<10}{ftp_key:<6}")
self.cuenta_partidas += 1
# Agregar Líneas de Facturas
lineas.extend(l_facturas)
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
self.flete_total = processor.flete_total
# MF80 (Totales Globales)
val_int = int(round(self.valor_total * 100))
pb_int = int(round(self.peso_bruto_total * 10000))
pn_int = int(round(self.peso_neto_total * 10000))
flete_int = int(round(self.flete_total * 100))
lineas.append(
f"MF80{val_int:012d}"
f"{self.cuenta_facturas:04d}"
f"{pb_int:012d}"
f"{flete_int:08d}"
f"{pn_int:012d}"
)
self.cuenta_partidas += 1
# Línea Z (Total de líneas)
lineas.append(f"Z {self.cuenta_partidas:05d}")
self.cuenta_partidas += 1
# 7. Generar Nombre y Guardar
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
if len(request.facturas) > 1:
nombre_archivo = f"MULTIPLE_Mainx30.dat"
if request.nomenclatura_factura and len(request.facturas) == 1:
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
content = '\r\n'.join(lineas)
return Mainx30Response(
success=len(self.errores_validacion) == 0,
message="Archivo generado" if len(self.errores_validacion) == 0 else "Archivo generado con errores de validación",
archivo_generado=nombre_archivo,
ruta_archivo="",
content=content,
errores_validacion=self.errores_validacion,
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,
tiene_inconsistencias=len(self.errores_validacion) > 0
)
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Error generando Mainx30 Definitivo: {str(e)}")
def _inicializar_variables(self):
self.errores_validacion = []
self.cuenta_partidas = 0
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 _obtener_datos_company(self, db: Session) -> EmpresaDatos:
empresa = db.query(GEmpresa).options(joinedload(GEmpresa.addresses)).first()
if not empresa:
return EmpresaDatos(broker="", responsable="", rfc="", tiene_linea_express="N", nombre_empresa="", manufacturer_id="", ftp_key="", main_activity="", city_state="")
city_state = ""
main_addr = next((a for a in (empresa.addresses or []) if a.address_type == 'main'), None)
if not main_addr and empresa.addresses:
main_addr = empresa.addresses[0]
if main_addr:
cp = (main_addr.postal_code or "")[:5]
city = (main_addr.city or "")[:11]
state = (main_addr.state or "")[:4]
city_state = f"{cp:<5}{city:<11}{state:<4}"
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],
main_activity=(empresa.main_activity or "")[:30],
city_state=city_state[:30]
)

View File

@@ -0,0 +1,45 @@
from core.celery_app import celery_app
from core.database import get_core_db as get_db
from .service import Mainx30DefinitiveService
from .schemas import Mainx30GenerationRequest
@celery_app.task(name="generar_transmission_definitiva_async", bind=True)
def generar_transmission_definitiva_async(self, request_data: dict, tenant_id: int):
"""
Celery task to generate Mainx30 file for Definitive Import
"""
try:
# Re-create db session for task
db = next(get_db())
# Deserialize request
request = Mainx30GenerationRequest(**request_data)
service = Mainx30DefinitiveService()
response = service.generar_mainx30(db, request, task_instance=self)
# Return result as dict for Celery serialization
result = response.model_dump()
# Add base64 encoding for content to match frontend expectations
if response.content:
import base64
encoded_content = base64.b64encode(response.content.encode('utf-8')).decode('utf-8')
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 = traceback.format_exc()
self.update_state(
state='FAILURE',
meta={
'exc_type': type(e).__name__,
'exc_message': str(e),
'traceback': traceback_print
}
)
raise e

View File

@@ -0,0 +1,428 @@
from typing import List, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from datetime import datetime
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.flete_total = 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"""
address = cliente.address
pais_raw = (address.country or "MX").upper() if address else "MX"
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 = 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()
# Clarion expects 20 chars for city_state: 5 CP + 11 City + 4 State
cp_formatted = (address.postal_code or "")[:5]
city_formatted = (address.city or "")[:11]
state_formatted = (address.state or "")[:4]
data["city_state"] = f"{cp_formatted:<5}{city_formatted:<11}{state_formatted:<4}"
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]
return data
def procesar_facturas(
self, db: Session, manifiesto: str, empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
# This base method is used for Manifest-based processing (Exportacion/Legacy)
lineas = []
self.errores = []
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))
# --- PROCESADORES ESPECIFICOS ---
class ScafDefProcessor(ScaiiProcessor):
"""Procesador para Importación Definitiva"""
def __init__(self):
super().__init__()
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
# Placeholder for Definitiva logic
lineas = []
self.errores = []
return lineas, self.errores
class ScafTempProcessor(ScaiiProcessor):
"""Procesador para Importación Temporal basado en lógica Clarion"""
def __init__(self):
super().__init__()
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas por número (Importación Temporal trabaja por factura)
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics)
).filter(
InvoiceHeader.invoice_number.in_(facturas_nums)
).all()
# Helper for ports from request
entry_port = request.entry_port or ""
exit_port = request.exit_port or ""
# Date for transmission records (YYMMDD)
fecha_trans = datetime.now().strftime("%y%m%d")
# Port descriptions from empresa_dict (populated in service.py)
entry_port_desc = empresa_dict.get('entry_port_desc', 'PUERTO ENTRADA')[:15]
exit_port_desc = empresa_dict.get('exit_port_desc', 'PUERTO SALIDA')[:15]
main_activity = empresa_dict.get('main_activity', 'RAW MATERIAL')[:30]
city_state = empresa_dict.get('city_state', '')[:30]
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
# MF01: Header per Invoice in Importacion Temporal
mod_trans = factura.logistics.transport_mode if factura.logistics else "30"
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else fecha_trans
lineas.append(
f"MF01{empresa_dict['broker'][:6]:<6}"
f"{exit_port[:5]:<5}"
f"{entry_port[:5]:<5}"
f"{fecha_trans}"
f" {mod_trans[:2]:<2}"
f"{f_fecha}"
f"{factura.invoice_number[:15]:<15}"
)
self.cuenta_partidas += 1
# MF03: Dynamic Driver/Carrier info
conductor = (factura.logistics.driver_name or "") if factura.logistics else ""
# Carrier Logic from Clarion:
# IF ERRORCODE() = 35 THEN Loc:NumTransporte = '00000TRUCK'
# ELSE IF GenTra:NombreCorto = '' THEN Loc:NumTransporte = GenTra:Nombre
# ELSE Loc:NumTransporte = GenTra:NombreCorto
carrier = "00000TRUCK"
if factura.logistics:
# Logic simplified: assume carrier_id holds the correct code/name or fallback
carrier = (factura.logistics.carrier_id or "00000TRUCK")
lineas.append(
f"MF03{carrier[:10]:<10}{conductor[:23]:<23}{entry_port_desc:<15}{exit_port_desc:<15}"
)
self.cuenta_partidas += 1
# MF20
num_transporte = (factura.logistics.transport_num or "") if factura.logistics else ""
lineas.append(
f"MF20{factura.invoice_number[:15]:<15}I{num_transporte[:15]:<15}{city_state[:20]:<20}"
f"{exit_port[:5]:<5}{entry_port_desc:<15} "
)
self.cuenta_partidas += 1
# MF22
lineas.append(f"MF22{main_activity:<60}")
self.cuenta_partidas += 1
# IV01: Header
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else "000000"
flete = int(round(float(factura.financials.freight or 0))) if factura.financials else 0
self.flete_total += float(factura.financials.freight or 0) if factura.financials else 0.0
s_tax = ""; c_tax = ""
if factura.compliance_mx:
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_tax = c_obj.rfc[:12] if c_obj.rfc else ""
if factura.compliance_mx.provider_id:
s_obj = db.query(ClientProvider).filter(ClientProvider.id == factura.compliance_mx.provider_id).first()
if s_obj: s_tax = s_obj.rfc[:12] if s_obj.rfc else ""
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}{f_fecha}{entry_port:<5}{' ':<11}C"
f"{empresa_dict['broker'][:6]:<6}{flete:08d}{c_tax:<13}"
)
self.cuenta_partidas += 1
# IV02: Shipper Name
lineas.append(f"IV02 {empresa_dict['nombre_empresa'][:40]:<40}")
self.cuenta_partidas += 1
# IV10: Responsible
lineas.append(f"IV10 {main_activity[:30]:<30}{empresa_dict['responsable'][:30]:<30}")
self.cuenta_partidas += 1
# IV11: Obs
lineas.append(f"IV11H")
lineas.append(f"IV11F")
self.cuenta_partidas += 2
# IV12-14 (S, C, T, I)
# Shipper (S)
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)
lineas.append(f"IV12S {s_data['nombre'][:39]:<39}")
lineas.append(f"IV13S {s_data['calle'][:35]:<35}{s_data['cp']:<9}")
lineas.append(f"IV14S{s_data['ciudad'][:20]:<20}{s_data['state_full'][:2] if 'state_full' in s_data else s_data['estado'][:2]}{s_data['pais'][:2]}{s_data['tel'][:30]:<30}{s_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Consignee (C), Ship To (T), and Intermediate (I)
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)
l12c = f"IV12C {c_data['nombre'][:39]:<39}"
l13c = f"IV13C {c_data['calle'][:35]:<35}{c_data['cp']:<9}"
l14c = f"IV14C{c_data['ciudad'][:20]:<20}{c_data['estado'][:2]}{c_data['pais'][:2]}{c_data['tel'][:30]:<30}{c_data['tax_id']:<15}00000"
# Output C
lineas.extend([l12c, l13c, l14c])
self.cuenta_partidas += 3
# T (Ship To) - Only if different from C
t_id = factura.compliance_mx.shipped_to_id
if t_id and t_id != factura.compliance_mx.sold_to_id:
t_cl = db.query(ClientProvider).filter(ClientProvider.id == t_id).first()
if t_cl:
t_data = self._obtener_datos_cliente(t_cl)
lineas.append(f"IV12T {t_data['nombre'][:39]:<39}")
lineas.append(f"IV13T {t_data['calle'][:35]:<35}{t_data['cp']:<9}")
lineas.append(f"IV14T{t_data['ciudad'][:20]:<20}{t_data['estado'][:2]}{t_data['pais'][:2]}{t_data['tel'][:30]:<30}{t_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# I (Intermediate) - Only if different from C and T
i_id = factura.compliance_mx.shipped_by_id
if i_id and i_id != factura.compliance_mx.sold_to_id and i_id != t_id:
i_cl = db.query(ClientProvider).filter(ClientProvider.id == i_id).first()
if i_cl:
i_data = self._obtener_datos_cliente(i_cl)
lineas.append(f"IV12I {i_data['nombre'][:39]:<39}")
lineas.append(f"IV13I {i_data['calle'][:35]:<35}{i_data['cp']:<9}")
lineas.append(f"IV14I{i_data['ciudad'][:20]:<20}{i_data['estado'][:2]}{i_data['pais'][:2]}{i_data['tel'][:30]:<30}{i_data['tax_id']:<15}00000")
self.cuenta_partidas += 3
# Partidas IV20, IV21, IV22, IV24, IV26, IV27
# Let's get Item IDs first to ensure we find them
items_headers = db.query(Item).filter(Item.invoice_id == factura.id).all()
item_ids = [ih.id for ih in items_headers]
if item_ids:
items_query = db.query(LineItem).filter(
LineItem.item_id.in_(item_ids)
).options(
joinedload(LineItem.part_info),
joinedload(LineItem.description),
joinedload(LineItem.financial),
joinedload(LineItem.quantity),
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"
# IV20
lineas.append(f"IV20{f_consec_partidas:03d} {part_num[:25]:<25}C")
self.cuenta_partidas += 1
# IV21
lineas.append(f"IV21")
self.cuenta_partidas += 1
# Data
val_me = float(line.financial.value_usd or 0) if line.financial else 0.0
qty = float(line.quantity.quantity or 0) if line.quantity else 0.0
pb = float(line.quantity.gross_weight or 0) if line.quantity else 0.0
pn = float(line.quantity.net_weight or 0) if line.quantity else 0.0
costo_u = float(line.financial.unit_price_usd or 0) if line.financial else 0.0
um = line.unit_of_measure_info.american_code if line.unit_of_measure_info else "PCS"
pais = (line.customs.origin_country or "MX")[:2] if line.customs else "MX"
hts = (line.customs.american_fraction or "").replace(".", "")[:10] if line.customs else ""
val_int = int(round(val_me * 10000))
qty_int = int(round(qty * 10000))
pb_int = int(round(pb * 10000))
pn_int = int(round(pn * 10000))
costo_int = int(round(costo_u * 100000))
# IV22
lineas.append(
f"IV22{hts:<10} "
f"{val_int:010d}{um[:3]:<3}{qty_int:09d}{pais:<2}0000010000000000100000 "
f"{pb_int:010d}{pn_int:010d}"
)
self.cuenta_partidas += 1
# IV24, IV26 (Zeros)
lineas.append(f"IV24 0000000000 000000000 0000000000000000000000")
lineas.append(f"IV26 0000000000 000000000 0000000000000000000000")
self.cuenta_partidas += 2
# IV27
lineas.append(f"IV27{hts:<10}{costo_int:011d}0000000000000000000000000000000000000")
self.cuenta_partidas += 1
f_val_total += val_me
f_pb += pb
f_pn += pn
# IV90: Footer per Invoice
# IV90
f_val_int = int(round(f_val_total * 100))
f_pb_int = int(round(f_pb * 10000))
f_pn_int = int(round(f_pn * 10000))
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
class ScaiiTempProcessor(ScafTempProcessor):
"""
Procesador para Importación Temporal (Versión Ligera/SCAII)
Se salta los registros MF20, MF22, IV10-14 para mayor velocidad y menor detalle.
"""
def procesar_facturas(
self, db: Session, facturas_nums: List[str], empresa_dict: Dict[str, Any], request: Mainx30GenerationRequest
) -> Tuple[List[str], List[ErrorValidacion]]:
lineas = []
self.errores = []
# 1. Traer Facturas por número
facturas = db.query(InvoiceHeader).join(
InvoiceComplianceMx, InvoiceHeader.id == InvoiceComplianceMx.invoice_id
).options(
joinedload(InvoiceHeader.financials),
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics)
).filter(
InvoiceHeader.invoice_number.in_(facturas_nums)
).all()
entry_port = request.entry_port or ""
exit_port = request.exit_port or ""
fecha_trans = datetime.now().strftime("%y%m%d")
entry_port_desc = empresa_dict.get('entry_port_desc', 'PUERTO ENTRADA')[:15]
exit_port_desc = empresa_dict.get('exit_port_desc', 'PUERTO SALIDA')[:15]
for factura in facturas:
self.cuenta_facturas += 1
f_val_total = 0.0
f_consec_partidas = 0
# MF01
mod_trans = factura.logistics.transport_mode if factura.logistics else "30"
f_fecha = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else fecha_trans
lineas.append(
f"MF01{empresa_dict['broker'][:6]:<6}{exit_port[:5]:<5}{entry_port[:5]:<5}"
f"{fecha_trans} {mod_trans[:2]:<2}{f_fecha}{factura.invoice_number[:15]:<15}"
)
self.cuenta_partidas += 1
# MF03
conductor = (factura.logistics.driver_name or "") if factura.logistics else ""
carrier = (factura.logistics.carrier_id or "00000TRUCK")
lineas.append(f"MF03{carrier[:10]:<10}{conductor[:23]:<23}{entry_port_desc:<15}{exit_port_desc:<15}")
self.cuenta_partidas += 1
# IV01
f_fecha_iv = factura.invoice_date.strftime("%y%m%d") if factura.invoice_date else "000000"
flete = int(round(float(factura.financials.freight or 0))) if factura.financials else 0
self.flete_total += float(factura.financials.freight or 0) if factura.financials else 0.0
lineas.append(
f"IV01{factura.invoice_number[:15]:<15}{f_fecha_iv}{entry_port:<5}{' ':<11}C"
f"{empresa_dict['broker'][:6]:<6}{flete:08d}{'':<13}"
)
self.cuenta_partidas += 1
# Partidas IV20, IV21, IV22
items_headers = db.query(Item).filter(Item.invoice_id == factura.id).all()
item_ids = [ih.id for ih in items_headers]
if item_ids:
items_query = db.query(LineItem).filter(LineItem.item_id.in_(item_ids)).all()
for line in items_query:
f_consec_partidas += 1
part_num = line.part_info.part_number if line.part_info else "S/N"
# IV20
lineas.append(f"IV20{f_consec_partidas:03d} {part_num[:25]:<25}C")
self.cuenta_partidas += 1
# IV22
qty = float(line.quantity.quantity or 0) if line.quantity else 0.0
val_me = float(line.financial.value_usd or 0) if line.financial else 0.0
hts = (line.customs.american_fraction or "").replace(".", "")[:10] if line.customs else ""
val_int = int(round(val_me * 10000))
qty_int = int(round(qty * 10000))
lineas.append(f"IV22{hts:<10} {val_int:010d}PCS{qty_int:09d}MX0000010000000000100000 00000000000000000000")
self.cuenta_partidas += 1
f_val_total += val_me
# IV90
fv_int = int(round(f_val_total * 100))
lineas.append(f"IV90{f_consec_partidas:05d}{fv_int:012d}00000000000000000000")
self.cuenta_partidas += 1
self.valor_total_factura += f_val_total
return lineas, self.errores

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_temporal_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_temporal_async.delay(request.model_dump(), tenant_id)
return {"task_id": task.id, "message": "Generación Temporal iniciada"}

View File

@@ -0,0 +1,77 @@
from typing import List, Optional, Any
from pydantic import BaseModel, Field
class Mainx30GenerationRequest(BaseModel):
"""
Schema for the Mainx30 file generation request
"""
manifiestos: Optional[List[str]] = Field(None, description="Lista de números de manifiesto a procesar")
facturas: Optional[List[str]] = Field(None, description="Lista de números de factura a procesar")
entry_port: Optional[str] = Field(None, description="Puerto de entrada")
exit_port: Optional[str] = Field(None, description="Puerto de salida")
regimen: Optional[str] = Field("Temporal", description="Regimen de importación (Temporal/Definitiva)")
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"
main_activity: str = "RAW MATERIAL"
city_state: str = ""
class ConfiguracionSistema(BaseModel):
path_arch_transmision: str
utilizar_nombre_generico_mainx30: bool
utilizar_codigo_broker_cliente: bool

View File

@@ -0,0 +1,177 @@
import os
import tempfile
from datetime import date, datetime
from typing import List, Tuple, Optional, Dict, Any
from pathlib import Path
from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException
from .schemas import (
Mainx30GenerationRequest, Mainx30Response, ErrorValidacion,
EmpresaDatos, ConfiguracionSistema
)
# --- 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
from api.v1.modules.a76.general_catalogs.ports.models import Port
# --- PROCESADORES ---
from .processors import ScaiiProcessor, ScafDefProcessor, ScafTempProcessor, ScaiiTempProcessor
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(
self,
db: Session,
request: Mainx30GenerationRequest,
task_instance=None
) -> Mainx30Response:
try:
self._inicializar_variables()
# 1. Obtener Datos de Empresa
datos_empresa = self._obtener_datos_company(db)
emp_dict = datos_empresa.model_dump()
# 2. Obtener Descripciones de Puertos
if request.entry_port:
p_ent = db.query(Port).filter(Port.port_code == request.entry_port).first()
if p_ent: emp_dict['entry_port_desc'] = p_ent.description or p_ent.location_description or ""
if request.exit_port:
p_sal = db.query(Port).filter(Port.port_code == request.exit_port).first()
if p_sal: emp_dict['exit_port_desc'] = p_sal.description or p_sal.location_description or ""
# 3. Fecha de Transmisión (Clarion @D11 = mm/dd/yy, but example uses YYMMDD)
fecha_transmision = datetime.now().strftime("%y%m%d")
# 4. Determinar Procesador
processor = ScafTempProcessor() # Now 'Temporal' defaults to Heavy (SCAF) logic per user request
if request.regimen == "TEMPORAL SCAF":
processor = ScaiiTempProcessor() # 'TEMPORAL SCAF' uses light (SCAII) logic
elif request.regimen == "Definitiva" or request.regimen == "DEFINITIVO SCAF":
processor = ScafDefProcessor()
# 5. Procesar Facturas
if not request.facturas:
raise HTTPException(status_code=400, detail="No se proporcionaron facturas para procesar.")
l_facturas, e_facturas = processor.procesar_facturas(db, request.facturas, emp_dict, request)
self.errores_validacion.extend(e_facturas)
# 6. Construir Líneas del Archivo
lineas = []
# Línea A
broker = (datos_empresa.broker or "")[:6]
ftp_key = (datos_empresa.ftp_key or "00SCSI")[:6]
lineas.append(f"A {fecha_transmision}03{broker:<6}{broker:<10}{ftp_key:<6}")
self.cuenta_partidas += 1
# Agregar Líneas de Facturas
lineas.extend(l_facturas)
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 Globales)
val_int = int(round(self.valor_total * 100))
pb_int = int(round(self.peso_bruto_total * 10000))
pn_int = int(round(self.peso_neto_total * 10000))
flete_int = 0
lineas.append(
f"MF80{val_int:012d}"
f"{self.cuenta_facturas:04d}"
f"{pb_int:012d}"
f"{flete_int:08d}"
f"{pn_int:012d}"
)
self.cuenta_partidas += 1
# Línea Z (Total de líneas)
lineas.append(f"Z {self.cuenta_partidas:05d}")
self.cuenta_partidas += 1
# 7. Generar Nombre y Guardar
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
if len(request.facturas) > 1:
nombre_archivo = f"MULTIPLE_Mainx30.dat"
if request.nomenclatura_factura and len(request.facturas) == 1:
nombre_archivo = f"{request.facturas[0][:15]}_Mainx30.dat"
content = '\r\n'.join(lineas)
return Mainx30Response(
success=len(self.errores_validacion) == 0,
message="Archivo generado" if len(self.errores_validacion) == 0 else "Archivo generado con errores de validación",
archivo_generado=nombre_archivo,
ruta_archivo="",
content=content,
errores_validacion=self.errores_validacion,
cuenta_partidas=self.cuenta_partidas,
valor_total=self.valor_total,
peso_bruto_total=self.peso_bruto_total,
peso_neto_total=self.peso_neto_total,
cuenta_facturas=self.cuenta_facturas
)
except HTTPException:
raise
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 = 0
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 _obtener_datos_company(self, db: Session) -> EmpresaDatos:
empresa = db.query(GEmpresa).options(joinedload(GEmpresa.addresses)).first()
if not empresa:
return EmpresaDatos(broker="", responsable="", rfc="", tiene_linea_express="N", nombre_empresa="", manufacturer_id="", ftp_key="", main_activity="", city_state="")
# Get city/state from main address, or first found
city_state = ""
main_addr = next((a for a in (empresa.addresses or []) if a.address_type == 'main'), None)
if not main_addr and empresa.addresses:
main_addr = empresa.addresses[0]
if main_addr:
# Clarion expects 20 chars for city_state: 5 CP + 11 City + 4 State
cp = (main_addr.postal_code or "")[:5]
city = (main_addr.city or "")[:11]
state = (main_addr.state or "")[:4]
city_state = f"{cp:<5}{city:<11}{state:<4}"
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],
main_activity=(empresa.main_activity or "")[:30],
city_state=city_state[:30]
)

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_temporal_async", bind=True)
def generar_transmission_temporal_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(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

@@ -39,6 +39,9 @@ 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
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
@@ -122,6 +125,24 @@ router.include_router(
tags=["a76 / manifests"]
)
router.include_router(
transmission_router,
prefix="/a76/reports/exportacion/transmission",
tags=["a76 / reports"]
)
router.include_router(
transmission_temporal_router,
prefix="/a76/reports/importacion/transmission/temporal",
tags=["a76 / reports"]
)
router.include_router(
transmission_definitive_router,
prefix="/a76/reports/importacion/transmission/definitive",
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

@@ -18,7 +18,10 @@ celery_app = Celery(
"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.imports.tasks"
"api.v1.modules.a76.imports.tasks",
"api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task",
"api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task",
"api.v1.modules.a76.reports.importacion.transmission.definitive.MAINX30.task"
] # Ruta al módulo donde están las tareas
)

View File

@@ -0,0 +1,95 @@
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();
},
triggerTemporalGeneration: async (request: any) => {
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/transmission/temporal/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 temporal');
return await response.json();
},
getTemporalTaskStatus: async (taskId: string) => {
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/transmission/temporal/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 temporal');
return await response.json();
},
triggerDefinitiveGeneration: async (request: any) => {
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/transmission/definitive/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 definitiva');
return await response.json();
},
getDefinitiveTaskStatus: async (taskId: string) => {
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/transmission/definitive/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 definitiva');
return await response.json();
}
};

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Search, Loader2 } from 'lucide-svelte';
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
import { companyStore } from '$lib/stores/company.svelte';
import { toast } from 'svelte-sonner';
let {
open = $bindable(false),
regimen = 'Temporal',
onSelect
}: {
open: boolean;
regimen: string;
onSelect: (invoice: Invoice) => void;
} = $props();
let invoices = $state<Invoice[]>([]);
let loading = $state(false);
let searchTerm = $state('');
async function searchInvoices() {
if (!companyStore.activeCompany) return;
loading = true;
try {
let filters: any = {
operation_type: 'imp',
invoice_number: searchTerm
};
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
filters.invoice_type = 'TEM';
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
filters.invoice_type = 'DEF';
}
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters);
if (res.data) {
invoices = res.data.items || [];
}
} catch (e) {
console.error('Error searching invoices:', e);
toast.error('Error al buscar facturas');
} finally {
loading = false;
}
}
function handleSelect(invoice: Invoice) {
onSelect(invoice);
open = false;
}
$effect(() => {
if (open) {
searchInvoices();
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Seleccionar Factura ({regimen})</Dialog.Title>
<Dialog.Description>
Busca y selecciona una factura del catálogo de importación para el régimen {regimen}.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4 py-4">
<div class="flex gap-2">
<div class="relative flex-1">
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por número..."
class="h-9 pl-8"
bind:value={searchTerm}
onkeydown={(e) => e.key === 'Enter' && searchInvoices()}
/>
</div>
<Button size="sm" onclick={searchInvoices} disabled={loading}>
{#if loading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Buscar
</Button>
</div>
<div class="max-h-[400px] overflow-y-auto rounded-md border text-xs">
<table class="w-full">
<thead class="sticky top-0 bg-muted/90 text-left backdrop-blur-sm">
<tr>
<th class="p-3 font-semibold tracking-wider text-muted-foreground uppercase"
>Número de Factura</th
>
<th class="p-3 font-semibold tracking-wider text-muted-foreground uppercase"
>Pedimento</th
>
</tr>
</thead>
<tbody class="divide-y">
{#if invoices.length === 0}
<tr>
<td colspan="2" class="p-12 text-center text-muted-foreground">
{#if loading}
Buscando facturas...
{:else}
No se encontraron resultados
{/if}
</td>
</tr>
{:else}
{#each invoices as invoice}
<tr
class="group cursor-pointer transition-colors hover:bg-muted/50"
onclick={() => handleSelect(invoice)}
>
<td
class="p-3 font-mono font-bold text-primary transition-colors group-hover:text-primary/80"
>
{invoice.invoice_number}
</td>
<td class="max-w-[400px] truncate p-3 text-muted-foreground italic">
{invoice.compliance_mx?.pedimento_r1 ||
invoice.compliance_mx?.pedimento_id ||
'-'}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</Dialog.Content>
</Dialog.Root>

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,679 @@
<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, Folder, X } from 'lucide-svelte';
import ManifestSelectorModal from './edit/ManifestSelectorModal.svelte';
import InvoiceSelectorModal from './edit/InvoiceSelectorModal.svelte';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import PortSelectorDialog from '$lib/components/dashboard/export/manifest/modals/port-selector-dialog.svelte';
import { reportsTransmissionApi } from '$lib/api/dashboard/a76/reports/reports-transmission';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table';
import { invoicesApi, type Invoice } from '$lib/api/dashboard/a76/invoices';
import { companyStore } from '$lib/stores/company.svelte';
import { manifestApi as manifestsApi } from '$lib/api/dashboard/a76/manifests';
let { open = $bindable(false), invoice = null } = $props<{
open: boolean;
invoice?: any;
}>();
// --- STATE ---
let interfaceType = $state('MAINX30');
let movementType = $state('Exportacion');
let regimen = $state('Temporal');
let activeTab = $state('movimiento');
let manifests = $state<string[]>([]);
// Selector Modal State
let isManifestSelectorOpen = $state(false);
let currentManifestIndex = $state(0);
// Invoice Manual State (12 slots)
let manualInvoices = $state<string[]>([]);
let isInvoiceSelectorOpen = $state(false);
let currentInvoiceIndex = $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');
let isTemporalTask = $state(false);
let isDefinitiveTask = $state(false);
// Ports State
let entryPort = $state('');
let exitPort = $state('');
let openEntryPortDialog = $state(false);
let openExitPortDialog = $state(false);
// Invoices State
let invoices = $state<Invoice[]>([]);
let loading = $state(false);
let items = $state<any[]>([]); // manifest items
let selectedItems = $state<Set<string>>(new Set());
let selectedInvoices = $state<Set<number>>(new Set());
// Assuming selectedItems and requestEmail are defined elsewhere or will be added
// requestEmail removed as placeholder
// Checkboxes State (matching backend schemas)
let checks = $state({
nomenclatura_factura: false,
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() {
if (movementType === 'Importacion') {
const validInvoices = manualInvoices.filter((i) => i && i.trim() !== '');
if (validInvoices.length === 0) {
toast.error('Debe seleccionar al menos una factura');
return;
}
if (!entryPort || !exitPort) {
toast.error('Debe seleccionar tanto el puerto de entrada como el de salida');
return;
}
const payload: any = {
regimen,
facturas: validInvoices,
entry_port: entryPort,
exit_port: exitPort,
...checks
};
try {
let res;
if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
res = await reportsTransmissionApi.triggerDefinitiveGeneration(payload);
isDefinitiveTask = true;
isTemporalTask = false;
} else {
res = await reportsTransmissionApi.triggerTemporalGeneration(payload);
isTemporalTask = true;
isDefinitiveTask = false;
}
if (res.task_id) {
taskId = res.task_id;
isProgressOpen = true;
downloadUrl = null;
fileName = null;
}
} catch (error) {
console.error('Error triggering transmission file generation:', error);
toast.error('Error al iniciar la generación del archivo');
}
} else {
// Exportacion Logic (Manifests)
const validManifests = manifests.filter((m) => m && m.trim() !== '');
if (validManifests.length === 0) {
toast.error('Debe seleccionar al menos un manifiesto');
return;
}
const payload: any = {
manifiestos: validManifests,
...checks
};
try {
const res = await reportsTransmissionApi.triggerGeneration(payload);
isTemporalTask = false;
if (res.task_id) {
taskId = res.task_id;
isProgressOpen = true;
downloadUrl = null;
fileName = null;
}
} catch (error) {
console.error('Error triggering transmission file generation:', error);
toast.error('Error al iniciar la generación del archivo');
}
}
}
async function loadManifests() {
if (!companyStore.activeCompany?.id) return;
loading = true;
try {
// If Importacion, load Invoices instead
if (movementType === 'Importacion') {
let filters: any = {
operation_type: 'imp'
};
// Filter by Regimen
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
filters.invoice_type = 'TEM';
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
filters.invoice_type = 'DEF';
}
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 100, filters);
invoices = res?.data?.items || [];
} else {
// Existing Manifest Logic
const res = await manifestsApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: 100,
status: 'open'
});
items = res?.data?.items || [];
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Error al cargar datos');
} finally {
loading = false;
}
}
$effect(() => {
if (open && companyStore.activeCompany?.id) {
loadManifests();
// Pre-fill first slot if we have a specific invoice and it's empty
if (invoice?.invoice_number && !manualInvoices.includes(invoice.invoice_number)) {
manualInvoices.push(invoice.invoice_number);
}
}
});
// Re-load when movement type changes
// Re-load when movement type or regimen changes
$effect(() => {
if (open && movementType) {
// Trigger re-load when movementType or regimen changes
// We access regimen here so it becomes a dependency
const currentRegimen = regimen;
loadManifests();
// Clear selections
selectedItems.clear();
selectedInvoices.clear();
}
});
function openManifestSelector(index: number) {
currentManifestIndex = index;
isManifestSelectorOpen = true;
}
function handleManifestSelect(manifest: any) {
if (currentManifestIndex === -1) {
manifests.push(manifest.manifest_number);
} else {
manifests[currentManifestIndex] = manifest.manifest_number;
}
isManifestSelectorOpen = false;
}
function openInvoiceSelector(index: number) {
currentInvoiceIndex = index;
isInvoiceSelectorOpen = true;
}
function handleInvoiceSelect(invoice: any) {
if (currentInvoiceIndex === -1) {
manualInvoices.push(invoice.invoice_number);
} else {
manualInvoices[currentInvoiceIndex] = invoice.invoice_number;
}
isInvoiceSelectorOpen = false;
}
// This function is expected by PdfProgressDialog to check status
async function checkTaskStatus(id: string) {
if (isDefinitiveTask) {
return await reportsTransmissionApi.getDefinitiveTaskStatus(id);
}
if (isTemporalTask) {
return await reportsTransmissionApi.getTemporalTaskStatus(id);
}
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-[1200px]">
<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-4 py-2">
<!-- TOP HEADER INPUTS -->
<div class="grid grid-cols-1 gap-4 rounded-lg border p-3 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>
{#if movementType === 'Importacion'}
<div class="grid gap-2">
<Label>Regimen</Label>
<Select.Root type="single" bind:value={regimen}>
<Select.Trigger>{regimen}</Select.Trigger>
<Select.Content>
<Select.Item value="Temporal" label="Temporal" />
<Select.Item value="Definitiva" label="Definitiva" />
<Select.Item value="TEMPORAL SCAF" label="TEMPORAL SCAF" />
<Select.Item value="DEFINITIVO SCAF" label="DEFINITIVO SCAF" />
</Select.Content>
</Select.Root>
</div>
{/if}
</div>
<!-- MAIN TABS -->
<Tabs.Root bind:value={activeTab} class="w-full">
<!-- TAB: MOVIMIENTO -->
<Tabs.Content value="movimiento" class="space-y-4 pt-2">
<div class="flex flex-col gap-4 md:flex-row">
<!-- LEFT: Manifests Table -->
<div class="flex-1 space-y-3">
{#if movementType === 'Exportacion'}
<div class="flex items-center justify-between py-1">
<Label class="text-base font-medium">MANIFIESTOS (ENTRYS)</Label>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{#each manifests as m, i}
<div
class="group relative flex flex-col items-center justify-center rounded-lg border-2 border-solid border-blue-500 bg-blue-50/30 p-4 transition-all"
role="button"
tabindex="0"
onclick={() => openManifestSelector(i)}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && openManifestSelector(i)}
>
<span class="absolute top-1 left-2 text-[10px] font-bold text-blue-400"
>{i + 1}</span
>
<div
class="w-full cursor-pointer truncate text-center text-sm font-semibold text-blue-700"
>
{m}
</div>
<button
class="absolute -top-2 -right-2 z-20 rounded-full bg-destructive p-1 text-white shadow-md hover:scale-110 active:scale-95"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
manifests.splice(i, 1);
}}
>
<X class="h-3 w-3" />
</button>
</div>
{/each}
<!-- Add Slot -->
<div
class="flex h-[72px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted p-4 transition-all hover:border-blue-400 hover:bg-blue-50/50"
role="button"
tabindex="0"
onclick={() => openManifestSelector(-1)}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && openManifestSelector(-1)}
>
<Folder class="mb-1 h-4 w-4 text-muted-foreground" />
<span class="text-xs font-medium text-muted-foreground">Agregar...</span>
</div>
</div>
{/if}
<div class="rounded-md border p-3">
{#if movementType === 'Importacion'}
<div class="mb-2 text-base font-medium">FACTURAS ({regimen?.toUpperCase()})</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
{#each manualInvoices as m, i}
<div
class="group relative flex flex-col items-center justify-center rounded-lg border-2 border-solid border-emerald-500 bg-emerald-50/30 p-4 transition-all"
role="button"
tabindex="0"
onclick={() => openInvoiceSelector(i)}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && openInvoiceSelector(i)}
>
<span class="absolute top-1 left-2 text-[10px] font-bold text-emerald-400"
>{i + 1}</span
>
<div
class="w-full cursor-pointer truncate text-center text-sm font-semibold text-emerald-700"
>
{m}
</div>
<button
class="absolute -top-2 -right-2 z-20 rounded-full bg-destructive p-1 text-white shadow-md hover:scale-110 active:scale-95"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
manualInvoices.splice(i, 1);
}}
>
<X class="h-3 w-3" />
</button>
</div>
{/each}
<!-- Add Slot -->
<div
class="flex h-[72px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted p-4 transition-all hover:border-emerald-400 hover:bg-emerald-50/50"
role="button"
tabindex="0"
onclick={() => openInvoiceSelector(-1)}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') && openInvoiceSelector(-1)}
>
<Folder class="mb-1 h-4 w-4 text-muted-foreground" />
<span class="text-xs font-medium text-muted-foreground">Agregar...</span>
</div>
</div>
{/if}
</div>
<!-- Ports Selection (Only for Importacion) -->
{#if movementType === 'Importacion'}
<div class="mt-2 grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Puerto Entrada</Label>
<div class="relative">
<Input bind:value={entryPort} placeholder="Seleccionar puerto..." readonly />
<div class="absolute top-0 right-0 flex h-full">
{#if entryPort}
<Button
variant="ghost"
size="icon"
onclick={() => (entryPort = '')}
title="Limpiar"
>
<X class="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
onclick={() => (openEntryPortDialog = true)}
title="Seleccionar"
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div class="space-y-2">
<Label>Puerto Salida</Label>
<div class="relative">
<Input bind:value={exitPort} placeholder="Seleccionar puerto..." readonly />
<div class="absolute top-0 right-0 flex h-full">
{#if exitPort}
<Button
variant="ghost"
size="icon"
onclick={() => (exitPort = '')}
title="Limpiar"
>
<X class="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
onclick={() => (openExitPortDialog = true)}
title="Seleccionar"
>
<Folder class="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
<!-- BOTTOM: Checklists -->
<div class="border-t pt-2">
<Label class="mb-2 block text-base font-medium">Opciones de Procesamiento</Label>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
<div class="flex items-start space-x-2">
<Checkbox id="chk0" bind:checked={checks.nomenclatura_factura} />
<Label for="chk0" class="cursor-pointer pt-0.5 text-sm leading-none font-normal">
Consolidar por Factura (Nomenclatura)
</Label>
</div>
<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-4 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>
<Button onclick={handleAction} class="bg-blue-600 text-white shadow hover:bg-blue-700">
<Send class="mr-2 h-4 w-4" />
Generar Archivo
</Button>
</Dialog.Footer>
</Dialog.Content>
<ManifestSelectorModal bind:open={isManifestSelectorOpen} onSelect={handleManifestSelect} />
<InvoiceSelectorModal
bind:open={isInvoiceSelectorOpen}
{regimen}
onSelect={handleInvoiceSelect}
/>
{#if taskId}
<PdfProgressDialog
bind:open={isProgressOpen}
{taskId}
title={progressTitle}
getStatus={checkTaskStatus}
onComplete={handleDownloadComplete}
onClose={() => (isProgressOpen = false)}
/>
{/if}
<PortSelectorDialog
bind:open={openEntryPortDialog}
onSelect={(item) => (entryPort = item.port_code)}
/>
<PortSelectorDialog
bind:open={openExitPortDialog}
onSelect={(item) => (exitPort = item.port_code)}
/>
</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(() => {
@@ -830,6 +833,16 @@
Packing List
</Button>
<Button
variant="outline"
size="sm"
onclick={() => (isTransferenciaModalOpen = true)}
disabled={!companyStore.activeCompany}
>
<Send class="mr-2 h-4 w-4" />
Transferencia Electrónica
</Button>
{#if selectedInvoice?.operation_type === 'exp'}
<Button
variant="outline"
@@ -847,7 +860,8 @@
<!-- ... -->
{#if selectedInvoice && companyStore.activeCompany}
{#if 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