356 lines
13 KiB
Python
356 lines
13 KiB
Python
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.")
|
|||
|
|
|
|||
|
|
###################################################
|