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}")
|
||||
async def update_resource(key: str, request: Request):
|
||||
data = await request.json()
|
||||
# Las cabeceras de Service Layer (B1S-*) las decide quien llama
|
||||
# (el frontend), no el backend: cualquier header "B1S-*" que
|
||||
# venga en el request se reenvía tal cual a SAP. Ej.:
|
||||
# B1S-ReplaceCollectionsOnPatch para reemplazar una colección
|
||||
# hija (StockTransferLines) en vez de mergearla por LineNum,
|
||||
# necesario solo cuando ese PATCH agrega o quita líneas.
|
||||
headers = dict(patch_headers or {})
|
||||
headers.update(
|
||||
{k: v for k, v in request.headers.items() if k.lower().startswith("b1s-")}
|
||||
)
|
||||
forward_to_sapb1(
|
||||
method="PATCH",
|
||||
endpoint_path=key_expr(key),
|
||||
json_data=data,
|
||||
extra_headers=patch_headers,
|
||||
extra_headers=headers or None,
|
||||
)
|
||||
|
||||
return router
|
||||
@@ -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.apigateway import router as apigateway_router
|
||||
from routers.reports import router as reports_router
|
||||
from routers.series import router as series_router
|
||||
from routers.user_tables import router as user_tables_router
|
||||
from routers.inventory_transfer_requests import router as inventory_transfer_requests_router
|
||||
|
||||
# ============================================================================
|
||||
load_dotenv(dotenv_path=".env")
|
||||
@@ -54,6 +57,9 @@ app.include_router(batch_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(apigateway_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(series_router)
|
||||
app.include_router(user_tables_router)
|
||||
app.include_router(inventory_transfer_requests_router)
|
||||
|
||||
# ============================================================================
|
||||
@app.middleware("http")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"/inventario/*"
|
||||
],
|
||||
"jose.huayta@tagumedica.com": [
|
||||
"/inventario/articulos",
|
||||
"/inventario/*",
|
||||
"/ventas/*",
|
||||
"/compras/*"
|
||||
],
|
||||
@@ -16,10 +16,16 @@
|
||||
"renato.bustamante@tagumedica.com": [
|
||||
"/inventario/articulos",
|
||||
"/inventario/lotes",
|
||||
"/inventario/inmovilizar-lotes",
|
||||
"/inventario/liberar-lotes",
|
||||
"/inventario/confirmar-liberacion-lote",
|
||||
"/ventas/*",
|
||||
"/compras/*"
|
||||
],
|
||||
"vania.barrios@tagumedica.com":[
|
||||
"/inventario/lotes"
|
||||
"/inventario/lotes",
|
||||
"/inventario/inmovilizar-lotes",
|
||||
"/inventario/liberar-lotes",
|
||||
"/inventario/confirmar-liberacion-lote"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from hdbcli import dbapi
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.crud_router import make_crud_router
|
||||
from core.hana import hana_connection, HANA_SCHEMA
|
||||
|
||||
router = make_crud_router(
|
||||
"BatchNumberDetails",
|
||||
@@ -6,3 +14,231 @@ router = make_crud_router(
|
||||
supports_orderby=True,
|
||||
supports_create=False,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# Stock de un lote por almacén. OBTN es el maestro de lotes: cada DistNumber
|
||||
# (número de lote) tiene un AbsEntry propio. OBTQ guarda la cantidad actual
|
||||
# por almacén, pero se enlaza a OBTN por "MdAbsEntry" = OBTN."AbsEntry" (no
|
||||
# por DistNumber ni ItemCode directamente). Confirmado contra el HANA real
|
||||
# de tagumedica con el lote 2090456 (AbsEntry 74701).
|
||||
BATCH_STOCK_QUERY = f"""
|
||||
SELECT
|
||||
T1."WhsCode" AS "WarehouseCode",
|
||||
T2."WhsName" AS "WarehouseName",
|
||||
T1."Quantity" AS "Quantity",
|
||||
T1."CommitQty" AS "CommitQty",
|
||||
T1."CountQty" AS "CountQty",
|
||||
T1."ItemCode" AS "ItemCode",
|
||||
T3."ItemName" AS "ItemDescription"
|
||||
FROM "{HANA_SCHEMA}".OBTN T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTQ T1 ON T1."MdAbsEntry" = T0."AbsEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OWHS T2 ON T2."WhsCode" = T1."WhsCode"
|
||||
INNER JOIN "{HANA_SCHEMA}".OITM T3 ON T3."ItemCode" = T1."ItemCode"
|
||||
WHERE T0."DistNumber" = ? AND T1."Quantity" > 0
|
||||
ORDER BY T3."ItemName", T2."WhsName"
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
class WarehouseStock(BaseModel):
|
||||
WarehouseCode: str
|
||||
WarehouseName: str
|
||||
Quantity: float
|
||||
CommitQty: float
|
||||
CountQty: float
|
||||
ItemCode: str
|
||||
ItemDescription: Optional[str] = None
|
||||
|
||||
# ============================================================================
|
||||
class BatchStockResponse(BaseModel):
|
||||
Batch: str
|
||||
Warehouses: List[WarehouseStock]
|
||||
|
||||
# ============================================================================
|
||||
@router.get("/api/batch/stock", response_model=BatchStockResponse)
|
||||
async def batch_stock(batch: str):
|
||||
batch = batch.strip()
|
||||
if not batch:
|
||||
raise HTTPException(status_code=400, detail="Debe indicar el número de lote")
|
||||
|
||||
try:
|
||||
with hana_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(BATCH_STOCK_QUERY, (batch,))
|
||||
rows = cursor.fetchall()
|
||||
columns = [d[0] for d in cursor.description]
|
||||
except dbapi.Error as exc:
|
||||
logger.error(f"Error al consultar stock del lote {batch}: {exc}")
|
||||
raise HTTPException(status_code=500, detail=f"Error al consultar el stock del lote: {exc}")
|
||||
|
||||
warehouses = [dict(zip(columns, row)) for row in rows]
|
||||
if not warehouses:
|
||||
raise HTTPException(status_code=404, detail=f"No se encontró stock para el lote '{batch}'")
|
||||
|
||||
return {"Batch": batch, "Warehouses": warehouses}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Para "Confirmar Liberación Lote": un lote inmovilizado (llevado a un
|
||||
# almacén como 302) puede haber llegado ahí en más de una transferencia y
|
||||
# desde más de un almacén de origen distinto (confirmado con datos reales:
|
||||
# ej. el lote 51232313 llegó a 302 con 2 unidades desde el almacén 207 y 1
|
||||
# desde el 208). Por eso la línea de la Solicitud de Liberación (que sale
|
||||
# con el total del lote desde 302) puede tener que dividirse en una línea
|
||||
# por cada almacén de origen encontrado, repartiendo la cantidad entre ellos
|
||||
# a prorrata de cuánto trajo cada transferencia históricamente.
|
||||
#
|
||||
# Se buscan todas las transferencias ejecutadas (OWTR, DocType 67 en OITL)
|
||||
# que ingresaron el lote al almacén dado; de cada una (mismo DocEntry) se
|
||||
# toma la línea hermana de salida (cantidad negativa) para saber de qué
|
||||
# almacén salió. OITL/ITL1 es el log de transacciones de lote/serie de SAP
|
||||
# (mismo que usa el reporte estándar "Transacciones de Números de Serie y
|
||||
# Lote"), unidas por "LogEntry"; OBTN da el AbsEntry del lote a partir de
|
||||
# ItemCode+DistNumber.
|
||||
INBOUND_TRANSFERS_QUERY = f"""
|
||||
SELECT DISTINCT T1."DocEntry", T1."DocDate"
|
||||
FROM "{HANA_SCHEMA}".ITL1 T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
||||
WHERE T2."ItemCode" = ? AND T2."DistNumber" = ?
|
||||
AND T1."LocCode" = ? AND T1."StockEff" = 1
|
||||
AND T0."Quantity" > 0 AND T1."DocType" = 67
|
||||
ORDER BY T1."DocDate", T1."DocEntry"
|
||||
"""
|
||||
|
||||
INBOUND_QUANTITY_QUERY = f"""
|
||||
SELECT T0."Quantity"
|
||||
FROM "{HANA_SCHEMA}".ITL1 T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
||||
WHERE T1."DocEntry" = ? AND T2."ItemCode" = ? AND T2."DistNumber" = ?
|
||||
AND T1."LocCode" = ? AND T0."Quantity" > 0
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
TRANSFER_SOURCE_WAREHOUSE_QUERY = f"""
|
||||
SELECT T1."LocCode"
|
||||
FROM "{HANA_SCHEMA}".ITL1 T0
|
||||
INNER JOIN "{HANA_SCHEMA}".OITL T1 ON T1."LogEntry" = T0."LogEntry"
|
||||
INNER JOIN "{HANA_SCHEMA}".OBTN T2 ON T2."AbsEntry" = T0."MdAbsEntry"
|
||||
WHERE T1."DocEntry" = ? AND T2."ItemCode" = ? AND T2."DistNumber" = ?
|
||||
AND T0."Quantity" < 0
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
class LotSourceWarehouseLine(BaseModel):
|
||||
ItemCode: str
|
||||
DistNumber: str
|
||||
# Almacén donde está inmovilizado hoy el lote (ej. 302): se busca de qué
|
||||
# almacén(es) vino, entre las transferencias que lo trajeron A este.
|
||||
Warehouse: str
|
||||
# Cantidad de la línea a liberar: se reparte entre los almacenes de
|
||||
# origen encontrados, a prorrata de cuánto trajo cada uno.
|
||||
Quantity: float
|
||||
|
||||
|
||||
class LotSourceWarehouseRequest(BaseModel):
|
||||
Lines: List[LotSourceWarehouseLine] = Field(min_length=1)
|
||||
|
||||
|
||||
class LotSourceAllocation(BaseModel):
|
||||
WarehouseCode: str
|
||||
Quantity: float
|
||||
|
||||
|
||||
class LotSourceWarehouseResult(BaseModel):
|
||||
ItemCode: str
|
||||
DistNumber: str
|
||||
Allocations: List[LotSourceAllocation] = []
|
||||
|
||||
|
||||
def _allocate_by_origin(
|
||||
origin_totals: Dict[str, float], release_qty: float
|
||||
) -> List[LotSourceAllocation]:
|
||||
"""
|
||||
Reparte release_qty entre los almacenes de origin_totals a prorrata de
|
||||
su participación histórica, redondeando cada asignación a un entero (los
|
||||
artículos se mueven en unidades completas). El último almacén se ajusta
|
||||
para que la suma coincida exactamente con release_qty (evita descuadres
|
||||
de redondeo, ya que el histórico puede no calzar 1 a 1 con la cantidad
|
||||
actual si parte del lote salió de 302 por otra vía mientras tanto).
|
||||
"""
|
||||
total_historical = sum(origin_totals.values())
|
||||
if total_historical <= 0:
|
||||
return []
|
||||
|
||||
origins = list(origin_totals.items())
|
||||
allocations: List[LotSourceAllocation] = []
|
||||
allocated_so_far = 0.0
|
||||
for index, (warehouse_code, historical_qty) in enumerate(origins):
|
||||
is_last = index == len(origins) - 1
|
||||
allocated_qty = (
|
||||
round(release_qty - allocated_so_far)
|
||||
if is_last
|
||||
else round(release_qty * (historical_qty / total_historical))
|
||||
)
|
||||
allocated_qty = max(allocated_qty, 0)
|
||||
allocated_so_far += allocated_qty
|
||||
if allocated_qty > 0:
|
||||
allocations.append(
|
||||
LotSourceAllocation(WarehouseCode=warehouse_code, Quantity=allocated_qty)
|
||||
)
|
||||
return allocations
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/batch/lot-source-warehouse",
|
||||
response_model=List[LotSourceWarehouseResult],
|
||||
)
|
||||
async def lot_source_warehouse(payload: LotSourceWarehouseRequest):
|
||||
results: List[LotSourceWarehouseResult] = []
|
||||
try:
|
||||
with hana_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
for line in payload.Lines:
|
||||
cursor.execute(
|
||||
INBOUND_TRANSFERS_QUERY,
|
||||
(line.ItemCode, line.DistNumber, line.Warehouse),
|
||||
)
|
||||
doc_entries = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
origin_totals: Dict[str, float] = {}
|
||||
for doc_entry in doc_entries:
|
||||
cursor.execute(
|
||||
INBOUND_QUANTITY_QUERY,
|
||||
(doc_entry, line.ItemCode, line.DistNumber, line.Warehouse),
|
||||
)
|
||||
inbound_row = cursor.fetchone()
|
||||
if not inbound_row:
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
TRANSFER_SOURCE_WAREHOUSE_QUERY,
|
||||
(doc_entry, line.ItemCode, line.DistNumber),
|
||||
)
|
||||
source_row = cursor.fetchone()
|
||||
if not source_row:
|
||||
continue
|
||||
|
||||
warehouse_code = source_row[0]
|
||||
origin_totals[warehouse_code] = origin_totals.get(
|
||||
warehouse_code, 0
|
||||
) + float(inbound_row[0])
|
||||
|
||||
results.append(
|
||||
LotSourceWarehouseResult(
|
||||
ItemCode=line.ItemCode,
|
||||
DistNumber=line.DistNumber,
|
||||
Allocations=_allocate_by_origin(origin_totals, line.Quantity),
|
||||
)
|
||||
)
|
||||
except dbapi.Error as exc:
|
||||
logger.error(f"Error al buscar el almacén de origen de lotes: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error al buscar el almacén de origen de los lotes: {exc}",
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -58,6 +58,49 @@ async def get_sunat_businesspartners_document_type():
|
||||
extra_headers=base_headers
|
||||
).json()
|
||||
|
||||
@router.get("/api/Catalogs/UdfValidValues")
|
||||
async def get_udf_valid_values(table_name: str, field_name: str):
|
||||
"""
|
||||
Genérico para cualquier campo de usuario (UDF) con lista de valores
|
||||
válidos: recibe la tabla SAP (ej. 'OWTQ') y el nombre del campo sin el
|
||||
prefijo 'U_' (ej. 'SIS_TIPDESP'), y devuelve sus valores válidos vía
|
||||
Service Layer (UserFieldsMD.ValidValuesMD), normalizados a {Code, Name}
|
||||
para poder usarse con useSapCatalog igual que las tablas de usuario.
|
||||
Equivale a lo que en HANA sería CUFD (por AliasID/TableID) + UFD1
|
||||
(por FieldID), pero sin necesidad de acceso directo a HANA.
|
||||
"""
|
||||
|
||||
def _escape(value: str) -> str:
|
||||
return value.replace("'", "''")
|
||||
|
||||
sap_params = {
|
||||
"$skip": 0,
|
||||
"$top": 200,
|
||||
"$inlinecount": "allpages",
|
||||
}
|
||||
sap_params["$filter"] = (
|
||||
f"TableName eq '{_escape(table_name)}' and Name eq '{_escape(field_name)}'"
|
||||
)
|
||||
|
||||
base_headers = {"B1S-PageSize": "200"}
|
||||
|
||||
response = forward_to_sapb1(
|
||||
method="GET",
|
||||
endpoint_path="UserFieldsMD",
|
||||
params=sap_params,
|
||||
extra_headers=base_headers,
|
||||
).json()
|
||||
|
||||
fields = response.get("value", [])
|
||||
valid_values = fields[0].get("ValidValuesMD", []) if fields else []
|
||||
|
||||
return {
|
||||
"value": [
|
||||
{"Code": v["Value"], "Name": v["Description"]} for v in valid_values
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/ItemGroups")
|
||||
async def get_item_groups():
|
||||
|
||||
|
||||
@@ -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
|
||||
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'
|
||||
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OWTQ' and Name eq 'SIS_TIPDESP'
|
||||
Content-Type: application/json
|
||||
|
||||
###
|
||||
@@ -79,4 +79,14 @@ Content-Type: application/json
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
GET {{url}}/BatchNumberDetails
|
||||
Content-Type: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
|
||||
###
|
||||
# @noRejectUnauthorized
|
||||
POST {{url}}/SeriesService_GetSeries
|
||||
Content-Type: application/json
|
||||
{
|
||||
"SeriesParams": {}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user