primer commit
This commit is contained in:
commit
d858235656
14 files changed
+21213
No files matched your search
+10
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
|
secrets.yaml
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
logs/
|
||||||
|
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"hana": {
|
||||||
|
"company": "SBO_TAGUMEDICA_21",
|
||||||
|
"server": "192.168.1.175",
|
||||||
|
"port": 30015,
|
||||||
|
"user": "SYSTEM",
|
||||||
|
"password": "SAPB1Admin.25"
|
||||||
|
},
|
||||||
|
"service_layer": {
|
||||||
|
"url": "https://192.168.1.175:50000/b1s/v1/",
|
||||||
|
"username": "manager",
|
||||||
|
"password": "m1r1",
|
||||||
|
"maxpagesize": 50,
|
||||||
|
"b1s_replacecollectionsonpatch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from config_model import Config
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Definir la ruta del archivo de configuración
|
||||||
|
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Carga la configuración desde el archivo JSON."""
|
||||||
|
try:
|
||||||
|
return Config.load(CONFIG_PATH)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Al cargar la configuracion: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Variable global CONFIG disponible para otros módulos
|
||||||
|
CONFIG = load_config()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HanaConfig:
|
||||||
|
company: str
|
||||||
|
server: str
|
||||||
|
port: int
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceLayerConfig:
|
||||||
|
url: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
maxpagesize: int
|
||||||
|
b1s_replacecollectionsonpatch: bool
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
hana: HanaConfig
|
||||||
|
service_layer: ServiceLayerConfig
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def load(path: str = "config.json") -> 'Config':
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError(f"No se encontró el archivo de configuración: {path}")
|
||||||
|
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
hana_data = data.get('hana', {})
|
||||||
|
service_layer_data = data.get('service_layer', {})
|
||||||
|
|
||||||
|
# Validar campos requeridos para HANA
|
||||||
|
required_hana = ['company', 'server', 'port', 'user', 'password']
|
||||||
|
for field in required_hana:
|
||||||
|
if field not in hana_data:
|
||||||
|
raise KeyError(f"Falta el campo requerido '{field}' en la sección 'hana' de {path}")
|
||||||
|
|
||||||
|
# Validar campos requeridos para Service Layer
|
||||||
|
required_service_layer = ['url', 'username', 'password', 'maxpagesize', 'b1s_replacecollectionsonpatch']
|
||||||
|
for field in required_service_layer:
|
||||||
|
if field not in service_layer_data:
|
||||||
|
raise KeyError(f"Falta el campo requerido '{field}' en la sección 'service_layer' de {path}")
|
||||||
|
|
||||||
|
hana = HanaConfig(**hana_data)
|
||||||
|
service_layer = ServiceLayerConfig(**service_layer_data)
|
||||||
|
|
||||||
|
return Config(hana=hana, service_layer=service_layer)
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import math
|
||||||
|
from decimal import Decimal
|
||||||
|
from itertools import combinations
|
||||||
|
from math import floor
|
||||||
|
import sap_services
|
||||||
|
import hana_client
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
reglas = {
|
||||||
|
"100": lambda m: m > 0,
|
||||||
|
"101": lambda m: m < 12,
|
||||||
|
"102": lambda m: m >= 12,
|
||||||
|
"103": lambda m: m >= 18,
|
||||||
|
"104": lambda m: m >= 24,
|
||||||
|
"105": lambda m: m >= 30,
|
||||||
|
"106": lambda m: m >= 36,
|
||||||
|
"107": lambda m: m >= 42,
|
||||||
|
"108": lambda m: m >= 48,
|
||||||
|
"109": lambda m: m >= 54,
|
||||||
|
"110": lambda m: m >= 60
|
||||||
|
}
|
||||||
|
|
||||||
|
vigencias = {
|
||||||
|
"100": "meses > 0",
|
||||||
|
"101": "meses < 12",
|
||||||
|
"102": "meses >= 12",
|
||||||
|
"103": "meses >= 18",
|
||||||
|
"104": "meses >= 24",
|
||||||
|
"105": "meses >= 30",
|
||||||
|
"106": "meses >= 36",
|
||||||
|
"107": "meses >= 42",
|
||||||
|
"108": "meses >= 48",
|
||||||
|
"109": "meses >= 54",
|
||||||
|
"110": "meses >= 60",
|
||||||
|
}
|
||||||
|
|
||||||
|
presentacion = {
|
||||||
|
"100": 1,
|
||||||
|
"101": 1,
|
||||||
|
"102": 2,
|
||||||
|
"103": 4,
|
||||||
|
"104": 6,
|
||||||
|
"105": 8,
|
||||||
|
"106": 10,
|
||||||
|
"107": 12,
|
||||||
|
"108": 24,
|
||||||
|
"109": 36,
|
||||||
|
"110": 12,
|
||||||
|
"111": 12,
|
||||||
|
"112": 24,
|
||||||
|
"113": 3
|
||||||
|
}
|
||||||
|
|
||||||
|
# si es venta consignación, se vende por unidad
|
||||||
|
# el multiplo = 1
|
||||||
|
# vigencia en meses > 0
|
||||||
|
ventas_tipo_consignacion = {
|
||||||
|
"110": "venta consignacion"
|
||||||
|
}
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
###################################################
|
||||||
|
def obtener_documento_formato_json(session,registro):
|
||||||
|
|
||||||
|
objkey = registro['DocEntry']
|
||||||
|
endpoint = f"Orders({objkey})"
|
||||||
|
|
||||||
|
return sap_services.get_by_key(session,endpoint)
|
||||||
|
###################################################
|
||||||
|
|
||||||
|
def actualizar_detalle_documento(session,documento,body):
|
||||||
|
objkey = documento['DocEntry']
|
||||||
|
endpoint = f"Orders({objkey})"
|
||||||
|
|
||||||
|
return sap_services.update_document(session,endpoint,body)
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def seleccionar_mejor_lotes(line,lotes,vigencia,unidades_por_caja,tipo_venta,max_lotes=3):
|
||||||
|
'''
|
||||||
|
Si el tipo_venta = consignacion
|
||||||
|
unidades_por_caja = 1
|
||||||
|
'''
|
||||||
|
# cantidad total de unidades requeridas
|
||||||
|
total_objetivo = line["Quantity"]
|
||||||
|
|
||||||
|
# si el tipo de venta no es consignacion
|
||||||
|
# se debe ver si las cantidades requeridas
|
||||||
|
# son multiplos o no de las unidades_por_caja
|
||||||
|
if not tipo_venta in ventas_tipo_consignacion:
|
||||||
|
# validar si la cantidad solicitada es múltiplo de unidades_por_caja
|
||||||
|
es_multiplo = True if total_objetivo % unidades_por_caja == 0 else False
|
||||||
|
|
||||||
|
# si no es multiplo, entonces obtengo el maximo comun divisor
|
||||||
|
if not es_multiplo:
|
||||||
|
maximo_comun_divisor = math.gcd(int(total_objetivo), int(unidades_por_caja))
|
||||||
|
print(f" ⚠️Se utilizara el MCD que es {maximo_comun_divisor}")
|
||||||
|
unidades_por_caja = maximo_comun_divisor
|
||||||
|
|
||||||
|
# quitar los lotes que no cumplan con los meses minimos requeridos de vencimiento de lotes
|
||||||
|
filtrados = [l for l in lotes if reglas[vigencia](l["Months"])]
|
||||||
|
# ordenar los lotes por fecha de creacion, el mas antiguo primero
|
||||||
|
filtrados = sorted(filtrados, key=lambda x: x["InDate"])
|
||||||
|
|
||||||
|
# agregar indice para priorizar antiguedad
|
||||||
|
for i, lote in enumerate(filtrados):
|
||||||
|
lote["indice"] = i
|
||||||
|
|
||||||
|
mejor_exacto = None
|
||||||
|
mejor_parcial = None
|
||||||
|
|
||||||
|
for r in range(1, max_lotes + 1):
|
||||||
|
for combo in combinations(filtrados, r):
|
||||||
|
total = 0
|
||||||
|
detalle = []
|
||||||
|
|
||||||
|
# clave: lista ordenada de indices
|
||||||
|
prioridad_fifo = sorted(l["indice"] for l in combo)
|
||||||
|
|
||||||
|
for lote in combo:
|
||||||
|
restante = total_objetivo - total
|
||||||
|
if restante <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
disponible = lote["AvailableQty"]
|
||||||
|
|
||||||
|
tomar = min(disponible, restante)
|
||||||
|
tomar = floor(tomar / unidades_por_caja) * unidades_por_caja
|
||||||
|
|
||||||
|
if tomar > 0:
|
||||||
|
detalle.append({
|
||||||
|
"DistNumber": lote["DistNumber"],
|
||||||
|
"InDate": lote["InDate"],
|
||||||
|
"Quantity": lote["Quantity"],
|
||||||
|
"AvailableQty": lote["AvailableQty"],
|
||||||
|
"TakeQuantity": tomar,
|
||||||
|
"ExpDate": lote["ExpDate"],
|
||||||
|
"Months": lote["Months"]
|
||||||
|
})
|
||||||
|
total += tomar
|
||||||
|
|
||||||
|
# ignorar combos que no aportan nada
|
||||||
|
if total == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# solucion exacta -> priorizar absoluta
|
||||||
|
if total == total_objetivo:
|
||||||
|
if not mejor_exacto or prioridad_fifo < mejor_exacto[2]:
|
||||||
|
mejor_exacto = (detalle, total, prioridad_fifo)
|
||||||
|
|
||||||
|
# mejor solucion parcial -> mayor total posible
|
||||||
|
elif total < total_objetivo:
|
||||||
|
if not mejor_parcial or (
|
||||||
|
total > mejor_parcial[1] or
|
||||||
|
(total == mejor_parcial[1] and prioridad_fifo < mejor_parcial[2])
|
||||||
|
):
|
||||||
|
mejor_parcial = (detalle, total, prioridad_fifo)
|
||||||
|
|
||||||
|
if mejor_exacto:
|
||||||
|
return mejor_exacto[0], mejor_exacto[1]
|
||||||
|
elif mejor_parcial:
|
||||||
|
return mejor_parcial[0], mejor_parcial[1]
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def se_le_debe_asignar_lote_a_linea(line):
|
||||||
|
cantidad = line["Quantity"]
|
||||||
|
lotes = [] if "BatchNumbers" not in line else line["BatchNumbers"]
|
||||||
|
|
||||||
|
linestatus = line["LineStatus"] ## bost_Open
|
||||||
|
|
||||||
|
if linestatus == "bost_Close":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# no tiene asignado lotes
|
||||||
|
if len(lotes) == 0:
|
||||||
|
# se deba asignar lotes
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# sumar cantidades de lotes
|
||||||
|
total_lotes = sum(lote["Quantity"] for lote in lotes)
|
||||||
|
if total_lotes == cantidad:
|
||||||
|
# no se deba asignar lotes
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# se deba asignar lotes
|
||||||
|
return True
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def agregar_columna_available_qty(lotes):
|
||||||
|
for lote in lotes:
|
||||||
|
lote["AvailableQty"] = (lote["Quantity"] or Decimal(0)) - (lote["CommitQty"] or Decimal(0))
|
||||||
|
|
||||||
|
return lotes
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def documento_requiere_desasignar_lotes(documento):
|
||||||
|
necesita_actualizar = False
|
||||||
|
nuevas_lineas = []
|
||||||
|
|
||||||
|
for line in documento.get("DocumentLines",[]):
|
||||||
|
|
||||||
|
nueva_linea = {
|
||||||
|
"LineNum": line.get("LineNum"),
|
||||||
|
"ItemCode": line.get("ItemCode"),
|
||||||
|
"Quantity": line.get("Quantity"),
|
||||||
|
"WarehouseCode": line.get("WarehouseCode"),
|
||||||
|
"BatchNumbers": []
|
||||||
|
}
|
||||||
|
|
||||||
|
batches = line.get("BatchNumbers",[])
|
||||||
|
|
||||||
|
if batches:
|
||||||
|
total_line = line.get("Quantity",0)
|
||||||
|
total_batches = sum(b.get("Quantity",0) for b in batches)
|
||||||
|
|
||||||
|
if total_batches != total_line:
|
||||||
|
necesita_actualizar = True
|
||||||
|
for b in batches:
|
||||||
|
nueva_linea["BatchNumbers"].append({
|
||||||
|
"BaseLineNumber": line.get("LineNum"),
|
||||||
|
"BatchNumber": b.get("BatchNumber"),
|
||||||
|
"Quantity": 0.00
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# copiar tal cual
|
||||||
|
for b in batches:
|
||||||
|
nueva_linea["BatchNumbers"].append(b.copy())
|
||||||
|
else:
|
||||||
|
# sin lotes -> se deja vacio
|
||||||
|
nueva_linea["BatchNumbers"] = []
|
||||||
|
|
||||||
|
nuevas_lineas.append(nueva_linea)
|
||||||
|
|
||||||
|
nuevo_json = {
|
||||||
|
"DocumentLines": nuevas_lineas
|
||||||
|
}
|
||||||
|
|
||||||
|
return necesita_actualizar, nuevo_json
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def procesar_registro(registro,session):
|
||||||
|
documento = obtener_documento_formato_json(session,registro)
|
||||||
|
|
||||||
|
necesita_actualizar, nuevo_json = documento_requiere_desasignar_lotes(documento)
|
||||||
|
|
||||||
|
if necesita_actualizar:
|
||||||
|
|
||||||
|
print(f" 🧹 Desasignando lotes previos del documento...")
|
||||||
|
status_code = actualizar_detalle_documento(session,documento, nuevo_json)
|
||||||
|
print(f" ✅ Documento actualizado (Desasignación).")
|
||||||
|
|
||||||
|
documento = obtener_documento_formato_json(session,registro)
|
||||||
|
else:
|
||||||
|
# continuar con el documento
|
||||||
|
pass
|
||||||
|
|
||||||
|
maxima_cantidad_lotes = 3
|
||||||
|
vigencia = "100"
|
||||||
|
tipo_venta = documento["U_SIS_TIPVEN"]
|
||||||
|
if tipo_venta in ventas_tipo_consignacion:
|
||||||
|
print(f" 🚨 Tipo de venta: {tipo_venta} -> {ventas_tipo_consignacion[tipo_venta]}")
|
||||||
|
print(f" 🚨 Presentacion: sera de 1 Unidad para todos los articulos")
|
||||||
|
print(f" 🚨 Meses de vigencia > 0 meses")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# de lo contrario se toma la vigencia seleccionada en el documento
|
||||||
|
vigencia = documento["U_SIS_VIGREQ"]
|
||||||
|
|
||||||
|
print(f" 📅 Vigencia requerida(meses de vencimiento): {vigencia} -> {vigencias[vigencia]}")
|
||||||
|
|
||||||
|
new_lines = []
|
||||||
|
# Reservas en memoria: {(itemcode, whscode, dist_number): cantidad_ya_reservada}
|
||||||
|
reservas = {}
|
||||||
|
|
||||||
|
for index, line in enumerate(documento.get("DocumentLines",[]),start=1):
|
||||||
|
|
||||||
|
treetype = line["TreeType"]
|
||||||
|
|
||||||
|
new_line = {
|
||||||
|
"LineNum": line.get("LineNum"),
|
||||||
|
"ItemCode": line.get("ItemCode"),
|
||||||
|
"Quantity": line.get("Quantity"),
|
||||||
|
"WarehouseCode": line.get("WarehouseCode"),
|
||||||
|
"BatchNumbers": []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if treetype == "iNotATree":
|
||||||
|
if se_le_debe_asignar_lote_a_linea(line):
|
||||||
|
codigo_presentacion = str(hana_client.obtener_presentacion_articulo(line["ItemCode"]))
|
||||||
|
|
||||||
|
# Presentacion del articulo o Unidaddes por Caja
|
||||||
|
unidades_por_caja = presentacion.get(codigo_presentacion, 1)
|
||||||
|
|
||||||
|
lotes_disponibles = hana_client.obtener_lotes_disponibles(line["ItemCode"],line["WarehouseCode"])
|
||||||
|
lotes_disponibles = agregar_columna_available_qty(lotes_disponibles)
|
||||||
|
|
||||||
|
# Descontar reservas ya asignadas a líneas anteriores del mismo documento
|
||||||
|
for lote in lotes_disponibles:
|
||||||
|
clave = (line["ItemCode"], line["WarehouseCode"], lote["DistNumber"])
|
||||||
|
ya_reservado = reservas.get(clave, 0)
|
||||||
|
if ya_reservado > 0:
|
||||||
|
lote["AvailableQty"] = max(lote["AvailableQty"] - ya_reservado, 0)
|
||||||
|
|
||||||
|
# core del proceso de asignacion
|
||||||
|
lotes_seleccionados, total_seleccionado = seleccionar_mejor_lotes(line,lotes_disponibles,vigencia
|
||||||
|
,1 if tipo_venta in ventas_tipo_consignacion else unidades_por_caja
|
||||||
|
,tipo_venta,maxima_cantidad_lotes)
|
||||||
|
|
||||||
|
### impresion de lotes disponibles
|
||||||
|
print(f" ➖ Línea {index} | Artículo: {line['ItemCode']} | Cant. req: {line['Quantity']} | Múltiplo de emp.: {unidades_por_caja} | Almacen: {line['WarehouseCode']}")
|
||||||
|
print(f" 📦 Se encontraron {len(lotes_disponibles)} lotes disponibles.")
|
||||||
|
print(" 🔍 Detalle lotes disponibles:")
|
||||||
|
for a in sorted(lotes_disponibles, key=lambda x: x["InDate"]):
|
||||||
|
print(f" - Lote: {a['DistNumber']:10} | F. Fab: {a['InDate']:10} | Disp: {float(a['AvailableQty']):10.2f} | Meses: {a['Months']:4}")
|
||||||
|
|
||||||
|
### impresion de lotes seleccionados
|
||||||
|
print(" 🎯 Lotes seleccionados:")
|
||||||
|
|
||||||
|
for a in lotes_seleccionados:
|
||||||
|
print(f" - Lote: {a['DistNumber']:10} | F. Fab: {a['InDate']:10} | A tomar: {float(a['TakeQuantity']):10.2f} | Meses: {a['Months']:4}")
|
||||||
|
|
||||||
|
for lote in lotes_seleccionados:
|
||||||
|
new_line["BatchNumbers"].append({
|
||||||
|
"BaseLineNumber": line.get("LineNum"),
|
||||||
|
"BatchNumber": lote.get("DistNumber"),
|
||||||
|
"Quantity": lote.get("TakeQuantity")
|
||||||
|
})
|
||||||
|
# Registrar la reserva en memoria
|
||||||
|
clave = (line["ItemCode"], line["WarehouseCode"], lote["DistNumber"])
|
||||||
|
reservas[clave] = reservas.get(clave, 0) + lote["TakeQuantity"]
|
||||||
|
else:
|
||||||
|
# el TreeType de esta Linea es diferente a iNotATree
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_lines.append(new_line)
|
||||||
|
|
||||||
|
# Obtener líneas que tienen lotes asignados
|
||||||
|
lineas_con_lotes = [line for line in new_lines if len(line["BatchNumbers"]) > 0]
|
||||||
|
|
||||||
|
# Debo enviar todas las lineas del documento, tengan o no lotes asignados
|
||||||
|
body = {
|
||||||
|
"DocumentLines": new_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
hizo_asignacion = len(lineas_con_lotes) > 0
|
||||||
|
|
||||||
|
# si no se realizo asignacion de lotes, no tiene sentido actualizar el documento
|
||||||
|
if hizo_asignacion:
|
||||||
|
print(f" 🔄 Asignando lotes en SAP ({len(lineas_con_lotes)} líneas con lotes)...")
|
||||||
|
status_code = actualizar_detalle_documento(session, documento, body)
|
||||||
|
print(f" ✅ Documento actualizado con lotes asignados.")
|
||||||
|
else:
|
||||||
|
print(f" ✅ No es necesario actualizar el documento.")
|
||||||
|
|
||||||
|
###################################################
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import json
|
||||||
|
import core
|
||||||
|
import sap_services
|
||||||
|
import logger_utilities
|
||||||
|
|
||||||
|
# Configurar Logger (Redirige stdout a archivo y consola)
|
||||||
|
log_path = logger_utilities.setup_logger()
|
||||||
|
|
||||||
|
|
||||||
|
session = sap_services.login()
|
||||||
|
registro = {
|
||||||
|
'DocEntry' : 111239
|
||||||
|
}
|
||||||
|
|
||||||
|
documento = core.obtener_documento_formato_json(session,registro)
|
||||||
|
|
||||||
|
new_lines = []
|
||||||
|
for index, line in enumerate(documento.get("DocumentLines",[]),start=1):
|
||||||
|
|
||||||
|
batches = line.get("BatchNumbers",[])
|
||||||
|
|
||||||
|
new_batches = []
|
||||||
|
|
||||||
|
if batches:
|
||||||
|
for b in batches:
|
||||||
|
new_batches.append({
|
||||||
|
"BaseLineNumber": line.get("LineNum"),
|
||||||
|
"BatchNumber": b.get("BatchNumber"),
|
||||||
|
"Quantity": 0.00
|
||||||
|
})
|
||||||
|
|
||||||
|
new_lines.append({
|
||||||
|
"LineNum": line.get("LineNum"),
|
||||||
|
"ItemCode": line.get("ItemCode"),
|
||||||
|
"Quantity": line.get("Quantity"),
|
||||||
|
"WarehouseCode": line.get("WarehouseCode"),
|
||||||
|
"BatchNumbers": new_batches
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"DocumentLines": new_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
print (json.dumps(body, indent=2))
|
||||||
|
|
||||||
|
status_code = core.actualizar_detalle_documento(session, documento, body)
|
||||||
|
|
||||||
|
print(status_code)
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
|
||||||
|
from hdbcli import dbapi
|
||||||
|
from config_loader import CONFIG
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def get_connection(schema):
|
||||||
|
"""Crea y retorna una conexión a SAP HANA."""
|
||||||
|
try:
|
||||||
|
conn = dbapi.connect(
|
||||||
|
address=CONFIG.hana.server,
|
||||||
|
port=CONFIG.hana.port,
|
||||||
|
user=CONFIG.hana.user,
|
||||||
|
password=CONFIG.hana.password
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error conectando a HANA para {schema}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def obtener_registros():
|
||||||
|
"""
|
||||||
|
Obtiene los documentos de los ultimos 'days' dias
|
||||||
|
Args:
|
||||||
|
company: Objeto con la configuracion de la base de datos
|
||||||
|
days: Numero de dias a consultar, entero positivo
|
||||||
|
Returns:
|
||||||
|
Lista de diccionarios con los documentos encontrados
|
||||||
|
Raises:
|
||||||
|
Exception: Si hay un error conectando a la base de datos
|
||||||
|
Example:
|
||||||
|
where DocDate >= ADD_DAYS(CURRENT_DATE, -{days})
|
||||||
|
"""
|
||||||
|
hana_config = CONFIG.hana
|
||||||
|
|
||||||
|
db_name = hana_config.company
|
||||||
|
|
||||||
|
conn = get_connection(db_name)
|
||||||
|
if not conn:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT T0."DocEntry", T0."DocNum", T0."ObjType", T2."USER_CODE" "UserCode"
|
||||||
|
FROM "{db_name}".ORDR T0
|
||||||
|
INNER JOIN "{db_name}".OUSR T2 ON T2."USERID" = T0."UserSign"
|
||||||
|
WHERE T0."CANCELED" = 'N' AND T0."DocStatus" = 'O'
|
||||||
|
AND T0."DocType" = 'I'
|
||||||
|
AND IFNULL("U_SIS_IMPAUT",'N') = 'Y'
|
||||||
|
AND IFNULL("U_SIS_VIGREQ",'') <> ''
|
||||||
|
AND TO_VARCHAR(T0."CreateDate",'YYYYMMDD') = '20260528'
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(query)
|
||||||
|
|
||||||
|
# Obtener nombres de columnas
|
||||||
|
columns = [column[0] for column in cursor.description]
|
||||||
|
|
||||||
|
# Convertir resultados a lista de diccionarios
|
||||||
|
results = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
results.append(dict(zip(columns, row)))
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"📄 Se encontraron {len(results)} documentos para procesar.")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutando la consulta en HANA: {e}")
|
||||||
|
if 'conn' in locals():
|
||||||
|
conn.close()
|
||||||
|
return []
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def obtener_lotes_disponibles(itemcode:str,whscode:str):
|
||||||
|
hana_config = CONFIG.hana
|
||||||
|
|
||||||
|
db_name = hana_config.company
|
||||||
|
|
||||||
|
conn = get_connection(db_name)
|
||||||
|
if not conn:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT T0."DistNumber", TO_VARCHAR(T0."InDate",'YYYY-MM-DD') "InDate",
|
||||||
|
TO_VARCHAR(T0."ExpDate",'YYYY-MM-DD') "ExpDate",
|
||||||
|
T0."AbsEntry", T1."WhsCode", T1."Quantity", T1."CommitQty",
|
||||||
|
FLOOR(MONTHS_BETWEEN(CURRENT_DATE, T0."ExpDate")) "Months"
|
||||||
|
FROM "{db_name}".OBTN T0
|
||||||
|
INNER JOIN "{db_name}".OBTQ T1 ON T1."MdAbsEntry" = T0."AbsEntry"
|
||||||
|
WHERE T0."ItemCode" = '{itemcode}'
|
||||||
|
AND T1."WhsCode" = '{whscode}'
|
||||||
|
AND T1."Quantity" > 0.00
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(query)
|
||||||
|
|
||||||
|
# Obtener nombres de columnas
|
||||||
|
columns = [column[0] for column in cursor.description]
|
||||||
|
|
||||||
|
# Convertir resultados a lista de diccionarios
|
||||||
|
results = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
results.append(dict(zip(columns, row)))
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutando la consulta en HANA: {e}")
|
||||||
|
if 'conn' in locals():
|
||||||
|
conn.close()
|
||||||
|
return []
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def obtener_presentacion_articulo(itemcode):
|
||||||
|
hana_config = CONFIG.hana
|
||||||
|
|
||||||
|
db_name = hana_config.company
|
||||||
|
|
||||||
|
conn = get_connection(db_name)
|
||||||
|
if not conn:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT IFNULL(T0."U_SIS_PRECAJ",'100') "Presentacion"
|
||||||
|
FROM "{db_name}".OITM T0
|
||||||
|
WHERE T0."ItemCode" = '{itemcode}'
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(query)
|
||||||
|
|
||||||
|
presentacion = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return presentacion
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error ejecutando la consulta en HANA: {e}")
|
||||||
|
if 'conn' in locals():
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
class MultiStream:
|
||||||
|
"""
|
||||||
|
Clase para redirigir la salida a múltiples flujos (consola y archivo).
|
||||||
|
"""
|
||||||
|
def __init__(self, *streams):
|
||||||
|
self.streams = streams
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
for stream in self.streams:
|
||||||
|
try:
|
||||||
|
stream.write(data)
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
# Fallback para consola (sys.stdout/stderr) que no soporte UTF-8
|
||||||
|
stream.write(data.encode('cp1252', 'replace').decode('cp1252'))
|
||||||
|
stream.flush()
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
for stream in self.streams:
|
||||||
|
stream.flush()
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def setup_logger():
|
||||||
|
"""
|
||||||
|
Configura la redirección de stdout y stderr a un archivo de log y a la consola.
|
||||||
|
Retorna la ruta del archivo de log creado.
|
||||||
|
"""
|
||||||
|
log_dir = "logs"
|
||||||
|
if not os.path.exists(log_dir):
|
||||||
|
os.makedirs(log_dir)
|
||||||
|
|
||||||
|
# Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
log_filename = f"{timestamp}.log"
|
||||||
|
log_path = os.path.join(log_dir, log_filename)
|
||||||
|
|
||||||
|
# Abrir archivo en modo append con encoding utf-8 para soportar iconos
|
||||||
|
log_file = open(log_path, "a", encoding="utf-8")
|
||||||
|
|
||||||
|
# Redirigir stdout y stderr
|
||||||
|
sys.stdout = MultiStream(sys.stdout, log_file)
|
||||||
|
sys.stderr = MultiStream(sys.stderr, log_file)
|
||||||
|
|
||||||
|
return log_path
|
||||||
|
|
||||||
|
########################################################
|
||||||
|
def limpiar_logs_antiguos(dias=7):
|
||||||
|
"""
|
||||||
|
Elimina archivos de log con más de 'n' días de antigüedad.
|
||||||
|
"""
|
||||||
|
log_dir = "logs"
|
||||||
|
if not os.path.exists(log_dir):
|
||||||
|
return
|
||||||
|
|
||||||
|
limite = datetime.now() - timedelta(days=dias)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for archivo in os.listdir(log_dir):
|
||||||
|
ruta_archivo = os.path.join(log_dir, archivo)
|
||||||
|
if os.path.isfile(ruta_archivo) and archivo.endswith(".log"):
|
||||||
|
fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo))
|
||||||
|
if fecha_archivo < limite:
|
||||||
|
try:
|
||||||
|
os.remove(ruta_archivo)
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error al eliminar log {archivo}: {e}")
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
import hana_client
|
||||||
|
import sap_services
|
||||||
|
import core
|
||||||
|
import logger_utilities
|
||||||
|
from datetime import datetime
|
||||||
|
###################################################
|
||||||
|
def main():
|
||||||
|
|
||||||
|
registros = hana_client.obtener_registros()
|
||||||
|
|
||||||
|
session = sap_services.login()
|
||||||
|
|
||||||
|
for registro in registros:
|
||||||
|
|
||||||
|
|
||||||
|
print(f"\n▶▶ Procesando registro número: {registro['DocNum']} (DocEntry: {registro['DocEntry']}) - Usuario: {registro.get('UserCode', 'N/A')}")
|
||||||
|
core.procesar_registro(registro, session)
|
||||||
|
|
||||||
|
sap_services.logout(session)
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
# Configurar Logger (Redirige stdout a archivo y consola)
|
||||||
|
log_path = logger_utilities.setup_logger()
|
||||||
|
logger_utilities.limpiar_logs_antiguos(dias=30)
|
||||||
|
|
||||||
|
inicio_ejecucion = datetime.now()
|
||||||
|
print("="*60)
|
||||||
|
print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"📄 Log guardado en: {log_path}")
|
||||||
|
print("="*60)
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
fin_ejecucion = datetime.now()
|
||||||
|
duracion = fin_ejecucion - inicio_ejecucion
|
||||||
|
print("\n" + "="*60)
|
||||||
|
print(f"🏁 FIN DE EJECUCIÓN: {fin_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(f"⏱️ Duración total: {str(duracion).split('.')[0]}")
|
||||||
|
print("="*60)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
import sys
|
||||||
|
import sap_utilities
|
||||||
|
from config_loader import CONFIG
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def login():
|
||||||
|
session = requests.Session()
|
||||||
|
|
||||||
|
sl_config = CONFIG.service_layer
|
||||||
|
hana_config = CONFIG.hana
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"CompanyDB": hana_config.company,
|
||||||
|
"UserName": sl_config.username,
|
||||||
|
"Password": sl_config.password
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = sap_utilities.post(session, "Login", body)
|
||||||
|
if response:
|
||||||
|
return session
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error de conexión al SAP Service Layer (Login): {e}")
|
||||||
|
tb = traceback.extract_tb(sys.exc_info()[2])
|
||||||
|
print(tb[-1].name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def logout(session):
|
||||||
|
try:
|
||||||
|
response = sap_utilities.post(session, "Logout")
|
||||||
|
if response:
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error de desconexión del SAP Service Layer (Logout): {e}")
|
||||||
|
tb = traceback.extract_tb(sys.exc_info()[2])
|
||||||
|
print(tb[-1].name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def get_by_key(session, endpoint):
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
print("❌ Error: No se pudo iniciar sesión en SAP Service Layer")
|
||||||
|
return None
|
||||||
|
|
||||||
|
recurso = endpoint
|
||||||
|
|
||||||
|
response = sap_utilities.get(session, recurso)
|
||||||
|
if response and response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
print(f"❌ Error obteniendo datos de {recurso}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def update_document(session, recurso, body):
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
print("❌ Error: No se pudo iniciar sesión en SAP Service Layer")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# print(f" 📤 [PATCH] Body enviado a {recurso}:")
|
||||||
|
# print(json.dumps(body, indent=2, default=str))
|
||||||
|
response = sap_utilities.patch(session, recurso, body)
|
||||||
|
if response and response.status_code == 204:
|
||||||
|
return response
|
||||||
|
|
||||||
|
status = response.status_code if response is not None else "Sin respuesta (None)"
|
||||||
|
print(f"❌ Error actualizando documento en SAP Service Layer: HTTP {status}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
from config_loader import CONFIG
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def post(session, recurso:str, json_body: dict = None):
|
||||||
|
|
||||||
|
url = CONFIG.service_layer.url
|
||||||
|
if not url.endswith('/'):
|
||||||
|
url += '/'
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"accept": "application/json",
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
end_point = f"{url}{recurso}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = None
|
||||||
|
if json_body:
|
||||||
|
response = session.post(
|
||||||
|
end_point,
|
||||||
|
headers = headers,
|
||||||
|
json = json_body,
|
||||||
|
verify = False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = session.post(
|
||||||
|
end_point,
|
||||||
|
headers = headers,
|
||||||
|
verify = False
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
print(f" ✅ [POST] {recurso} - Status {response.status_code}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except requests.RequestException as ex:
|
||||||
|
print(f" ❌ [POST] {recurso} - Falló: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def get(session, recurso:str):
|
||||||
|
|
||||||
|
SL_MAXPAGESIZE = CONFIG.service_layer.maxpagesize
|
||||||
|
|
||||||
|
url = CONFIG.service_layer.url
|
||||||
|
if not url.endswith('/'):
|
||||||
|
url += '/'
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"accept": "application/json",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"Prefer": f"odata.maxpagesize={SL_MAXPAGESIZE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
end_point = f"{url}{recurso}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
response = session.get(
|
||||||
|
end_point,
|
||||||
|
headers = headers,
|
||||||
|
verify = False
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
print(f" ✅ [GET] {recurso} - Status {response.status_code}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except requests.RequestException as ex:
|
||||||
|
print(f" ❌ [GET] {recurso} - Falló: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
|
def patch(session, recurso:str, json_body: dict):
|
||||||
|
|
||||||
|
url = CONFIG.service_layer.url
|
||||||
|
REPLACE_COLLECTIONS = CONFIG.service_layer.b1s_replacecollectionsonpatch
|
||||||
|
|
||||||
|
if not url.endswith('/'):
|
||||||
|
url += '/'
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"accept": "application/json",
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if REPLACE_COLLECTIONS:
|
||||||
|
headers["B1S-ReplaceCollectionsOnPatch"] = "true"
|
||||||
|
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
end_point = f"{url}{recurso}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
response = session.patch(
|
||||||
|
end_point,
|
||||||
|
headers = headers,
|
||||||
|
json = json_body,
|
||||||
|
verify = False
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
print(f" ✅ [PATCH] {recurso} - Status {response.status_code}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
if 400 <= response.status_code <= 499:
|
||||||
|
print(f" ❌ [PATCH] {recurso} - Error HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
error_body = response.json()
|
||||||
|
import json as _json
|
||||||
|
error = error_body.get("error", {})
|
||||||
|
mensaje = error.get("message", {})
|
||||||
|
if isinstance(mensaje, dict):
|
||||||
|
mensaje = mensaje.get("value", str(mensaje))
|
||||||
|
codigo = error.get("code", "N/A")
|
||||||
|
print(f" 📋 SAP Error Code : {codigo}")
|
||||||
|
print(f" 📋 SAP Error Message: {mensaje}")
|
||||||
|
# TransactionNotification (validaciones de UDF, triggers, etc.)
|
||||||
|
notif = error_body.get("TransactionNotification") or error.get("innererror", {}).get("TransactionNotification")
|
||||||
|
if notif:
|
||||||
|
print(f" 📋 TransactionNotification: {notif}")
|
||||||
|
except Exception:
|
||||||
|
print(f" 📋 Respuesta SAP (raw): {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Otros errores no esperados
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
except requests.RequestException as ex:
|
||||||
|
print(f" ❌ [PATCH] {recurso} - Excepción: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
###################################################
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
info:
|
||||||
|
name: Login
|
||||||
|
type: http
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: https://192.168.1.175:50000/b1s/v1/Login
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
data: |-
|
||||||
|
{
|
||||||
|
"CompanyDB": "SBO_TAGUMEDICA_21",
|
||||||
|
"UserName": "manager",
|
||||||
|
"Password": "m1r1"
|
||||||
|
}
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
opencollection: 1.0.0
|
||||||
|
|
||||||
|
info:
|
||||||
|
name: SAPBusinessOne
|
||||||
|
bundled: false
|
||||||
|
extensions:
|
||||||
|
bruno:
|
||||||
|
ignore:
|
||||||
|
- node_modules
|
||||||
|
- .git
|
||||||
+20191
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user