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

+
+ + {loadError && ( +
+ {loadError} +
+ )} + + {loading && ( +
Cargando solicitudes...
+ )} + + {!loading && !loadError && requests.length === 0 && ( +
+ No hay Solicitudes de Transferencia abiertas pendientes de confirmar liberación. +
+ )} + + {!loading && requests.length > 0 && ( +
+ {requests.map((req) => { + const isSelected = req.DocEntry === selectedDocEntry; + return ( +
setSelectedDocEntry(req.DocEntry)} + className={`p-3 border cursor-pointer ${ + isSelected + ? "border-[var(--color-primary)] bg-[var(--color-primary-active)]" + : "border-[var(--color-border)] bg-[var(--color-bg)] hover:bg-[var(--color-sidebar-hover)]" + }`} + > +
+ setSelectedDocEntry(req.DocEntry)} + onClick={(e) => e.stopPropagation()} + /> + + + Solicitud #{req.DocNum} + + + Fecha: {formatDisplayDate(req.DocDate)} + +
+
+ + + + + + + + + + + + {lineBatchRows(req.StockTransferLines).map((row) => ( + + + + + + + + ))} + +
ArtículoDescripciónCantidadNúmero de LoteAlmacén destino
{row.ItemCode} + {itemNames[row.ItemCode] ?? row.ItemCode} + + {row.Quantity} UND + {row.BatchNumber}{row.WarehouseCode}
+
+ + {isSelected && ( +
e.stopPropagation()} + > + {resolving && ( +
+ 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. + + )} +
+
+ + + + + + + + + + {line.allocations.map((allocation) => ( + + + + + + ))} + +
CantidadAlmacén destino
+ + updateAllocationQuantity( + line.LineNum, + allocation.rowId, + e.target.value, + ) + } + className="w-24 px-2 h-[26px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs text-right" + /> + +
+ + setWarehousePickerTarget({ + lineNum: line.LineNum, + rowId: allocation.rowId, + }) + } + className="flex-1 min-w-0 h-[26px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer" + /> + +
+
+ +
+
+ +
+ ))} +
+ )} + + {confirmError && ( +
+ {confirmError} +
+ )} + {confirmSuccess && ( +
+ {confirmSuccess} +
+ )} + +
+ + {confirming ? "Actualizando..." : "Actualizar solicitud"} + +
+
+ )} +
+ ); + })} +
+ )} + + setWarehousePickerTarget(null)} + title="Buscar Almacén" + endpoint="Warehouses" + fields={["WarehouseCode", "WarehouseName"]} + columns={[ + { key: "WarehouseCode", title: "Código", width: "120px" }, + { key: "WarehouseName", title: "Nombre" }, + ]} + baseFilter="" + seleccion="unica" + onConfirmarSeleccion={handleWarehouseSeleccionado} + rowKey="WarehouseCode" + /> +
+ ); +} diff --git a/mini-frontend/src/views/inventario/InmovilizarLotesView.tsx b/mini-frontend/src/views/inventario/InmovilizarLotesView.tsx new file mode 100644 index 0000000..24b0b3f --- /dev/null +++ b/mini-frontend/src/views/inventario/InmovilizarLotesView.tsx @@ -0,0 +1,712 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { API_URL, apiFetch } from "../../utils/api"; +import { getErrorMessage } from "../../utils/errors"; +import FormattedField from "../../components/ui/FormattedField"; +import PrimaryButton from "../../components/ui/PrimaryButton"; +import SecondaryButton from "../../components/ui/SecondaryButton"; +import Icon from "../../components/ui/Icon"; +import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate"; +import ConfirmModal from "../../components/ui/ConfirmModal"; +import FieldTooltip from "../../components/ui/FieldTooltip"; +import { useSapCatalog } from "../../hooks/useSapCatalog"; +import type { + BatchStockResponse, + BatchWarehouseStock, + InventoryTransferRequestPayload, + SeriesOption, +} from "../../types/sapInventoryLock"; + +const MOTIVO_MAX = 100; +const TEXT_MAX = 254; +// Almacén destino por defecto para inmovilizar (302 - INMOVILIZADOS DRO), +// pedido explícitamente por el usuario. Es un valor fijo del frontend: no se +// resuelve contra SAP, se manda directo en el JSON de la Solicitud de +// Transferencia. El usuario puede cambiarlo igual con la lupa. +const DEFAULT_TO_WAREHOUSE = { code: "302", name: "INMOVILIZADOS DRO" }; +// Código de objeto SAP B1 (BoObjectTypes) para Solicitud de Transferencia de +// Inventario, usado para pedirle al endpoint genérico de series las suyas. +const INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE = "1250000001"; +// Valores por defecto pedidos explícitamente por el usuario para los campos +// de usuario U_SIS_MOVINV, U_EXX_TIPOOPER, U_SIS_TIPDESP y U_SIS_MOTALM. +const DEFAULT_MOVIMIENTO_INVENTARIO = "XTD"; +const DEFAULT_TIPO_OPERACION = "11"; +const DEFAULT_TIPO_DESPACHO = "N"; +const DEFAULT_MOVIMIENTO_ALMACEN = "107"; + +function today(): string { + return new Date().toISOString().split("T")[0]; +} + +interface ItemStockGroup { + ItemCode: string; + ItemDescription?: string; + rows: BatchWarehouseStock[]; +} + +// Un mismo número de lote puede repetirse en más de un artículo en SAP, así +// que el árbol se arma por Artículo (no solo por Almacén). +function groupByItem(warehouses: BatchWarehouseStock[]): ItemStockGroup[] { + const groups: ItemStockGroup[] = []; + for (const w of warehouses) { + let group = groups.find((g) => g.ItemCode === w.ItemCode); + if (!group) { + group = { ItemCode: w.ItemCode, ItemDescription: w.ItemDescription, rows: [] }; + groups.push(group); + } + group.rows.push(w); + } + return groups; +} + +// Identifica una fila de stock (Artículo + Almacén) para usarla como key de +// selección: un mismo almacén puede repetirse en distintos artículos del lote. +function rowKey(row: BatchWarehouseStock): string { + return `${row.ItemCode}::${row.WarehouseCode}`; +} + +function quantityErrorFor(row: BatchWarehouseStock, rawQuantity: string): string { + if (rawQuantity === "") return ""; + const quantityNumber = Number(rawQuantity); + if (quantityNumber <= 0) return "La cantidad debe ser mayor a 0"; + if (quantityNumber > row.Quantity) { + return `La cantidad no puede superar el stock disponible en el almacén (${row.Quantity})`; + } + return ""; +} + +export default function InmovilizarLotesView() { + const [batchInput, setBatchInput] = useState(""); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(""); + const [result, setResult] = useState(null); + + // Selección múltiple: qué filas (Artículo + Almacén) están marcadas para + // inmovilizar y qué cantidad tiene cada una, por rowKey. + const [selections, setSelections] = useState>({}); + const [docDate, setDocDate] = useState(today()); + const [toWarehouse, setToWarehouse] = useState<{ code: string; name: string } | null>(null); + const [isWarehouseModalOpen, setIsWarehouseModalOpen] = useState(false); + const [seriesOptions, setSeriesOptions] = useState([]); + const [defaultSeries, setDefaultSeries] = useState(""); + const [series, setSeries] = useState(""); + const [movimientoInventario, setMovimientoInventario] = useState(DEFAULT_MOVIMIENTO_INVENTARIO); + const [tipoOperacion, setTipoOperacion] = useState(DEFAULT_TIPO_OPERACION); + const [tipoDespacho, setTipoDespacho] = useState(DEFAULT_TIPO_DESPACHO); + const [movimientoAlmacen, setMovimientoAlmacen] = useState(DEFAULT_MOVIMIENTO_ALMACEN); + const { options: movimientoInventarioOptions } = useSapCatalog( + "UserTables/MovimientoInventario", + ); + const { options: tipoOperacionOptions } = useSapCatalog("UserTables/TipoOperacion"); + const { options: tipoDespachoOptions } = useSapCatalog("Catalogs/UdfValidValues", { + table_name: "OWTQ", + field_name: "SIS_TIPDESP", + }); + const { options: movimientoAlmacenOptions } = useSapCatalog("Catalogs/UdfValidValues", { + table_name: "OWTQ", + field_name: "SIS_MOTALM", + }); + const [motivo, setMotivo] = useState(""); + const [comentario, setComentario] = useState(""); + const [accion, setAccion] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(""); + const [success, setSuccess] = useState(""); + const [showConfirm, setShowConfirm] = useState(false); + + // Serie de la Solicitud de Transferencia: se trae la lista de series + // activas (no bloqueadas) y cuál es la default, para preseleccionarla en + // el combo sin impedir que el usuario elija otra. + useEffect(() => { + const fetchSeries = async () => { + try { + const response = await apiFetch( + `${API_URL}/api/series?document=${INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE}`, + ); + if (!response.ok) return; + const data = await response.json(); + setSeriesOptions(data.Options ?? []); + if (data.Default != null) { + setDefaultSeries(data.Default); + setSeries(data.Default); + } + } catch { + /* sin series cargadas, el usuario no podrá inmovilizar hasta reintentar */ + } + }; + fetchSeries(); + }, []); + + const resetPanel = () => { + setSelections({}); + setDocDate(today()); + setToWarehouse(DEFAULT_TO_WAREHOUSE); + setSeries(defaultSeries); + setMovimientoInventario(DEFAULT_MOVIMIENTO_INVENTARIO); + setTipoOperacion(DEFAULT_TIPO_OPERACION); + setTipoDespacho(DEFAULT_TIPO_DESPACHO); + setMovimientoAlmacen(DEFAULT_MOVIMIENTO_ALMACEN); + setMotivo(""); + setComentario(""); + setAccion(""); + setFormError(""); + setSuccess(""); + }; + + const handleBuscar = async () => { + const batch = batchInput.trim(); + if (!batch) return; + setSearching(true); + setSearchError(""); + setResult(null); + resetPanel(); + try { + const response = await apiFetch( + `${API_URL}/api/batch/stock?batch=${encodeURIComponent(batch)}`, + ); + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.detail || "No se encontró stock para el lote indicado"); + } + const data: BatchStockResponse = await response.json(); + setResult(data); + } catch (err: unknown) { + setSearchError(getErrorMessage(err, "Error al buscar el lote")); + } finally { + setSearching(false); + } + }; + + const toggleRow = (row: BatchWarehouseStock) => { + const key = rowKey(row); + setSelections((prev) => { + if (key in prev) { + const { [key]: _removed, ...rest } = prev; + return rest; + } + // Al marcar la fila, se precarga con el stock disponible (el usuario + // puede editarlo después, pero no superarlo). + return { ...prev, [key]: String(row.Quantity) }; + }); + }; + + const handleQuantityChange = (row: BatchWarehouseStock, value: string) => { + const numericValue = Number(value); + const clampedValue = + value !== "" && !Number.isNaN(numericValue) && numericValue > row.Quantity + ? String(row.Quantity) + : value; + setSelections((prev) => ({ ...prev, [rowKey(row)]: clampedValue })); + }; + + const handleWarehouseSeleccionado = (rows: any[]) => { + if (rows.length === 0) return; + setToWarehouse({ code: rows[0].WarehouseCode, name: rows[0].WarehouseName }); + }; + + const selectedRows = result + ? result.Warehouses.filter((w) => rowKey(w) in selections) + : []; + + const warehouseError = + toWarehouse && selectedRows.some((row) => row.WarehouseCode === toWarehouse.code) + ? "El almacén destino debe ser distinto al almacén de origen" + : ""; + + const allQuantitiesValid = selectedRows.every((row) => { + const rawQuantity = selections[rowKey(row)] ?? ""; + return rawQuantity !== "" && !quantityErrorFor(row, rawQuantity); + }); + + const canSubmit = + selectedRows.length > 0 && + allQuantitiesValid && + !!toWarehouse && + !warehouseError && + !!docDate && + series !== "" && + movimientoInventario !== "" && + tipoOperacion !== "" && + tipoDespacho !== "" && + movimientoAlmacen !== "" && + motivo.trim() !== "" && + comentario.trim() !== "" && + accion.trim() !== ""; + + // El submit del form solo abre la confirmación; el POST real (que crea un + // documento en SAP) ocurre recién si el usuario confirma en el modal. + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return; + setShowConfirm(true); + }; + + const handleConfirmLock = async () => { + setShowConfirm(false); + if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return; + setSubmitting(true); + setFormError(""); + try { + // El JSON que espera SAP se arma acá, no en el backend: el backend + // solo reenvía este payload tal cual a Service Layer y es SAP quien + // rechaza el documento si no hay stock suficiente. + const payload: InventoryTransferRequestPayload = { + DocDate: docDate, + Series: Number(series), + U_SIS_MOVINV: movimientoInventario, + U_EXX_TIPOOPER: tipoOperacion, + U_SIS_TIPDESP: tipoDespacho, + U_SIS_MOTALM: movimientoAlmacen, + U_SIS_MOTEST: motivo, + U_SIS_COMEST: comentario, + U_SIS_ACCTOM: accion, + StockTransferLines: selectedRows.map((row) => { + const quantity = Number(selections[rowKey(row)]); + return { + ItemCode: row.ItemCode, + Quantity: quantity, + WarehouseCode: toWarehouse.code, + FromWarehouseCode: row.WarehouseCode, + BatchNumbers: [{ BatchNumber: result.Batch, Quantity: quantity }], + }; + }), + }; + + const response = await apiFetch(`${API_URL}/api/InventoryTransferRequests`, { + method: "POST", + body: JSON.stringify(payload), + }); + if (!response.ok) { + // Error: se queda en el panel, con todo lo ya completado, para que + // el usuario pueda corregir y reintentar sin volver a escribir todo. + const data = await response.json().catch(() => null); + throw new Error(data?.detail || "Error al generar la Solicitud de Transferencia"); + } + const data = await response.json(); + const docNumber = data.DocEntry; + // Éxito: se vuelve a la pantalla inicial de búsqueda (no al mismo + // lote) y el mensaje de éxito queda visible arriba, ya que el panel + // donde estaba antes desaparece junto con la búsqueda. + setBatchInput(""); + setResult(null); + resetPanel(); + setSuccess(`Solicitud de Transferencia generada en SAP B1 (Doc. #${docNumber})`); + } catch (err: unknown) { + setFormError(getErrorMessage(err, "Error al generar la Solicitud de Transferencia")); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ + Inmovilizar Lotes +

