Nuevos formatos de transferencia electronica, ademas de ajsytes en la parte visual y ademas manejos de incosistencias

This commit is contained in:
2026-05-28 10:39:20 -05:00
parent 9cb77d6e16
commit 6855eb9531
15 changed files with 2661 additions and 73 deletions

View File

@@ -0,0 +1,31 @@
import base64
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from .schemas import XmlOptimaExpoRequest
from .service import XmlOptimaService
router = APIRouter()
@router.post("/exportacion/generate")
async def generate_optima_expo(
request: XmlOptimaExpoRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
result = XmlOptimaService().generar_expo(db, request)
if not result.success:
raise HTTPException(status_code=500, detail=result.message)
return {
"success": True,
"message": result.message,
"file_name": result.archivo_generado,
"media_type": "application/xml",
"content": base64.b64encode(result.content.encode("utf-8")).decode("utf-8"),
"inconsistencias": result.inconsistencias,
}

View File

@@ -0,0 +1,17 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class XmlOptimaExpoRequest(BaseModel):
manifest_numbers: List[str] = Field(..., description="Números de manifiesto a procesar")
consolidar_partidas: bool = Field(False, description="Consolidar partidas en una sola por factura (VarLoc:ConsolidarPartidas)")
class XmlOptimaResponse(BaseModel):
success: bool
message: str
archivo_generado: Optional[str] = None
content: Optional[str] = None
cuenta_manifiestos: int = 0
cuenta_partidas: int = 0
inconsistencias: List[str] = []

View File

@@ -0,0 +1,764 @@
"""Generador de XML Optima para Exportación (manifiestos con shipments)."""
from datetime import date
from decimal import Decimal
from typing import List, Optional
from sqlalchemy.orm import Session, joinedload
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.invoices.models import (
InvoiceComplianceMx,
InvoiceHeader,
WeightUnit,
)
from api.v1.modules.a76.items.line_quantities.models import LineQuantity
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.manifests.driver.models import ManifestDriver
from api.v1.modules.a76.manifests.manifest.models import Manifest
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.transportation.drivers.models import Driver
from api.v1.modules.a76.transportation.trailers.models import Trailer
from api.v1.modules.a76.transportation.transporters.models import Transporter
from api.v1.modules.a76.transportation.vehicles.models import Vehicle
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.states.models import State
from .schemas import XmlOptimaExpoRequest, XmlOptimaResponse
# =============================================================================
# Helpers de formato
# =============================================================================
def _xml_escape(s: Optional[str]) -> str:
if not s:
return ""
return (
str(s)
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _clean_filename(s: str) -> str:
"""Equivale al loop de Clarion que quita /\\:*"?<>| y - del nombre del manifiesto."""
bad = set('/\\:*"?<>|-')
return "".join(c for c in (s or "") if c not in bad)
def _format_entry_date(entry_date: Optional[int]) -> str:
"""entry_date está en BD como entero YYYYMMDD. Devuelve YYYY-MM-DD."""
if not entry_date:
return ""
s = str(int(entry_date)).zfill(8)
return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
def _format_entry_hour(entry_hour: Optional[int]) -> str:
"""entry_hour en BD como entero HHMM. Devuelve HH:MM."""
if entry_hour is None:
return ""
s = str(int(entry_hour)).zfill(4)
return f"{s[:2]}:{s[2:4]}"
def _format_pedimento_clean(p: Optional[Pedimentos]) -> str:
"""Genera 'YY+customs+license+number' (sin guiones) como en Clarion:
SUB(YEAR(PedMat:Fecha_Inicio),3,2) & PedimentoExpo_sin_guiones.
Aquí: pedimento.year ya está como 2 dígitos."""
if not p:
return ""
yy = (p.year or "")[-2:]
return f"{yy}{p.customs_office or ''}{p.license or ''}{p.pedimento_number or ''}"
def _csv_append(acc: str, value: str) -> str:
"""Append value a una lista separada por comas, sin duplicar."""
if not value:
return acc
if not acc:
return value
if value in acc.split(", "):
return acc
return f"{acc}, {value}"
# =============================================================================
# Validadores (acumulan inconsistencias)
# =============================================================================
def _validate_trip_header(
transporter: Optional[Transporter],
manifest: Manifest,
issues: list,
) -> tuple[str, str]:
"""Valida transportista + manifiesto. Retorna (preparerSCAC, carrierSCAC)."""
preparer_scac = ""
carrier_scac = ""
if not transporter:
issues.append(
f"No existe el Transportista (clave: '{manifest.carrier_code or ''}') del Manifiesto '{manifest.manifest_number}'. "
f"Solución: capturar el transportista con código y SCAC code."
)
else:
preparer_scac = transporter.transporter_key or ""
carrier_scac = transporter.loader_code or ""
if not preparer_scac:
issues.append(
"Falta capturar el Código de Transportista. "
"Solución: capturar la información correspondiente en los datos del Transportista."
)
if not carrier_scac:
issues.append(
"Falta capturar el Código del Cargador (SCAC). "
"Solución: capturar la información correspondiente en los datos del Transportista."
)
if not manifest.entry_date:
issues.append(
f"Falta capturar la Fecha de Entrada del Manifiesto '{manifest.manifest_number}'. "
"Solución: capturar la información en los datos del manifiesto."
)
if manifest.entry_hour is None:
issues.append(
f"Falta capturar la Hora de Entrada del Manifiesto '{manifest.manifest_number}'. "
"Solución: capturar la información en los datos del manifiesto."
)
if not manifest.entry_port:
issues.append(
f"Falta capturar el Puerto de Entrada del Manifiesto '{manifest.manifest_number}'. "
"Solución: capturar la información en los datos del manifiesto."
)
return preparer_scac, carrier_scac
def _validate_client_optima(
db: Session,
client: Optional[ClientProvider],
client_key: str,
role: str,
manifest_num: str,
issues: list,
) -> dict:
"""
Valida un cliente (Shipper o Consignee) para XML Optima.
Retorna dict con: name, address, address2, city, zip, state, country, contact, phone, email.
"""
out = {
"name": "", "address": "", "address2": "", "city": "", "zip": "",
"state": "", "country": "", "contact": "", "phone": "", "email": "",
}
if not client:
issues.append(
f"No existe el {role} con clave '{client_key}' del Manifiesto '{manifest_num}'. "
f"Solución: capturar el cliente con datos obligatorios."
)
return out
display = client.short_name or client.name or client_key
if not client.name:
issues.append(f"Falta capturar el nombre del cliente '{display}' ({role}). "
"Solución: capturar la información en los datos del cliente.")
out["name"] = client.name or ""
addr = client.address
if not (addr and addr.streets):
issues.append(f"Falta capturar la dirección del cliente '{display}' ({role}). "
"Solución: capturar la información en los datos del cliente.")
if addr:
out["address"] = f"{(addr.streets or '').strip()} {(addr.exterior_number or '').strip()}".strip()
out["address2"] = addr.neighborhood or ""
if not addr.city:
issues.append(f"Falta capturar la ciudad del cliente '{display}' ({role}). "
"Solución: capturar la información en los datos del cliente.")
out["city"] = addr.city or ""
if not addr.postal_code:
issues.append(f"Falta capturar el código postal del cliente '{display}' ({role}). "
"Solución: capturar la información en los datos del cliente.")
out["zip"] = addr.postal_code or ""
# Estado: mexicano o americano según type_nat_foreign
state_obj = None
if addr.country and addr.state:
state_obj = (
db.query(State)
.filter(State.m3_key == addr.country, State.description == addr.state)
.first()
)
if (client.type_nat_foreign or "").upper() == "N":
state_key = (state_obj.mex_key if state_obj else "") or ""
if not state_key:
issues.append(
f"Falta capturar la clave del estado mexicano '{addr.state or ''}' del cliente '{display}'. "
"Solución: capturar la información en el catálogo de Países/Estados."
)
out["state"] = state_key
else:
# Clave americana del estado: el modelo State no tiene ame_key, queda como inconsistencia
issues.append(
f"Falta la clave americana del estado '{addr.state or ''}' del cliente '{display}'. "
"Solución: el catálogo de Estados no soporta clave americana actualmente; capturar manualmente."
)
out["state"] = ""
# País: clave americana
country_obj = (
db.query(Country).filter(Country.m3_key == addr.country).first()
if addr.country else None
)
ame_country = (country_obj.ame_key if country_obj else "") or ""
if not ame_country:
issues.append(
f"Falta capturar la clave americana del País '{addr.country or ''}' del cliente '{display}'. "
"Solución: capturar la información en el catálogo de Países."
)
out["country"] = ame_country
out["contact"] = addr.contact or ""
out["phone"] = addr.phone or ""
out["email"] = addr.email or ""
return out
# =============================================================================
# Builders
# =============================================================================
def _lookup_client_by_key(db: Session, client_key: Optional[str]) -> Optional[ClientProvider]:
"""
Busca ClientProvider por la clave que trae Manifest.sent_by / consigned_to.
Intenta como id entero primero; si falla, busca por short_name (CliPro:Cliente).
"""
if not client_key:
return None
opts = [joinedload(ClientProvider.address), joinedload(ClientProvider.programs)]
try:
cid = int(client_key.strip())
return db.query(ClientProvider).options(*opts).filter(ClientProvider.id == cid).first()
except (ValueError, TypeError):
return (
db.query(ClientProvider)
.options(*opts)
.filter(ClientProvider.short_name == client_key.strip())
.first()
)
def _query_invoices_for_manifest(db: Session, manifest_num: str) -> List[InvoiceHeader]:
"""Trae todas las facturas (SCAII/SCAF/REPAR) ligadas al manifiesto con estatus procesado."""
return (
db.query(InvoiceHeader)
.join(InvoiceComplianceMx, InvoiceComplianceMx.invoice_id == InvoiceHeader.id)
.options(
joinedload(InvoiceHeader.compliance_mx),
joinedload(InvoiceHeader.logistics),
)
.filter(
InvoiceComplianceMx.manifest_number == manifest_num,
InvoiceHeader.status == "processed",
)
.all()
)
def _query_line_items_for_invoice(db: Session, invoice_id: int) -> List[LineItem]:
return (
db.query(LineItem)
.filter(LineItem.invoice_id == invoice_id)
.options(
joinedload(LineItem.part_info),
joinedload(LineItem.class_info),
joinedload(LineItem.description),
joinedload(LineItem.quantity).joinedload(LineQuantity.package_info),
)
.all()
)
def _line_description_en(line: LineItem) -> str:
"""Descripción inglés según tipo de partida (SCAII usa Part, SCAF usa Class)."""
# 1) Si hay LineDescription con descripcion_english, usarla
desc = (line.description.description_english if line.description else None) or ""
if desc:
return desc.upper()
# 2) SCAII: usar Part.description_english
if line.part_info and getattr(line.part_info, "description_english", None):
return (line.part_info.description_english or "").upper()
# 3) SCAF: usar Class.description_en
if line.class_info and getattr(line.class_info, "description_en", None):
return (line.class_info.description_en or "").upper()
return ""
def _gross_uom(weight_type: Optional[WeightUnit]) -> str:
if weight_type == WeightUnit.KGS:
return "K"
if weight_type == WeightUnit.LBS:
return "L"
return ""
def _build_trailers_for_manifest(
db: Session, manifest: Manifest, carrier_scac: str
) -> tuple[list, dict]:
"""
Construye lista de <trailer> únicos (formato CodCargador$TrailerAce$Country$State$Plate)
y devuelve metadatos del trailer principal (para <trailer1>).
"""
trailer_list: list[str] = []
seen: set[str] = set()
main: dict = {"type": "", "equipmentNumber": "", "plate": "", "plateState": "", "plateCountry": ""}
if not manifest.trailer_number:
return trailer_list, main
trailer = (
db.query(Trailer)
.filter(Trailer.trailer_number == manifest.trailer_number)
.first()
)
if not trailer:
return trailer_list, main
ace = trailer.ace_trailer_number or ""
country = trailer.country or ""
state = trailer.state or ""
plate = trailer.plate_number or ""
trailer_type = trailer.trailer_type_key or ""
reg = f"{carrier_scac}${ace}${country}${state}${plate}"
if reg not in seen and reg != "$$$$":
trailer_list.append(reg)
seen.add(reg)
main = {
"type": trailer_type,
"equipmentNumber": ace,
"plate": plate,
"plateState": state,
"plateCountry": country,
}
return trailer_list, main
def _collect_seals_from_invoices(invoices: List[InvoiceHeader]) -> str:
"""Concatena seal_number único de cada factura, separados por ', '."""
seals: list[str] = []
seen = set()
for inv in invoices:
log = inv.logistics
seal = (log.seal_number or "").strip() if log else ""
if seal and seal not in seen:
seals.append(seal)
seen.add(seal)
return ", ".join(seals)
# Nota: Clarion convertía Pais_Ame + descripción → Clave_Ame del estado para el plate
# del trailer. El modelo State actual no tiene ame_key, así que el plate state se emite
# con la descripción cruda y se acumula inconsistencia si aplica.
# =============================================================================
# DET: <merchandise> elements
# =============================================================================
def _emit_merchandise(
desc: str,
bulks,
bulks_uom: str,
gross_weight,
gross_uom: str,
out: list,
) -> None:
out.append(" <merchandise>")
out.append(f" <desc>{_xml_escape(desc)}</desc>")
out.append(f" <bulks>{bulks if bulks else ''}</bulks>")
out.append(f" <bulksUOM>{_xml_escape(bulks_uom)}</bulksUOM>")
if gross_uom:
gw = f"{Decimal(gross_weight or 0):.4f}" if gross_weight is not None else ""
out.append(f" <grossWeight>{gw}</grossWeight>")
out.append(f" <grossUOM>{gross_uom}</grossUOM>")
out.append(" </merchandise>")
def _build_merchandise_normal(
db: Session,
invoices: List[InvoiceHeader],
out: list,
issues: list,
) -> int:
"""Sin consolidar: emite un <merchandise> por cada partida."""
count = 0
for inv in invoices:
weight_type = inv.logistics.weight_type if inv.logistics else None
gross_uom = _gross_uom(weight_type)
for line in _query_line_items_for_invoice(db, inv.id):
desc = _line_description_en(line)
if not desc:
ref = (line.part_info.part_number if line.part_info else None) \
or (line.class_info.class_code if line.class_info else "")
tipo = "número de parte" if line.part_info else "clase"
issues.append(
f"Falta capturar la descripción en inglés del {tipo} '{ref}'. "
"Solución: capturar la información en los datos de la partida."
)
qty = line.quantity
bulks = qty.package_quantity if qty else None
pkg = qty.package_info if qty else None
bulks_uom = (pkg.code_ace or "").upper() if pkg else ""
gross = qty.gross_weight if qty else None
_emit_merchandise(desc, bulks, bulks_uom, gross, gross_uom, out)
count += 1
return count
def _build_merchandise_consolidado(
db: Session,
invoices: List[InvoiceHeader],
out: list,
issues: list,
) -> int:
"""
Consolidación equivalente a VarLoc:ConsolidarPartidas=1 de Clarion.
Agrupa partidas por (invoice_number, package_id) y suma pesos brutos.
"""
# estructura: line_no → {desc, bulks, package_id, gross_weight, bulks_uom, gross_uom}
grouped: dict[int, dict] = {}
consecutivo = 0
last_invoice = None
# tomar gross_uom de la primera factura (Clarion usa MatFex:TipoPeso global del primero)
primary_uom = ""
if invoices:
primary_uom = _gross_uom(invoices[0].logistics.weight_type if invoices[0].logistics else None)
for inv in invoices:
for line in _query_line_items_for_invoice(db, inv.id):
if last_invoice != inv.invoice_number:
consecutivo += 1
last_invoice = inv.invoice_number
qty = line.quantity
bulks = (qty.package_quantity if qty else None) or 0
if bulks > 0:
consecutivo += 1
desc = _line_description_en(line)
if not desc:
ref = (line.part_info.part_number if line.part_info else None) \
or (line.class_info.class_code if line.class_info else "")
tipo = "número de parte" if line.part_info else "clase"
issues.append(
f"Falta capturar la descripción en inglés del {tipo} '{ref}'. "
"Solución: capturar la información en los datos de la partida."
)
pkg = qty.package_info if qty else None
bulks_uom = (pkg.code_ace or "").upper() if (pkg and bulks > 0) else ""
gross = qty.gross_weight if qty else Decimal(0)
existing = grouped.get(consecutivo)
if existing is None:
grouped[consecutivo] = {
"desc": desc,
"bulks": bulks,
"bulks_uom": bulks_uom,
"gross_weight": Decimal(gross or 0),
}
else:
# Concatenar descripción si no está
if desc and desc not in existing["desc"]:
existing["desc"] = (
f"{existing['desc']} - {desc}" if existing["desc"] else desc
)
existing["gross_weight"] += Decimal(gross or 0)
for _, data in sorted(grouped.items()):
_emit_merchandise(
data["desc"], data["bulks"], data["bulks_uom"],
data["gross_weight"], primary_uom, out,
)
return len(grouped)
# =============================================================================
# ENC: <Invoice> + <shipper> + <consignee>
# =============================================================================
def _build_shipment_enc(
db: Session,
manifest: Manifest,
invoices: List[InvoiceHeader],
out: list,
issues: list,
) -> None:
"""
Acumula CSVs de pedimento/factura/remesa/fecha de TODAS las facturas
y emite <Invoice>, <shipper>, <consignee>.
"""
# 1) Validar Shipper (EnviadoPor) y Consignee (ConsignadoA)
if not manifest.sent_by:
issues.append(
f"Falta capturar el Shipper en el Manifiesto '{manifest.manifest_number}'. "
"Solución: capturar la información en los datos del manifiesto."
)
if not manifest.consigned_to:
issues.append(
f"Falta capturar el Consignado del Manifiesto '{manifest.manifest_number}'. "
"Solución: capturar la información en los datos del manifiesto."
)
shipper_data = _validate_client_optima(
db, _lookup_client_by_key(db, manifest.sent_by),
manifest.sent_by or "", "Shipper", manifest.manifest_number or "", issues,
)
consignee_data = _validate_client_optima(
db, _lookup_client_by_key(db, manifest.consigned_to),
manifest.consigned_to or "", "Consignee", manifest.manifest_number or "", issues,
)
# 2) Acumular pedimento / factura / remesa / fecha
inv_pedimento = ""
inv_factura = ""
inv_remesa = ""
inv_fecha = ""
for inv in invoices:
cmx = inv.compliance_mx
ped = None
if cmx and cmx.pedimento_id:
ped = db.query(Pedimentos).filter(Pedimentos.id == cmx.pedimento_id).first()
inv_pedimento = _csv_append(inv_pedimento, _format_pedimento_clean(ped))
inv_factura = _csv_append(inv_factura, inv.invoice_number or "")
if cmx and cmx.remesa is not None:
inv_remesa = _csv_append(inv_remesa, str(cmx.remesa))
if inv.invoice_date:
inv_fecha = _csv_append(inv_fecha, inv.invoice_date.strftime("%Y-%m-%d"))
# 3) <Invoice>
out.append(" <Invoice>")
out.append(f" <number>{_xml_escape(inv_factura)}</number>")
out.append(f" <pedimento>{_xml_escape(inv_pedimento)}</pedimento>")
out.append(f" <remesa>{_xml_escape(inv_remesa)}</remesa>")
out.append(f" <invoice_date>{_xml_escape(inv_fecha)}</invoice_date>")
out.append(" </Invoice>")
# 4) <shipper>
out.append(" <shipper>")
for tag in ("name", "address", "address2", "city", "zip", "state", "country", "contact"):
out.append(f" <{tag}>{_xml_escape(shipper_data[tag])}</{tag}>")
out.append(f" <contactPhone>{_xml_escape(shipper_data['phone'])}</contactPhone>")
out.append(f" <contactEmail>{_xml_escape(shipper_data['email'])}</contactEmail>")
out.append(" </shipper>")
# 5) <consignee>
out.append(" <consignee>")
for tag in ("name", "address", "address2", "city", "zip", "state", "country", "contact"):
out.append(f" <{tag}>{_xml_escape(consignee_data[tag])}</{tag}>")
out.append(f" <contactPhone>{_xml_escape(consignee_data['phone'])}</contactPhone>")
out.append(f" <contactEmail>{_xml_escape(consignee_data['email'])}</contactEmail>")
out.append(" </consignee>")
# =============================================================================
# Servicio principal
# =============================================================================
class XmlOptimaService:
def generar_expo(
self, db: Session, request: XmlOptimaExpoRequest
) -> XmlOptimaResponse:
"""Genera el XML <trip> con shipments para cada manifiesto."""
try:
issues: list[str] = []
lines: list[str] = []
cuenta_partidas = 0
cuenta_manifiestos = 0
if not request.manifest_numbers:
return XmlOptimaResponse(
success=False, message="No se proporcionaron manifiestos."
)
# Manifiesto principal (primer manifiesto) — el resto se procesan como shipments
primary_num = request.manifest_numbers[0]
primary_manifest = (
db.query(Manifest)
.filter(Manifest.manifest_number == primary_num)
.first()
)
if not primary_manifest:
return XmlOptimaResponse(
success=False,
message=f"No se encontró el manifiesto '{primary_num}'.",
)
# Transportista principal
transporter = None
if primary_manifest.carrier_code:
transporter = (
db.query(Transporter)
.filter(Transporter.transporter_key == primary_manifest.carrier_code)
.first()
)
preparer_scac, carrier_scac = _validate_trip_header(
transporter, primary_manifest, issues
)
# Conductor (busca ManifestDriver → Driver)
manifest_driver_row = (
db.query(ManifestDriver)
.filter(ManifestDriver.manifest_number == primary_num)
.first()
)
driver: Optional[Driver] = None
if manifest_driver_row and primary_manifest.carrier_code:
driver = (
db.query(Driver)
.filter(
Driver.transporter_key == primary_manifest.carrier_code,
Driver.driver_name == manifest_driver_row.driver_name,
)
.first()
)
license_num = (driver.license_number or "") if driver else ""
if not license_num:
issues.append(
f"Falta capturar el Número de Licencia del Conductor del Manifiesto '{primary_num}'. "
"Solución: capturar la información en los datos del conductor."
)
# Tractor (vía vehicle_key = manifest.trailer_number)
vehicle: Optional[Vehicle] = None
if primary_manifest.trailer_number:
vehicle = (
db.query(Vehicle)
.filter(Vehicle.vehicle_key == primary_manifest.trailer_number)
.first()
)
tractor_plate = (vehicle.plate_number or "") if vehicle else ""
if not tractor_plate:
issues.append(
f"Falta capturar el Número de Placas del Tractor del Manifiesto '{primary_num}'. "
"Solución: capturar la información en los datos del trailer."
)
# Trailers (únicos) y datos de <trailer1>
trailer_regs, trailer_main = _build_trailers_for_manifest(
db, primary_manifest, carrier_scac
)
# <trailer1>.plateState: Clarion convertía descripción → clave americana.
# Sin ame_key en State, se emite la descripción cruda del trailer.
trailer_state_ame = trailer_main["plateState"]
# Tipo de transporte (vehículo)
vehicle_type = (vehicle.transport_type or "") if vehicle else ""
# Sellos: agregados de todas las facturas de todos los manifiestos solicitados
all_invoices_for_seals: list[InvoiceHeader] = []
for mn in request.manifest_numbers:
all_invoices_for_seals.extend(_query_invoices_for_manifest(db, mn))
seal_csv = _collect_seals_from_invoices(all_invoices_for_seals)
# ----- COMIENZO DEL XML -----
clean_trip = _clean_filename(primary_num)
lines.append('<?xml version="1.0" standalone="yes"?>')
lines.append("<trip>")
lines.append(f" <preparerSCAC>{_xml_escape(preparer_scac)}</preparerSCAC>")
lines.append(f" <carrierSCAC>{_xml_escape(carrier_scac)}</carrierSCAC>")
lines.append(f" <tripNumber>{_xml_escape(clean_trip)}</tripNumber>")
lines.append(f" <arrivalDate>{_format_entry_date(primary_manifest.entry_date)}</arrivalDate>")
lines.append(f" <arrivalTime>{_format_entry_hour(primary_manifest.entry_hour)}</arrivalTime>")
lines.append(f" <arrivalPort>{_xml_escape(primary_manifest.entry_port)}</arrivalPort>")
lines.append(f" <tractor>{_xml_escape(tractor_plate)}</tractor>")
for reg in trailer_regs:
lines.append(f" <trailer>{_xml_escape(reg)}</trailer>")
lines.append(f" <driver>{_xml_escape(license_num)}</driver>")
lines.append(" <tripType>R</tripType>")
lines.append(f" <conveyanceSeal>{_xml_escape(seal_csv)}</conveyanceSeal>")
lines.append(" <trailer1>")
lines.append(f" <type>{_xml_escape(vehicle_type or trailer_main['type'])}</type>")
lines.append(f" <equipmentNumber>{_xml_escape(trailer_main['equipmentNumber'])}</equipmentNumber>")
lines.append(f" <plate>{_xml_escape(trailer_main['plate'])}</plate>")
lines.append(f" <plateState>{_xml_escape(trailer_state_ame)}</plateState>")
lines.append(f" <plateCountry>{_xml_escape(trailer_main['plateCountry'])}</plateCountry>")
lines.append(" </trailer1>")
# ----- <shipment> por cada manifiesto -----
for mn in request.manifest_numbers:
manifest = (
db.query(Manifest)
.filter(Manifest.manifest_number == mn)
.first()
)
if not manifest:
issues.append(f"No se encontró el manifiesto '{mn}'. Se omite.")
continue
if not manifest.foreign_exit_port:
issues.append(
f"Falta capturar el Puerto Exterior de Salida (Port of Lading) del Manifiesto '{mn}'. "
"Solución: capturar la información en los datos del manifiesto."
)
clean_num = _clean_filename(mn)
invoices = _query_invoices_for_manifest(db, mn)
lines.append(" <shipment>")
lines.append(f" <controlNumber>{_xml_escape(clean_num)}</controlNumber>")
lines.append(f" <entry>{_xml_escape(clean_num)}</entry>")
lines.append(f" <portOfLading>{_xml_escape(manifest.foreign_exit_port)}</portOfLading>")
lines.append(" <countryOfOrigin>MX</countryOfOrigin>")
lines.append(f" <shipmentType>{_xml_escape(manifest.manifest_type)}</shipmentType>")
lines.append(f" <filerCode>{_xml_escape(mn[:3])}</filerCode>")
_build_shipment_enc(db, manifest, invoices, lines, issues)
if request.consolidar_partidas:
n = _build_merchandise_consolidado(db, invoices, lines, issues)
else:
n = _build_merchandise_normal(db, invoices, lines, issues)
cuenta_partidas += n
lines.append(" </shipment>")
cuenta_manifiestos += 1
lines.append("</trip>")
xml_str = "\n".join(lines)
filename = f"XML_OPTIMA_{clean_trip}_{date.today().strftime('%Y%m%d')}.xml"
msg = f"{cuenta_manifiestos} manifiesto(s), {cuenta_partidas} partida(s) generadas"
if issues:
msg += f"{len(issues)} inconsistencia(s)"
return XmlOptimaResponse(
success=True,
message=msg,
archivo_generado=filename,
content=xml_str,
cuenta_manifiestos=cuenta_manifiestos,
cuenta_partidas=cuenta_partidas,
inconsistencias=issues,
)
except Exception as e:
import traceback
traceback.print_exc()
return XmlOptimaResponse(success=False, message=str(e))

View File

@@ -0,0 +1,90 @@
import base64
from typing import Dict, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from .schemas import XmlRbSystemsImpoRequest, XmlRbSystemsExpoRequest
from .service import XmlRbSystemsService
router = APIRouter()
@router.post("/importacion-definitiva/generate")
async def generate_impo_def(
request: XmlRbSystemsImpoRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
result = XmlRbSystemsService().generar_impo_def(db, request)
if not result.success:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=result.message)
return {
"success": True,
"message": result.message,
"file_name": result.archivo_generado,
"media_type": "application/xml",
"content": base64.b64encode(result.content.encode("utf-8")).decode("utf-8"),
"inconsistencias": result.inconsistencias,
}
@router.post("/importacion-temporal/generate")
async def generate_impo_temp(
request: XmlRbSystemsImpoRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
result = XmlRbSystemsService().generar_impo_temp(db, request)
if not result.success:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=result.message)
return {
"success": True,
"message": result.message,
"file_name": result.archivo_generado,
"media_type": "application/xml",
"content": base64.b64encode(result.content.encode("utf-8")).decode("utf-8"),
"inconsistencias": result.inconsistencias,
}
@router.post("/importacion/generate")
async def generate_impo(
request: XmlRbSystemsImpoRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
result = XmlRbSystemsService().generar_impo(db, request)
if not result.success:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=result.message)
return {
"success": True,
"message": result.message,
"file_name": result.archivo_generado,
"media_type": "application/xml",
"content": base64.b64encode(result.content.encode("utf-8")).decode("utf-8"),
}
@router.post("/exportacion/generate")
async def generate_expo(
request: XmlRbSystemsExpoRequest,
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
result = XmlRbSystemsService().generar_expo(db, request)
if not result.success:
from fastapi import HTTPException
raise HTTPException(status_code=500, detail=result.message)
return {
"success": True,
"message": result.message,
"file_name": result.archivo_generado,
"media_type": "application/xml",
"content": base64.b64encode(result.content.encode("utf-8")).decode("utf-8"),
}

View File

@@ -0,0 +1,25 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class XmlRbSystemsImpoRequest(BaseModel):
invoice_numbers: List[str] = Field(..., description="Números de factura a incluir")
entry_port: Optional[str] = Field(None, description="Puerto de entrada")
exit_port: Optional[str] = Field(None, description="Puerto de salida")
class XmlRbSystemsExpoRequest(BaseModel):
manifest_numbers: List[str] = Field(..., description="Números de manifiesto a procesar")
entry_port: Optional[str] = Field(None, description="Puerto de entrada")
exit_port: Optional[str] = Field(None, description="Puerto de salida")
include_emanifest: bool = Field(True, description="Incluir sección <Emanifest> (VarLoc:NoEmanifest=0 en Clarion)")
class XmlRbSystemsResponse(BaseModel):
success: bool
message: str
archivo_generado: Optional[str] = None
content: Optional[str] = None
cuenta_facturas: int = 0
cuenta_partidas: int = 0
inconsistencias: List[str] = []

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
from core.celery_app import celery_app
from core.database import get_core_db as get_db
from .service import XmlRbSystemsService
from .schemas import XmlRbSystemsImpoRequest, XmlRbSystemsExpoRequest
@celery_app.task(name="generar_xml_rb_systems_impo_temp_async", bind=True)
def generar_xml_rb_systems_impo_temp_async(self, request_data: dict, tenant_id: int):
try:
db = next(get_db())
request = XmlRbSystemsImpoRequest(**request_data)
response = XmlRbSystemsService().generar_impo_temp(db, request)
result = response.model_dump()
if response.content:
import base64
result["content"] = base64.b64encode(response.content.encode("utf-8")).decode("utf-8")
result["file_name"] = response.archivo_generado
result["media_type"] = "application/xml"
return result
except Exception as e:
self.update_state(state="FAILURE", meta={"exc_type": type(e).__name__, "exc_message": str(e)})
raise e
@celery_app.task(name="generar_xml_rb_systems_impo_async", bind=True)
def generar_xml_rb_systems_impo_async(self, request_data: dict, tenant_id: int):
try:
db = next(get_db())
request = XmlRbSystemsImpoRequest(**request_data)
response = XmlRbSystemsService().generar_impo(db, request)
result = response.model_dump()
if response.content:
import base64
result["content"] = base64.b64encode(response.content.encode("utf-8")).decode("utf-8")
result["file_name"] = response.archivo_generado
result["media_type"] = "application/xml"
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)})
raise e
@celery_app.task(name="generar_xml_rb_systems_expo_async", bind=True)
def generar_xml_rb_systems_expo_async(self, request_data: dict, tenant_id: int):
try:
db = next(get_db())
request = XmlRbSystemsExpoRequest(**request_data)
response = XmlRbSystemsService().generar_expo(db, request)
result = response.model_dump()
if response.content:
import base64
result["content"] = base64.b64encode(response.content.encode("utf-8")).decode("utf-8")
result["file_name"] = response.archivo_generado
result["media_type"] = "application/xml"
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)})
raise e

