Mejoras genéricas traídas: - core/crud_router.py: el PATCH genérico reenvía cualquier header B1S-* recibido del caller en vez de decidirlo el backend. - routers/catalogs.py: nuevo endpoint /api/Catalogs/UdfValidValues (valores válidos de cualquier UDF vía Service Layer). - routers/batch.py: /api/batch/stock (stock de un lote por almacén) y /api/batch/lot-source-warehouse (resuelve contra HANA de qué almacén salió un lote, repartiendo por origen). - routers/inventory_transfer_requests.py + core/inventory_transfer_requests.py: CRUD genérico de Solicitudes de Transferencia de Inventario. - routers/series.py + core/series.py: series activas de un tipo de documento. - routers/user_tables.py: catálogo Code/Name genérico de tablas de usuario (mapeo USER_TABLES vacío, cada cliente completa el suyo). - routers/reports.py / templates/reports/stickers.html: fix de márgenes/alto de página del reporte de etiquetas. - mini-backend/test/ -> mini-backend/tools/ (rename organizativo). Limpieza de datos de Tagumedica que ya estaban en el commit base de main (anteriores a la branch cliente/tagumedica, no relacionados con el backport): - tools/sap-tagumedica/sl.http: eliminado (tenía CompanyDB y password reales). - tools/ApiGateway/Login.yml: CompanyDB/host/password reemplazados por placeholders. - templates/reports/stickers.html: título hardcodeado "TAGUMEDICA" reemplazado por "EMPRESA". - menu_permissions.json: reemplazado por un ejemplo demo (usuarios/módulos ficticios) en vez de emails reales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
134 lines
4.5 KiB
Python
134 lines
4.5 KiB
Python
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Request
|
|
|
|
from core.sap import forward_to_sapb1
|
|
|
|
|
|
def _build_sap_params(
|
|
skip: int,
|
|
top: int,
|
|
fields: Optional[str],
|
|
filter: Optional[str],
|
|
orderby: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
sap_params: Dict[str, Any] = {
|
|
"$skip": skip,
|
|
"$top": top,
|
|
"$inlinecount": "allpages",
|
|
}
|
|
if fields:
|
|
sap_params["$select"] = fields.replace(" ", "")
|
|
if filter:
|
|
sap_params["$filter"] = filter
|
|
if orderby:
|
|
sap_params["$orderby"] = orderby
|
|
return sap_params
|
|
|
|
|
|
def _build_list_headers(top: int, filter: Optional[str]) -> Dict[str, str]:
|
|
headers = {"B1S-PageSize": str(top)}
|
|
if filter:
|
|
headers["B1S-CaseInsensitive"] = "true"
|
|
return headers
|
|
|
|
|
|
def make_crud_router(
|
|
resource: str,
|
|
*,
|
|
tags: List[str],
|
|
key_field: Optional[str] = None,
|
|
key_quoted: bool = False,
|
|
supports_orderby: bool = False,
|
|
supports_get_by_key: bool = True,
|
|
supports_create: bool = True,
|
|
supports_patch: bool = True,
|
|
patch_headers: Optional[Dict[str, str]] = None,
|
|
) -> APIRouter:
|
|
"""
|
|
Genera el router list/get/create/update repetido antes en cada módulo de
|
|
routers/ (mismo bloque de $skip/$top/$filter y de forward_to_sapb1, solo
|
|
cambiaba el nombre del recurso OData). Centralizarlo evita que un
|
|
copy-paste entre recursos deje, por ejemplo, un PATCH apuntando al
|
|
endpoint_path de otro recurso.
|
|
"""
|
|
router = APIRouter(tags=tags)
|
|
|
|
def key_expr(key: str) -> str:
|
|
return f"{resource}('{key}')" if key_quoted else f"{resource}({key})"
|
|
|
|
if supports_orderby:
|
|
@router.get(f"/api/{resource}", name=f"list_{resource}")
|
|
async def list_resource(
|
|
skip: int = 0,
|
|
top: int = 50,
|
|
fields: Optional[str] = None,
|
|
filter: Optional[str] = None,
|
|
orderby: Optional[str] = None,
|
|
):
|
|
sap_params = _build_sap_params(skip, top, fields, filter, orderby)
|
|
return forward_to_sapb1(
|
|
method="GET",
|
|
endpoint_path=resource,
|
|
params=sap_params,
|
|
extra_headers=_build_list_headers(top, filter),
|
|
).json()
|
|
else:
|
|
@router.get(f"/api/{resource}", name=f"list_{resource}")
|
|
async def list_resource(
|
|
skip: int = 0,
|
|
top: int = 50,
|
|
fields: Optional[str] = None,
|
|
filter: Optional[str] = None,
|
|
):
|
|
sap_params = _build_sap_params(skip, top, fields, filter)
|
|
return forward_to_sapb1(
|
|
method="GET",
|
|
endpoint_path=resource,
|
|
params=sap_params,
|
|
extra_headers=_build_list_headers(top, filter),
|
|
).json()
|
|
|
|
if supports_get_by_key:
|
|
@router.get(f"/api/{resource}/{{key}}", name=f"get_{resource}_by_key")
|
|
async def get_by_key(key: str):
|
|
return forward_to_sapb1(
|
|
method="GET",
|
|
endpoint_path=key_expr(key),
|
|
).json()
|
|
|
|
if supports_create:
|
|
@router.post(f"/api/{resource}", status_code=201, name=f"create_{resource}")
|
|
async def create_resource(request: Request):
|
|
data = await request.json()
|
|
resp = forward_to_sapb1(
|
|
method="POST",
|
|
endpoint_path=resource,
|
|
json_data=data,
|
|
)
|
|
created = resp.json()
|
|
return {key_field: created[key_field]}
|
|
|
|
if supports_patch:
|
|
@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=headers or None,
|
|
)
|
|
|
|
return router
|