Agregar flujo de inmovilización/liberación de lotes (Inmovilizar, Liberar y Confirmar Liberación Lote)
- Inmovilizar Lotes: selección múltiple de almacén origen con cantidad editable, genera Solicitud de Transferencia hacia un almacén de inmovilizados. - Liberar Lotes: inversa de la anterior, con almacenes/campos de usuario propios (U_SIS_MOVINV, U_EXX_TIPOOPER, U_SIS_TIPDESP, U_SIS_MOTALM). - Confirmar Liberación Lote: lista las Solicitudes abiertas pendientes (U_SIS_MOTALM=108) y, por cada lote, resuelve contra el historial de transferencias en HANA a qué almacén(es) debe volver, repartiendo la cantidad entre orígenes; permite editar cantidad, almacén destino, y agregar/quitar líneas antes de actualizar la Solicitud en SAP. - Backend: nuevo endpoint /api/batch/lot-source-warehouse (HANA) para resolver el origen histórico de un lote; endpoint genérico /api/Catalogs/UdfValidValues (Service Layer) para valores válidos de un UDF; PATCH genérico ahora reenvía cualquier header B1S-* que mande el frontend en vez de decidirlos en el backend. - Registro de rutas, menú y permisos para las tres pantallas nuevas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d900b62f33
commit
7e0a646853
19 files changed
+2830
-5
No files matched your search
@@ -113,11 +113,21 @@ def make_crud_router(
|
|||||||
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
|
@router.patch(f"/api/{resource}/{{key}}", status_code=204, name=f"update_{resource}")
|
||||||
async def update_resource(key: str, request: Request):
|
async def update_resource(key: str, request: Request):
|
||||||
data = await request.json()
|
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(
|
forward_to_sapb1(
|
||||||
method="PATCH",
|
method="PATCH",
|
||||||
endpoint_path=key_expr(key),
|
endpoint_path=key_expr(key),
|
||||||
json_data=data,
|
json_data=data,
|
||||||
extra_headers=patch_headers,
|
extra_headers=headers or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -22,6 +22,9 @@ from routers.batch import router as batch_router
|
|||||||
from routers.permissions import router as permissions_router
|
from routers.permissions import router as permissions_router
|
||||||
from routers.apigateway import router as apigateway_router
|
from routers.apigateway import router as apigateway_router
|
||||||
from routers.reports import router as reports_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")
|
load_dotenv(dotenv_path=".env")
|
||||||
@@ -54,6 +57,9 @@ app.include_router(batch_router)
|
|||||||
app.include_router(permissions_router)
|
app.include_router(permissions_router)
|
||||||
app.include_router(apigateway_router)
|
app.include_router(apigateway_router)
|
||||||
app.include_router(reports_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")
|
@app.middleware("http")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"/inventario/*"
|
"/inventario/*"
|
||||||
],
|
],
|
||||||
"jose.huayta@tagumedica.com": [
|
"jose.huayta@tagumedica.com": [
|
||||||
"/inventario/articulos",
|
"/inventario/*",
|
||||||
"/ventas/*",
|
"/ventas/*",
|
||||||
"/compras/*"
|
"/compras/*"
|
||||||
],
|
],
|
||||||
@@ -16,10 +16,16 @@
|
|||||||
"renato.bustamante@tagumedica.com": [
|
"renato.bustamante@tagumedica.com": [
|
||||||
"/inventario/articulos",
|
"/inventario/articulos",
|
||||||
"/inventario/lotes",
|
"/inventario/lotes",
|
||||||
|
"/inventario/inmovilizar-lotes",
|
||||||
|
"/inventario/liberar-lotes",
|
||||||
|
"/inventario/confirmar-liberacion-lote",
|
||||||
"/ventas/*",
|
"/ventas/*",
|
||||||
"/compras/*"
|
"/compras/*"
|
||||||
],
|
],
|
||||||
"vania.barrios@tagumedica.com":[
|
"vania.barrios@tagumedica.com":[
|
||||||
"/inventario/lotes"
|
"/inventario/lotes",
|
||||||
|
"/inventario/inmovilizar-lotes",
|
||||||
|
"/inventario/liberar-lotes",
|
||||||
|
"/inventario/confirmar-liberacion-lote"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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.crud_router import make_crud_router
|
||||||
|
from core.hana import hana_connection, HANA_SCHEMA
|
||||||
|
|
||||||
router = make_crud_router(
|
router = make_crud_router(
|
||||||
"BatchNumberDetails",
|
"BatchNumberDetails",
|
||||||
@@ -6,3 +14,231 @@ router = make_crud_router(
|
|||||||
supports_orderby=True,
|
supports_orderby=True,
|
||||||
supports_create=False,
|
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
|
||||||
@@ -58,6 +58,49 @@ async def get_sunat_businesspartners_document_type():
|
|||||||
extra_headers=base_headers
|
extra_headers=base_headers
|
||||||
).json()
|
).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")
|
@router.get("/api/ItemGroups")
|
||||||
async def get_item_groups():
|
async def get_item_groups():
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -33,7 +33,7 @@ Content-Type: application/json
|
|||||||
|
|
||||||
###
|
###
|
||||||
# @noRejectUnauthorized
|
# @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
|
Content-Type: application/json
|
||||||
|
|
||||||
###
|
###
|
||||||
@@ -79,4 +79,14 @@ Content-Type: application/json
|
|||||||
###
|
###
|
||||||
# @noRejectUnauthorized
|
# @noRejectUnauthorized
|
||||||
GET {{url}}/BatchNumberDetails
|
GET {{url}}/BatchNumberDetails
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
|
|
||||||
|
###
|
||||||
|
# @noRejectUnauthorized
|
||||||
|
POST {{url}}/SeriesService_GetSeries
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"SeriesParams": {}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,6 +24,15 @@ const BatchNumberDetailsList = lazy(
|
|||||||
const BatchNumberDetails = lazy(
|
const BatchNumberDetails = lazy(
|
||||||
() => import("./views/inventario/BatchNumberDetailsView"),
|
() => 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
|
// 2. Socios de Negocios
|
||||||
const CustomersList = lazy(() => import("./views/socios/CustomersList"));
|
const CustomersList = lazy(() => import("./views/socios/CustomersList"));
|
||||||
@@ -131,6 +140,18 @@ export default function App() {
|
|||||||
path="inventario/lotes/mantenimiento"
|
path="inventario/lotes/mantenimiento"
|
||||||
element={<BatchNumberDetails />}
|
element={<BatchNumberDetails />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="inventario/inmovilizar-lotes"
|
||||||
|
element={<InmovilizarLotes />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="inventario/liberar-lotes"
|
||||||
|
element={<LiberarLotes />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="inventario/confirmar-liberacion-lote"
|
||||||
|
element={<ConfirmarLiberacionLote />}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 2. Socios de Negocios */}
|
{/* 2. Socios de Negocios */}
|
||||||
<Route path="socios/clientes" element={<CustomersList />} />
|
<Route path="socios/clientes" element={<CustomersList />} />
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 antialiased font-sans text-xs text-[var(--color-text)]">
|
||||||
|
<div className="bg-[var(--color-surface)] w-full max-w-md border border-[var(--color-border)] flex flex-col">
|
||||||
|
<div className="h-10 bg-[var(--color-header-bg)] text-white flex items-center px-3 select-none shrink-0">
|
||||||
|
<span className="font-semibold">{title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4">{message}</div>
|
||||||
|
|
||||||
|
<div className="p-2 border-t border-[var(--color-border-light)] bg-[var(--color-bg)] flex justify-end gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-1.5 bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-sidebar-title)] rounded-none cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
autoFocus
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-5 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-none border-none cursor-pointer text-xs"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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/<endpoint>` (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<string, string>,
|
||||||
|
): {
|
||||||
|
options: CodeNameOption[];
|
||||||
|
loading: boolean;
|
||||||
|
} {
|
||||||
|
const [options, setOptions] = useState<CodeNameOption[]>([]);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ const menuConfig: MenuItem[] = [
|
|||||||
subItems: [
|
subItems: [
|
||||||
{ name: "Articulos", path: "/inventario/articulos" },
|
{ name: "Articulos", path: "/inventario/articulos" },
|
||||||
{ name: "Lotes", path: "/inventario/lotes" },
|
{ 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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<InventoryTransferRequestSummary[]>([]);
|
||||||
|
const [itemNames, setItemNames] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
const [selectedDocEntry, setSelectedDocEntry] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [resolveError, setResolveError] = useState("");
|
||||||
|
const [draftLines, setDraftLines] = useState<DraftLine[] | null>(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 (
|
||||||
|
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)] overflow-auto">
|
||||||
|
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2">
|
||||||
|
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)] flex items-center gap-2">
|
||||||
|
<Icon name="clipboard" className="w-4 h-4" />
|
||||||
|
Confirmar Liberación Lote
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Solicitudes de Transferencia abiertas generadas por "Liberar Lotes", de la más reciente
|
||||||
|
a la más antigua. Seleccione una para continuar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="mb-4 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-[var(--color-text-muted)]">Cargando solicitudes...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !loadError && requests.length === 0 && (
|
||||||
|
<div className="text-[var(--color-text-muted)]">
|
||||||
|
No hay Solicitudes de Transferencia abiertas pendientes de confirmar liberación.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && requests.length > 0 && (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{requests.map((req) => {
|
||||||
|
const isSelected = req.DocEntry === selectedDocEntry;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={req.DocEntry}
|
||||||
|
onClick={() => 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)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="solicitud"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => setSelectedDocEntry(req.DocEntry)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Icon name="document" className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Solicitud #{req.DocNum}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Fecha: {formatDisplayDate(req.DocDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
|
||||||
|
<th className="text-left py-1 px-2">Artículo</th>
|
||||||
|
<th className="text-left py-1 px-2">Descripción</th>
|
||||||
|
<th className="text-right py-1 px-2">Cantidad</th>
|
||||||
|
<th className="text-left py-1 px-2">Número de Lote</th>
|
||||||
|
<th className="text-left py-1 px-2">Almacén destino</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lineBatchRows(req.StockTransferLines).map((row) => (
|
||||||
|
<tr
|
||||||
|
key={row.key}
|
||||||
|
className="border-b border-[var(--color-border-light)]"
|
||||||
|
>
|
||||||
|
<td className="py-1.5 px-2">{row.ItemCode}</td>
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
{itemNames[row.ItemCode] ?? row.ItemCode}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">
|
||||||
|
{row.Quantity} UND
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2">{row.BatchNumber}</td>
|
||||||
|
<td className="py-1.5 px-2">{row.WarehouseCode}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div
|
||||||
|
className="mt-3 pt-3 border-t border-[var(--color-border-light)] space-y-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{resolving && (
|
||||||
|
<div className="text-[var(--color-text-muted)]">
|
||||||
|
Buscando en el historial de transferencias de qué almacén(es) salió cada
|
||||||
|
lote...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resolveError && (
|
||||||
|
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{resolveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{draftLines && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[var(--color-text-muted)]">
|
||||||
|
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:
|
||||||
|
</p>
|
||||||
|
{draftLines.map((line) => (
|
||||||
|
<div
|
||||||
|
key={line.LineNum}
|
||||||
|
className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2"
|
||||||
|
>
|
||||||
|
<div className="text-[11px] text-[var(--color-text-muted)] mb-1">
|
||||||
|
{line.ItemCode} - Lote {line.DistNumber || "(sin lote)"}
|
||||||
|
{line.allocations.length === 0 && (
|
||||||
|
<span className="text-[var(--color-error)]">
|
||||||
|
{" "}
|
||||||
|
· No se encontró transferencia de ingreso; agregue el almacén
|
||||||
|
destino manualmente.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
|
||||||
|
<th className="text-right py-1 px-2 w-28">Cantidad</th>
|
||||||
|
<th className="text-left py-1 px-2">Almacén destino</th>
|
||||||
|
<th className="py-1 px-2 w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{line.allocations.map((allocation) => (
|
||||||
|
<tr
|
||||||
|
key={allocation.rowId}
|
||||||
|
className="border-b border-[var(--color-border-light)]"
|
||||||
|
>
|
||||||
|
<td className="py-1.5 px-2 text-right">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={allocation.Quantity}
|
||||||
|
onChange={(e) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)] max-w-xs">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
allocation.WarehouseCode
|
||||||
|
? `${allocation.WarehouseCode}${
|
||||||
|
allocation.WarehouseName
|
||||||
|
? ` - ${allocation.WarehouseName}`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
placeholder="Seleccionar almacén..."
|
||||||
|
onClick={() =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setWarehousePickerTarget({
|
||||||
|
lineNum: line.LineNum,
|
||||||
|
rowId: allocation.rowId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-[26px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||||
|
title="Buscar almacén"
|
||||||
|
>
|
||||||
|
<Icon name="search" className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
removeAllocationRow(line.LineNum, allocation.rowId)
|
||||||
|
}
|
||||||
|
className="bg-transparent border-none cursor-pointer text-[var(--color-error)]"
|
||||||
|
title="Quitar fila"
|
||||||
|
>
|
||||||
|
<Icon name="trash" className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addAllocationRow(line.LineNum)}
|
||||||
|
className="mt-1 bg-transparent border-none cursor-pointer text-[var(--color-primary)] text-[11px]"
|
||||||
|
>
|
||||||
|
+ Agregar almacén
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmError && (
|
||||||
|
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{confirmError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confirmSuccess && (
|
||||||
|
<div className="p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
|
||||||
|
{confirmSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleConfirmApply}
|
||||||
|
size="md"
|
||||||
|
disabled={
|
||||||
|
resolving ||
|
||||||
|
confirming ||
|
||||||
|
!draftLines ||
|
||||||
|
hasIncompleteRows ||
|
||||||
|
!hasAnyAllocation
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{confirming ? "Actualizando..." : "Actualizar solicitud"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={!!warehousePickerTarget}
|
||||||
|
onClose={() => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<BatchStockResponse | null>(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<Record<string, string>>({});
|
||||||
|
const [docDate, setDocDate] = useState(today());
|
||||||
|
const [toWarehouse, setToWarehouse] = useState<{ code: string; name: string } | null>(null);
|
||||||
|
const [isWarehouseModalOpen, setIsWarehouseModalOpen] = useState(false);
|
||||||
|
const [seriesOptions, setSeriesOptions] = useState<SeriesOption[]>([]);
|
||||||
|
const [defaultSeries, setDefaultSeries] = useState<number | "">("");
|
||||||
|
const [series, setSeries] = useState<number | "">("");
|
||||||
|
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 (
|
||||||
|
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)] overflow-auto">
|
||||||
|
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2">
|
||||||
|
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)] flex items-center gap-2">
|
||||||
|
<Icon name="lock" className="w-4 h-4" />
|
||||||
|
Inmovilizar Lotes
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Consulta de stock por lote y almacén, y generación de Solicitud de Transferencia en SAP Business One
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] mb-4">
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Número de Lote
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={batchInput}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<PrimaryButton onClick={handleBuscar} disabled={searching || !batchInput.trim()}>
|
||||||
|
{searching ? "Buscando..." : "Buscar"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<div className="mb-4 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{searchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{/* 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) => (
|
||||||
|
<div
|
||||||
|
key={group.ItemCode}
|
||||||
|
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon name="box" className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Lote {result.Batch} · {group.ItemCode} - {group.ItemDescription}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)] ml-auto">
|
||||||
|
Stock total: {group.rows.reduce((acc, w) => acc + w.Quantity, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
|
||||||
|
<th className="text-left py-1 px-2 w-6"></th>
|
||||||
|
<th className="text-left py-1 px-2">Almacén</th>
|
||||||
|
<th className="text-right py-1 px-2">Cantidad</th>
|
||||||
|
<th className="text-right py-1 px-2">Cantidad a inmovilizar</th>
|
||||||
|
<th className="text-right py-1 px-2">Comprometido</th>
|
||||||
|
<th className="text-right py-1 px-2">Contado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.rows.map((w) => {
|
||||||
|
const key = rowKey(w);
|
||||||
|
const isChecked = key in selections;
|
||||||
|
const rawQuantity = selections[key] ?? "";
|
||||||
|
const rowQuantityError = isChecked ? quantityErrorFor(w, rawQuantity) : "";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
onClick={() => toggleRow(w)}
|
||||||
|
className={`cursor-pointer border-b border-[var(--color-border-light)] ${
|
||||||
|
isChecked
|
||||||
|
? "bg-[var(--color-primary-active)]"
|
||||||
|
: "hover:bg-[var(--color-sidebar-hover)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-1.5 px-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => toggleRow(w)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2">
|
||||||
|
{w.WarehouseCode} - {w.WarehouseName}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">{w.Quantity} UND</td>
|
||||||
|
<td
|
||||||
|
className="py-1.5 px-2 text-right"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={w.Quantity}
|
||||||
|
step="any"
|
||||||
|
disabled={!isChecked}
|
||||||
|
value={rawQuantity}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-[var(--color-error)]">
|
||||||
|
{rowQuantityError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">
|
||||||
|
{w.CommitQty} UND
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">{w.CountQty} UND</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRows.length > 0 && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Inmovilizar Lote {result?.Batch} · {selectedRows.length} almacén(es) seleccionado(s)
|
||||||
|
</span>
|
||||||
|
<ul className="mt-1 text-[11px] text-[var(--color-text-muted)] space-y-0.5">
|
||||||
|
{selectedRows.map((row) => (
|
||||||
|
<li key={rowKey(row)}>
|
||||||
|
{row.ItemCode} - {row.ItemDescription} · Almacén origen: {row.WarehouseCode} -{" "}
|
||||||
|
{row.WarehouseName} · Cantidad a inmovilizar:{" "}
|
||||||
|
{selections[rowKey(row)] || "-"} UND (stock disponible: {row.Quantity} UND)
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<FormattedField
|
||||||
|
type="date"
|
||||||
|
label="Fecha"
|
||||||
|
value={docDate}
|
||||||
|
mode="input"
|
||||||
|
name="DocDate"
|
||||||
|
required
|
||||||
|
onChange={(e) => setDocDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Serie <span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={series}
|
||||||
|
onChange={(e) => setSeries(e.target.value ? Number(e.target.value) : "")}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
<option value="">Seleccione serie</option>
|
||||||
|
{seriesOptions.map((s) => (
|
||||||
|
<option key={s.Series} value={s.Series}>
|
||||||
|
{s.Series} - {s.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Almacén destino <span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={toWarehouse ? `${toWarehouse.code} - ${toWarehouse.name}` : ""}
|
||||||
|
placeholder="Seleccionar almacén..."
|
||||||
|
onClick={() => setIsWarehouseModalOpen(true)}
|
||||||
|
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsWarehouseModalOpen(true)}
|
||||||
|
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||||
|
title="Buscar almacén"
|
||||||
|
>
|
||||||
|
<Icon name="search" className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{warehouseError && (
|
||||||
|
<div className="mt-1 text-[11px] text-[var(--color-error)]">{warehouseError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Movimiento de inventario <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_SIS_MOVINV" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={movimientoInventario}
|
||||||
|
onChange={(e) => setMovimientoInventario(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{movimientoInventarioOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Tipo de operación <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_EXX_TIPOOPER" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={tipoOperacion}
|
||||||
|
onChange={(e) => setTipoOperacion(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{tipoOperacionOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Tipo de despacho <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_SIS_TIPDESP" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={tipoDespacho}
|
||||||
|
onChange={(e) => setTipoDespacho(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{tipoDespachoOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Movimiento almacén <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_SIS_MOTALM" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={movimientoAlmacen}
|
||||||
|
onChange={(e) => setMovimientoAlmacen(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{movimientoAlmacenOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-[var(--color-border-light)] space-y-3">
|
||||||
|
<h2 className="text-xs font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Datos de la Solicitud de Transferencia (SAP B1)
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<FormattedField
|
||||||
|
type="text"
|
||||||
|
label="Motivo de estado"
|
||||||
|
dbField="U_SIS_MOTEST"
|
||||||
|
value={motivo}
|
||||||
|
mode="input"
|
||||||
|
name="MotivoEstado"
|
||||||
|
required
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
maxLength={MOTIVO_MAX}
|
||||||
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{motivo.length} / {MOTIVO_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormattedField
|
||||||
|
type="text"
|
||||||
|
label="Comentario de estado"
|
||||||
|
dbField="U_SIS_COMEST"
|
||||||
|
value={comentario}
|
||||||
|
mode="input"
|
||||||
|
name="ComentarioEstado"
|
||||||
|
required
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
maxLength={TEXT_MAX}
|
||||||
|
onChange={(e) => setComentario(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{comentario.length} / {TEXT_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormattedField
|
||||||
|
type="text"
|
||||||
|
label="Acción a tomar"
|
||||||
|
dbField="U_SIS_ACCTOM"
|
||||||
|
value={accion}
|
||||||
|
mode="input"
|
||||||
|
name="AccionATomar"
|
||||||
|
required
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
maxLength={TEXT_MAX}
|
||||||
|
onChange={(e) => setAccion(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{accion.length} / {TEXT_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2 border-t border-[var(--color-border-light)] space-x-2">
|
||||||
|
<SecondaryButton type="button" onClick={resetPanel}>
|
||||||
|
Cancelar
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton type="submit" size="md" disabled={!canSubmit || submitting}>
|
||||||
|
{submitting ? "Procesando..." : "Inmovilizar"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isWarehouseModalOpen}
|
||||||
|
onClose={() => 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConfirm}
|
||||||
|
title="Confirmar inmovilización"
|
||||||
|
onConfirm={handleConfirmLock}
|
||||||
|
onCancel={() => setShowConfirm(false)}
|
||||||
|
message={
|
||||||
|
selectedRows.length > 0 &&
|
||||||
|
toWarehouse && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>¿Confirma que desea generar la Solicitud de Transferencia en SAP B1?</p>
|
||||||
|
<ul className="mt-2 space-y-0.5 text-[var(--color-text-muted)]">
|
||||||
|
<li>Lote: {result?.Batch}</li>
|
||||||
|
<li>
|
||||||
|
Almacén destino: {toWarehouse.code} - {toWarehouse.name}
|
||||||
|
</li>
|
||||||
|
{selectedRows.map((row) => (
|
||||||
|
<li key={rowKey(row)}>
|
||||||
|
{row.ItemCode} - {row.ItemDescription} · Origen: {row.WarehouseCode} -{" "}
|
||||||
|
{row.WarehouseName} · Cantidad: {selections[rowKey(row)]} UND
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<BatchStockResponse | null>(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<Record<string, string>>({});
|
||||||
|
const [docDate, setDocDate] = useState(today());
|
||||||
|
const [toWarehouse, setToWarehouse] = useState(DEFAULT_TO_WAREHOUSE);
|
||||||
|
const [isToWarehouseModalOpen, setIsToWarehouseModalOpen] = useState(false);
|
||||||
|
const [seriesOptions, setSeriesOptions] = useState<SeriesOption[]>([]);
|
||||||
|
const [defaultSeries, setDefaultSeries] = useState<number | "">("");
|
||||||
|
const [series, setSeries] = useState<number | "">("");
|
||||||
|
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 (
|
||||||
|
<div className="p-4 bg-[var(--color-surface)] h-full flex flex-col font-sans select-none text-xs text-[var(--color-text)] overflow-auto">
|
||||||
|
<div className="mb-4 border-b border-[var(--color-border-light)] pb-2">
|
||||||
|
<h1 className="text-base font-semibold text-[var(--color-sidebar-title)] flex items-center gap-2">
|
||||||
|
<Icon name="check-circle" className="w-4 h-4" />
|
||||||
|
Liberar Lotes
|
||||||
|
</h1>
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Consulta de stock inmovilizado por lote en un almacén, y generación de Solicitud de
|
||||||
|
Transferencia en SAP Business One
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] mb-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-3">
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Almacén de origen
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={`${fromWarehouse.code} - ${fromWarehouse.name}`}
|
||||||
|
onClick={() => setIsFromWarehouseModalOpen(true)}
|
||||||
|
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsFromWarehouseModalOpen(true)}
|
||||||
|
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||||
|
title="Buscar almacén"
|
||||||
|
>
|
||||||
|
<Icon name="search" className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Número de Lote
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={batchInput}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<PrimaryButton onClick={handleBuscar} disabled={searching || !batchInput.trim()}>
|
||||||
|
{searching ? "Buscando..." : "Buscar"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-2 bg-[var(--color-success-bg)] border border-[var(--color-success-border)] text-[var(--color-success)]">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchError && (
|
||||||
|
<div className="mb-4 p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{searchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="space-y-3 mb-4">
|
||||||
|
{/* 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) => (
|
||||||
|
<div
|
||||||
|
key={group.ItemCode}
|
||||||
|
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon name="box" className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Lote {result.Batch} · {group.ItemCode} - {group.ItemDescription} · Almacén{" "}
|
||||||
|
{fromWarehouse.code} - {fromWarehouse.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)] ml-auto">
|
||||||
|
Stock total: {group.rows.reduce((acc, w) => acc + w.Quantity, 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-6 border-l border-[var(--color-sidebar-submenu-border)] ml-2 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] text-[var(--color-text-muted)] border-b border-[var(--color-border)]">
|
||||||
|
<th className="text-left py-1 px-2 w-6"></th>
|
||||||
|
<th className="text-right py-1 px-2">Cantidad</th>
|
||||||
|
<th className="text-right py-1 px-2">Cantidad a liberar</th>
|
||||||
|
<th className="text-right py-1 px-2">Comprometido</th>
|
||||||
|
<th className="text-right py-1 px-2">Contado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.rows.map((w) => {
|
||||||
|
const key = rowKey(w);
|
||||||
|
const isChecked = key in selections;
|
||||||
|
const rawQuantity = selections[key] ?? "";
|
||||||
|
const rowQuantityError = isChecked ? quantityErrorFor(w, rawQuantity) : "";
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={key}
|
||||||
|
onClick={() => toggleRow(w)}
|
||||||
|
className={`cursor-pointer border-b border-[var(--color-border-light)] ${
|
||||||
|
isChecked
|
||||||
|
? "bg-[var(--color-primary-active)]"
|
||||||
|
: "hover:bg-[var(--color-sidebar-hover)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-1.5 px-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => toggleRow(w)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">{w.Quantity} UND</td>
|
||||||
|
<td
|
||||||
|
className="py-1.5 px-2 text-right"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={w.Quantity}
|
||||||
|
step="any"
|
||||||
|
disabled={!isChecked}
|
||||||
|
value={rawQuantity}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-[var(--color-error)]">
|
||||||
|
{rowQuantityError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">
|
||||||
|
{w.CommitQty} UND
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 px-2 text-right font-mono">{w.CountQty} UND</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRows.length > 0 && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
className="p-3 bg-[var(--color-bg)] border border-[var(--color-border)] space-y-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Liberar Lote {result?.Batch} · {selectedRows.length} artículo(s) seleccionado(s)
|
||||||
|
</span>
|
||||||
|
<ul className="mt-1 text-[11px] text-[var(--color-text-muted)] space-y-0.5">
|
||||||
|
{selectedRows.map((row) => (
|
||||||
|
<li key={rowKey(row)}>
|
||||||
|
{row.ItemCode} - {row.ItemDescription} · Cantidad a liberar:{" "}
|
||||||
|
{selections[rowKey(row)] || "-"} UND (stock disponible: {row.Quantity} UND)
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div className="p-2 bg-[var(--color-error-bg)] border border-[var(--color-error-border)] text-[var(--color-error)]">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<FormattedField
|
||||||
|
type="date"
|
||||||
|
label="Fecha"
|
||||||
|
value={docDate}
|
||||||
|
mode="input"
|
||||||
|
name="DocDate"
|
||||||
|
required
|
||||||
|
onChange={(e) => setDocDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Serie <span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={series}
|
||||||
|
onChange={(e) => setSeries(e.target.value ? Number(e.target.value) : "")}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
<option value="">Seleccione serie</option>
|
||||||
|
{seriesOptions.map((s) => (
|
||||||
|
<option key={s.Series} value={s.Series}>
|
||||||
|
{s.Series} - {s.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Almacén destino <span className="text-[var(--color-error)]">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center border border-[var(--color-input-border)] bg-[var(--color-surface)] focus-within:border-[var(--color-input-focus)]">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={toWarehouse ? `${toWarehouse.code} - ${toWarehouse.name}` : ""}
|
||||||
|
placeholder="Seleccionar almacén..."
|
||||||
|
onClick={() => setIsToWarehouseModalOpen(true)}
|
||||||
|
className="flex-1 min-w-0 h-[28px] px-2 border-none outline-none bg-transparent text-xs cursor-pointer"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsToWarehouseModalOpen(true)}
|
||||||
|
className="h-[28px] px-1.5 bg-transparent border-none cursor-pointer text-[var(--color-primary)] shrink-0"
|
||||||
|
title="Buscar almacén"
|
||||||
|
>
|
||||||
|
<Icon name="search" className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{warehouseError && (
|
||||||
|
<div className="mt-1 text-[11px] text-[var(--color-error)]">{warehouseError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Movimiento de inventario <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_SIS_MOVINV" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={movimientoInventario}
|
||||||
|
onChange={(e) => setMovimientoInventario(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{movimientoInventarioOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Tipo de operación <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_EXX_TIPOOPER" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={tipoOperacion}
|
||||||
|
onChange={(e) => setTipoOperacion(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{tipoOperacionOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Tipo de despacho <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_SIS_TIPDESP" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={tipoDespacho}
|
||||||
|
onChange={(e) => setTipoDespacho(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{tipoDespachoOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-[var(--color-text-muted)] font-medium mb-1 text-xs">
|
||||||
|
Movimiento almacén <span className="text-[var(--color-error)]">*</span>
|
||||||
|
<FieldTooltip text="U_SIS_MOTALM" />
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={movimientoAlmacen}
|
||||||
|
onChange={(e) => setMovimientoAlmacen(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
{movimientoAlmacenOptions.map((o) => (
|
||||||
|
<option key={o.Code} value={o.Code}>
|
||||||
|
{o.Code} - {o.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-[var(--color-border-light)] space-y-3">
|
||||||
|
<h2 className="text-xs font-semibold text-[var(--color-sidebar-title)]">
|
||||||
|
Datos de la Solicitud de Transferencia (SAP B1)
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<FormattedField
|
||||||
|
type="text"
|
||||||
|
label="Motivo de estado"
|
||||||
|
dbField="U_SIS_MOTEST"
|
||||||
|
value={motivo}
|
||||||
|
mode="input"
|
||||||
|
name="MotivoEstado"
|
||||||
|
required
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
maxLength={MOTIVO_MAX}
|
||||||
|
onChange={(e) => setMotivo(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{motivo.length} / {MOTIVO_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormattedField
|
||||||
|
type="text"
|
||||||
|
label="Comentario de estado"
|
||||||
|
dbField="U_SIS_COMEST"
|
||||||
|
value={comentario}
|
||||||
|
mode="input"
|
||||||
|
name="ComentarioEstado"
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
maxLength={TEXT_MAX}
|
||||||
|
onChange={(e) => setComentario(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{comentario.length} / {TEXT_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FormattedField
|
||||||
|
type="text"
|
||||||
|
label="Acción a tomar"
|
||||||
|
dbField="U_SIS_ACCTOM"
|
||||||
|
value={accion}
|
||||||
|
mode="input"
|
||||||
|
name="AccionATomar"
|
||||||
|
multiline
|
||||||
|
rows={6}
|
||||||
|
maxLength={TEXT_MAX}
|
||||||
|
onChange={(e) => setAccion(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{accion.length} / {TEXT_MAX}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2 border-t border-[var(--color-border-light)] space-x-2">
|
||||||
|
<SecondaryButton type="button" onClick={resetPanel}>
|
||||||
|
Cancelar
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton type="submit" size="md" disabled={!canSubmit || submitting}>
|
||||||
|
{submitting ? "Procesando..." : "Liberar"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isFromWarehouseModalOpen}
|
||||||
|
onClose={() => 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModalSearchTemplate
|
||||||
|
isOpen={isToWarehouseModalOpen}
|
||||||
|
onClose={() => 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConfirm}
|
||||||
|
title="Confirmar liberación"
|
||||||
|
onConfirm={handleConfirmRelease}
|
||||||
|
onCancel={() => setShowConfirm(false)}
|
||||||
|
message={
|
||||||
|
selectedRows.length > 0 &&
|
||||||
|
toWarehouse && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>¿Confirma que desea generar la Solicitud de Transferencia en SAP B1?</p>
|
||||||
|
<ul className="mt-2 space-y-0.5 text-[var(--color-text-muted)]">
|
||||||
|
<li>Lote: {result?.Batch}</li>
|
||||||
|
<li>
|
||||||
|
Almacén origen: {fromWarehouse.code} - {fromWarehouse.name}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Almacén destino: {toWarehouse.code} - {toWarehouse.name}
|
||||||
|
</li>
|
||||||
|
{selectedRows.map((row) => (
|
||||||
|
<li key={rowKey(row)}>
|
||||||
|
{row.ItemCode} - {row.ItemDescription} · Cantidad:{" "}
|
||||||
|
{selections[rowKey(row)]} UND
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user