Backport de mejoras genéricas de backend (desde cliente/tagumedica) + limpieza de datos de cliente en main
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>
This commit is contained in:
1 parent
b2fb4e2d0a
commit
fef21898f9
30 files changed
+461
-111
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 Service Layer real:
|
||||||
|
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")
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
{
|
{
|
||||||
"renato.ortega@tagumedica.com": [
|
"admin.demo@empresa.com": [
|
||||||
"*"
|
"*"
|
||||||
],
|
],
|
||||||
"rodrigo.catari@tagumedica.com": [
|
"ventas.demo@empresa.com": [
|
||||||
"/inventario/*"
|
|
||||||
],
|
|
||||||
"jose.huayta@tagumedica.com": [
|
|
||||||
"/inventario/articulos",
|
|
||||||
"/ventas/*",
|
"/ventas/*",
|
||||||
"/compras/*"
|
"/socios/clientes"
|
||||||
],
|
],
|
||||||
"ricardo.merino@tagumedica.com": [
|
"inventario.demo@empresa.com": [
|
||||||
"/socios/*"
|
|
||||||
],
|
|
||||||
"renato.bustamante@tagumedica.com": [
|
|
||||||
"/inventario/articulos",
|
"/inventario/articulos",
|
||||||
"/inventario/lotes",
|
"/inventario/lotes"
|
||||||
"/ventas/*",
|
|
||||||
"/compras/*"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,230 @@ 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 HANA real.
|
||||||
|
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,
|
||||||
|
)
|
||||||
@@ -26,9 +26,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# Tamaño de papel que usa cada reporte al renderizar el PDF (CSS @page.size).
|
# Tamaño de papel que usa cada reporte al renderizar el PDF (CSS @page.size).
|
||||||
PAPER_SIZES = {"A4": "A4", "Carta": "letter"}
|
PAPER_SIZES = {"A4": "A4", "Carta": "letter"}
|
||||||
|
|
||||||
# Alto útil de la página en orientación horizontal (tamaño de papel menos los
|
# Alto útil de la página en orientación horizontal (tamaño de papel menos el
|
||||||
# 0.3in de margen superior e inferior definidos en el @page de stickers.html).
|
# 1.6cm de margen superior + 1cm de margen inferior definidos en el @page de
|
||||||
PAGINA_ALTO_LANDSCAPE = {"A4": "7.67in", "Carta": "7.9in"}
|
# stickers.html).
|
||||||
|
PAGINA_ALTO_LANDSCAPE = {"A4": "18.4cm", "Carta": "18.99cm"}
|
||||||
|
|
||||||
# Query original de Crystal Reports para DLN20015, adaptada: el placeholder
|
# Query original de Crystal Reports para DLN20015, adaptada: el placeholder
|
||||||
# de Crystal `{?Dockey@}` se reemplaza por un bind param `?` de hdbcli, y cada
|
# de Crystal `{?Dockey@}` se reemplaza por un bind param `?` de hdbcli, y cada
|
||||||
|
|||||||
@@ -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,38 @@
|
|||||||
|
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 de un mismo cliente (una tabla de usuario puede exponerse con
|
||||||
|
# prefijo "U_" y otra sin él), así que se centraliza acá en vez de que cada
|
||||||
|
# módulo tenga que adivinar o repetir el nombre de endpoint real. Cada
|
||||||
|
# cliente completa este mapeo según sus propias tablas de usuario en SAP.
|
||||||
|
USER_TABLES: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
@page {
|
@page {
|
||||||
size: {{ paper_size }} landscape;
|
size: {{ paper_size }} landscape;
|
||||||
margin: 0.3in;
|
margin: 1.6cm 3cm 1cm 2.2cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -33,8 +33,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sticker {
|
.sticker {
|
||||||
|
height: 100%;
|
||||||
border: 2px solid #000;
|
border: 2px solid #000;
|
||||||
padding: 0.3in 0.35in;
|
padding: 0.15in 0.35in;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -47,15 +48,15 @@
|
|||||||
font-size: 20pt;
|
font-size: 20pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.campo {
|
.campo {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.campo-direccion {
|
.campo-direccion {
|
||||||
min-height: 0.65in;
|
min-height: 0.5in;
|
||||||
}
|
}
|
||||||
|
|
||||||
.campo-label {
|
.campo-label {
|
||||||
@@ -77,10 +78,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.documento-linea {
|
.documento-linea {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.35in;
|
||||||
|
right: 0.35in;
|
||||||
|
bottom: 0.15in;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.documento-linea .campo {
|
.documento-linea .campo {
|
||||||
@@ -94,7 +98,7 @@
|
|||||||
<div class="pagina">
|
<div class="pagina">
|
||||||
{% for sticker in pagina %}
|
{% for sticker in pagina %}
|
||||||
<div class="sticker">
|
<div class="sticker">
|
||||||
<div class="titulo">TAGUMEDICA</div>
|
<div class="titulo">EMPRESA</div>
|
||||||
|
|
||||||
<div class="campo">
|
<div class="campo">
|
||||||
<div class="campo-label">DATOS DEL CLIENTE:</div>
|
<div class="campo-label">DATOS DEL CLIENTE:</div>
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
@url=https://192.168.1.175:50000/b1s/v2
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
POST {{url}}/Login HTTP/1.1
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"CompanyDB": "SBO_TAGUMEDICA_21",
|
|
||||||
"UserName": "manager",
|
|
||||||
"Password": "m1r1"
|
|
||||||
}
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/$metadata
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/EmployeesInfo?$select=EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS&$filter=U_WEB_USER eq 'renato.ortega@tagumedica.com'
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/Items?$top=20&$orderby=ItemCode desc
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/DeliveryNotes(29360)
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/UserFieldsMD?$filter=TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/Warehouses
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/PurchaseInvoices(135235)
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/Orders?$filter=DocumentStatus eq 'bost_Open'
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
POST {{url}}/MessagesService_GetInbox HTTP/1.1
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/Messages(173810)
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/ItemGroups?$select=Number,GroupName
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
POST {{url}}/FactoringIndicators?$select=IndicatorCode,IndicatorName&$filter=U_EXX_REGVEN eq 'Y'
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/Currencies
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
# @noRejectUnauthorized
|
|
||||||
GET {{url}}/BatchNumberDetails
|
|
||||||
Content-Type: application/json
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -4,15 +4,15 @@ info:
|
|||||||
|
|
||||||
http:
|
http:
|
||||||
method: POST
|
method: POST
|
||||||
url: https://192.168.1.175:60000/login
|
url: https://<host>:60000/login
|
||||||
body:
|
body:
|
||||||
type: json
|
type: json
|
||||||
data: |-
|
data: |-
|
||||||
{
|
{
|
||||||
"CompanyDB":"SBO_TAGUMEDICA_21",
|
"CompanyDB":"<CompanyDB>",
|
||||||
"UserName": "manager",
|
"UserName": "manager",
|
||||||
"Password": "m1r1",
|
"Password": "<password>",
|
||||||
"DBInstance":"NDB@192.168.1.175:30013"
|
"DBInstance":"NDB@<host>:30013"
|
||||||
}
|
}
|
||||||
auth: inherit
|
auth: inherit
|
||||||
|
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user