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 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