+

+ Consulta de stock por lote y almacén, y generación de Solicitud de Transferencia en SAP Business One +

+
+ +
+ +
+ setBatchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleBuscar(); + }} + placeholder="Ingrese el número de lote" + className="flex-1 max-w-sm px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs" + /> + + {searching ? "Buscando..." : "Buscar"} + +
+
+ + {success && ( +
+ {success} +
+ )} + + {searchError && ( +
+ {searchError} +
+ )} + + {result && ( +
+ {/* 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) => ( +
+
+ + + Lote {result.Batch} · {group.ItemCode} - {group.ItemDescription} + + + Stock total: {group.rows.reduce((acc, w) => acc + w.Quantity, 0)} + +
+
+ + + + + + + + + + + + + {group.rows.map((w) => { + const key = rowKey(w); + const isChecked = key in selections; + const rawQuantity = selections[key] ?? ""; + const rowQuantityError = isChecked ? quantityErrorFor(w, rawQuantity) : ""; + return ( + toggleRow(w)} + className={`cursor-pointer border-b border-[var(--color-border-light)] ${ + isChecked + ? "bg-[var(--color-primary-active)]" + : "hover:bg-[var(--color-sidebar-hover)]" + }`} + > + + + + + + + + ); + })} + +
AlmacénCantidadCantidad a inmovilizarComprometidoContado
e.stopPropagation()}> + toggleRow(w)} + /> + + {w.WarehouseCode} - {w.WarehouseName} + {w.Quantity} UND e.stopPropagation()} + > + handleQuantityChange(w, e.target.value)} + placeholder="Cantidad" + className="w-24 px-2 h-[26px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs text-right disabled:bg-[var(--color-disabled-bg)] disabled:cursor-not-allowed" + /> + {rowQuantityError && ( +
+ {rowQuantityError} +
+ )} +
+ {w.CommitQty} UND + {w.CountQty} UND
+
+
+ ))} +
+ )} + + {selectedRows.length > 0 && ( +
+
+ + Inmovilizar Lote {result?.Batch} · {selectedRows.length} almacén(es) seleccionado(s) + +
    + {selectedRows.map((row) => ( +
  • + {row.ItemCode} - {row.ItemDescription} · Almacén origen: {row.WarehouseCode} -{" "} + {row.WarehouseName} · Cantidad a inmovilizar:{" "} + {selections[rowKey(row)] || "-"} UND (stock disponible: {row.Quantity} UND) +
  • + ))} +
+
+ + {formError && ( +
+ {formError} +
+ )} + +
+ setDocDate(e.target.value)} + /> +
+ + +
+
+ +
+ setIsWarehouseModalOpen(true)} + className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer" + /> + +
+ {warehouseError && ( +
{warehouseError}
+ )} +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

+ Datos de la Solicitud de Transferencia (SAP B1) +

+
+ setMotivo(e.target.value)} + /> +
+ {motivo.length} / {MOTIVO_MAX} +
+
+
+ setComentario(e.target.value)} + /> +
+ {comentario.length} / {TEXT_MAX} +
+
+
+ setAccion(e.target.value)} + /> +
+ {accion.length} / {TEXT_MAX} +
+
+
+ +
+ + Cancelar + + + {submitting ? "Procesando..." : "Inmovilizar"} + +
+
+ )} + + setIsWarehouseModalOpen(false)} + title="Buscar Almacén" + endpoint="Warehouses" + fields={["WarehouseCode", "WarehouseName"]} + columns={[ + { key: "WarehouseCode", title: "Código", width: "120px" }, + { key: "WarehouseName", title: "Nombre" }, + ]} + baseFilter="" + seleccion="unica" + onConfirmarSeleccion={handleWarehouseSeleccionado} + rowKey="WarehouseCode" + /> + + setShowConfirm(false)} + message={ + selectedRows.length > 0 && + toWarehouse && ( +
+

¿Confirma que desea generar la Solicitud de Transferencia en SAP B1?

+
    +
  • Lote: {result?.Batch}
  • +
  • + Almacén destino: {toWarehouse.code} - {toWarehouse.name} +
  • + {selectedRows.map((row) => ( +
  • + {row.ItemCode} - {row.ItemDescription} · Origen: {row.WarehouseCode} -{" "} + {row.WarehouseName} · Cantidad: {selections[rowKey(row)]} UND +
  • + ))} +
+
+ ) + } + /> +
+ ); +} diff --git a/mini-frontend/src/views/inventario/LiberarLotesView.tsx b/mini-frontend/src/views/inventario/LiberarLotesView.tsx new file mode 100644 index 0000000..f34ca28 --- /dev/null +++ b/mini-frontend/src/views/inventario/LiberarLotesView.tsx @@ -0,0 +1,773 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { API_URL, apiFetch } from "../../utils/api"; +import { getErrorMessage } from "../../utils/errors"; +import FormattedField from "../../components/ui/FormattedField"; +import PrimaryButton from "../../components/ui/PrimaryButton"; +import SecondaryButton from "../../components/ui/SecondaryButton"; +import Icon from "../../components/ui/Icon"; +import ModalSearchTemplate from "../../components/ui/ModalSearchTemplate"; +import ConfirmModal from "../../components/ui/ConfirmModal"; +import FieldTooltip from "../../components/ui/FieldTooltip"; +import { useSapCatalog } from "../../hooks/useSapCatalog"; +import type { + BatchStockResponse, + BatchWarehouseStock, + InventoryTransferRequestPayload, + SeriesOption, +} from "../../types/sapInventoryLock"; + +const MOTIVO_MAX = 100; +const TEXT_MAX = 254; +// Almacén de origen por defecto para liberar (302 - INMOVILIZADOS DRO): es el +// almacén donde "Inmovilizar Lotes" deja el stock, así que es el punto de +// partida natural acá. El usuario puede cambiarlo igual con la lupa. +const DEFAULT_FROM_WAREHOUSE = { code: "302", name: "INMOVILIZADOS DRO" }; +// Código de objeto SAP B1 (BoObjectTypes) para Solicitud de Transferencia de +// Inventario, usado para pedirle al endpoint genérico de series las suyas. +const INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE = "1250000001"; +// Mismos valores por defecto que "Inmovilizar Lotes" para estos campos de +// usuario, confirmados explícitamente por el usuario para "Liberar Lotes". +const DEFAULT_MOVIMIENTO_INVENTARIO = "XTD"; +const DEFAULT_TIPO_OPERACION = "11"; +const DEFAULT_TIPO_DESPACHO = "N"; +const DEFAULT_MOVIMIENTO_ALMACEN = "108"; +const DEFAULT_TO_WAREHOUSE = { code: "201", name: "APROBADOS DRO" }; + +function today(): string { + return new Date().toISOString().split("T")[0]; +} + +interface ItemStockGroup { + ItemCode: string; + ItemDescription?: string; + rows: BatchWarehouseStock[]; +} + +// Un mismo número de lote puede repetirse en más de un artículo en SAP, así +// que el árbol se arma por Artículo (no solo por Almacén). +function groupByItem(warehouses: BatchWarehouseStock[]): ItemStockGroup[] { + const groups: ItemStockGroup[] = []; + for (const w of warehouses) { + let group = groups.find((g) => g.ItemCode === w.ItemCode); + if (!group) { + group = { ItemCode: w.ItemCode, ItemDescription: w.ItemDescription, rows: [] }; + groups.push(group); + } + group.rows.push(w); + } + return groups; +} + +// Identifica una fila de stock (Artículo + Almacén) para usarla como key de +// selección: un mismo almacén puede repetirse en distintos artículos del lote. +function rowKey(row: BatchWarehouseStock): string { + return `${row.ItemCode}::${row.WarehouseCode}`; +} + +function quantityErrorFor(row: BatchWarehouseStock, rawQuantity: string): string { + if (rawQuantity === "") return ""; + const quantityNumber = Number(rawQuantity); + if (quantityNumber <= 0) return "La cantidad debe ser mayor a 0"; + if (quantityNumber > row.Quantity) { + return `La cantidad no puede superar el stock disponible en el almacén (${row.Quantity})`; + } + return ""; +} + +export default function LiberarLotesView() { + const [fromWarehouse, setFromWarehouse] = useState(DEFAULT_FROM_WAREHOUSE); + const [isFromWarehouseModalOpen, setIsFromWarehouseModalOpen] = useState(false); + const [batchInput, setBatchInput] = useState(""); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(""); + const [result, setResult] = useState(null); + + // Selección múltiple: qué filas (Artículo + Almacén) están marcadas para + // liberar y qué cantidad tiene cada una, por rowKey. + const [selections, setSelections] = useState>({}); + const [docDate, setDocDate] = useState(today()); + const [toWarehouse, setToWarehouse] = useState(DEFAULT_TO_WAREHOUSE); + const [isToWarehouseModalOpen, setIsToWarehouseModalOpen] = useState(false); + const [seriesOptions, setSeriesOptions] = useState([]); + const [defaultSeries, setDefaultSeries] = useState(""); + const [series, setSeries] = useState(""); + const [movimientoInventario, setMovimientoInventario] = useState(DEFAULT_MOVIMIENTO_INVENTARIO); + const [tipoOperacion, setTipoOperacion] = useState(DEFAULT_TIPO_OPERACION); + const [tipoDespacho, setTipoDespacho] = useState(DEFAULT_TIPO_DESPACHO); + const [movimientoAlmacen, setMovimientoAlmacen] = useState(DEFAULT_MOVIMIENTO_ALMACEN); + const { options: movimientoInventarioOptions } = useSapCatalog( + "UserTables/MovimientoInventario", + ); + const { options: tipoOperacionOptions } = useSapCatalog("UserTables/TipoOperacion"); + const { options: tipoDespachoOptions } = useSapCatalog("Catalogs/UdfValidValues", { + table_name: "OWTQ", + field_name: "SIS_TIPDESP", + }); + const { options: movimientoAlmacenOptions } = useSapCatalog("Catalogs/UdfValidValues", { + table_name: "OWTQ", + field_name: "SIS_MOTALM", + }); + const [motivo, setMotivo] = useState(""); + const [comentario, setComentario] = useState(""); + const [accion, setAccion] = useState(""); + + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(""); + const [success, setSuccess] = useState(""); + const [showConfirm, setShowConfirm] = useState(false); + + // Serie de la Solicitud de Transferencia: se trae la lista de series + // activas (no bloqueadas) y cuál es la default, para preseleccionarla en + // el combo sin impedir que el usuario elija otra. + useEffect(() => { + const fetchSeries = async () => { + try { + const response = await apiFetch( + `${API_URL}/api/series?document=${INVENTORY_TRANSFER_REQUEST_DOCUMENT_TYPE}`, + ); + if (!response.ok) return; + const data = await response.json(); + setSeriesOptions(data.Options ?? []); + if (data.Default != null) { + setDefaultSeries(data.Default); + setSeries(data.Default); + } + } catch { + /* sin series cargadas, el usuario no podrá liberar hasta reintentar */ + } + }; + fetchSeries(); + }, []); + + const resetPanel = () => { + setSelections({}); + setDocDate(today()); + setToWarehouse(DEFAULT_TO_WAREHOUSE); + setSeries(defaultSeries); + setMovimientoInventario(DEFAULT_MOVIMIENTO_INVENTARIO); + setTipoOperacion(DEFAULT_TIPO_OPERACION); + setTipoDespacho(DEFAULT_TIPO_DESPACHO); + setMovimientoAlmacen(DEFAULT_MOVIMIENTO_ALMACEN); + setMotivo(""); + setComentario(""); + setAccion(""); + setFormError(""); + setSuccess(""); + }; + + const handleBuscar = async () => { + const batch = batchInput.trim(); + if (!batch) return; + setSearching(true); + setSearchError(""); + setResult(null); + resetPanel(); + try { + const response = await apiFetch( + `${API_URL}/api/batch/stock?batch=${encodeURIComponent(batch)}`, + ); + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.detail || "No se encontró stock para el lote indicado"); + } + const data: BatchStockResponse = await response.json(); + const hasStockInWarehouse = data.Warehouses.some( + (w) => w.WarehouseCode === fromWarehouse.code, + ); + if (!hasStockInWarehouse) { + throw new Error( + `El lote '${batch}' no tiene stock en el almacén ${fromWarehouse.code} - ${fromWarehouse.name}`, + ); + } + setResult(data); + } catch (err: unknown) { + setSearchError(getErrorMessage(err, "Error al buscar el lote")); + } finally { + setSearching(false); + } + }; + + const toggleRow = (row: BatchWarehouseStock) => { + const key = rowKey(row); + setSelections((prev) => { + if (key in prev) { + const { [key]: _removed, ...rest } = prev; + return rest; + } + // Al marcar la fila, se precarga con el stock disponible (el usuario + // puede editarlo después, pero no superarlo). + return { ...prev, [key]: String(row.Quantity) }; + }); + }; + + const handleQuantityChange = (row: BatchWarehouseStock, value: string) => { + const numericValue = Number(value); + const clampedValue = + value !== "" && !Number.isNaN(numericValue) && numericValue > row.Quantity + ? String(row.Quantity) + : value; + setSelections((prev) => ({ ...prev, [rowKey(row)]: clampedValue })); + }; + + const handleFromWarehouseSeleccionado = (rows: any[]) => { + if (rows.length === 0) return; + setFromWarehouse({ code: rows[0].WarehouseCode, name: rows[0].WarehouseName }); + }; + + const handleToWarehouseSeleccionado = (rows: any[]) => { + if (rows.length === 0) return; + setToWarehouse({ code: rows[0].WarehouseCode, name: rows[0].WarehouseName }); + }; + + // El resultado de la búsqueda trae stock de todos los almacenes del lote; + // acá se muestra solo el del almacén de origen elegido. Si el usuario + // cambia el almacén de origen después de buscar, esto se recalcula sin + // necesidad de volver a pedirle al backend. + const warehouseRows = result + ? result.Warehouses.filter((w) => w.WarehouseCode === fromWarehouse.code) + : []; + + const selectedRows = warehouseRows.filter((w) => rowKey(w) in selections); + + const warehouseError = + toWarehouse && toWarehouse.code === fromWarehouse.code + ? "El almacén destino debe ser distinto al almacén de origen" + : ""; + + const allQuantitiesValid = selectedRows.every((row) => { + const rawQuantity = selections[rowKey(row)] ?? ""; + return rawQuantity !== "" && !quantityErrorFor(row, rawQuantity); + }); + + const canSubmit = + selectedRows.length > 0 && + allQuantitiesValid && + !!toWarehouse && + !warehouseError && + !!docDate && + series !== "" && + movimientoInventario !== "" && + tipoOperacion !== "" && + tipoDespacho !== "" && + movimientoAlmacen !== "" && + motivo.trim() !== ""; + + // El submit del form solo abre la confirmación; el POST real (que crea un + // documento en SAP) ocurre recién si el usuario confirma en el modal. + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return; + setShowConfirm(true); + }; + + const handleConfirmRelease = async () => { + setShowConfirm(false); + if (selectedRows.length === 0 || !toWarehouse || !result || !canSubmit) return; + setSubmitting(true); + setFormError(""); + try { + // El JSON que espera SAP se arma acá, no en el backend: el backend + // solo reenvía este payload tal cual a Service Layer y es SAP quien + // rechaza el documento si no hay stock suficiente. + const payload: InventoryTransferRequestPayload = { + DocDate: docDate, + Series: Number(series), + U_SIS_MOVINV: movimientoInventario, + U_EXX_TIPOOPER: tipoOperacion, + U_SIS_TIPDESP: tipoDespacho, + U_SIS_MOTALM: movimientoAlmacen, + U_SIS_MOTEST: motivo, + U_SIS_COMEST: comentario, + U_SIS_ACCTOM: accion, + StockTransferLines: selectedRows.map((row) => { + const quantity = Number(selections[rowKey(row)]); + return { + ItemCode: row.ItemCode, + Quantity: quantity, + WarehouseCode: toWarehouse.code, + FromWarehouseCode: fromWarehouse.code, + BatchNumbers: [{ BatchNumber: result.Batch, Quantity: quantity }], + }; + }), + }; + + const response = await apiFetch(`${API_URL}/api/InventoryTransferRequests`, { + method: "POST", + body: JSON.stringify(payload), + }); + if (!response.ok) { + // Error: se queda en el panel, con todo lo ya completado, para que + // el usuario pueda corregir y reintentar sin volver a escribir todo. + const data = await response.json().catch(() => null); + throw new Error(data?.detail || "Error al generar la Solicitud de Transferencia"); + } + const data = await response.json(); + const docNumber = data.DocEntry; + // Éxito: se vuelve a la pantalla inicial de búsqueda (no al mismo + // lote) y el mensaje de éxito queda visible arriba, ya que el panel + // donde estaba antes desaparece junto con la búsqueda. + setBatchInput(""); + setResult(null); + resetPanel(); + setSuccess(`Solicitud de Transferencia generada en SAP B1 (Doc. #${docNumber})`); + } catch (err: unknown) { + setFormError(getErrorMessage(err, "Error al generar la Solicitud de Transferencia")); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ + Liberar Lotes +

+

+ Consulta de stock inmovilizado por lote en un almacén, y generación de Solicitud de + Transferencia en SAP Business One +

+
+ +
+
+
+ +
+ setIsFromWarehouseModalOpen(true)} + className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer" + /> + +
+
+
+ +
+ setBatchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleBuscar(); + }} + placeholder="Ingrese el número de lote" + className="flex-1 max-w-sm px-2 h-[28px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs" + /> + + {searching ? "Buscando..." : "Buscar"} + +
+
+
+
+ + {success && ( +
+ {success} +
+ )} + + {searchError && ( +
+ {searchError} +
+ )} + + {result && ( +
+ {/* 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) => ( +
+
+ + + Lote {result.Batch} · {group.ItemCode} - {group.ItemDescription} · Almacén{" "} + {fromWarehouse.code} - {fromWarehouse.name} + + + Stock total: {group.rows.reduce((acc, w) => acc + w.Quantity, 0)} + +
+
+ + + + + + + + + + + + {group.rows.map((w) => { + const key = rowKey(w); + const isChecked = key in selections; + const rawQuantity = selections[key] ?? ""; + const rowQuantityError = isChecked ? quantityErrorFor(w, rawQuantity) : ""; + return ( + toggleRow(w)} + className={`cursor-pointer border-b border-[var(--color-border-light)] ${ + isChecked + ? "bg-[var(--color-primary-active)]" + : "hover:bg-[var(--color-sidebar-hover)]" + }`} + > + + + + + + + ); + })} + +
CantidadCantidad a liberarComprometidoContado
e.stopPropagation()}> + toggleRow(w)} + /> + {w.Quantity} UND e.stopPropagation()} + > + handleQuantityChange(w, e.target.value)} + placeholder="Cantidad" + className="w-24 px-2 h-[26px] border border-[var(--color-input-border)] bg-[var(--color-surface)] rounded-none outline-none focus:border-[var(--color-input-focus)] text-xs text-right disabled:bg-[var(--color-disabled-bg)] disabled:cursor-not-allowed" + /> + {rowQuantityError && ( +
+ {rowQuantityError} +
+ )} +
+ {w.CommitQty} UND + {w.CountQty} UND
+
+
+ ))} +
+ )} + + {selectedRows.length > 0 && ( +
+
+ + Liberar Lote {result?.Batch} · {selectedRows.length} artículo(s) seleccionado(s) + +
    + {selectedRows.map((row) => ( +
  • + {row.ItemCode} - {row.ItemDescription} · Cantidad a liberar:{" "} + {selections[rowKey(row)] || "-"} UND (stock disponible: {row.Quantity} UND) +
  • + ))} +
+
+ + {formError && ( +
+ {formError} +
+ )} + +
+ setDocDate(e.target.value)} + /> +
+ + +
+
+ +
+ setIsToWarehouseModalOpen(true)} + className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer" + /> + +
+ {warehouseError && ( +
{warehouseError}
+ )} +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

+ Datos de la Solicitud de Transferencia (SAP B1) +

+
+ setMotivo(e.target.value)} + /> +
+ {motivo.length} / {MOTIVO_MAX} +
+
+
+ setComentario(e.target.value)} + /> +
+ {comentario.length} / {TEXT_MAX} +
+
+
+ setAccion(e.target.value)} + /> +
+ {accion.length} / {TEXT_MAX} +
+
+
+ +
+ + Cancelar + + + {submitting ? "Procesando..." : "Liberar"} + +
+
+ )} + + setIsFromWarehouseModalOpen(false)} + title="Buscar Almacén" + endpoint="Warehouses" + fields={["WarehouseCode", "WarehouseName"]} + columns={[ + { key: "WarehouseCode", title: "Código", width: "120px" }, + { key: "WarehouseName", title: "Nombre" }, + ]} + baseFilter="" + seleccion="unica" + onConfirmarSeleccion={handleFromWarehouseSeleccionado} + rowKey="WarehouseCode" + /> + + setIsToWarehouseModalOpen(false)} + title="Buscar Almacén" + endpoint="Warehouses" + fields={["WarehouseCode", "WarehouseName"]} + columns={[ + { key: "WarehouseCode", title: "Código", width: "120px" }, + { key: "WarehouseName", title: "Nombre" }, + ]} + baseFilter="" + seleccion="unica" + onConfirmarSeleccion={handleToWarehouseSeleccionado} + rowKey="WarehouseCode" + /> + + setShowConfirm(false)} + message={ + selectedRows.length > 0 && + toWarehouse && ( +
+

¿Confirma que desea generar la Solicitud de Transferencia en SAP B1?

+
    +
  • Lote: {result?.Batch}
  • +
  • + Almacén origen: {fromWarehouse.code} - {fromWarehouse.name} +
  • +
  • + Almacén destino: {toWarehouse.code} - {toWarehouse.name} +
  • + {selectedRows.map((row) => ( +
  • + {row.ItemCode} - {row.ItemDescription} · Cantidad:{" "} + {selections[rowKey(row)]} UND +
  • + ))} +
+
+ ) + } + /> +
+ ); +}