diff --git a/mini-backend/core/crud_router.py b/mini-backend/core/crud_router.py
index 01290cb..4cf71bd 100644
--- a/mini-backend/core/crud_router.py
+++ b/mini-backend/core/crud_router.py
@@ -113,11 +113,21 @@ def make_crud_router(
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
async def update_resource(key: str, request: Request):
data = await request.json()
+ # Las cabeceras de Service Layer (B1S-*) las decide quien llama
+ # (el frontend), no el backend: cualquier header "B1S-*" que
+ # venga en el request se reenvía tal cual a SAP. Ej.:
+ # B1S-ReplaceCollectionsOnPatch para reemplazar una colección
+ # hija (StockTransferLines) en vez de mergearla por LineNum,
+ # necesario solo cuando ese PATCH agrega o quita líneas.
+ headers = dict(patch_headers or {})
+ headers.update(
+ {k: v for k, v in request.headers.items() if k.lower().startswith("b1s-")}
+ )
forward_to_sapb1(
method="PATCH",
endpoint_path=key_expr(key),
json_data=data,
- extra_headers=patch_headers,
+ extra_headers=headers or None,
)
return router
diff --git a/mini-backend/core/inventory_transfer_requests.py b/mini-backend/core/inventory_transfer_requests.py
new file mode 100644
index 0000000..7a3b1da
--- /dev/null
+++ b/mini-backend/core/inventory_transfer_requests.py
@@ -0,0 +1,4 @@
+# Objeto Service Layer para la Solicitud de Transferencia de Inventario
+# (BoObjectTypes "1250000001"). Centralizado acá para que el router de
+# routers/inventory_transfer_requests.py no repita el nombre del endpoint.
+INVENTORY_TRANSFER_REQUESTS_RESOURCE = "InventoryTransferRequests"
diff --git a/mini-backend/core/series.py b/mini-backend/core/series.py
new file mode 100644
index 0000000..72628ab
--- /dev/null
+++ b/mini-backend/core/series.py
@@ -0,0 +1,51 @@
+import logging
+from typing import List, Optional
+
+from core.sap import forward_to_sapb1
+
+logger = logging.getLogger(__name__)
+
+
+def get_default_series(document_type: str) -> Optional[int]:
+ """
+ Consulta a Service Layer (SeriesService_GetDefaultSeries) la serie activa
+ por defecto para un tipo de documento de SAP B1 (código BoObjectTypes,
+ ej. "1250000001" para Solicitud de Transferencia). Si la llamada falla o
+ la respuesta no trae el campo esperado, se registra en el log y se
+ devuelve None; quien llama decide cómo seguir sin una serie por defecto.
+ """
+ try:
+ response = forward_to_sapb1(
+ method="POST",
+ endpoint_path="SeriesService_GetDefaultSeries",
+ json_data={"DocumentTypeParams": {"Document": document_type}},
+ )
+ except Exception as exc:
+ logger.warning(
+ f"No se pudo obtener la serie por defecto de SAP (documento {document_type}): {exc}"
+ )
+ return None
+
+ data = response.json()
+ series = data.get("Series")
+ if series is None:
+ logger.warning(
+ f"SeriesService_GetDefaultSeries no devolvió 'Series' (documento {document_type}): {data}"
+ )
+ return series
+
+
+def get_active_series(document_type: str) -> List[dict]:
+ """
+ Lista las series no bloqueadas de un tipo de documento de SAP B1, vía
+ SeriesService_GetDocumentSeries. Confirmado contra el HANA/Service Layer
+ real de tagumedica: la respuesta viene en "value", cada item con
+ "Series" (código) y "Name"; "Locked" en "tYES"/"tNO".
+ """
+ response = forward_to_sapb1(
+ method="POST",
+ endpoint_path="SeriesService_GetDocumentSeries",
+ json_data={"DocumentTypeParams": {"Document": document_type}},
+ )
+ series_list = response.json().get("value", [])
+ return [s for s in series_list if str(s.get("Locked", "")).lower() != "tyes"]
diff --git a/mini-backend/main.py b/mini-backend/main.py
index c00d830..8664189 100644
--- a/mini-backend/main.py
+++ b/mini-backend/main.py
@@ -22,6 +22,9 @@ from routers.batch import router as batch_router
from routers.permissions import router as permissions_router
from routers.apigateway import router as apigateway_router
from routers.reports import router as reports_router
+from routers.series import router as series_router
+from routers.user_tables import router as user_tables_router
+from routers.inventory_transfer_requests import router as inventory_transfer_requests_router
# ============================================================================
load_dotenv(dotenv_path=".env")
@@ -54,6 +57,9 @@ app.include_router(batch_router)
app.include_router(permissions_router)
app.include_router(apigateway_router)
app.include_router(reports_router)
+app.include_router(series_router)
+app.include_router(user_tables_router)
+app.include_router(inventory_transfer_requests_router)
# ============================================================================
@app.middleware("http")
diff --git a/mini-backend/menu_permissions.json b/mini-backend/menu_permissions.json
index 4a0d036..135a007 100644
--- a/mini-backend/menu_permissions.json
+++ b/mini-backend/menu_permissions.json
@@ -6,7 +6,7 @@
"/inventario/*"
],
"jose.huayta@tagumedica.com": [
- "/inventario/articulos",
+ "/inventario/*",
"/ventas/*",
"/compras/*"
],
@@ -16,10 +16,16 @@
"renato.bustamante@tagumedica.com": [
"/inventario/articulos",
"/inventario/lotes",
+ "/inventario/inmovilizar-lotes",
+ "/inventario/liberar-lotes",
+ "/inventario/confirmar-liberacion-lote",
"/ventas/*",
"/compras/*"
],
"vania.barrios@tagumedica.com":[
- "/inventario/lotes"
+ "/inventario/lotes",
+ "/inventario/inmovilizar-lotes",
+ "/inventario/liberar-lotes",
+ "/inventario/confirmar-liberacion-lote"
]
}
\ No newline at end of file
diff --git a/mini-backend/routers/batch.py b/mini-backend/routers/batch.py
index 0ab8cce..940fa2b 100644
--- a/mini-backend/routers/batch.py
+++ b/mini-backend/routers/batch.py
@@ -1,4 +1,12 @@
+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",
@@ -6,3 +14,231 @@ router = make_crud_router(
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
diff --git a/mini-backend/routers/catalogs.py b/mini-backend/routers/catalogs.py
index 4dab655..27b1521 100644
--- a/mini-backend/routers/catalogs.py
+++ b/mini-backend/routers/catalogs.py
@@ -58,6 +58,49 @@ async def get_sunat_businesspartners_document_type():
extra_headers=base_headers
).json()
+@router.get("/api/Catalogs/UdfValidValues")
+async def get_udf_valid_values(table_name: str, field_name: str):
+ """
+ Genérico para cualquier campo de usuario (UDF) con lista de valores
+ válidos: recibe la tabla SAP (ej. 'OWTQ') y el nombre del campo sin el
+ prefijo 'U_' (ej. 'SIS_TIPDESP'), y devuelve sus valores válidos vía
+ Service Layer (UserFieldsMD.ValidValuesMD), normalizados a {Code, Name}
+ para poder usarse con useSapCatalog igual que las tablas de usuario.
+ Equivale a lo que en HANA sería CUFD (por AliasID/TableID) + UFD1
+ (por FieldID), pero sin necesidad de acceso directo a HANA.
+ """
+
+ def _escape(value: str) -> str:
+ return value.replace("'", "''")
+
+ sap_params = {
+ "$skip": 0,
+ "$top": 200,
+ "$inlinecount": "allpages",
+ }
+ sap_params["$filter"] = (
+ f"TableName eq '{_escape(table_name)}' and Name eq '{_escape(field_name)}'"
+ )
+
+ base_headers = {"B1S-PageSize": "200"}
+
+ response = forward_to_sapb1(
+ method="GET",
+ endpoint_path="UserFieldsMD",
+ params=sap_params,
+ extra_headers=base_headers,
+ ).json()
+
+ fields = response.get("value", [])
+ valid_values = fields[0].get("ValidValuesMD", []) if fields else []
+
+ return {
+ "value": [
+ {"Code": v["Value"], "Name": v["Description"]} for v in valid_values
+ ]
+ }
+
+
@router.get("/api/ItemGroups")
async def get_item_groups():
diff --git a/mini-backend/routers/inventory_transfer_requests.py b/mini-backend/routers/inventory_transfer_requests.py
new file mode 100644
index 0000000..cf56b9d
--- /dev/null
+++ b/mini-backend/routers/inventory_transfer_requests.py
@@ -0,0 +1,15 @@
+from core.crud_router import make_crud_router
+from core.inventory_transfer_requests import INVENTORY_TRANSFER_REQUESTS_RESOURCE
+
+# CRUD genérico de Solicitudes de Transferencia (list/get/create). El
+# frontend arma acá el JSON completo que espera SAP (StockTransferLines,
+# BatchNumbers, campos U_* de usuario, etc.) y lo manda directo a este
+# endpoint; el backend no valida stock ni transforma nada, es SAP quien
+# rechaza el documento si no hay stock suficiente (ver "Inmovilizar Lotes"
+# en mini-frontend/src/views/inventario/InmovilizarLotesView.tsx).
+router = make_crud_router(
+ INVENTORY_TRANSFER_REQUESTS_RESOURCE,
+ tags=["Solicitudes de Transferencia"],
+ key_field="DocEntry",
+ supports_orderby=True,
+)
diff --git a/mini-backend/routers/series.py b/mini-backend/routers/series.py
new file mode 100644
index 0000000..d3ddb54
--- /dev/null
+++ b/mini-backend/routers/series.py
@@ -0,0 +1,34 @@
+from typing import List, Optional
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from core.series import get_active_series, get_default_series
+
+router = APIRouter(tags=["Series"])
+
+
+class SeriesOption(BaseModel):
+ Series: int
+ Name: str
+
+
+class SeriesListResponse(BaseModel):
+ Default: Optional[int] = None
+ Options: List[SeriesOption]
+
+
+@router.get("/api/series", response_model=SeriesListResponse)
+async def list_series(document: str):
+ """
+ Series activas (no bloqueadas) y serie por defecto para un tipo de
+ documento de SAP B1. `document` es el código BoObjectTypes del
+ documento (ej. "1250000001" para Solicitud de Transferencia); lo define
+ cada módulo que lo consuma, no este router.
+ """
+ active_series = get_active_series(document)
+ options = [
+ {"Series": s["Series"], "Name": s.get("Name") or str(s["Series"])}
+ for s in active_series
+ ]
+ return {"Default": get_default_series(document), "Options": options}
diff --git a/mini-backend/routers/user_tables.py b/mini-backend/routers/user_tables.py
new file mode 100644
index 0000000..01156cd
--- /dev/null
+++ b/mini-backend/routers/user_tables.py
@@ -0,0 +1,41 @@
+from typing import Dict
+
+from fastapi import APIRouter, HTTPException
+
+from core.sap import forward_to_sapb1
+
+router = APIRouter(tags=["Tablas de Usuario"])
+
+# Mapea un nombre amigable de tabla de usuario (@...) a su nombre real de
+# entidad en Service Layer. La convención de nombre no es uniforme entre
+# tablas (confirmado contra el SAP real de tagumedica: @SIS_MOVINV se expone
+# como "U_SIS_MOVINV", pero @EXX_TIPOOPER se expone como "EXX_TIPOOPER", sin
+# prefijo "U_"), así que se centraliza acá en vez de que cada módulo tenga
+# que adivinar o repetir el nombre de endpoint real.
+USER_TABLES: Dict[str, str] = {
+ "MovimientoInventario": "U_SIS_MOVINV",
+ "TipoOperacion": "EXX_TIPOOPER",
+}
+
+
+@router.get("/api/UserTables/{name}")
+async def get_user_table(name: str):
+ """
+ Catálogo Code/Name de una tabla de usuario de SAP B1, identificada por un
+ nombre amigable (ver USER_TABLES) en vez del nombre real de tabla/entidad.
+ Pensado para reutilizarse en cualquier formulario que necesite un select
+ cargado desde una tabla de usuario de este tipo.
+ """
+ entity = USER_TABLES.get(name)
+ if not entity:
+ raise HTTPException(status_code=404, detail=f"Tabla de usuario '{name}' no está registrada")
+
+ # Service Layer limita el tamaño de página por defecto (server-side, no
+ # depende solo de "$top") salvo que se indique explícitamente el header
+ # B1S-PageSize; sin él, catálogos con más de ~20 filas quedaban truncados.
+ return forward_to_sapb1(
+ method="GET",
+ endpoint_path=entity,
+ params={"$select": "Code,Name", "$top": 200},
+ extra_headers={"B1S-PageSize": "200"},
+ ).json()
diff --git a/mini-backend/tools/sap-tagumedica/sl.http b/mini-backend/tools/sap-tagumedica/sl.http
index a27bd50..adf931c 100644
--- a/mini-backend/tools/sap-tagumedica/sl.http
+++ b/mini-backend/tools/sap-tagumedica/sl.http
@@ -33,7 +33,7 @@ Content-Type: application/json
###
# @noRejectUnauthorized
-GET {{url}}/UserFieldsMD?$filter=TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'
+GET {{url}}/UserFieldsMD?$filter=TableName eq 'OWTQ' and Name eq 'SIS_TIPDESP'
Content-Type: application/json
###
@@ -79,4 +79,14 @@ Content-Type: application/json
###
# @noRejectUnauthorized
GET {{url}}/BatchNumberDetails
-Content-Type: application/json
\ No newline at end of file
+Content-Type: application/json
+
+
+###
+# @noRejectUnauthorized
+POST {{url}}/SeriesService_GetSeries
+Content-Type: application/json
+{
+ "SeriesParams": {}
+}
+
diff --git a/mini-frontend/src/App.tsx b/mini-frontend/src/App.tsx
index 30bc069..f32a3cc 100644
--- a/mini-frontend/src/App.tsx
+++ b/mini-frontend/src/App.tsx
@@ -24,6 +24,15 @@ const BatchNumberDetailsList = lazy(
const BatchNumberDetails = lazy(
() => import("./views/inventario/BatchNumberDetailsView"),
);
+const InmovilizarLotes = lazy(
+ () => import("./views/inventario/InmovilizarLotesView"),
+);
+const LiberarLotes = lazy(
+ () => import("./views/inventario/LiberarLotesView"),
+);
+const ConfirmarLiberacionLote = lazy(
+ () => import("./views/inventario/ConfirmarLiberacionLoteView"),
+);
// 2. Socios de Negocios
const CustomersList = lazy(() => import("./views/socios/CustomersList"));
@@ -131,6 +140,18 @@ export default function App() {
path="inventario/lotes/mantenimiento"
element={}
/>
+ }
+ />
+ }
+ />
+ }
+ />
{/* 2. Socios de Negocios */}
} />
diff --git a/mini-frontend/src/components/ui/ConfirmModal.tsx b/mini-frontend/src/components/ui/ConfirmModal.tsx
new file mode 100644
index 0000000..4abdb90
--- /dev/null
+++ b/mini-frontend/src/components/ui/ConfirmModal.tsx
@@ -0,0 +1,61 @@
+import type { ReactNode } from "react";
+
+interface ConfirmModalProps {
+ isOpen: boolean;
+ title: string;
+ message: ReactNode;
+ confirmLabel?: string;
+ cancelLabel?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+/**
+ * Diálogo de confirmación genérico para acciones que no se pueden deshacer
+ * (crear un documento en SAP, etc.). "Cancelar" es el default: tiene el foco
+ * (`autoFocus`, dispara con Enter) y además es el botón con el estilo
+ * prominente (azul), para que lo que se ve resaltado sea lo mismo que se
+ * ejecuta si el usuario aprieta Enter sin querer. "Confirmar" queda con
+ * estilo secundario a propósito, para no invitar a un clic apurado.
+ */
+export default function ConfirmModal({
+ isOpen,
+ title,
+ message,
+ confirmLabel = "Sí",
+ cancelLabel = "No",
+ onConfirm,
+ onCancel,
+}: ConfirmModalProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
+
+ {title}
+
+
+
{message}
+
+
+
+
+
+
+
+ );
+}
diff --git a/mini-frontend/src/hooks/useSapCatalog.ts b/mini-frontend/src/hooks/useSapCatalog.ts
new file mode 100644
index 0000000..0aed78a
--- /dev/null
+++ b/mini-frontend/src/hooks/useSapCatalog.ts
@@ -0,0 +1,58 @@
+import { useEffect, useState } from "react";
+import { API_URL, apiFetch } from "../utils/api";
+
+export interface CodeNameOption {
+ Code: string;
+ Name: string;
+}
+
+/**
+ * Trae un catálogo simple Code/Name expuesto por el backend en
+ * `/api/` (tablas de usuario como U_SIS_MOVINV/EXX_TIPOOPER,
+ * valores válidos de un UDF vía `Catalogs/UdfValidValues`, u otros
+ * catálogos registrados igual en catalogs.py). Pensado para poblar
+ * selects reutilizables en distintos formularios. `params` se manda como
+ * query string adicional (ej. `table_name`/`field_name` para un UDF).
+ */
+export function useSapCatalog(
+ endpoint: string,
+ params?: Record,
+): {
+ options: CodeNameOption[];
+ loading: boolean;
+} {
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const paramsKey = params ? JSON.stringify(params) : "";
+
+ useEffect(() => {
+ let cancelled = false;
+ const fetchOptions = async () => {
+ setLoading(true);
+ try {
+ const query = new URLSearchParams({
+ fields: "Code,Name",
+ top: "200",
+ ...params,
+ });
+ const response = await apiFetch(`${API_URL}/api/${endpoint}?${query}`);
+ if (!response.ok) return;
+ const data = await response.json();
+ if (!cancelled) setOptions(data.value ?? []);
+ } catch {
+ /* el select queda vacío; el usuario puede recargar la pantalla */
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+ fetchOptions();
+ return () => {
+ cancelled = true;
+ };
+ // paramsKey representa el contenido de `params`; se usa en vez del
+ // objeto para no re-disparar el fetch en cada render.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [endpoint, paramsKey]);
+
+ return { options, loading };
+}
diff --git a/mini-frontend/src/layout/MainLayout.tsx b/mini-frontend/src/layout/MainLayout.tsx
index 91ab354..0e35a08 100644
--- a/mini-frontend/src/layout/MainLayout.tsx
+++ b/mini-frontend/src/layout/MainLayout.tsx
@@ -27,6 +27,9 @@ const menuConfig: MenuItem[] = [
subItems: [
{ name: "Articulos", path: "/inventario/articulos" },
{ name: "Lotes", path: "/inventario/lotes" },
+ { name: "Inmovilizar Lotes", path: "/inventario/inmovilizar-lotes" },
+ { name: "Liberar Lotes", path: "/inventario/liberar-lotes" },
+ { name: "Confirmar Liberación Lote", path: "/inventario/confirmar-liberacion-lote" },
],
},
{
diff --git a/mini-frontend/src/types/sapInventoryLock.ts b/mini-frontend/src/types/sapInventoryLock.ts
new file mode 100644
index 0000000..09786e6
--- /dev/null
+++ b/mini-frontend/src/types/sapInventoryLock.ts
@@ -0,0 +1,96 @@
+export interface BatchWarehouseStock {
+ WarehouseCode: string;
+ WarehouseName: string;
+ Quantity: number;
+ CommitQty: number;
+ CountQty: number;
+ ItemCode: string;
+ ItemDescription?: string;
+}
+
+export interface BatchStockResponse {
+ Batch: string;
+ Warehouses: BatchWarehouseStock[];
+}
+
+export interface SeriesOption {
+ Series: number;
+ Name: string;
+}
+
+export interface SeriesListResponse {
+ Default?: number;
+ Options: SeriesOption[];
+}
+
+// Shape que espera SAP Service Layer para crear una Solicitud de
+// Transferencia de Inventario (entidad "InventoryTransferRequests"). El
+// frontend arma este JSON completo (incluyendo los campos de usuario U_*) y
+// lo manda directo a /api/InventoryTransferRequests: el backend no lo
+// transforma ni lo valida, solo lo reenvía a SAP.
+export interface StockTransferLineBatchNumber {
+ BatchNumber: string;
+ Quantity: number;
+}
+
+export interface StockTransferLine {
+ ItemCode: string;
+ Quantity: number;
+ WarehouseCode: string;
+ FromWarehouseCode: string;
+ BatchNumbers: StockTransferLineBatchNumber[];
+}
+
+export interface InventoryTransferRequestPayload {
+ DocDate: string;
+ Series: number;
+ U_SIS_MOVINV: string;
+ U_EXX_TIPOOPER: string;
+ U_SIS_TIPDESP: string;
+ U_SIS_MOTALM: string;
+ U_SIS_MOTEST: string;
+ U_SIS_COMEST: string;
+ U_SIS_ACCTOM: string;
+ StockTransferLines: StockTransferLine[];
+}
+
+// Shape que devuelve SAP Service Layer al leer una Solicitud de
+// Transferencia de Inventario ya creada (GET /api/InventoryTransferRequests).
+// A diferencia de StockTransferLine (lo que se envía al crear), acá SAP
+// agrega "LineNum" a cada línea; "ItemDescription" no viene en la línea, hay
+// que resolverla aparte por ItemCode (ver Items).
+export interface StockTransferLineDetail {
+ LineNum: number;
+ ItemCode: string;
+ Quantity: number;
+ WarehouseCode: string;
+ FromWarehouseCode: string;
+ BatchNumbers?: StockTransferLineBatchNumber[];
+}
+
+export interface InventoryTransferRequestSummary {
+ DocEntry: number;
+ DocNum: number;
+ DocDate: string;
+ DocumentStatus: string;
+ U_SIS_MOTALM?: string;
+ StockTransferLines: StockTransferLineDetail[];
+}
+
+// Respuesta de /api/batch/lot-source-warehouse: para cada Artículo+Lote, la
+// cantidad se reparte entre los almacenes de los que realmente salió antes
+// de llegar al almacén donde está hoy inmovilizado (resuelto contra el
+// historial de transferencias en HANA). Puede haber más de una asignación
+// si el lote llegó en transferencias distintas desde almacenes distintos;
+// Allocations viene vacío si no se encontró ninguna transferencia de
+// ingreso (OWTR) para ese lote.
+export interface LotSourceAllocation {
+ WarehouseCode: string;
+ Quantity: number;
+}
+
+export interface LotSourceWarehouseResult {
+ ItemCode: string;
+ DistNumber: string;
+ Allocations: LotSourceAllocation[];
+}
diff --git a/mini-frontend/src/views/inventario/ConfirmarLiberacionLoteView.tsx b/mini-frontend/src/views/inventario/ConfirmarLiberacionLoteView.tsx
new file mode 100644
index 0000000..6553c49
--- /dev/null
+++ b/mini-frontend/src/views/inventario/ConfirmarLiberacionLoteView.tsx
@@ -0,0 +1,645 @@
+import { useEffect, useRef, useState } from "react";
+import { API_URL, apiFetch } from "../../utils/api";
+import { getErrorMessage } from "../../utils/errors";
+import { formatDisplayDate } from "../../utils/dateFormat";
+import Icon from "../../components/ui/Icon";
+import PrimaryButton from "../../components/ui/PrimaryButton";
+import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate";
+import type {
+ InventoryTransferRequestSummary,
+ LotSourceWarehouseResult,
+ StockTransferLineDetail,
+} from "../../types/sapInventoryLock";
+
+// "108" es el mismo código de U_SIS_MOTALM que "Liberar Lotes" manda por
+// defecto al crear la Solicitud de Transferencia: identifica los documentos
+// que quedaron pendientes de confirmar la liberación. Fijo a propósito, no
+// es un valor que el usuario deba elegir acá.
+const PENDING_MOVIMIENTO_ALMACEN = "108";
+const OPEN_REQUESTS_FILTER = `DocumentStatus eq 'bost_Open' and U_SIS_MOTALM eq '${PENDING_MOVIMIENTO_ALMACEN}'`;
+
+interface LineBatchRow {
+ key: string;
+ LineNum: number;
+ ItemCode: string;
+ Quantity: number;
+ BatchNumber: string;
+ WarehouseCode: string;
+}
+
+// Cada línea trae su(s) número(s) de lote en BatchNumbers; se muestra una
+// fila de subdetalle por cada combinación línea+lote (normalmente una sola).
+function lineBatchRows(lines: StockTransferLineDetail[]): LineBatchRow[] {
+ const rows: LineBatchRow[] = [];
+ for (const line of lines) {
+ const batches = line.BatchNumbers?.length ? line.BatchNumbers : [{ BatchNumber: "-", Quantity: line.Quantity }];
+ batches.forEach((batch, index) => {
+ rows.push({
+ key: `${line.LineNum}-${index}`,
+ LineNum: line.LineNum,
+ ItemCode: line.ItemCode,
+ Quantity: batch.Quantity,
+ BatchNumber: batch.BatchNumber,
+ WarehouseCode: line.WarehouseCode,
+ });
+ });
+ }
+ return rows;
+}
+
+// Una fila editable del detalle de liberación: a qué almacén y con qué
+// cantidad vuelve una porción del lote. El usuario puede agregar, quitar o
+// editar estas filas antes de confirmar (además de lo que se precarga
+// resolviendo contra el historial de transferencias en HANA).
+interface DraftAllocation {
+ rowId: number;
+ WarehouseCode: string;
+ WarehouseName: string;
+ Quantity: string;
+}
+
+interface DraftLine {
+ LineNum: number;
+ ItemCode: string;
+ DistNumber: string;
+ FromWarehouseCode: string;
+ allocations: DraftAllocation[];
+}
+
+export default function ConfirmarLiberacionLoteView() {
+ const [requests, setRequests] = useState([]);
+ const [itemNames, setItemNames] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState("");
+ const [selectedDocEntry, setSelectedDocEntry] = useState(null);
+
+ const [resolving, setResolving] = useState(false);
+ const [resolveError, setResolveError] = useState("");
+ const [draftLines, setDraftLines] = useState(null);
+ const nextRowIdRef = useRef(0);
+ const [warehousePickerTarget, setWarehousePickerTarget] = useState<{
+ lineNum: number;
+ rowId: number;
+ } | null>(null);
+
+ const [confirming, setConfirming] = useState(false);
+ const [confirmError, setConfirmError] = useState("");
+ const [confirmSuccess, setConfirmSuccess] = useState("");
+
+ useEffect(() => {
+ const fetchRequests = async () => {
+ setLoading(true);
+ setLoadError("");
+ try {
+ const response = await apiFetch(
+ `${API_URL}/api/InventoryTransferRequests?filter=${encodeURIComponent(
+ OPEN_REQUESTS_FILTER,
+ )}&orderby=${encodeURIComponent("DocEntry desc")}&top=200`,
+ );
+ if (!response.ok) {
+ const data = await response.json().catch(() => null);
+ throw new Error(data?.detail || "Error al obtener las solicitudes de transferencia");
+ }
+ const data = await response.json();
+ const summaries: InventoryTransferRequestSummary[] = data.value ?? [];
+
+ // SAP Service Layer no devuelve BatchNumbers (ni otros datos de
+ // línea) completos en una consulta de lista: solo un GET directo
+ // por DocEntry trae el documento completo con sus lotes. Por eso,
+ // después de listar, se vuelve a pedir cada solicitud por su clave.
+ const fullRequests = await Promise.all(
+ summaries.map(async (summary) => {
+ try {
+ const detailResponse = await apiFetch(
+ `${API_URL}/api/InventoryTransferRequests/${summary.DocEntry}`,
+ );
+ if (!detailResponse.ok) return summary;
+ return (await detailResponse.json()) as InventoryTransferRequestSummary;
+ } catch {
+ return summary;
+ }
+ }),
+ );
+ const value = fullRequests;
+ setRequests(value);
+ setSelectedDocEntry(null);
+
+ // La descripción del artículo no viene en StockTransferLines: se
+ // resuelve aparte por ItemCode, una sola vez por código distinto.
+ const itemCodes = Array.from(
+ new Set(value.flatMap((req) => req.StockTransferLines.map((l) => l.ItemCode))),
+ );
+ const entries = await Promise.all(
+ itemCodes.map(async (code) => {
+ try {
+ const itemResponse = await apiFetch(
+ `${API_URL}/api/Items/${encodeURIComponent(code)}?fields=ItemCode,ItemName`,
+ );
+ if (!itemResponse.ok) return null;
+ const item = await itemResponse.json();
+ return [code, item.ItemName ?? code] as const;
+ } catch {
+ return null;
+ }
+ }),
+ );
+ setItemNames(Object.fromEntries(entries.filter((e): e is [string, string] => e !== null)));
+ } catch (err: unknown) {
+ setLoadError(getErrorMessage(err, "Error al obtener las solicitudes de transferencia"));
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchRequests();
+ }, []);
+
+ const selectedRequest = requests.find((req) => req.DocEntry === selectedDocEntry) ?? null;
+
+ // Apenas se selecciona una solicitud, se averigua contra HANA de qué
+ // almacén(es) vino realmente cada lote (puede ser más de uno, si llegó al
+ // almacén actual en transferencias distintas), repartiendo la cantidad de
+ // cada línea entre ellos. El resultado se arma como filas editables: si
+ // para alguna línea no se encuentra transferencia de ingreso, queda sin
+ // filas y el usuario agrega el almacén manualmente.
+ useEffect(() => {
+ setDraftLines(null);
+ setResolveError("");
+ setConfirmError("");
+ setConfirmSuccess("");
+
+ if (!selectedRequest) return;
+
+ const resolveDestinations = async () => {
+ setResolving(true);
+ try {
+ const lines = selectedRequest.StockTransferLines.map((line) => ({
+ LineNum: line.LineNum,
+ ItemCode: line.ItemCode,
+ DistNumber: line.BatchNumbers?.[0]?.BatchNumber ?? "",
+ Warehouse: line.FromWarehouseCode,
+ Quantity: line.Quantity,
+ }));
+
+ const response = await apiFetch(`${API_URL}/api/batch/lot-source-warehouse`, {
+ method: "POST",
+ body: JSON.stringify({
+ Lines: lines.map(({ ItemCode, DistNumber, Warehouse, Quantity }) => ({
+ ItemCode,
+ DistNumber,
+ Warehouse,
+ Quantity,
+ })),
+ }),
+ });
+ if (!response.ok) {
+ const data = await response.json().catch(() => null);
+ throw new Error(data?.detail || "Error al buscar el almacén de origen de los lotes");
+ }
+ const results: LotSourceWarehouseResult[] = await response.json();
+
+ setDraftLines(
+ lines.map((line, index) => ({
+ LineNum: line.LineNum,
+ ItemCode: line.ItemCode,
+ DistNumber: line.DistNumber,
+ FromWarehouseCode: line.Warehouse,
+ allocations: results[index].Allocations.map((allocation) => ({
+ rowId: nextRowIdRef.current++,
+ WarehouseCode: allocation.WarehouseCode,
+ WarehouseName: "",
+ Quantity: String(allocation.Quantity),
+ })),
+ })),
+ );
+ } catch (err: unknown) {
+ setResolveError(
+ getErrorMessage(err, "Error al buscar el almacén de origen de los lotes"),
+ );
+ } finally {
+ setResolving(false);
+ }
+ };
+ resolveDestinations();
+ }, [selectedDocEntry]);
+
+ const addAllocationRow = (lineNum: number) => {
+ setDraftLines(
+ (prev) =>
+ prev?.map((line) =>
+ line.LineNum === lineNum
+ ? {
+ ...line,
+ allocations: [
+ ...line.allocations,
+ { rowId: nextRowIdRef.current++, WarehouseCode: "", WarehouseName: "", Quantity: "" },
+ ],
+ }
+ : line,
+ ) ?? null,
+ );
+ };
+
+ const removeAllocationRow = (lineNum: number, rowId: number) => {
+ setDraftLines(
+ (prev) =>
+ prev?.map((line) =>
+ line.LineNum === lineNum
+ ? { ...line, allocations: line.allocations.filter((a) => a.rowId !== rowId) }
+ : line,
+ ) ?? null,
+ );
+ };
+
+ const updateAllocationQuantity = (lineNum: number, rowId: number, value: string) => {
+ setDraftLines(
+ (prev) =>
+ prev?.map((line) =>
+ line.LineNum === lineNum
+ ? {
+ ...line,
+ allocations: line.allocations.map((a) =>
+ a.rowId === rowId ? { ...a, Quantity: value } : a,
+ ),
+ }
+ : line,
+ ) ?? null,
+ );
+ };
+
+ const handleWarehouseSeleccionado = (rows: any[]) => {
+ if (rows.length === 0 || !warehousePickerTarget) return;
+ const { lineNum, rowId } = warehousePickerTarget;
+ setDraftLines(
+ (prev) =>
+ prev?.map((line) =>
+ line.LineNum === lineNum
+ ? {
+ ...line,
+ allocations: line.allocations.map((a) =>
+ a.rowId === rowId
+ ? { ...a, WarehouseCode: rows[0].WarehouseCode, WarehouseName: rows[0].WarehouseName }
+ : a,
+ ),
+ }
+ : line,
+ ) ?? null,
+ );
+ setWarehousePickerTarget(null);
+ };
+
+ const hasIncompleteRows = Boolean(
+ draftLines?.some((line) =>
+ line.allocations.some(
+ (a) => !a.WarehouseCode || a.Quantity.trim() === "" || Number(a.Quantity) <= 0,
+ ),
+ ),
+ );
+ const hasAnyAllocation = Boolean(draftLines?.some((line) => line.allocations.length > 0));
+
+ // El botón actualiza directo la Solicitud de Transferencia en SAP (PATCH)
+ // con las filas armadas/editadas por el usuario. Una línea con una sola
+ // fila se actualiza en el lugar (mismo LineNum); una línea con varias
+ // filas se reemplaza por varias líneas: la primera reusa el LineNum
+ // original, las siguientes van sin LineNum para que SAP las agregue como
+ // líneas nuevas. Una línea sin filas (el usuario borró todas) se elimina
+ // del documento.
+ const handleConfirmApply = async () => {
+ if (!selectedRequest || !draftLines) return;
+ setConfirming(true);
+ setConfirmError("");
+ try {
+ const updatedLines = draftLines.flatMap((line) =>
+ line.allocations.map((allocation, index) => ({
+ ...(index === 0 ? { LineNum: line.LineNum } : {}),
+ ItemCode: line.ItemCode,
+ Quantity: Number(allocation.Quantity),
+ WarehouseCode: allocation.WarehouseCode,
+ FromWarehouseCode: line.FromWarehouseCode,
+ BatchNumbers: [{ BatchNumber: line.DistNumber, Quantity: Number(allocation.Quantity) }],
+ })),
+ );
+
+ // Se puede estar agregando o quitando líneas (repartir una línea
+ // entre varios almacenes destino), así que Service Layer debe
+ // reemplazar StockTransferLines entero en vez de mergearlo por
+ // LineNum con lo que ya tenía el documento. El backend reenvía tal
+ // cual cualquier header B1S-* que se mande acá.
+ const response = await apiFetch(
+ `${API_URL}/api/InventoryTransferRequests/${selectedRequest.DocEntry}`,
+ {
+ method: "PATCH",
+ headers: { "B1S-ReplaceCollectionsOnPatch": "true" },
+ body: JSON.stringify({ StockTransferLines: updatedLines }),
+ },
+ );
+ if (!response.ok) {
+ const data = await response.json().catch(() => null);
+ throw new Error(data?.detail || "Error al actualizar la Solicitud de Transferencia");
+ }
+
+ // Se vuelve a pedir la solicitud actualizada en vez de simular las
+ // líneas localmente: SAP es quien asigna los LineNum definitivos de
+ // las líneas nuevas que se agregaron por el reparto.
+ const refreshed = await apiFetch(
+ `${API_URL}/api/InventoryTransferRequests/${selectedRequest.DocEntry}`,
+ );
+ if (refreshed.ok) {
+ const refreshedData: InventoryTransferRequestSummary = await refreshed.json();
+ setRequests((prev) =>
+ prev.map((req) => (req.DocEntry === selectedRequest.DocEntry ? refreshedData : req)),
+ );
+ }
+ setConfirmSuccess(
+ `Solicitud #${selectedRequest.DocNum} actualizada con los almacenes destino correctos.`,
+ );
+ } catch (err: unknown) {
+ setConfirmError(
+ getErrorMessage(err, "Error al actualizar la Solicitud de Transferencia"),
+ );
+ } finally {
+ setConfirming(false);
+ setDraftLines(null);
+ }
+ };
+
+ return (
+
+
+
+
+ Confirmar Liberación Lote
+
+
+ Solicitudes de Transferencia abiertas generadas por "Liberar Lotes", de la más reciente
+ a la más antigua. Seleccione una para continuar.
+
+ Buscando en el historial de transferencias de qué almacén(es) salió cada
+ lote...
+
+ )}
+
+ {resolveError && (
+
+ {resolveError}
+
+ )}
+
+ {draftLines && (
+
+
+ Así va a quedar la Solicitud al confirmar. Puede editar la cantidad,
+ cambiar el almacén destino, agregar o quitar filas por artículo/lote
+ antes de confirmar:
+
+ {draftLines.map((line) => (
+
+
+ {line.ItemCode} - Lote {line.DistNumber || "(sin lote)"}
+ {line.allocations.length === 0 && (
+
+ {" "}
+ · No se encontró transferencia de ingreso; agregue el almacén
+ destino manualmente.
+
+ )}
+
+ {/* Un mismo número de lote (DistNumber) puede repetirse en más de un
+ artículo en SAP: se agrupa por Artículo y ese dato va en el
+ mismo encabezado que el Lote (no se repite fila por fila). */}
+ {groupByItem(result.Warehouses).map((group) => (
+
+ {/* Un mismo número de lote (DistNumber) puede repetirse en más de un
+ artículo en SAP: se agrupa por Artículo y ese dato va en el
+ mismo encabezado que el Lote (no se repite fila por fila). El
+ Almacén no se repite por fila porque acá siempre es el mismo:
+ el elegido arriba en "Almacén de origen". */}
+ {groupByItem(warehouseRows).map((group) => (
+