2026-09-28 13:08:58 -05:00
|
|
|
import logging
|
|
|
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
from hdbcli import dbapi
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
2026-08-17 10:34:12 -05:00
|
|
|
from core.crud_router import make_crud_router
|
2026-09-28 13:08:58 -05:00
|
|
|
from core.hana import hana_connection, HANA_SCHEMA
|
2026-08-17 10:34:12 -05:00
|
|
|
|
|
|
|
|
router = make_crud_router(
|
|
|
|
|
"BatchNumberDetails",
|
|
|
|
|
tags=["Lotes"],
|
|
|
|
|
supports_orderby=True,
|
|
|
|
|
supports_create=False,
|
|
|
|
|
)
|
2026-09-28 13:08:58 -05:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Stock de un lote por almacén. OBTN es el maestro de lotes: cada DistNumber
|
|
|
|
|
# (número de lote) tiene un AbsEntry propio. OBTQ guarda la cantidad actual
|
|
|
|
|
# por almacén, pero se enlaza a OBTN por "MdAbsEntry" = OBTN."AbsEntry" (no
|
|
|
|
|
# por DistNumber ni ItemCode directamente). Confirmado contra el HANA real
|
|
|
|
|
# de tagumedica con el lote 2090456 (AbsEntry 74701).
|
|
|
|
|
BATCH_STOCK_QUERY = f"""
|
|
|
|
|
SELECT
|
|
|
|
|
T1."WhsCode" AS "WarehouseCode",
|
|
|
|
|
T2."WhsName" AS "WarehouseName",
|
|
|
|
|
T1."Quantity" AS "Quantity",
|
|
|
|
|
T1."CommitQty" AS "CommitQty",
|
|
|
|
|
T1."CountQty" AS "CountQty",
|
|
|
|
|
T1."ItemCode" AS "ItemCode",
|
|
|
|
|
T3."ItemName" AS "ItemDescription"
|
|
|
|
|
FROM "{HANA_SCHEMA}".OBTN T0
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OBTQ T1 ON T1."MdAbsEntry" = T0."AbsEntry"
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OWHS T2 ON T2."WhsCode" = T1."WhsCode"
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OITM T3 ON T3."ItemCode" = T1."ItemCode"
|
|
|
|
|
WHERE T0."DistNumber" = ? AND T1."Quantity" > 0
|
|
|
|
|
ORDER BY T3."ItemName", T2."WhsName"
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
class WarehouseStock(BaseModel):
|
|
|
|
|
WarehouseCode: str
|
|
|
|
|
WarehouseName: str
|
|
|
|
|
Quantity: float
|
|
|
|
|
CommitQty: float
|
|
|
|
|
CountQty: float
|
|
|
|
|
ItemCode: str
|
|
|
|
|
ItemDescription: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
class BatchStockResponse(BaseModel):
|
|
|
|
|
Batch: str
|
|
|
|
|
Warehouses: List[WarehouseStock]
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
@router.get("/api/batch/stock", response_model=BatchStockResponse)
|
|
|
|
|
async def batch_stock(batch: str):
|
|
|
|
|
batch = batch.strip()
|
|
|
|
|
if not batch:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Debe indicar el número de lote")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with hana_connection() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute(BATCH_STOCK_QUERY, (batch,))
|
|
|
|
|
rows = cursor.fetchall()
|
|
|
|
|
columns = [d[0] for d in cursor.description]
|
|
|
|
|
except dbapi.Error as exc:
|
|
|
|
|
logger.error(f"Error al consultar stock del lote {batch}: {exc}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Error al consultar el stock del lote: {exc}")
|
|
|
|
|
|
|
|
|
|
warehouses = [dict(zip(columns, row)) for row in rows]
|
|
|
|
|
if not warehouses:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"No se encontró stock para el lote '{batch}'")
|
|
|
|
|
|
|
|
|
|
return {"Batch": batch, "Warehouses": warehouses}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Para "Confirmar Liberación Lote": un lote inmovilizado (llevado a un
|
|
|
|
|
# almacén como 302) puede haber llegado ahí en más de una transferencia y
|
|
|
|
|
# desde más de un almacén de origen distinto (confirmado con datos reales:
|
|
|
|
|
# ej. el lote 51232313 llegó a 302 con 2 unidades desde el almacén 207 y 1
|
|
|
|
|
# desde el 208). Por eso la línea de la Solicitud de Liberación (que sale
|
|
|
|
|
# con el total del lote desde 302) puede tener que dividirse en una línea
|
|
|
|
|
# por cada almacén de origen encontrado, repartiendo la cantidad entre ellos
|
|
|
|
|
# a prorrata de cuánto trajo cada transferencia históricamente.
|
|
|
|
|
#
|
|
|
|
|
# Se buscan todas las transferencias ejecutadas (OWTR, DocType 67 en OITL)
|
|
|
|
|
# que ingresaron el lote al almacén dado; de cada una (mismo DocEntry) se
|
|
|
|
|
# toma la línea hermana de salida (cantidad negativa) para saber de qué
|
|
|
|
|
# almacén salió. OITL/ITL1 es el log de transacciones de lote/serie de SAP
|
|
|
|
|
# (mismo que usa el reporte estándar "Transacciones de Números de Serie y
|
|
|
|
|
# Lote"), unidas por "LogEntry"; OBTN da el AbsEntry del lote a partir de
|
|
|
|
|
# ItemCode+DistNumber.
|
|
|
|
|
INBOUND_TRANSFERS_QUERY = f"""
|
|
|
|
|
SELECT DISTINCT T1."DocEntry", T1."DocDate"
|
|
|
|
|
FROM "{HANA_SCHEMA}".ITL1 T0
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
|
|
|
|
WHERE T2."ItemCode" = ? AND T2."DistNumber" = ?
|
|
|
|
|
AND T1."LocCode" = ? AND T1."StockEff" = 1
|
|
|
|
|
AND T0."Quantity" > 0 AND T1."DocType" = 67
|
|
|
|
|
ORDER BY T1."DocDate", T1."DocEntry"
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
INBOUND_QUANTITY_QUERY = f"""
|
|
|
|
|
SELECT T0."Quantity"
|
|
|
|
|
FROM "{HANA_SCHEMA}".ITL1 T0
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
|
|
|
|
WHERE T1."DocEntry" = ? AND T2."ItemCode" = ? AND T2."DistNumber" = ?
|
|
|
|
|
AND T1."LocCode" = ? AND T0."Quantity" > 0
|
|
|
|
|
LIMIT 1
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
TRANSFER_SOURCE_WAREHOUSE_QUERY = f"""
|
|
|
|
|
SELECT T1."LocCode"
|
|
|
|
|
FROM "{HANA_SCHEMA}".ITL1 T0
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
|
|
|
|
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
|
|
|
|
WHERE T1."DocEntry" = ? AND T2."ItemCode" = ? AND T2."DistNumber" = ?
|
|
|
|
|
AND T0."Quantity" < 0
|
|
|
|
|
LIMIT 1
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LotSourceWarehouseLine(BaseModel):
|
|
|
|
|
ItemCode: str
|
|
|
|
|
DistNumber: str
|
|
|
|
|
# Almacén donde está inmovilizado hoy el lote (ej. 302): se busca de qué
|
|
|
|
|
# almacén(es) vino, entre las transferencias que lo trajeron A este.
|
|
|
|
|
Warehouse: str
|
|
|
|
|
# Cantidad de la línea a liberar: se reparte entre los almacenes de
|
|
|
|
|
# origen encontrados, a prorrata de cuánto trajo cada uno.
|
|
|
|
|
Quantity: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LotSourceWarehouseRequest(BaseModel):
|
|
|
|
|
Lines: List[LotSourceWarehouseLine] = Field(min_length=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LotSourceAllocation(BaseModel):
|
|
|
|
|
WarehouseCode: str
|
|
|
|
|
Quantity: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LotSourceWarehouseResult(BaseModel):
|
|
|
|
|
ItemCode: str
|
|
|
|
|
DistNumber: str
|
|
|
|
|
Allocations: List[LotSourceAllocation] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _allocate_by_origin(
|
|
|
|
|
origin_totals: Dict[str, float], release_qty: float
|
|
|
|
|
) -> List[LotSourceAllocation]:
|
|
|
|
|
"""
|
|
|
|
|
Reparte release_qty entre los almacenes de origin_totals a prorrata de
|
|
|
|
|
su participación histórica, redondeando cada asignación a un entero (los
|
|
|
|
|
artículos se mueven en unidades completas). El último almacén se ajusta
|
|
|
|
|
para que la suma coincida exactamente con release_qty (evita descuadres
|
|
|
|
|
de redondeo, ya que el histórico puede no calzar 1 a 1 con la cantidad
|
|
|
|
|
actual si parte del lote salió de 302 por otra vía mientras tanto).
|
|
|
|
|
"""
|
|
|
|
|
total_historical = sum(origin_totals.values())
|
|
|
|
|
if total_historical <= 0:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
origins = list(origin_totals.items())
|
|
|
|
|
allocations: List[LotSourceAllocation] = []
|
|
|
|
|
allocated_so_far = 0.0
|
|
|
|
|
for index, (warehouse_code, historical_qty) in enumerate(origins):
|
|
|
|
|
is_last = index == len(origins) - 1
|
|
|
|
|
allocated_qty = (
|
|
|
|
|
round(release_qty - allocated_so_far)
|
|
|
|
|
if is_last
|
|
|
|
|
else round(release_qty * (historical_qty / total_historical))
|
|
|
|
|
)
|
|
|
|
|
allocated_qty = max(allocated_qty, 0)
|
|
|
|
|
allocated_so_far += allocated_qty
|
|
|
|
|
if allocated_qty > 0:
|
|
|
|
|
allocations.append(
|
|
|
|
|
LotSourceAllocation(WarehouseCode=warehouse_code, Quantity=allocated_qty)
|
|
|
|
|
)
|
|
|
|
|
return allocations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
"/api/batch/lot-source-warehouse",
|
|
|
|
|
response_model=List[LotSourceWarehouseResult],
|
|
|
|
|
)
|
|
|
|
|
async def lot_source_warehouse(payload: LotSourceWarehouseRequest):
|
|
|
|
|
results: List[LotSourceWarehouseResult] = []
|
|
|
|
|
try:
|
|
|
|
|
with hana_connection() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
for line in payload.Lines:
|
|
|
|
|
cursor.execute(
|
|
|
|
|
INBOUND_TRANSFERS_QUERY,
|
|
|
|
|
(line.ItemCode, line.DistNumber, line.Warehouse),
|
|
|
|
|
)
|
|
|
|
|
doc_entries = [row[0] for row in cursor.fetchall()]
|
|
|
|
|
|
|
|
|
|
origin_totals: Dict[str, float] = {}
|
|
|
|
|
for doc_entry in doc_entries:
|
|
|
|
|
cursor.execute(
|
|
|
|
|
INBOUND_QUANTITY_QUERY,
|
|
|
|
|
(doc_entry, line.ItemCode, line.DistNumber, line.Warehouse),
|
|
|
|
|
)
|
|
|
|
|
inbound_row = cursor.fetchone()
|
|
|
|
|
if not inbound_row:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
cursor.execute(
|
|
|
|
|
TRANSFER_SOURCE_WAREHOUSE_QUERY,
|
|
|
|
|
(doc_entry, line.ItemCode, line.DistNumber),
|
|
|
|
|
)
|
|
|
|
|
source_row = cursor.fetchone()
|
|
|
|
|
if not source_row:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
warehouse_code = source_row[0]
|
|
|
|
|
origin_totals[warehouse_code] = origin_totals.get(
|
|
|
|
|
warehouse_code, 0
|
|
|
|
|
) + float(inbound_row[0])
|
|
|
|
|
|
|
|
|
|
results.append(
|
|
|
|
|
LotSourceWarehouseResult(
|
|
|
|
|
ItemCode=line.ItemCode,
|
|
|
|
|
DistNumber=line.DistNumber,
|
|
|
|
|
Allocations=_allocate_by_origin(origin_totals, line.Quantity),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
except dbapi.Error as exc:
|
|
|
|
|
logger.error(f"Error al buscar el almacén de origen de lotes: {exc}")
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=500,
|
|
|
|
|
detail=f"Error al buscar el almacén de origen de los lotes: {exc}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return results
|