Nuevos formatos de transferencia electronica, ademas de ajsytes en la parte visual y ademas manejos de incosistencias
This commit is contained in:
31
backend/api/v1/modules/a76/reports/xml_optima/routes.py
Normal file
31
backend/api/v1/modules/a76/reports/xml_optima/routes.py
Normal 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,
|
||||
}
|
||||
17
backend/api/v1/modules/a76/reports/xml_optima/schemas.py
Normal file
17
backend/api/v1/modules/a76/reports/xml_optima/schemas.py
Normal 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] = []
|
||||
764
backend/api/v1/modules/a76/reports/xml_optima/service.py
Normal file
764
backend/api/v1/modules/a76/reports/xml_optima/service.py
Normal 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("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
90
backend/api/v1/modules/a76/reports/xml_rb_systems/routes.py
Normal file
90
backend/api/v1/modules/a76/reports/xml_rb_systems/routes.py
Normal 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"),
|
||||
}
|
||||
25
backend/api/v1/modules/a76/reports/xml_rb_systems/schemas.py
Normal file
25
backend/api/v1/modules/a76/reports/xml_rb_systems/schemas.py
Normal 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] = []
|
||||
1469
backend/api/v1/modules/a76/reports/xml_rb_systems/service.py
Normal file
1469
backend/api/v1/modules/a76/reports/xml_rb_systems/service.py
Normal file
File diff suppressed because it is too large
Load Diff
62
backend/api/v1/modules/a76/reports/xml_rb_systems/task.py
Normal file
62
backend/api/v1/modules/a76/reports/xml_rb_systems/task.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -791,6 +791,7 @@
|
||||
bind:open={showImportInvoiceModal}
|
||||
regimen={editingItem.fa_data?.movement_type_import === 'DEF' ? 'Definitiva' : 'Temporal'}
|
||||
operationType="imp"
|
||||
status="processed"
|
||||
onSelect={handleSelectImportInvoice}
|
||||
/>
|
||||
<InvoiceSelectorModal
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user