From 6855eb9531c915d631235030c6531c81f227cfd7 Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Thu, 28 May 2026 10:39:20 -0500 Subject: [PATCH] Nuevos formatos de transferencia electronica, ademas de ajsytes en la parte visual y ademas manejos de incosistencias --- .../a76/reports/xml_optima/__init__.py | 0 .../modules/a76/reports/xml_optima/routes.py | 31 + .../modules/a76/reports/xml_optima/schemas.py | 17 + .../modules/a76/reports/xml_optima/service.py | 764 +++++++++ .../a76/reports/xml_rb_systems/__init__.py | 0 .../a76/reports/xml_rb_systems/routes.py | 90 + .../a76/reports/xml_rb_systems/schemas.py | 25 + .../a76/reports/xml_rb_systems/service.py | 1469 +++++++++++++++++ .../a76/reports/xml_rb_systems/task.py | 62 + backend/api/v1/modules/a76/router.py | 14 + backend/core/celery_app.py | 1 + .../a76/reports/reports-transmission.ts | 80 + .../invoices/edit/InvoiceSelectorModal.svelte | 3 +- .../edit/items/fa/item-sheet-fa.svelte | 1 + .../transferencia-electronica-modal.svelte | 177 +- 15 files changed, 2661 insertions(+), 73 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/xml_optima/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/xml_optima/routes.py create mode 100644 backend/api/v1/modules/a76/reports/xml_optima/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/xml_optima/service.py create mode 100644 backend/api/v1/modules/a76/reports/xml_rb_systems/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/xml_rb_systems/routes.py create mode 100644 backend/api/v1/modules/a76/reports/xml_rb_systems/schemas.py create mode 100644 backend/api/v1/modules/a76/reports/xml_rb_systems/service.py create mode 100644 backend/api/v1/modules/a76/reports/xml_rb_systems/task.py diff --git a/backend/api/v1/modules/a76/reports/xml_optima/__init__.py b/backend/api/v1/modules/a76/reports/xml_optima/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/xml_optima/routes.py b/backend/api/v1/modules/a76/reports/xml_optima/routes.py new file mode 100644 index 00000000..56660515 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_optima/routes.py @@ -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, + } diff --git a/backend/api/v1/modules/a76/reports/xml_optima/schemas.py b/backend/api/v1/modules/a76/reports/xml_optima/schemas.py new file mode 100644 index 00000000..e93ebff5 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_optima/schemas.py @@ -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] = [] diff --git a/backend/api/v1/modules/a76/reports/xml_optima/service.py b/backend/api/v1/modules/a76/reports/xml_optima/service.py new file mode 100644 index 00000000..40d50cd0 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_optima/service.py @@ -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 únicos (formato CodCargador$TrailerAce$Country$State$Plate) + y devuelve metadatos del trailer principal (para ). + """ + 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: elements +# ============================================================================= + +def _emit_merchandise( + desc: str, + bulks, + bulks_uom: str, + gross_weight, + gross_uom: str, + out: list, +) -> None: + out.append(" ") + out.append(f" {_xml_escape(desc)}") + out.append(f" {bulks if bulks else ''}") + out.append(f" {_xml_escape(bulks_uom)}") + if gross_uom: + gw = f"{Decimal(gross_weight or 0):.4f}" if gross_weight is not None else "" + out.append(f" {gw}") + out.append(f" {gross_uom}") + out.append(" ") + + +def _build_merchandise_normal( + db: Session, + invoices: List[InvoiceHeader], + out: list, + issues: list, +) -> int: + """Sin consolidar: emite un 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: + + +# ============================================================================= + +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 , , . + """ + # 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) + out.append(" ") + out.append(f" {_xml_escape(inv_factura)}") + out.append(f" {_xml_escape(inv_pedimento)}") + out.append(f" {_xml_escape(inv_remesa)}") + out.append(f" {_xml_escape(inv_fecha)}") + out.append(" ") + + # 4) + out.append(" ") + for tag in ("name", "address", "address2", "city", "zip", "state", "country", "contact"): + out.append(f" <{tag}>{_xml_escape(shipper_data[tag])}") + out.append(f" {_xml_escape(shipper_data['phone'])}") + out.append(f" {_xml_escape(shipper_data['email'])}") + out.append(" ") + + # 5) + out.append(" ") + for tag in ("name", "address", "address2", "city", "zip", "state", "country", "contact"): + out.append(f" <{tag}>{_xml_escape(consignee_data[tag])}") + out.append(f" {_xml_escape(consignee_data['phone'])}") + out.append(f" {_xml_escape(consignee_data['email'])}") + out.append(" ") + + +# ============================================================================= +# Servicio principal +# ============================================================================= + +class XmlOptimaService: + + def generar_expo( + self, db: Session, request: XmlOptimaExpoRequest + ) -> XmlOptimaResponse: + """Genera el XML 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 + trailer_regs, trailer_main = _build_trailers_for_manifest( + db, primary_manifest, carrier_scac + ) + + # .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('') + lines.append("") + lines.append(f" {_xml_escape(preparer_scac)}") + lines.append(f" {_xml_escape(carrier_scac)}") + lines.append(f" {_xml_escape(clean_trip)}") + lines.append(f" {_format_entry_date(primary_manifest.entry_date)}") + lines.append(f" {_format_entry_hour(primary_manifest.entry_hour)}") + lines.append(f" {_xml_escape(primary_manifest.entry_port)}") + lines.append(f" {_xml_escape(tractor_plate)}") + for reg in trailer_regs: + lines.append(f" {_xml_escape(reg)}") + lines.append(f" {_xml_escape(license_num)}") + lines.append(" R") + lines.append(f" {_xml_escape(seal_csv)}") + lines.append(" ") + lines.append(f" {_xml_escape(vehicle_type or trailer_main['type'])}") + lines.append(f" {_xml_escape(trailer_main['equipmentNumber'])}") + lines.append(f" {_xml_escape(trailer_main['plate'])}") + lines.append(f" {_xml_escape(trailer_state_ame)}") + lines.append(f" {_xml_escape(trailer_main['plateCountry'])}") + lines.append(" ") + + # ----- 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(" ") + lines.append(f" {_xml_escape(clean_num)}") + lines.append(f" {_xml_escape(clean_num)}") + lines.append(f" {_xml_escape(manifest.foreign_exit_port)}") + lines.append(" MX") + lines.append(f" {_xml_escape(manifest.manifest_type)}") + lines.append(f" {_xml_escape(mn[:3])}") + + _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(" ") + cuenta_manifiestos += 1 + + lines.append("") + + 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)) diff --git a/backend/api/v1/modules/a76/reports/xml_rb_systems/__init__.py b/backend/api/v1/modules/a76/reports/xml_rb_systems/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/xml_rb_systems/routes.py b/backend/api/v1/modules/a76/reports/xml_rb_systems/routes.py new file mode 100644 index 00000000..f22787f3 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_rb_systems/routes.py @@ -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"), + } diff --git a/backend/api/v1/modules/a76/reports/xml_rb_systems/schemas.py b/backend/api/v1/modules/a76/reports/xml_rb_systems/schemas.py new file mode 100644 index 00000000..cd52cc8a --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_rb_systems/schemas.py @@ -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 (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] = [] diff --git a/backend/api/v1/modules/a76/reports/xml_rb_systems/service.py b/backend/api/v1/modules/a76/reports/xml_rb_systems/service.py new file mode 100644 index 00000000..f3eede1a --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_rb_systems/service.py @@ -0,0 +1,1469 @@ +"""Generador de XML en formato RB Systems (SED/AES) para transferencia electrónica.""" + +import xml.etree.ElementTree as ET +from datetime import date +from xml.dom import minidom +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.general_catalogs.company.models import Company as GEmpresa +from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceComplianceMx +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.manifests.driver.models import ManifestDriver +from api.v1.modules.a76.manifests.manifest.models import Manifest +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 XmlRbSystemsImpoRequest, XmlRbSystemsExpoRequest, XmlRbSystemsResponse + + +def _sub(parent: ET.Element, tag: str, text: str) -> ET.Element: + el = ET.SubElement(parent, tag) + el.text = text or "" + return el + + +def _format_weight(value) -> str: + try: + n = float(value or 0) + return f"{n:,.0f}" + except Exception: + return "0" + + +def _origin_indicator(country: Optional[str]) -> str: + """D = doméstico (USA), F = extranjero.""" + if not country: + return "F" + return "D" if country.upper() in ("US", "USA", "") else "F" + + +def _strip_hts(raw: str) -> str: + """Elimina puntos, apóstrofes y letras de fracción americana. + Equivale al loop FA# de Clarion: excluye '.', "'", y caracteres alfa.""" + return "".join(c for c in raw if c not in (".", "'") and not c.isalpha()) + + +def _lookup_provider(db: Session, client_id: Optional[int]) -> Optional[ClientProvider]: + if not client_id: + return None + return ( + db.query(ClientProvider) + .options(joinedload(ClientProvider.programs), joinedload(ClientProvider.address)) + .filter(ClientProvider.id == client_id) + .first() + ) + + +def _ame_country(db: Session, m3_key: Optional[str]) -> str: + if not m3_key: + return "" + country = db.query(Country).filter(Country.m3_key == m3_key).first() + return (country.ame_key or "") if country else "" + + +def _ame_state(db: Session, m3_key: Optional[str], state_desc: Optional[str]) -> str: + if not m3_key or not state_desc: + return "" + state = ( + db.query(State) + .filter(State.m3_key == m3_key, State.description == state_desc) + .first() + ) + return getattr(state, "ame_key", "") or "" if state else "" + + +def _ame_state_from_ame_country(db: Session, ame_country: Optional[str], state_desc: Optional[str]) -> str: + """Trailer/Vehicle almacenan el país como ame_key; resuelve m3_key antes de buscar el estado.""" + if not ame_country or not state_desc: + return "" + country = db.query(Country).filter(Country.ame_key == ame_country).first() + if not country: + return "" + return _ame_state(db, country.m3_key, state_desc) + + +def _lookup_provider_by_key(db: Session, key: Optional[str]) -> Optional[ClientProvider]: + """Busca ClientProvider por clave de texto (Manifest.sent_by / consigned_to). + Intenta parsear como int (id); si falla busca por short_name.""" + if not key: + return None + opts = [joinedload(ClientProvider.programs), joinedload(ClientProvider.address)] + try: + client_id = int(key.strip()) + return db.query(ClientProvider).options(*opts).filter(ClientProvider.id == client_id).first() + except (ValueError, TypeError): + return db.query(ClientProvider).options(*opts).filter(ClientProvider.short_name == key.strip()).first() + + +def _clean_manifest_number(manifest: str) -> str: + """Elimina caracteres especiales del número de manifiesto (para FilerCode/EntryNumber).""" + skip = set("/\\*<>?:-") + return "".join(c for c in manifest if c not in skip) + + +def _format_clarion_date(clarion_int: Optional[int]) -> str: + """Convierte fecha Clarion (días desde 1800-12-28) a MM/DD/YYYY.""" + if not clarion_int: + return "" + try: + from datetime import date as dt, timedelta + base = dt(1800, 12, 28) + return (base + timedelta(days=int(clarion_int))).strftime("%m/%d/%Y") + except Exception: + return "" + + +def _get_niu_for_manifest(db: Session, manifest_number: str) -> str: + """Para TripFlightNumber (MOT=40/aéreo): NIU de la primera factura del manifiesto.""" + record = ( + db.query(InvoiceComplianceMx.niu_number) + .filter(InvoiceComplianceMx.manifest_number == manifest_number) + .order_by(InvoiceComplianceMx.invoice_id.desc()) + .first() + ) + return (record[0] or "") if record else "" + + +def _xml_escape(s: Optional[str]) -> str: + if not s: + return "" + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _clean_product_name(name: Optional[str]) -> str: + """Limpia nombre de producto: elimina los mismos chars que Clarion en DescripcionI. + Clarion descarta: / & * \\ < > ? : -""" + if not name: + return "" + skip = set("/&*\\<>?:-") + cleaned = "".join(c for c in name if c not in skip) + return _xml_escape(cleaned[:80]) + + +def _special_country_program(has_certificate: Optional[bool], origin_m3: Optional[str]) -> str: + """ + Equivale a la lógica TieneCO + PaisOrigen de Clarion EXPO_SCAII_CONSOLIDADO. + Si tiene CO y el origen es MEX/USA/CAN retorna el código AME; si no, vacío. + """ + if not has_certificate: + return "" + mapping = {"MEX": "MX", "USA": "US", "CAN": "CA"} + return mapping.get((origin_m3 or "").strip().upper(), "") + + +def _append_invoice_line_scaii( + db: Session, + line: LineItem, + lines: list, + country_of_export: str, +) -> dict: + """ + Emite un para una partida de exportación SCAII (CONSOLIDADO). + Retorna dict {hts_frac: {valorD, valorN, valorP}} para acumular en QueueRBSystem2. + Equivale al bloque de partidas en GENERACION_XML_RBSYSTEMS_EXPO_SCAII_CONSOLIDADO. + """ + customs = line.customs + qty_info = line.quantity + + part_key = (line.part_info.part_number or "") if line.part_info else "" + part_name = _clean_product_name((line.part_info.description_english or "") if line.part_info else "") + + origin_m3 = (customs.origin_country or "").strip() if customs else "" + country_of_origin = _ame_country(db, origin_m3) if origin_m3 else "" + + # SpecialCountryProgram: TieneCO × PaisOrigen (Clarion: MatPex:TieneCO / MatPex:PaisOrigen) + scp = _special_country_program(line.has_certificate, origin_m3) + + qty_val = f"{float(qty_info.quantity or 0):.4f}" if qty_info else "0.0000" + unit_code = "PC" + if line.unit_of_measure_info: + unit_code = line.unit_of_measure_info.american_code or line.uma_key or "PC" + elif line.uma_key: + unit_code = line.uma_key + + net_weight_kg = f"{float(qty_info.net_weight or 0):.4f}" if qty_info else "0.0000" + + # NaftaNetCost: 'Y'/'N' (Clarion: MatPex:TieneCO) + nafta_net = "Y" if line.has_certificate else "N" + + lines.append(' ') + lines.append(f' {_xml_escape(part_key)}') + lines.append(f' {part_name}') + lines.append(' ') + lines.append(f' {country_of_origin}') + lines.append(f' {country_of_export}') + lines.append(f' {scp}') + lines.append(f' {qty_val}') + lines.append(f' {net_weight_kg}') + lines.append(f' {nafta_net}') + + # FdaLine (Clarion: Loc:TieneFDA / GCodigosFDA) + if line.has_fda_code and line.fda_key: + fda = ( + db.query(FDACatalog) + .options(joinedload(FDACatalog.affirmation_codes)) + .filter(FDACatalog.fda_key == line.fda_key) + .first() + ) + if fda: + lines.append(' ') + lines.append(f' {_xml_escape(fda.fda_code or "")}') + lines.append(f' {_xml_escape(fda.description or "")}') + lines.append(f' {_xml_escape(fda.storage_status or "")}') + lines.append(f' {_xml_escape(fda.manufacturer_number or "")}') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + for aoc in (fda.affirmation_codes or []): + lines.append(f' {_xml_escape(aoc.aoc_code or "")}') + lines.append(' ') + lines.append(' ') + + # DotLine siempre presente con todos sus hijos (Clarion: sección ) + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + + # FccLine (Clarion: Loc:TieneFCC / GCodigosFCC) + # GCodigosFCC no tiene modelo equivalente en el nuevo sistema; se usa fcc_key como identificador. + if line.fcc_key: + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(f' {_xml_escape(line.fcc_key)}') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + + lines.append(' ') + + # Construye contribución al QueueRBSystem2 (Clarion: SFracAmeFact D/N/P) + hts_contrib: dict = {} + val_d = float((line.financial.value_usd or 0)) if line.financial else 0.0 + if customs: + for frac_raw, key in ( + (customs.american_fraction, "D"), + (customs.tlcan_fraction, "N"), + (customs.extra_american_fraction, "P"), + ): + if frac_raw: + frac = _strip_hts(frac_raw) + if frac: + entry = hts_contrib.setdefault(frac, {"valorD": 0.0, "valorN": 0.0, "valorP": 0.0}) + if key == "D": + entry["valorD"] += val_d + elif key == "N": + entry["valorN"] += val_d + else: + entry["valorP"] += val_d + return hts_contrib + + +def _emit_hts_queue(hts_queue: dict, lines: list) -> None: + """ + Emite las secciones , , acumuladas. + Equivale al LOOP FV# final de GENERACION_XML_RBSYSTEMS_EXPO_SCAII_CONSOLIDADO. + """ + for frac, vals in hts_queue.items(): + lines.append(f' {frac}') + lines.append(f' {vals["valorD"]:.2f}') + + # SpecialProgram para 9802/9813 (Clarion: SUB(FraccionD,1,4)) + if frac[:4] in ("9802", "9813"): + lines.append(' ') + lines.append(f' {frac}') + lines.append(f' {vals["valorN"]:.2f}') + lines.append(' ') + + # Packing para fracciones no vacías y no 9801 (Clarion: FraccionD <> '' AND <> '9801') + if frac and frac[:4] != "9801": + lines.append(' ') + lines.append(f' {frac}') + lines.append(f' {vals["valorP"]:.2f}') + lines.append(' ') + + +def _build_entry_invoices_scaii( + db: Session, + manifest_num: str, + manufacturer_id: str, + lines: list, +) -> tuple[int, int]: + """ + Genera los dentro de y el bloque HTS consolidado al final. + Equivale a GENERACION_XML_RBSYSTEMS_EXPO_SCAII_CONSOLIDADO de Clarion. + Retorna (num_facturas, num_partidas). + """ + facturas = ( + db.query(InvoiceHeader) + .join(InvoiceComplianceMx, InvoiceComplianceMx.invoice_id == InvoiceHeader.id) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + ) + .filter( + InvoiceComplianceMx.manifest_number == manifest_num, + InvoiceHeader.status == "processed", + ) + .all() + ) + + # QueueRBSystem2: acumula fracciones HTS de todas las partidas del manifiesto + hts_queue: dict = {} + + total_partidas = 0 + for factura in facturas: + cmx = factura.compliance_mx + fin = factura.financials + log = factura.logistics + + # ConsigneeKey: VendidoA.programs.broker (Clarion: CliVen:Broker) + sold_to = _lookup_provider(db, cmx.sold_to_id if cmx else None) + consignee_key = (sold_to.programs.broker or "") if sold_to and sold_to.programs else "" + + # CountryOfExport: país del proveedor de la factura (Clarion: CliEnv:Pais → AME) + provider = _lookup_provider(db, cmx.provider_id if cmx else None) + provider_m3 = (provider.address.country if provider and provider.address else None) + country_of_export = _ame_country(db, provider_m3) if provider_m3 else "" + + invoice_date = factura.invoice_date.strftime("%m/%d/%Y") if factura.invoice_date else "" + freight = f"{float(fin.freight or 0):.2f}" if fin else "0.00" + total_packages = str(fin.total_packages or 0) if fin else "0" + incoterm = _xml_escape((log.incoterm or "") if log else "") + + lines.append(' ') + lines.append(f' {_xml_escape(factura.invoice_number or "")}') + lines.append(f' {_xml_escape(consignee_key)}') + lines.append(f' {invoice_date}') + lines.append(f' {_xml_escape(manufacturer_id)}') + lines.append(f' {freight}') + lines.append(f' {total_packages}') + lines.append(f' {incoterm}') + + items = _query_invoice_items(db, factura.id) + for item in items: + contrib = _append_invoice_line_scaii(db, item, lines, country_of_export) + # Acumula en QueueRBSystem2 + for frac, vals in contrib.items(): + entry = hts_queue.setdefault(frac, {"valorD": 0.0, "valorN": 0.0, "valorP": 0.0}) + entry["valorD"] += vals["valorD"] + entry["valorN"] += vals["valorN"] + entry["valorP"] += vals["valorP"] + total_partidas += len(items) + + lines.append(' ') + + # Emite HTS consolidado después del loop de facturas (Clarion: LOOP FV#) + _emit_hts_queue(hts_queue, lines) + + return len(facturas), total_partidas + + +def _build_shipment( + shipment_set: ET.Element, + exporter_name: str, + invoice: InvoiceHeader, + items: List[LineItem], + entry_port: str = "", + exit_port: str = "", +) -> int: + """Agrega un elemento al ShipmentSet. Retorna número de partidas.""" + shipment = ET.SubElement(shipment_set, "Shipment") + + _sub(shipment, "ExporterName", exporter_name) + _sub(shipment, "CustomerReference", invoice.invoice_number or "") + _sub(shipment, "InvoiceNumber", invoice.invoice_number or "") + + mot = "" + export_date = "" + export_port = "" + container = "" + seal = "" + scac = "" + + if invoice.logistics: + mot = invoice.logistics.transport_mode or "" + container = invoice.logistics.transport_num or "" + seal = invoice.logistics.seal_number or "" + scac = invoice.logistics.carrier_id or "" + + if invoice.invoice_date: + export_date = invoice.invoice_date.strftime("%m/%d/%y") + + # Puerto: compliance_mx > parámetro de la request + export_port = (invoice.compliance_mx.port_of_entry if invoice.compliance_mx else None) or entry_port + + _sub(shipment, "MOT", mot) + _sub(shipment, "ExportDate", export_date) + _sub(shipment, "ExportPort", export_port) + _sub(shipment, "ExitPort", exit_port) + _sub(shipment, "Container", container) + _sub(shipment, "Seal", seal) + _sub(shipment, "SCAC", scac) + + for line in items: + item_el = ET.SubElement(shipment, "Item") + + hts = "" + license_code = "" + origin_ind = "F" + permit_num = "" + + if line.customs: + raw_hts = line.customs.american_fraction or line.customs.fraction or "" + hts = raw_hts.replace(".", "").strip() + license_code = line.customs.advalorem or "" + origin_ind = _origin_indicator(line.customs.origin_country) + + permit_num = line.permit_number or "" + + qty = "" + unit1 = "PC" + value = "" + gross_weight = "" + + if line.quantity: + qty = str(line.quantity.quantity or "") + gross_weight = _format_weight(line.quantity.gross_weight) + + if line.unit_of_measure_info: + unit1 = line.unit_of_measure_info.american_code or line.uma_key or "PC" + elif line.uma_key: + unit1 = line.uma_key + + if line.financial: + value = str(line.financial.value_usd or "") + + _sub(item_el, "HTSNumber", hts) + _sub(item_el, "LicenseCode", license_code) + _sub(item_el, "OriginIndicator", origin_ind) + _sub(item_el, "Qty1", qty) + _sub(item_el, "Unit1", unit1) + _sub(item_el, "Value", value) + _sub(item_el, "GrossWeight", gross_weight) + _sub(item_el, "LicenseNumber", permit_num) + + return len(items) + + +def _pretty_xml(root: ET.Element) -> str: + raw = ET.tostring(root, encoding="unicode") + return minidom.parseString(raw).toprettyxml(indent=" ", encoding=None) + + +def _query_invoice_items(db: Session, invoice_id: int) -> List[LineItem]: + return ( + db.query(LineItem) + .filter(LineItem.invoice_id == invoice_id) + .options( + joinedload(LineItem.part_info), + joinedload(LineItem.description), + joinedload(LineItem.financial), + joinedload(LineItem.quantity).joinedload(LineQuantity.package_info), + joinedload(LineItem.customs), + joinedload(LineItem.unit_of_measure_info), + ) + .all() + ) + + +def _query_invoice_items_scaf(db: Session, invoice_id: int) -> List[LineItem]: + """Partidas SCAF: class_id IS NOT NULL (activos fijos / QEqeMaq en Clarion).""" + return ( + db.query(LineItem) + .filter(LineItem.invoice_id == invoice_id, LineItem.class_id.isnot(None)) + .options( + joinedload(LineItem.class_info), + joinedload(LineItem.description), + joinedload(LineItem.financial), + joinedload(LineItem.quantity), + joinedload(LineItem.customs), + joinedload(LineItem.unit_of_measure_info), + ) + .all() + ) + + +def _append_invoice_line_scaf(db: Session, line: LineItem, lines: list) -> None: + """ + Emite un para una partida SCAF de exportación. + Equivale al bloque de partidas en GENERACION_XML_RBSYSTEMS_EXPO_SCAF de Clarion. + HtsProduct + SpecialProgram se emiten inline (no hay queue). + """ + customs = line.customs + qty_info = line.quantity + + # ProductKey = EqiPex:Clase (class_code, no NumParte) + product_key = (line.class_info.class_code if line.class_info else "") or "" + # ProductName = EqiPex:DescripcionI — solo XML-escape, sin limpiar chars especiales + product_name = _xml_escape( + (line.description.description_english or "") if line.description else "" + ) + + origin_m3 = (customs.origin_country or "").strip() if customs else "" + # CountryOfExport = CountryOfOrigin (Clarion reutiliza GenPai de PaisOrigen) + country_ame = _ame_country(db, origin_m3) if origin_m3 else "" + + scp = _special_country_program(line.has_certificate, origin_m3) + + qty_val = f"{float(qty_info.quantity or 0):.4f}" if qty_info else "0.0000" + unit_code = "PC" + if line.unit_of_measure_info: + unit_code = line.unit_of_measure_info.american_code or line.uma_key or "PC" + elif line.uma_key: + unit_code = line.uma_key + + # Fracción americana (EqiPex:FraccionAmericana) + frac_scaf = _strip_hts(customs.american_fraction or "") if customs else "" + + # ValorExpoME (EqiPex:ValorExpoME) + valor_expo_me = f"{float(line.financial.value_usd or 0):.2f}" if line.financial else "0.00" + + net_weight_kg = f"{float(qty_info.net_weight or 0):.4f}" if qty_info else "0.0000" + nafta_net = "Y" if line.has_certificate else "N" + + lines.append(' ') + lines.append(f' {_xml_escape(product_key)}') + lines.append(f' {product_name}') + lines.append(' ') + lines.append(f' {country_ame}') + lines.append(f' {country_ame}') + lines.append(f' {scp}') + lines.append(f' {qty_val}') + # HtsProduct + HtsProductValue inline (Clarion: no usa queue) + lines.append(f' {frac_scaf}') + lines.append(f' {valor_expo_me}') + lines.append(f' {net_weight_kg}') + lines.append(f' {nafta_net}') + + # SpecialProgram 9802/9813 inline + if frac_scaf[:4] in ("9802", "9813"): + lines.append(' ') + lines.append(f' {frac_scaf}') + lines.append(f' {valor_expo_me}') + lines.append(' ') + + # FdaLine (mismo estructura que SCAII — Clarion: Loc:TieneFDA / GCodigosFDA) + if line.has_fda_code and line.fda_key: + fda = ( + db.query(FDACatalog) + .options(joinedload(FDACatalog.affirmation_codes)) + .filter(FDACatalog.fda_key == line.fda_key) + .first() + ) + if fda: + lines.append(' ') + lines.append(f' {_xml_escape(fda.fda_code or "")}') + lines.append(f' {_xml_escape(fda.description or "")}') + lines.append(f' {_xml_escape(fda.storage_status or "")}') + lines.append(f' {_xml_escape(fda.manufacturer_number or "")}') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + for aoc in (fda.affirmation_codes or []): + lines.append(f' {_xml_escape(aoc.aoc_code or "")}') + lines.append(' ') + lines.append(' ') + + # DotLine: comentado en Clarion para SCAF — no se emite + + # FccLine (misma estructura que SCAII) + if line.fcc_key: + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(f' {_xml_escape(line.fcc_key)}') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + + lines.append(' ') + + +def _build_entry_invoices_scaf( + db: Session, + manifest_num: str, + lines: list, +) -> tuple[int, int]: + """ + Genera los de facturas SCAF definitivas dentro de . + Equivale a GENERACION_XML_RBSYSTEMS_EXPO_SCAF de Clarion (QFacExp/QEqeMaq). + Discriminador: download_def == True (DESCARGADEF). + ManufactureId viene del proveedor de la factura (CliPro), no del manifiesto. + Retorna (num_facturas, num_partidas). + """ + facturas = ( + db.query(InvoiceHeader) + .join(InvoiceComplianceMx, InvoiceComplianceMx.invoice_id == InvoiceHeader.id) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + ) + .filter( + InvoiceComplianceMx.manifest_number == manifest_num, + InvoiceHeader.status == "processed", + InvoiceHeader.download_def == True, + ) + .all() + ) + + total_facturas = 0 + total_partidas = 0 + + for factura in facturas: + items = _query_invoice_items_scaf(db, factura.id) + if not items: + continue # factura sin partidas SCAF, es SCAII — la omitimos + + cmx = factura.compliance_mx + fin = factura.financials + log = factura.logistics + + # ManufactureId: proveedor de la factura (Clarion: CliPro:ManufacterID) + provider = _lookup_provider(db, cmx.provider_id if cmx else None) + manufacturer_id = (provider.programs.manufacturer_id or "") if provider and provider.programs else "" + + sold_to = _lookup_provider(db, cmx.sold_to_id if cmx else None) + consignee_key = (sold_to.programs.broker or "") if sold_to and sold_to.programs else "" + + invoice_date = factura.invoice_date.strftime("%m/%d/%Y") if factura.invoice_date else "" + freight = f"{float(fin.freight or 0):.2f}" if fin else "0.00" + total_packages = str(fin.total_packages or 0) if fin else "0" + incoterm = _xml_escape((log.incoterm or "") if log else "") + + lines.append(' ') + lines.append(f' {_xml_escape(factura.invoice_number or "")}') + lines.append(f' {_xml_escape(consignee_key)}') + lines.append(f' {invoice_date}') + lines.append(f' {_xml_escape(manufacturer_id)}') + lines.append(f' {freight}') + lines.append(f' {total_packages}') + lines.append(f' {incoterm}') + + for item in items: + _append_invoice_line_scaf(db, item, lines) + total_partidas += len(items) + + lines.append(' ') + total_facturas += 1 + + return total_facturas, total_partidas + + +def _build_entry_invoices_tem_scaf( + db: Session, + manifest_num: str, + lines: list, +) -> tuple[int, int]: + """ + Genera los de facturas SCAF temporales dentro de . + Equivale a GENERACION_XML_RBSYSTEMS_EXPO_TEM_SCAF de Clarion (QFacExpRep/QEqeMaqRep). + Discriminador: download_def IS NOT True (DESCARGADEF = False/NULL → temporal). + XML idéntico a SCAF definitivo; solo difiere la fuente de datos. + Retorna (num_facturas, num_partidas). + """ + facturas = ( + db.query(InvoiceHeader) + .join(InvoiceComplianceMx, InvoiceComplianceMx.invoice_id == InvoiceHeader.id) + .options( + joinedload(InvoiceHeader.compliance_mx), + joinedload(InvoiceHeader.financials), + joinedload(InvoiceHeader.logistics), + ) + .filter( + InvoiceComplianceMx.manifest_number == manifest_num, + InvoiceHeader.status == "processed", + InvoiceHeader.download_def.isnot(True), + ) + .all() + ) + + total_facturas = 0 + total_partidas = 0 + + for factura in facturas: + items = _query_invoice_items_scaf(db, factura.id) + if not items: + continue # factura sin partidas SCAF temporal — omitir + + cmx = factura.compliance_mx + fin = factura.financials + log = factura.logistics + + # ManufactureId: proveedor de la factura (Clarion: CliPro:ManufacterID) + provider = _lookup_provider(db, cmx.provider_id if cmx else None) + manufacturer_id = (provider.programs.manufacturer_id or "") if provider and provider.programs else "" + + sold_to = _lookup_provider(db, cmx.sold_to_id if cmx else None) + consignee_key = (sold_to.programs.broker or "") if sold_to and sold_to.programs else "" + + invoice_date = factura.invoice_date.strftime("%m/%d/%Y") if factura.invoice_date else "" + freight = f"{float(fin.freight or 0):.2f}" if fin else "0.00" + total_packages = str(fin.total_packages or 0) if fin else "0" + incoterm = _xml_escape((log.incoterm or "") if log else "") + + lines.append(' ') + lines.append(f' {_xml_escape(factura.invoice_number or "")}') + lines.append(f' {_xml_escape(consignee_key)}') + lines.append(f' {invoice_date}') + lines.append(f' {_xml_escape(manufacturer_id)}') + lines.append(f' {freight}') + lines.append(f' {total_packages}') + lines.append(f' {incoterm}') + + for item in items: + _append_invoice_line_scaf(db, item, lines) + total_partidas += len(items) + + lines.append(' ') + total_facturas += 1 + + return total_facturas, total_partidas + + +def _validate_client_provider_impo( + db: Session, + provider: Optional[ClientProvider], + provider_db_id: str, + role: str, + issues: list, +) -> tuple[str, str]: + """ + Valida campos obligatorios de un CliPro para XML IMPO. + Equivale a los bloques IF ERRORCODE()=35 / CliPro:BrokerImpo='' de Clarion. + Retorna (ame_state, ame_country). + """ + if not provider: + issues.append( + f"No existe el {role} (ID: {provider_db_id}) — el archivo puede estar incorrecto. " + f"Solución: capturar la clave con datos obligatorios (Broker Impo, País, Estado)." + ) + return "", "" + + # Clave visible: short_name equivale a CliPro:Cliente en Clarion + display_key = provider.short_name or provider.name or provider_db_id + + import_broker = (provider.programs.import_broker or "") if provider.programs else "" + if not import_broker: + issues.append( + f"Falta capturar la clave de Broker de Importación del {role} '{display_key}'. " + f"Solución: capturar la información correspondiente al {role}." + ) + + m3_country = (provider.address.country or "") if provider.address else "" + ame_country = "" + if not m3_country: + issues.append( + f"Falta capturar la clave del País del {role} '{display_key}'. " + f"Solución: capturar la información correspondiente al {role}." + ) + else: + ame_country = _ame_country(db, m3_country) + + m3_state = (provider.address.state or "") if provider.address else "" + ame_state = "" + if not m3_state: + issues.append( + f"Falta capturar la descripción del Estado del {role} '{display_key}'. " + f"Solución: capturar la información correspondiente al {role}." + ) + elif m3_country: + state_obj = db.query(State).filter(State.m3_key == m3_country, State.description == m3_state).first() + if not state_obj: + issues.append( + f"No existe el estado '{m3_state}' con País '{m3_country}' para el {role} '{display_key}' — " + f"el archivo puede estar incorrecto. " + f"Solución: dar de alta el estado con clave americana en el catálogo de Países/Estados." + ) + elif not (state_obj.ame_key or ""): + issues.append( + f"Falta capturar la clave americana del Estado '{m3_state}'. " + f"Solución: capturar la información en el catálogo de Países/Estados." + ) + else: + ame_state = state_obj.ame_key + + return ame_state, ame_country + + +def _validate_transporter_impo( + log, + transporter: Optional[Transporter], + invoice_number: str, + issues: list, +) -> str: + """ + Valida transportista para XML IMPO. Equivale a IF MatFim:Transportista='' de Clarion. + Retorna loader_code (SCAC). + """ + if not log or not log.carrier_int_id: + issues.append( + f"Falta capturar la clave de Transportista en la Factura '{invoice_number}'. " + f"Solución: capturar o seleccionar la clave de Transportista en el encabezado de la factura." + ) + return "" + + if not transporter: + issues.append( + f"No existe el Transportista (ID: {log.carrier_int_id}) en catálogo para la Factura '{invoice_number}' — " + f"el archivo puede estar incorrecto. " + f"Solución: capturar el transportista con SCAC Code, Nombre y RFC." + ) + return "" + + scac = transporter.loader_code or "" + if not scac: + issues.append( + f"Falta capturar el Código del Cargador del Transportista (SCAC Code) " + f"para la Factura '{invoice_number}'. " + f"Solución: capturar la información correspondiente al transportista." + ) + return scac + + +class XmlRbSystemsService: + + def _exporter_name(self, db: Session) -> str: + empresa = db.query(GEmpresa).first() + return (empresa.name or "") if empresa else "" + + def generar_impo_temp( + self, db: Session, request: XmlRbSystemsImpoRequest + ) -> XmlRbSystemsResponse: + """ + Genera XML RB Systems para Importación Temporal (IT). + Estructura: + por factura
/. + Equivale a GENERACION_XML_RBSYSTEMS_IMPO_TEMP de Clarion. + """ + try: + root = ET.Element("Data") + issues: list[str] = [] + + facturas = ( + db.query(InvoiceHeader) + .options( + joinedload(InvoiceHeader.logistics), + joinedload(InvoiceHeader.compliance_mx), + ) + .filter(InvoiceHeader.invoice_number.in_(request.invoice_numbers)) + .all() + ) + + if not facturas: + return XmlRbSystemsResponse( + success=False, + message="No se encontraron facturas con los números proporcionados", + ) + + first_cmx = facturas[0].compliance_mx + first_prov = _lookup_provider(db, first_cmx.provider_id if first_cmx else None) + first_prov_name = (first_prov.name or "") if first_prov else "" + + app_info = ET.SubElement(root, "ApplicationInformation") + _sub(app_info, "ExporterName", first_prov_name) + _sub(app_info, "SoftwareProvider", "ADUANASOFT") + _sub(app_info, "Module", "SED") + _sub(app_info, "Version", "1.0") + _sub(app_info, "Action", "A") + + cuenta_facturas = 0 + cuenta_partidas = 0 + + for factura in facturas: + cmx = factura.compliance_mx + log = factura.logistics + inv_num = factura.invoice_number or "" + + provider = _lookup_provider(db, cmx.provider_id if cmx else None) + consignee = _lookup_provider(db, cmx.sold_to_id if cmx else None) + provider_key = str(cmx.provider_id or "") if cmx else "" + consignee_key = str(cmx.sold_to_id or "") if cmx else "" + + export_state, _ = _validate_client_provider_impo(db, provider, provider_key, "Proveedor", issues) + state_dest, country_dest = _validate_client_provider_impo(db, consignee, consignee_key, "Consignado/Vendido A", issues) + + transporter: Optional[Transporter] = None + if log and log.carrier_int_id: + transporter = ( + db.query(Transporter) + .filter(Transporter.transporter_id == log.carrier_int_id) + .first() + ) + scac = _validate_transporter_impo(log, transporter, inv_num, issues) + + vehicle: Optional[Vehicle] = None + if log and log.transport_int_id: + vehicle = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == log.transport_int_id) + .first() + ) + vin = (vehicle.transport_identifier or "") if vehicle else "" + + export_broker = (provider.programs.import_broker or "") if provider and provider.programs else "" + consignee_broker = (consignee.programs.import_broker or "") if consignee and consignee.programs else "" + export_date = factura.invoice_date.strftime("%m/%d/%y") if factura.invoice_date else "" + + header = ET.SubElement(root, "Header") + _sub(header, "CustomerReference", inv_num) + _sub(header, "HazMat", "N") + _sub(header, "Exporter", export_broker) + _sub(header, "Consignee", consignee_broker) + _sub(header, "MOT", (log.transport_mode or "") if log else "") + _sub(header, "ExportDate", export_date) + _sub(header, "CountryDestination", country_dest) + _sub(header, "StateDestination", state_dest) + _sub(header, "ExportPort", request.exit_port or "") + _sub(header, "ExportState", export_state) + _sub(header, "Container", (log.transport_id or "") if log else "") + _sub(header, "Seal", (log.seal_number or "") if log else "") + _sub(header, "SCAC", scac) + + items = _query_invoice_items(db, factura.id) + detail = ET.SubElement(root, "Detail") + invoice_el = ET.SubElement(detail, "Invoice") + _sub(invoice_el, "InvoiceNumber", inv_num) + + for line in items: + raw_hts = (line.customs.american_fraction or "") if line.customs else "" + qty = str(line.quantity.quantity or "") if line.quantity else "" + gross_weight = _format_weight(line.quantity.gross_weight if line.quantity else None) + value = str(line.financial.value_usd or "") if line.financial else "" + + unit1 = "PC" + if line.unit_of_measure_info: + unit1 = line.unit_of_measure_info.american_code or line.uma_key or "PC" + elif line.uma_key: + unit1 = line.uma_key + + eccn = (line.part_info.eccn or "") if line.part_info else "" + lic_num = (line.part_info.exclusion_symbol or "") if line.part_info else "" + + hts_el = ET.SubElement(invoice_el, "HTS") + _sub(hts_el, "HTSNumber", _strip_hts(raw_hts)) + _sub(hts_el, "LicenseCode", "C33") + _sub(hts_el, "OriginIndicator", "D") + _sub(hts_el, "Qty1", qty) + _sub(hts_el, "Unit1", unit1) + _sub(hts_el, "Qty2", "") + _sub(hts_el, "Unit2", "") + _sub(hts_el, "Value", value) + _sub(hts_el, "GrossWeight", gross_weight) + _sub(hts_el, "ECCN", eccn) + _sub(hts_el, "LicenseNumber", lic_num) + _sub(hts_el, "DDTC", "") + + if vin: + vd = ET.SubElement(hts_el, "VehicleDetail") + vi = ET.SubElement(vd, "VehicleInformation") + _sub(vi, "VIN", vin) + _sub(vi, "TitleNumber", "") + _sub(vi, "IDQualifier", "") + _sub(vi, "TitleStateCode", "") + else: + _sub(hts_el, "VehicleDetail", "") + + cuenta_facturas += 1 + cuenta_partidas += len(items) + + xml_str = _pretty_xml(root) + filename = f"XML_RB_IMP_TEMP_{date.today().strftime('%Y%m%d')}.xml" + msg = f"{cuenta_facturas} factura(s), {cuenta_partidas} partida(s) generadas" + if issues: + msg += f" — {len(issues)} inconsistencia(s)" + + return XmlRbSystemsResponse( + success=True, + message=msg, + archivo_generado=filename, + content=xml_str, + cuenta_facturas=cuenta_facturas, + cuenta_partidas=cuenta_partidas, + inconsistencias=issues, + ) + + except Exception as e: + import traceback + traceback.print_exc() + return XmlRbSystemsResponse(success=False, message=str(e)) + + def generar_impo_def( + self, db: Session, request: XmlRbSystemsImpoRequest + ) -> XmlRbSystemsResponse: + """ + Genera XML RB Systems para Importación Definitiva (ID / SComprasMexID). + Estructura XML idéntica a IMPO_TEMP; diferencia: siempre presente. + Equivale a GENERACION_XML_RBSYSTEMS_IMPO_DEF de Clarion. + """ + try: + root = ET.Element("Data") + issues: list[str] = [] + + facturas = ( + db.query(InvoiceHeader) + .options( + joinedload(InvoiceHeader.logistics), + joinedload(InvoiceHeader.compliance_mx), + ) + .filter(InvoiceHeader.invoice_number.in_(request.invoice_numbers)) + .all() + ) + + if not facturas: + return XmlRbSystemsResponse( + success=False, + message="No se encontraron facturas con los números proporcionados", + ) + + first_cmx = facturas[0].compliance_mx + first_prov = _lookup_provider(db, first_cmx.provider_id if first_cmx else None) + first_prov_name = (first_prov.name or "") if first_prov else "" + + app_info = ET.SubElement(root, "ApplicationInformation") + _sub(app_info, "ExporterName", first_prov_name) + _sub(app_info, "SoftwareProvider", "ADUANASOFT") + _sub(app_info, "Module", "SED") + _sub(app_info, "Version", "1.0") + _sub(app_info, "Action", "A") + + cuenta_facturas = 0 + cuenta_partidas = 0 + + for factura in facturas: + cmx = factura.compliance_mx + log = factura.logistics + inv_num = factura.invoice_number or "" + + provider = _lookup_provider(db, cmx.provider_id if cmx else None) + consignee = _lookup_provider(db, cmx.sold_to_id if cmx else None) + provider_key = str(cmx.provider_id or "") if cmx else "" + consignee_key = str(cmx.sold_to_id or "") if cmx else "" + + export_state, _ = _validate_client_provider_impo(db, provider, provider_key, "Proveedor", issues) + state_dest, country_dest = _validate_client_provider_impo(db, consignee, consignee_key, "Consignado/Vendido A", issues) + + transporter: Optional[Transporter] = None + if log and log.carrier_int_id: + transporter = ( + db.query(Transporter) + .filter(Transporter.transporter_id == log.carrier_int_id) + .first() + ) + scac = _validate_transporter_impo(log, transporter, inv_num, issues) + + vehicle: Optional[Vehicle] = None + if log and log.transport_int_id: + vehicle = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_id == log.transport_int_id) + .first() + ) + vin = (vehicle.transport_identifier or "") if vehicle else "" + + export_broker = (provider.programs.import_broker or "") if provider and provider.programs else "" + consignee_broker = (consignee.programs.import_broker or "") if consignee and consignee.programs else "" + export_date = factura.invoice_date.strftime("%m/%d/%y") if factura.invoice_date else "" + + header = ET.SubElement(root, "Header") + _sub(header, "CustomerReference", inv_num) + _sub(header, "HazMat", "N") + _sub(header, "Exporter", export_broker) + _sub(header, "Consignee", consignee_broker) + _sub(header, "MOT", (log.transport_mode or "") if log else "") + _sub(header, "ExportDate", export_date) + _sub(header, "CountryDestination", country_dest) + _sub(header, "StateDestination", state_dest) + _sub(header, "ExportPort", request.exit_port or "") + _sub(header, "ExportState", export_state) + _sub(header, "Container", (log.transport_id or "") if log else "") + _sub(header, "Seal", (log.seal_number or "") if log else "") + _sub(header, "SCAC", scac) + + items = _query_invoice_items(db, factura.id) + detail = ET.SubElement(root, "Detail") + invoice_el = ET.SubElement(detail, "Invoice") + _sub(invoice_el, "InvoiceNumber", inv_num) + + for line in items: + raw_hts = (line.customs.american_fraction or "") if line.customs else "" + qty = str(line.quantity.quantity or "") if line.quantity else "" + gross_weight = _format_weight(line.quantity.gross_weight if line.quantity else None) + value = str(line.financial.value_usd or "") if line.financial else "" + + unit1 = "PC" + if line.unit_of_measure_info: + unit1 = line.unit_of_measure_info.american_code or line.uma_key or "PC" + elif line.uma_key: + unit1 = line.uma_key + + eccn = (line.part_info.eccn or "") if line.part_info else "" + lic_num = (line.part_info.exclusion_symbol or "") if line.part_info else "" + + hts_el = ET.SubElement(invoice_el, "HTS") + _sub(hts_el, "HTSNumber", _strip_hts(raw_hts)) + _sub(hts_el, "LicenseCode", "C33") + _sub(hts_el, "OriginIndicator", "D") + _sub(hts_el, "Qty1", qty) + _sub(hts_el, "Unit1", unit1) + _sub(hts_el, "Qty2", "") + _sub(hts_el, "Unit2", "") + _sub(hts_el, "Value", value) + _sub(hts_el, "GrossWeight", gross_weight) + _sub(hts_el, "ECCN", eccn) + _sub(hts_el, "LicenseNumber", lic_num) + _sub(hts_el, "DDTC", "") + + # IMPO_DEF: VehicleDetail siempre presente (incluso con VIN vacío) + vd = ET.SubElement(hts_el, "VehicleDetail") + vi = ET.SubElement(vd, "VehicleInformation") + _sub(vi, "VIN", vin) + _sub(vi, "TitleNumber", "") + _sub(vi, "IDQualifier", "") + _sub(vi, "TitleStateCode", "") + + cuenta_facturas += 1 + cuenta_partidas += len(items) + + xml_str = _pretty_xml(root) + filename = f"XML_RB_IMP_DEF_{date.today().strftime('%Y%m%d')}.xml" + msg = f"{cuenta_facturas} factura(s), {cuenta_partidas} partida(s) generadas" + if issues: + msg += f" — {len(issues)} inconsistencia(s)" + + return XmlRbSystemsResponse( + success=True, + message=msg, + archivo_generado=filename, + content=xml_str, + cuenta_facturas=cuenta_facturas, + cuenta_partidas=cuenta_partidas, + inconsistencias=issues, + ) + + except Exception as e: + import traceback + traceback.print_exc() + return XmlRbSystemsResponse(success=False, message=str(e)) + + def generar_impo( + self, db: Session, request: XmlRbSystemsImpoRequest + ) -> XmlRbSystemsResponse: + try: + exporter_name = self._exporter_name(db) + shipment_set = ET.Element("ShipmentSet") + + cuenta_facturas = 0 + cuenta_partidas = 0 + + facturas = ( + db.query(InvoiceHeader) + .options( + joinedload(InvoiceHeader.logistics), + joinedload(InvoiceHeader.compliance_mx), + ) + .filter(InvoiceHeader.invoice_number.in_(request.invoice_numbers)) + .all() + ) + + for factura in facturas: + items = _query_invoice_items(db, factura.id) + n = _build_shipment( + shipment_set, exporter_name, factura, items, + entry_port=request.entry_port or "", + exit_port=request.exit_port or "", + ) + cuenta_facturas += 1 + cuenta_partidas += n + + xml_str = _pretty_xml(shipment_set) + filename = f"XML_RB_IMP_{date.today().strftime('%Y%m%d')}.xml" + + return XmlRbSystemsResponse( + success=True, + message=f"{cuenta_facturas} factura(s), {cuenta_partidas} partida(s) generadas", + archivo_generado=filename, + content=xml_str, + cuenta_facturas=cuenta_facturas, + cuenta_partidas=cuenta_partidas, + ) + + except Exception as e: + import traceback + traceback.print_exc() + return XmlRbSystemsResponse(success=False, message=str(e)) + + def generar_expo( + self, db: Session, request: XmlRbSystemsExpoRequest + ) -> XmlRbSystemsResponse: + """ + Genera XML RB Systems para Exportación (Entry/Manifest/Emanifest). + Input: números de manifiesto. Un bloque por manifiesto. + Equivale a GENERACION_XML_RBSYSTEMS_EXPO + EXPO_SCAII de Clarion. + """ + empresa = db.query(GEmpresa).first() + company_name = (empresa.name or "") if empresa else "" + company_broker = (empresa.broker_company or "") if empresa else "" + + full_content_parts: list[str] = [] + cuenta_manifiestos = 0 + cuenta_partidas = 0 + + for manifest_num in request.manifest_numbers: + manifiesto = ( + db.query(Manifest) + .filter(Manifest.manifest_number == manifest_num) + .first() + ) + if not manifiesto: + continue + + # Transportista → SCAC + transporter: Optional[Transporter] = None + if manifiesto.carrier_code: + transporter = ( + db.query(Transporter) + .filter(Transporter.transporter_key == manifiesto.carrier_code) + .first() + ) + + # EnviadoPor / ConsignadoA + sent_by = _lookup_provider_by_key(db, manifiesto.sent_by) + consigned_to = _lookup_provider_by_key(db, manifiesto.consigned_to) + + sent_by_broker = (sent_by.programs.broker or "") if sent_by and sent_by.programs else "" + consigned_broker = (consigned_to.programs.broker or "") if consigned_to and consigned_to.programs else "" + + clean_num = _clean_manifest_number(manifest_num) + entry_date_str = _format_clarion_date(manifiesto.entry_date) + + # Conductor + manifest_driver_row = ( + db.query(ManifestDriver) + .filter(ManifestDriver.manifest_number == manifest_num) + .first() + ) + driver: Optional[Driver] = None + if manifest_driver_row and manifiesto.carrier_code: + driver = ( + db.query(Driver) + .filter( + Driver.transporter_key == manifiesto.carrier_code, + Driver.driver_name == manifest_driver_row.driver_name, + ) + .first() + ) + + driver_badge = (driver.badge_number or "") if driver else "" + driver_first = (driver.first_name or "") if driver else "" + driver_last = (driver.last_name or "") if driver else "" + driver_ace = (driver.ace_id or "") if driver else "" + + # Trailer + trailer: Optional[Trailer] = None + if manifiesto.trailer_number: + trailer = ( + db.query(Trailer) + .filter(Trailer.trailer_number == manifiesto.trailer_number) + .first() + ) + trailer_state = _ame_state_from_ame_country( + db, + (trailer.country or "") if trailer else None, + (trailer.state or "") if trailer else None, + ) + + # Transporte/tractor (GTransportes — buscado por NumTrailer) + vehicle: Optional[Vehicle] = None + if manifiesto.trailer_number: + vehicle = ( + db.query(Vehicle) + .filter(Vehicle.vehicle_key == manifiesto.trailer_number) + .first() + ) + vehicle_state = _ame_state_from_ame_country( + db, + (vehicle.country or "") if vehicle else None, + (vehicle.state or "") if vehicle else None, + ) + + lines: list[str] = [] + lines.append('') + lines.append('') + lines.append('') + + lines.append(' ') + lines.append(f' {company_name}') + lines.append(f' {company_broker}') + lines.append(' ADUANASOFT') + lines.append(' ENTRY') + lines.append(' 1.0') + lines.append(' A') + lines.append(' ') + + lines.append(' ') + lines.append(f' {manifest_num[:3]}') + lines.append(f' {clean_num[3:10]}') + lines.append(f' {manifest_num[-1] if manifest_num else ""}') + lines.append(f' {manifiesto.manifest_type or "01"}') + lines.append(f' {manifiesto.transport_mode or ""}') + lines.append(f' {(transporter.loader_code or "") if transporter else ""}') + lines.append(f' {sent_by_broker}') + lines.append(f' {consigned_broker}') + lines.append(f' {manifiesto.entry_port or ""}') + lines.append(f' {manifiesto.entry_port_loc or ""}') + lines.append(f' {entry_date_str}') + lines.append(f' {entry_date_str}') + lines.append(f' {manifiesto.foreign_exit_port or ""}') + lines.append(f' {entry_date_str}') + + if manifiesto.transport_mode == "40": + niu = _get_niu_for_manifest(db, manifest_num) + lines.append(f' {niu[:5]}') + else: + lines.append(' ') + + lines.append(' ') + lines.append(' N') + lines.append(' N') + lines.append(f' {manifiesto.trailer_number or ""}') + + lines.append(' ') + mfr_id = (sent_by.programs.manufacturer_id or "") if sent_by and sent_by.programs else "" + _, n_scaii = _build_entry_invoices_scaii(db, manifest_num, mfr_id, lines) + _, n_scaf = _build_entry_invoices_scaf(db, manifest_num, lines) + _, n_tem_scaf = _build_entry_invoices_tem_scaf(db, manifest_num, lines) + cuenta_partidas += n_scaii + n_scaf + n_tem_scaf + lines.append(' ') + lines.append(' ') + + # Manifest/Fast + lines.append(' ') + lines.append(' ') + lines.append(f' {driver_badge}') + lines.append(f' {(driver_first + " " + driver_last).strip()}') + lines.append(f' {manifiesto.trailer_number or ""}') + lines.append(f' {(trailer.plate_number or "") if trailer else ""}') + lines.append(f' {trailer_state}') + lines.append(f' {(vehicle.plate_number or "") if vehicle else ""}') + lines.append(f' {vehicle_state}') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(' ') + + # Emanifest (opcional) + if request.include_emanifest: + consignee_state_str = "" + consignee_country_str = "" + consignee_name = (consigned_to.name or "") if consigned_to else "" + consignee_addr = "" + consignee_city = "" + consignee_zip = "" + + if consigned_to and consigned_to.address: + addr = consigned_to.address + consignee_addr = addr.streets or "" + consignee_city = addr.city or "" + consignee_zip = addr.postal_code or "" + consignee_country_str = _ame_country(db, addr.country) + consignee_state_str = _ame_state(db, addr.country, addr.state) + + lines.append(' ') + lines.append(' ') + lines.append(f' {sent_by_broker}') + lines.append(f' {manifiesto.foreign_exit_port or ""}') + lines.append(f' {manifiesto.entry_port or ""}') + desc = (manifiesto.description or "")[:45] + lines.append(f' {desc}') + lines.append(' ') + lines.append(' ') + lines.append(' ') + lines.append(f' {clean_num[:10]}') + lines.append(' ') + lines.append(f' {consignee_name}') + lines.append(f'
{consignee_addr}
') + lines.append(f' {consignee_city}') + lines.append(f' {consignee_state_str}') + lines.append(f' {consignee_zip}') + lines.append(f' {consignee_country_str}') + lines.append('
') + lines.append(' ') + lines.append(f' {(vehicle.ace_vehicle_key or "") if vehicle else ""}') + lines.append(f' {(vehicle.seal or "") if vehicle else ""}') + lines.append(' ') + lines.append(' ') + lines.append(f' {(trailer.ace_trailer_number or "") if trailer else ""}') + lines.append(f' {(trailer.trailer_number or "") if trailer else ""}') + lines.append(f' {(trailer.trailer_type_key or "") if trailer else ""}') + lines.append(f' {(trailer.plate_number or "") if trailer else ""}') + lines.append(f' {(trailer.country or "") if trailer else ""}') + lines.append(f' {trailer_state}') + lines.append(f' {(trailer.seal or "") if trailer else ""}') + lines.append(' ') + lines.append(' ') + lines.append(f' {(driver_first + " " + driver_last).strip()}') + lines.append(f' {driver_ace}') + lines.append(' ') + lines.append('
') + lines.append('
') + + lines.append('
') + full_content_parts.append("\n".join(lines)) + cuenta_manifiestos += 1 + + filename = f"XML_RB_EXP_{request.manifest_numbers[0] if request.manifest_numbers else 'EXPO'}_{date.today().strftime('%Y%m%d')}.xml" + + return XmlRbSystemsResponse( + success=True, + message=f"{cuenta_manifiestos} manifiesto(s), {cuenta_partidas} partida(s) generadas", + archivo_generado=filename, + content="\n".join(full_content_parts), + cuenta_facturas=cuenta_manifiestos, + cuenta_partidas=cuenta_partidas, + ) diff --git a/backend/api/v1/modules/a76/reports/xml_rb_systems/task.py b/backend/api/v1/modules/a76/reports/xml_rb_systems/task.py new file mode 100644 index 00000000..e9396903 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/xml_rb_systems/task.py @@ -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 diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 0a51b63c..9f00f43f 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -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 diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 1028e4ef..a1e46db5 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -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 ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-transmission.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-transmission.ts index ac26ef7b..a845ed10 100644 --- a/frontend/src/lib/api/dashboard/a76/reports/reports-transmission.ts +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-transmission.ts @@ -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(); } }; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte index 50c7ecc2..e90694a3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/InvoiceSelectorModal.svelte @@ -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([]); let loading = $state(false); let searchTerm = $state(''); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index f8c548e7..c9b5ab4c 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -791,6 +791,7 @@ bind:open={showImportInvoiceModal} regimen={editingItem.fa_data?.movement_type_import === 'DEF' ? 'Definitiva' : 'Temporal'} operationType="imp" + status="processed" onSelect={handleSelectImportInvoice} /> ([]); + // Invoices State - let invoices = $state([]); - let loading = $state(false); - let items = $state([]); // manifest items - let selectedItems = $state>(new Set()); - let selectedInvoices = $state>(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} - - {#if movementType === 'Importacion'} + + {#if movementType === 'Importacion' || interfaceType === 'XML_RB_SYSTEMS'}
@@ -637,6 +659,19 @@ + + {#if inconsistencias.length > 0} +
+

+ ⚠ El archivo fue generado con {inconsistencias.length} inconsistencia(s): +

+
    + {#each inconsistencias as inc} +
  • {inc}
  • + {/each} +
+
+ {/if}