View File

@@ -50,6 +50,8 @@ from .reports.exportacion.transmission.MAINX30.routes import router as transmiss
from .reports.importacion.transmission.temporal.MAINX30.routes import router as transmission_temporal_router
from .reports.importacion.transmission.definitive.MAINX30.routes import router as transmission_definitive_router
from .reports.importacion.winsaai.router import router as winsaai_router
from .reports.xml_rb_systems.routes import router as xml_rb_systems_router
from .reports.xml_optima.routes import router as xml_optima_router
from .app_settings.routes import router as app_settings_router
from .manifests.manifest.routes import router as manifests_router
@@ -195,6 +197,18 @@ router.include_router(
tags=["a76 / reports"]
)
router.include_router(
xml_rb_systems_router,
prefix="/a76/reports/xml-rb-systems",
tags=["a76 / reports"]
)
router.include_router(
xml_optima_router,
prefix="/a76/reports/xml-optima",
tags=["a76 / reports"]
)
router.include_router(app_settings_router)
# Registrar router de bitácora

View File

@@ -154,6 +154,7 @@ celery_app.conf.update(
"api.v1.modules.a76.layouts_csv.common.victor",
"api.v1.modules.a76.factura_cove.tasks",
"api.v1.modules.a76.expediente_archivos.tasks",
"api.v1.modules.a76.reports.xml_rb_systems.task",
] # Ruta al módulo donde están las tareas
)

