Initial commit: mini-erp (backend FastAPI + frontend React/Vite)

Base para el flujo de ramas por cliente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-08-17 10:34:12 -05:00
commit ab3c613165
154 files changed
+16872

No files matched your search

View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
import base64
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from core.api_gateway import forward_to_api_gateway
from typing import Any, List
from io import BytesIO
router = APIRouter(
tags=["APIGateway - Crystal Reports"]
)
@router.get("/api/apigateway/reports")
async def list_reports():
resp = forward_to_api_gateway(
method="GET",
endpoint="rs/v1/LoadAuthorizedCRList"
)
data = resp.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
return data.get("value", data.get("AuthorizedCRList", [data]))
return []
@router.get("/api/apigateway/reports/{docCode}/parameters")
async def get_report_parameters(docCode: str):
resp = forward_to_api_gateway(
method="GET",
endpoint=f"rs/v1/LoadCR",
params={"DocCode": docCode}
)
return resp.json()
@router.post("/api/apigateway/reports/{docCode}/export")
async def export_report_pdf(docCode: str, body: List[Any]):
resp = forward_to_api_gateway(
method="POST",
endpoint="rs/v1/ExportPDFData",
params={"DocCode": docCode},
json_data=body,
)
pdf_bytes = base64.b64decode(resp.text)
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'inline; filename="{docCode}.pdf"',
}
)
+90
View File
@@ -0,0 +1,90 @@
import os
import logging
from fastapi import APIRouter, Request, HTTPException
from core.sap import forward_to_sapb1, ensure_sap_session
from core.session import create_employee_session, remove_employee_session_by_token
from context import set_current_user
from dotenv import load_dotenv
# Cargar variables de entorno desde .env
load_dotenv(dotenv_path=".env")
SAPB1_SL_COMPANYDB = os.getenv("SAPB1_SL_COMPANYDB", "")
logger = logging.getLogger(__name__)
router = APIRouter(
tags=["Autenticación"]
)
@router.post("/api/Login")
async def login(request: Request):
data = await request.json()
webUser = data.get("Username")
password = data.get("Password")
logger.info(f"Intento de login recibido para el usuario web {webUser}")
webUser_escaped = (webUser or "").replace("'", "''")
try:
employee_response = forward_to_sapb1(
method="GET",
endpoint_path="EmployeesInfo",
params={
"$select": "EmployeeID,FirstName,LastName,Active,U_WEB_USER,U_WEB_PASS",
"$filter": f"U_WEB_USER eq '{webUser_escaped}'"
}
)
except HTTPException:
raise HTTPException(status_code=401, detail="Usuario o contraseña incorrectos")
except Exception as exc:
logger.error(f"Error al consultar empleados en SAP a través de proxy: {exc}")
raise HTTPException(status_code=500, detail=f"Error al consultar empleados en SAP: {exc}")
employees = employee_response.json().get("value", [])
employee_data = employees[0] if employees else None
if employee_data is None:
logger.warning(f"Login fallido: no se encontró ningún empleado con U_WEB_USER {webUser}")
raise HTTPException(status_code=401, detail="Usuario no registrado")
empId = employee_data.get("EmployeeID")
if employee_data.get("Active") != "tYES":
logger.warning(f"Login fallido: el empleado {empId} está inactivo")
raise HTTPException(status_code=401, detail="Usuario inactivo")
if employee_data.get("U_WEB_PASS") != password:
logger.warning(f"Login fallido: la contraseña no coincide para el empleado {empId}")
raise HTTPException(status_code=401, detail="Usuario o contraseña incorrectos")
full_name = f"{employee_data.get('FirstName', '')} {employee_data.get('LastName', '')}".strip()
set_current_user(webUser, full_name, empId)
logger.info(f"Login exitoso para el usuario {webUser} (empleado SAP {empId})")
token = create_employee_session(
webUser=webUser,
emp_id=empId,
first_name=employee_data.get('FirstName', ''),
last_name=employee_data.get('LastName', '')
)
ensure_sap_session()
return {
"message": "Login exitoso",
"token": token,
"username": employee_data.get('FirstName'),
"company": SAPB1_SL_COMPANYDB
}
@router.post("/api/Logout")
async def logout(request: Request):
token = request.headers.get("X-Session-Token")
if token:
remove_employee_session_by_token(token)
return {"message": "Logout exitoso"}
+8
View File
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"BatchNumberDetails",
tags=["Lotes"],
supports_orderby=True,
supports_create=False,
)
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"BusinessPartners",
tags=["Socios de Negocio"],
key_field="CardCode",
key_quoted=True,
)
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter
from core.sap import forward_to_sapb1
from core.crud_router import make_crud_router
router = APIRouter(
tags=["Catálogos"]
)
for _resource in ("Warehouses", "SalesTaxCodes", "FactoringIndicators"):
router.include_router(
make_crud_router(
_resource,
tags=["Catálogos"],
supports_get_by_key=False,
supports_create=False,
supports_patch=False,
)
)
@router.get("/api/Currencies")
async def get_currencies():
return forward_to_sapb1(
method="GET",
endpoint_path="Currencies",
params={"$select": "Code,Name", "$top": 200}
).json()
@router.get("/api/BusinessPartnerGroups")
async def get_business_partner_groups():
return forward_to_sapb1(
method="GET",
endpoint_path="BusinessPartnerGroups",
params={"$select": "Number,GroupName"}
).json()
@router.get("/api/Sunat/BusinessPartners/DocumentType")
async def get_sunat_businesspartners_document_type():
sap_params = {
"$skip": 0,
"$top": 200,
"$inlinecount": "allpages"
}
sap_params["$filter"] = "TableName eq 'OCRD' and Name eq 'EXX_TIPODOCU'"
base_headers = {"B1S-PageSize": "200"}
return forward_to_sapb1(
method="GET",
endpoint_path="UserFieldsMD",
params=sap_params,
extra_headers=base_headers
).json()
@router.get("/api/ItemGroups")
async def get_item_groups():
sap_params = {
"$skip": 0,
"$top": 200,
"$inlinecount": "allpages"
}
sap_params["$select"] = "Number,GroupName"
base_headers = {"B1S-PageSize": "200"}
return forward_to_sapb1(
method="GET",
endpoint_path="ItemGroups",
params=sap_params,
extra_headers=base_headers
).json()
#
+8
View File
@@ -0,0 +1,8 @@
from core.crud_router import make_crud_router
router = make_crud_router(
"Items",
tags=["Artículos"],
key_field="ItemCode",
key_quoted=True,
)
+26
View File
@@ -0,0 +1,26 @@
import json
import os
from fastapi import APIRouter, HTTPException, Request
MENU_PERMISSIONS_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "menu_permissions.json")
router = APIRouter(tags=["Permisos"])
def load_permissions() -> dict:
if not os.path.exists(MENU_PERMISSIONS_PATH):
return {}
with open(MENU_PERMISSIONS_PATH, "r") as f:
return json.load(f)
@router.get("/api/user/menu")
async def get_user_menu(request: Request):
webUser = str(getattr(request.state, "webUser", ""))
if not webUser:
raise HTTPException(status_code=401, detail="Usuario no autenticado")
permissions = load_permissions()
allowed_paths = permissions.get(webUser, [])
return {"allowedPaths": allowed_paths}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from core.crud_router import make_crud_router
router = APIRouter()
_MARKETING_DOC_PATCH_HEADERS = {"B1S-ReplaceCollectionsOnPatch": "true"}
for _resource in ("PurchaseOrders", "PurchaseDeliveryNotes", "PurchaseInvoices"):
router.include_router(
make_crud_router(
_resource,
tags=["Compras"],
key_field="DocEntry",
supports_orderby=True,
patch_headers=_MARKETING_DOC_PATCH_HEADERS,
)
)
+172
View File
@@ -0,0 +1,172 @@
import logging
from io import BytesIO
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel
from weasyprint import HTML
from hdbcli import dbapi
from core.hana import hana_connection, HANA_SCHEMA
router = APIRouter(
tags=["Reportes (Jinja/HTML)"]
)
TEMPLATES_REPORTS_DIR = Path(__file__).resolve().parent.parent / "templates" / "reports"
templates_env = Environment(loader=FileSystemLoader("templates/reports"))
logger = logging.getLogger(__name__)
# Tamaño de papel que usa cada reporte al renderizar el PDF (CSS @page.size).
PAPER_SIZES = {"A4": "A4", "Carta": "letter"}
# Alto útil de la página en orientación horizontal (tamaño de papel menos los
# 0.3in de margen superior e inferior definidos en el @page de stickers.html).
PAGINA_ALTO_LANDSCAPE = {"A4": "7.67in", "Carta": "7.9in"}
# Query original de Crystal Reports para DLN20015, adaptada: el placeholder
# de Crystal `{?Dockey@}` se reemplaza por un bind param `?` de hdbcli, y cada
# tabla queda calificada con el schema de la compañía SAP B1 (HANA_SCHEMA).
STICKERS_QUERY = f"""
SELECT TOP 1
T0."DocEntry",
T0."DocNum",
CASE
WHEN IFNULL(T0."NumAtCard",'') <> '' THEN T0."NumAtCard"
ELSE T0."FolioPref"||'-'||T0."FolioNum"
END AS "GUIA_REMISION",
T0."U_SIS_ORDCOM" AS "ORDEN_COMPRA",
CASE
WHEN T0."Indicator" = 'AN' THEN 'ANULADO'
END AS "ESTADO",
T0."CardCode",
T0."CardName",
T4."Phone1" AS "TELEFONO",
T7."StreetS" AS "DIRECCION",
T7."CountyS" AS "PROVINCIA",
T14."Name" AS "DEPARTAMENTO",
T7."ZipCodeS" AS "DISTRITO",
T6."IndDesc" AS "GRUPO_CLIENTE",
CASE
WHEN T4."GroupCode" = '100' THEN 'VENTA LOCAL'
WHEN T4."GroupCode" = '102' THEN 'VENTA EXTERIOR'
END AS "TIPO_VENTA",
T0."DocDate" AS "FECHA",
T1."ItemCode",
T2."ItemName",
T3."Name" AS "LINEA",
T10."BatchNum" AS "LOTE",
T1."unitMsr" AS "UNIDADES",
T10."Quantity" AS "CANTIDAD",
T0."Indicator" AS "DOCUMENTO",
T0."U_SIS_FECENT" AS "FECHA_ENTREGA",
T8."SlpName" AS "REPRESENTANTE",
UPPER(T9."U_NAME") AS "ASISTENTE_VENTA",
T0."Comments" AS "COMENTARIOS",
CASE T0."U_SIS_TIPDESP"
WHEN 'N' THEN 'LOCAL'
WHEN 'A' THEN 'PROVINCIA'
WHEN 'U' THEN 'LOCAL - URGENTE'
WHEN 'O' THEN 'OFICINA'
WHEN 'V' THEN '-'
WHEN 'J' THEN 'PROVINCIA -URGENTE'
WHEN 'R' THEN 'REGULARIZACION'
ELSE 'LOCAL'
END AS "TIPO_DESPACHO",
T11."ExpDate" AS "FECHA VENCIMIENTO",
CASE T0."U_SIS_HORREC"
WHEN '100' THEN 'SIN HORARIO'
WHEN '101' THEN '08 AM - 10 AM'
WHEN '102' THEN '10 AM - 01 PM'
WHEN '103' THEN '01 PM - 03 PM'
WHEN '104' THEN '03 PM - 06 PM'
ELSE 'SIN HORARIO'
END AS "HORARIO RECOJO",
T11."LotNumber" AS "UBICACION",
T1."WhsCode" AS "ALMACEN",
T1."VisOrder",
T0.U_EXX_NOMTRANS AS "TRANSPORTISTA",
ROW_NUMBER() OVER (PARTITION BY T1."VisOrder" ORDER BY T1."VisOrder" DESC) AS row_num
FROM "{HANA_SCHEMA}".ODLN T0
INNER JOIN "{HANA_SCHEMA}".DLN1 T1 ON T0."DocEntry" = T1."DocEntry"
INNER JOIN "{HANA_SCHEMA}".OITM T2 ON T1."ItemCode" = T2."ItemCode"
LEFT JOIN "{HANA_SCHEMA}"."@SIS_LINART" T3 ON T2.U_SIS_LINART = T3."Code"
INNER JOIN "{HANA_SCHEMA}".OCRD T4 ON T0."CardCode" = T4."CardCode"
INNER JOIN "{HANA_SCHEMA}".NNM1 T5 ON T0."Series"=T5."Series"
LEFT JOIN "{HANA_SCHEMA}".OOND T6 ON T4."IndustryC" = T6."IndCode"
LEFT JOIN "{HANA_SCHEMA}".DLN12 T7 ON T0."DocEntry" = T7."DocEntry"
LEFT JOIN "{HANA_SCHEMA}".OCST T14 ON T14."Code" = T7."StateS" AND T14."Country" = T7."CountryS"
LEFT JOIN "{HANA_SCHEMA}".OSLP T8 ON T0."SlpCode" = T8."SlpCode"
LEFT JOIN "{HANA_SCHEMA}".OUSR T9 ON T0."UserSign" = T9."USERID"
LEFT JOIN "{HANA_SCHEMA}".IBT1 T10 ON T1."ItemCode"=T10."ItemCode" AND T0."DocEntry"=T10."BaseEntry" AND T1."WhsCode"=T10."WhsCode" AND T0."ObjType"=T10."BaseType" AND T0."DocNum"=T10."BaseNum" AND T1."LineNum" = T10."BaseLinNum" AND T10."Direction"='1'
LEFT JOIN "{HANA_SCHEMA}".OBTN T11 ON T11."ItemCode" = T1."ItemCode" AND T10."BatchNum" = T11."DistNumber"
WHERE T0."DocEntry" = ?
"""
# ============================================================================
class StickersParams(BaseModel):
doc_entry: int
cantidad: int
formato: Literal["pdf", "html"] = "pdf"
papel: Literal["A4", "Carta"] = "Carta"
# ============================================================================
@router.post("/api/reports/stickers")
async def generar_stickers(params: StickersParams):
log_extra = {"http_method": "POST", "endpoint": "reports/stickers"}
logger.info(f"Generando reporte de stickers para DocEntry {params.doc_entry}", extra=log_extra)
try:
with hana_connection() as conn:
cursor = conn.cursor()
cursor.execute(STICKERS_QUERY, (params.doc_entry,))
row = cursor.fetchone()
columns = [d[0] for d in cursor.description]
except dbapi.Error as exc:
logger.error(f"Error al consultar la guía de remisión {params.doc_entry}: {exc}", extra=log_extra)
raise HTTPException(status_code=500, detail=f"Error al consultar el reporte: {exc}")
if not row:
logger.warning(f"DocEntry {params.doc_entry} no encontrado", extra=log_extra)
raise HTTPException(status_code=404, detail=f"No se encontró el documento {params.doc_entry}")
fila = dict(zip(columns, row))
lugar = " - ".join(
p for p in (fila.get("DEPARTAMENTO"), fila.get("PROVINCIA"), fila.get("DISTRITO")) if p
)
datos = {
"cliente": fila.get("CardName"),
"direccion": fila.get("DIRECCION"),
"lugar": lugar,
"documento": fila.get("GUIA_REMISION"),
}
stickers = [{"numero": i, "total": params.cantidad} for i in range(1, params.cantidad + 1)]
paginas = [stickers[i:i + 4] for i in range(0, len(stickers), 4)]
template = templates_env.get_template("stickers.html")
html_renderizado = template.render(
datos=datos,
paginas=paginas,
paper_size=PAPER_SIZES[params.papel],
pagina_alto=PAGINA_ALTO_LANDSCAPE[params.papel],
)
if params.formato == "html":
logger.info(f"Reporte de stickers generado en HTML para DocEntry {params.doc_entry}", extra={**log_extra, "status_code": 200})
return HTMLResponse(content=html_renderizado)
pdf_bytes = HTML(string=html_renderizado, base_url=str(TEMPLATES_REPORTS_DIR)).write_pdf()
logger.info(f"Reporte de stickers generado en PDF para DocEntry {params.doc_entry}", extra={**log_extra, "status_code": 200})
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="stickers_{params.doc_entry}.pdf"'},
)
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from core.crud_router import make_crud_router
router = APIRouter()
_MARKETING_DOC_PATCH_HEADERS = {"B1S-ReplaceCollectionsOnPatch": "true"}
for _resource in ("Invoices", "Orders", "DeliveryNotes"):
router.include_router(
make_crud_router(
_resource,
tags=["Ventas"],
key_field="DocEntry",
supports_orderby=True,
patch_headers=_MARKETING_DOC_PATCH_HEADERS,
)
)