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:
commit
ab3c613165
154 files changed
+16872
No files matched your search
Whitespace-only changes.
@@ -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"',
|
||||
}
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
#
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user