View File

@@ -163,5 +163,85 @@ export const reportsTransmissionApi = {
throw new Error(errorMessage);
}
return await response.json();
},
triggerXmlRbSystemsImpoTemp: async (request: { invoice_numbers: string[]; entry_port?: string; exit_port?: string }) => {
const endpoint = `${BASE_URL}/v1/a76/reports/xml-rb-systems/importacion-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) {
let errorMessage = 'Error al generar XML RB Systems (importación temporal)';
try { errorMessage = (await response.json()).detail || errorMessage; } catch (e) {}
throw new Error(errorMessage);
}
return await response.json();
},
triggerXmlRbSystemsImpoDef: async (request: { invoice_numbers: string[]; entry_port?: string; exit_port?: string }) => {
const endpoint = `${BASE_URL}/v1/a76/reports/xml-rb-systems/importacion-definitiva/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) {
let errorMessage = 'Error al generar XML RB Systems (importación definitiva)';
try { errorMessage = (await response.json()).detail || errorMessage; } catch (e) {}
throw new Error(errorMessage);
}
return await response.json();
},
triggerXmlRbSystemsImpo: async (request: { invoice_numbers: string[]; entry_port?: string; exit_port?: string }) => {
const endpoint = `${BASE_URL}/v1/a76/reports/xml-rb-systems/importacion/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) {
let errorMessage = 'Error al generar XML RB Systems (importación)';
try { errorMessage = (await response.json()).detail || errorMessage; } catch (e) {}
throw new Error(errorMessage);
}
return await response.json();
},
triggerXmlRbSystemsExpo: async (request: { manifest_numbers: string[]; entry_port?: string; exit_port?: string; include_emanifest?: boolean }) => {
const endpoint = `${BASE_URL}/v1/a76/reports/xml-rb-systems/exportacion/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) {
let errorMessage = 'Error al generar XML RB Systems (exportación)';
try { errorMessage = (await response.json()).detail || errorMessage; } catch (e) {}
throw new Error(errorMessage);
}
return await response.json();
},
triggerXmlOptimaExpo: async (request: { manifest_numbers: string[]; consolidar_partidas?: boolean }) => {
const endpoint = `${BASE_URL}/v1/a76/reports/xml-optima/exportacion/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) {
let errorMessage = 'Error al generar XML Optima (exportación)';
try { errorMessage = (await response.json()).detail || errorMessage; } catch (e) {}
throw new Error(errorMessage);
}
return await response.json();
}
};

View File

@@ -21,12 +21,11 @@
open = $bindable(false),
regimen = 'Temporal',
operationType = 'imp' as 'imp' | 'exp',
status = undefined,
onSelect,
onClear
}: Props = $props();
let status = $derived(operationType === 'imp' ? 'processed' : undefined);
let invoices = $state<Invoice[]>([]);
let loading = $state(false);
let searchTerm = $state('');

View File

@@ -791,6 +791,7 @@
bind:open={showImportInvoiceModal}
regimen={editingItem.fa_data?.movement_type_import === 'DEF' ? 'Definitiva' : 'Temporal'}
operationType="imp"
status="processed"
onSelect={handleSelectImportInvoice}
/>
<InvoiceSelectorModal

View File

@@ -21,9 +21,6 @@
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;
@@ -62,14 +59,10 @@
let openEntryPortDialog = $state(false);
let openExitPortDialog = $state(false);
// Inconsistencias State
let inconsistencias = $state<string[]>([]);
// 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({
@@ -91,7 +84,9 @@
{ 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' }
{ value: 'EDI-EDA V2', label: 'EDI-EDA V2' },
{ value: 'XML_RB_SYSTEMS', label: 'XML_RB_SYSTEMS' },
{ value: 'XML_OPTIMA', label: 'XML_OPTIMA' }
];
const movementOptions = [
@@ -105,6 +100,76 @@
}
async function handleAction() {
isTemporalTask = false;
isDefinitiveTask = false;
// --- XML_OPTIMA ---
if (interfaceType === 'XML_OPTIMA') {
if (movementType !== 'Exportacion') {
toast.error('XML Optima solo está disponible para Exportación');
return;
}
const validManifests = manifests.filter((m) => m && m.trim() !== '');
if (validManifests.length === 0) {
toast.error('Debe seleccionar al menos un manifiesto');
return;
}
try {
const res = await reportsTransmissionApi.triggerXmlOptimaExpo({
manifest_numbers: validManifests,
consolidar_partidas: checks.consolidar_partidas
});
handleDownloadComplete(res);
} catch (error: any) {
console.error('Error triggering XML Optima generation:', error);
toast.error(error?.message || 'Error al generar XML Optima');
}
return;
}
// --- XML_RB_SYSTEMS ---
if (interfaceType === 'XML_RB_SYSTEMS') {
if (!entryPort || !exitPort) {
toast.error('Debe seleccionar tanto el puerto de entrada como el de salida');
return;
}
try {
let res;
if (movementType === 'Importacion') {
const validInvoices = manualInvoices.filter((i) => i && i.trim() !== '');
if (validInvoices.length === 0) {
toast.error('Debe seleccionar al menos una factura');
return;
}
const impoPayload = { invoice_numbers: validInvoices, entry_port: entryPort, exit_port: exitPort };
if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
res = await reportsTransmissionApi.triggerXmlRbSystemsImpoDef(impoPayload);
} else {
// Temporal / TEMPORAL SCAF
res = await reportsTransmissionApi.triggerXmlRbSystemsImpoTemp(impoPayload);
}
} else {
const validManifests = manifests.filter((m) => m && m.trim() !== '');
if (validManifests.length === 0) {
toast.error('Debe seleccionar al menos un manifiesto');
return;
}
res = await reportsTransmissionApi.triggerXmlRbSystemsExpo({
manifest_numbers: validManifests,
entry_port: entryPort,
exit_port: exitPort,
include_emanifest: !checks.no_enviar_emanifest
});
}
// Respuesta síncrona — descarga inmediata sin polling
handleDownloadComplete(res);
} catch (error: any) {
console.error('Error triggering XML RB Systems generation:', error);
toast.error(error?.message || 'Error al generar XML RB Systems');
}
return;
}
if (movementType === 'Importacion') {
const validInvoices = manualInvoices.filter((i) => i && i.trim() !== '');
if (validInvoices.length === 0) {
@@ -130,11 +195,9 @@
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) {
@@ -148,7 +211,6 @@
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) {
@@ -163,7 +225,6 @@
try {
const res = await reportsTransmissionApi.triggerGeneration(payload);
isTemporalTask = false;
if (res.task_id) {
taskId = res.task_id;
@@ -178,63 +239,20 @@
}
}
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;
}
}
// Limpiar listas al abrir el modal
$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);
}
if (open) {
manualInvoices = [];
manifests = [];
inconsistencias = [];
}
});
// Re-load when movement type changes
// Re-load when movement type or regimen changes
// Limpiar listas al cambiar tipo de movimiento o régimen
$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();
if (open && (movementType || regimen)) {
manualInvoices = [];
manifests = [];
}
});
@@ -266,7 +284,6 @@
isInvoiceSelectorOpen = false;
}
// This function is expected by PdfProgressDialog to check status
async function checkTaskStatus(id: string) {
if (isDefinitiveTask) {
return await reportsTransmissionApi.getDefinitiveTaskStatus(id);
@@ -302,7 +319,12 @@
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Archivo descargado correctamente');
if (result.inconsistencias?.length > 0) {
inconsistencias = result.inconsistencias;
toast.warning(`Archivo generado con ${result.inconsistencias.length} inconsistencia(s). Revisa el detalle.`);
} else {
toast.success('Archivo descargado correctamente');
}
} catch (e) {
console.error('Error downloading file', e);
toast.error('Error al descargar el archivo');
@@ -472,8 +494,8 @@
{/if}
</div>
<!-- Ports Selection (Only for Importacion) -->
{#if movementType === 'Importacion'}
<!-- Ports Selection (Importacion y XML_RB_SYSTEMS) -->
{#if movementType === 'Importacion' || interfaceType === 'XML_RB_SYSTEMS'}
<div class="mt-2 grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label>Puerto Entrada</Label>
@@ -637,6 +659,19 @@
</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if inconsistencias.length > 0}
<div class="mt-3 rounded-md border border-yellow-300 bg-yellow-50 p-3">
<p class="mb-2 text-sm font-semibold text-yellow-800">
⚠ El archivo fue generado con {inconsistencias.length} inconsistencia(s):
</p>
<ul class="max-h-40 space-y-1 overflow-y-auto">
{#each inconsistencias as inc}
<li class="text-xs text-yellow-700">{inc}</li>
{/each}
</ul>
</div>
{/if}
</div>
<Dialog.Footer>