Files
mini-erp/mini-backend/routers/batch.py
T

243 lines
9.2 KiB
Python
Raw Normal View History

import logging
from typing import Dict, List, Optional
from fastapi import HTTPException
from hdbcli import dbapi
from pydantic import BaseModel, Field
from core.crud_router import make_crud_router
from core.hana import hana_connection, HANA_SCHEMA
router = make_crud_router(
"BatchNumberDetails",
tags=["Lotes"],
supports_orderby=True,
supports_create=False,
)
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 HANA real.
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