se agrega fallback OWTR y calculo de fecha de vencimiento por condicion de pago

- hana_manager.get_delivery ahora busca en ODLN y, si no encuentra, en OWTR
- sapb1_manager resuelve StockTransfers -> Orders (via U_IDC_NUMOV) para
  validar precios de flete cuando la guia es un traslado de inventario
- se corrige el orden de parametros tipo/serie/numero (sin cruces) en
  get_delivery, get_delivery_notes y _get_delivery_entries
- se agrega sapb1_manager.obtener_condicion_pago (BusinessPartners +
  PaymentTermsTypes) y pipeline._calcular_fecha_vencimiento para que
  DocDueDate del draft sea fecha de contabilizacion + dias de la condicion
  de pago del proveedor
- se agrega variante de regex para "VALOR REFER" truncado en sunat_schema.py
- se actualiza documentacion (docs/) reflejando estos cambios

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
georgeandClaude Sonnet 5 committed 2026-09-10 13:21:04 -05:00
1 parent 6b0ed6f519
commit 15e3b5079c
231 files changed
+21421 -475

No files matched your search

+128 -11
View File
@@ -12,6 +12,8 @@ import os
import re
import json
import math
import shutil
import ntpath
from datetime import datetime, timedelta
import email_utilities
@@ -22,6 +24,9 @@ import sapb1_manager
import pdf_utilities
import hana_manager
RUTA_RED_FACTURACION = os.getenv("RUTA_RED_FACTURACION", "")
RUC_EMPRESA = os.getenv("RUC_EMPRESA", "")
# ============================================================================
# HELPERS
@@ -30,6 +35,75 @@ def _ruta_carpeta(subcarpeta: str) -> str:
return os.path.join(os.path.dirname(__file__), subcarpeta)
def _copiar_a_red(data: dict, _log) -> None:
"""
Copia el PDF y el XML de un comprobante ya procesado a la ruta de red
de Facturación Electrónica, organizada como::
{RUTA_RED_FACTURACION}\\{año}\\{mes}\\{día}\\{tipo}\\{RUC_EMPRESA}\\{ruc_emisor}\\
{RUC_EMPRESA}-{tipo}-{serie}-{numero}_FormatoImpreso.pdf
{RUC_EMPRESA}-{tipo}-{serie}-{numero}.xml
``RUC_EMPRESA`` es el RUC de Aberio (fijo, por ahora); ``ruc_emisor``
es el RUC del proveedor que emitió la factura (varía por comprobante).
Año/mes/día se toman de ``fecha_emision`` (mes y día sin ceros a la
izquierda). No falla el pipeline si la ruta de red no está configurada
o no está disponible: solo registra una advertencia.
Guarda las rutas resultantes en ``data["ruta_red_pdf"]`` y
``data["ruta_red_xml"]`` para que ``_build_sap_draft`` las incluya en
el draft (``U_VS_ANEXO_PDF`` / ``U_VS_ANEXO_XML``).
"""
if not RUTA_RED_FACTURACION:
return
ruc_emisor = data.get("ruc_emisor", "")
tipo = data.get("tipo", "")
serie = data.get("serie", "")
numero = data.get("numero", "")
xml_path = data.get("ruta_xml")
pdf_path = data.get("ruta_pdf")
try:
fecha = datetime.strptime(data.get("fecha_emision", ""), "%Y-%m-%d")
except (ValueError, TypeError):
_log(f"⚠️ No se pudo copiar a red: fecha_emision inválida ({data.get('fecha_emision')})", 3)
return
carpeta_destino = os.path.join(
RUTA_RED_FACTURACION,
str(fecha.year), str(fecha.month), str(fecha.day),
tipo, RUC_EMPRESA, ruc_emisor,
)
# Ruta guardada en el draft SAP (U_VS_ANEXO_PDF/XML): siempre estilo
# Windows/UNC con backslash, sin importar el separador del SO donde
# corre el script (evita mezclar "\\" y "/" al usar os.path.join en
# un host no-Windows).
carpeta_destino_unc = ntpath.join(
RUTA_RED_FACTURACION,
str(fecha.year), str(fecha.month), str(fecha.day),
tipo, RUC_EMPRESA, ruc_emisor,
)
nombre_base = f"{RUC_EMPRESA}-{tipo}-{serie}-{numero}"
try:
os.makedirs(carpeta_destino, exist_ok=True)
if pdf_path and os.path.exists(pdf_path):
ruta_red_pdf = os.path.join(carpeta_destino, f"{nombre_base}_FormatoImpreso.pdf")
shutil.copy2(pdf_path, ruta_red_pdf)
data["ruta_red_pdf"] = ntpath.join(carpeta_destino_unc, f"{nombre_base}_FormatoImpreso.pdf")
if xml_path and os.path.exists(xml_path):
ruta_red_xml = os.path.join(carpeta_destino, f"{nombre_base}.xml")
shutil.copy2(xml_path, ruta_red_xml)
data["ruta_red_xml"] = ntpath.join(carpeta_destino_unc, f"{nombre_base}.xml")
_log(f"📁 Copiado a red: {carpeta_destino}", 3)
except OSError as e:
_log(f"⚠️ Error copiando a ruta de red: {e}", 3)
# ============================================================================
# PASO 1: Autenticación Microsoft Graph
# ============================================================================
@@ -327,6 +401,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
pdf_path = pdf_path if os.path.exists(pdf_path) else None
data["ruta_pdf"] = pdf_path
_copiar_a_red(data, _log)
# Cruzar documentos_referencia desde el PDF renombrado (pdf_cache por nombre canónico)
if not data.get("documentos_referencia"):
pdf_data = pdf_cache.get(pdf_canonical)
@@ -520,7 +596,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
continue
doc = {
"ruc_emisor": raw.get("ruc_emisor"),
"razon_social_emisor": raw.get("nombre_receptor"),
"razon_social_emisor": None, # el PDF no extrae la razón social del emisor
"fecha_emision": raw.get("fecha_emision"),
"tipo": tipo,
"serie": serie_p,
@@ -586,6 +662,35 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
return saved
def _calcular_fecha_vencimiento(fecha_base: str, condicion_pago: dict | None) -> str:
"""
Calcula la fecha de vencimiento sumando a ``fecha_base`` los días de
la condición de pago (PaymentTermsTypes) del proveedor.
Usa ``NumberOfAdditionalDays`` como días a sumar; si ese valor es 0,
usa ``NumberOfAdditionalMonths * 30`` en su lugar.
Args:
fecha_base: Fecha de contabilización, formato "YYYY-MM-DD".
condicion_pago: Resultado de ``sapb1_manager.obtener_condicion_pago``
(campos ``NumberOfAdditionalDays``, ``NumberOfAdditionalMonths``),
o None si el proveedor no tiene condición de pago configurada.
Returns:
str — Fecha de vencimiento, formato "YYYY-MM-DD". Si
``condicion_pago`` es None, retorna ``fecha_base`` sin cambios.
"""
fecha = datetime.strptime(fecha_base, "%Y-%m-%d")
if not condicion_pago:
return fecha.strftime("%Y-%m-%d")
dias = int(condicion_pago.get("NumberOfAdditionalDays") or 0)
if dias == 0:
dias = int(condicion_pago.get("NumberOfAdditionalMonths") or 0) * 30
fecha += timedelta(days=dias)
return fecha.strftime("%Y-%m-%d")
def _build_sap_draft(factura: dict, resultados_val: list[dict]) -> dict:
"""
Construye el payload JSON para crear un Draft en SAP B1 Service Layer
@@ -654,10 +759,15 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
ruc_emisor = factura.get("ruc_emisor", "")
razon_social_emisor = factura.get("razon_social_emisor", "")
card_code = f"PL{ruc_emisor}"
fecha_contabilizacion = datetime.now().strftime("%Y-%m-%d")
emision = datetime.strptime(fecha_emision, "%Y-%m-%d")
item_code = "C103002-A" if (emision.year, emision.month) < (datetime.now().year, datetime.now().month) else "C103002"
condicion_pago = sapb1_manager.obtener_condicion_pago(card_code)
fecha_vencimiento = _calcular_fecha_vencimiento(fecha_contabilizacion, condicion_pago)
if tiene_detraccion:
# neto = round(total - monto_detraccion, 2)
@@ -713,9 +823,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
return {
"DocType": "dDocument_Items",
"DocDate": fecha_contabilizacion,
"DocDueDate": fecha_emision,
"DocDueDate": fecha_vencimiento,
"TaxDate": fecha_emision,
"CardCode": f"PL{ruc_emisor}",
"CardCode": card_code,
"NumAtCard": f"{tipo}{serie}-{numero}",
"U_IDC_NUMGR": "" if not guia_referencia else guia_referencia,
"DocCurrency": "SOL",
@@ -737,6 +847,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
"U_IDC_FLETE": "02",
"U_IDC_LINEANE": "01",
"U_IDC_VALREF": valor_referencial,
"U_VS_ANEXO_PDF": factura.get("ruta_red_pdf", ""),
"U_VS_ANEXO_XML": factura.get("ruta_red_xml", ""),
"DocumentLines": doc_lines,
"DocumentInstallments": installments,
}
@@ -830,7 +942,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
try:
delivery_notes = sapb1_manager.get_delivery_notes(
tipo=guia["serie"], serie=guia["tipo"], numero=guia["numero"]
tipo=guia["tipo"], serie=guia["serie"], numero=guia["numero"]
)
wh_set = set()
@@ -1175,14 +1287,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
_log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
html_body = _build_notification_body("skipped", factura, validation_table, valor_referencial_table)
# 16. Enviar notificación
# 16. Enviar notificación al remitente original, con copia al equipo
if token:
outlook_utilities.send_email(
token, email_addr,
["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
f"Draft SAP B1 - {serie}-{numero}",
html_body, adjuntos_email,
)
destinatario = email.get("sender")
if destinatario and destinatario != "Desconocido":
outlook_utilities.send_email(
token, email_addr,
[destinatario],
f"Draft SAP B1 - {serie}-{numero}",
html_body, adjuntos_email,
cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
)
else:
_log("⚠️ No se pudo determinar el remitente, notificación no enviada")
# ============================================================================