+ Transferir la propiedad es permanente. El nuevo propietario tendrá control total
+ de la organización y podrá cambiar todos los permisos, incluyendo los tuyos.
+
+ Funcionalidad Exclusiva para Profesionales y Empresas
+
+
+ El análisis de rendimiento avanzado está disponible solo para planes Professional y Enterprise.
+ Actualiza tu plan para acceder a métricas transversales de rendimiento, análisis de procesos integrados y optimización operativa.
+
- Prueba BakeryIA
+ Prueba El Panadero Digitalsin compromiso
- Explora nuestro sistema con datos reales de panaderías españolas.
- Elige el tipo de negocio que mejor se adapte a tu caso.
+ Elige el tipo de panadería que se ajuste a tu negocio
{t('landing:business_models.central_workshop.subtitle', 'Punto de venta con obrador central')}
- {t('landing:business_models.central_workshop.description', 'Produces centralmente y distribuyes a múltiples puntos de venta. Necesitas coordinar producción, logística y demanda entre ubicaciones para optimizar cada punto.')}
+ {t('landing:business_models.central_workshop.description', 'Operas un punto de venta que recibe productos de un obrador central. Necesitas gestionar pedidos, inventario y ventas para optimizar tu operación retail.')}
- Predicción agregada y por punto de venta individual') }} />
+ Gestión de pedidos al obrador central') }} />
- Gestión de distribución multi-ubicación coordinada') }} />
+ Control de inventario de productos recibidos') }} />
- Visibilidad centralizada con control granular') }} />
+ Previsión de ventas para tu punto') }} />
diff --git a/frontend/src/router/ProtectedRoute.tsx b/frontend/src/router/ProtectedRoute.tsx
index d32567d8..547993f7 100644
--- a/frontend/src/router/ProtectedRoute.tsx
+++ b/frontend/src/router/ProtectedRoute.tsx
@@ -1,5 +1,11 @@
/**
* Protected Route component for handling authentication and authorization
+ *
+ * This component integrates with the unified permission system to provide
+ * comprehensive access control for routes. It checks both global user roles
+ * and tenant-specific permissions.
+ *
+ * For permission checking logic, see utils/permissions.ts
*/
import React from 'react';
@@ -8,6 +14,7 @@ import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
import { useHasAccess, useIsDemoMode } from '../hooks/useAccessControl';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
+import { checkCombinedPermission, type User, type TenantAccess } from '../utils/permissions';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -311,40 +318,46 @@ export const ConditionalRender: React.FC = ({
};
// Route guard for admin-only routes (global admin or tenant owner/admin)
+// Uses unified permission system
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- return (
- }
- >
- {children}
-
- );
+ const user = useAuthUser();
+ const tenantAccess = useCurrentTenantAccess();
+
+ // Check using unified permission system
+ const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
+ globalRoles: ['admin', 'super_admin'],
+ tenantRoles: ['owner', 'admin']
+ });
+
+ return hasAccess ? <>{children}> : ;
};
// Route guard for manager-level routes (global admin/manager or tenant admin/owner)
+// Uses unified permission system
export const ManagerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- return (
- }
- >
- {children}
-
- );
+ const user = useAuthUser();
+ const tenantAccess = useCurrentTenantAccess();
+
+ // Check using unified permission system
+ const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
+ globalRoles: ['admin', 'super_admin', 'manager'],
+ tenantRoles: ['owner', 'admin', 'member']
+ });
+
+ return hasAccess ? <>{children}> : ;
};
// Route guard for tenant owner-only routes
+// Uses unified permission system
export const OwnerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- return (
- }
- >
- {children}
-
- );
+ const user = useAuthUser();
+ const tenantAccess = useCurrentTenantAccess();
+
+ // Check using unified permission system
+ const hasAccess = checkCombinedPermission(user as User | undefined, tenantAccess as TenantAccess | undefined, {
+ globalRoles: ['super_admin'],
+ tenantRoles: ['owner']
+ });
+
+ return hasAccess ? <>{children}> : ;
};
\ No newline at end of file
diff --git a/frontend/src/types/roles.ts b/frontend/src/types/roles.ts
index 4681e60a..ef23e9a2 100644
--- a/frontend/src/types/roles.ts
+++ b/frontend/src/types/roles.ts
@@ -1,21 +1,47 @@
/**
* Role Types - Must match backend role definitions exactly
+ *
+ * This system uses TWO DISTINCT role systems for fine-grained access control:
+ *
+ * 1. GLOBAL USER ROLES (Auth Service):
+ * - System-wide permissions across the platform
+ * - Managed by the Auth service
+ * - Stored in the User model
+ * - Used for cross-tenant operations and platform administration
+ *
+ * 2. TENANT-SPECIFIC ROLES (Tenant Service):
+ * - Organization/tenant-level permissions
+ * - Managed by the Tenant service
+ * - Stored in the TenantMember model
+ * - Used for per-tenant access control and team management
+ *
+ * ROLE MAPPING (Tenant → Global):
+ * When creating users through tenant management, tenant roles are mapped to global roles:
+ * - tenant 'admin' → global 'admin' (full administrative access)
+ * - tenant 'member' → global 'manager' (management-level access)
+ * - tenant 'viewer' → global 'user' (basic user access)
+ * - tenant 'owner' → No automatic mapping (owner is tenant-specific)
+ *
+ * This mapping ensures users have appropriate platform-level permissions
+ * that align with their organizational role.
*/
// Global User Roles (Auth Service)
+// Platform-wide roles for system-level access control
export const GLOBAL_USER_ROLES = {
- USER: 'user',
- ADMIN: 'admin',
- MANAGER: 'manager',
- SUPER_ADMIN: 'super_admin',
+ USER: 'user', // Basic authenticated user
+ ADMIN: 'admin', // System administrator
+ MANAGER: 'manager', // Mid-level management access
+ SUPER_ADMIN: 'super_admin', // Full platform access
} as const;
// Tenant-Specific Roles (Tenant Service)
+// Organization-level roles for tenant-scoped operations
export const TENANT_ROLES = {
- OWNER: 'owner',
- ADMIN: 'admin',
- MEMBER: 'member',
- VIEWER: 'viewer',
+ OWNER: 'owner', // Tenant owner (full control, can transfer ownership)
+ ADMIN: 'admin', // Tenant administrator (team management, most operations)
+ MEMBER: 'member', // Standard team member (regular operations)
+ VIEWER: 'viewer', // Read-only observer (view-only access)
} as const;
// Combined role types
diff --git a/frontend/src/utils/permissions.ts b/frontend/src/utils/permissions.ts
new file mode 100644
index 00000000..400cb0ff
--- /dev/null
+++ b/frontend/src/utils/permissions.ts
@@ -0,0 +1,380 @@
+/**
+ * Unified Permission Checking Utility
+ *
+ * This module provides a centralized system for checking permissions across
+ * both global user roles and tenant-specific roles.
+ *
+ * WHEN TO USE WHICH PERMISSION CHECK:
+ *
+ * 1. Use checkGlobalPermission() for:
+ * - Platform-wide features (user management, system settings)
+ * - Cross-tenant operations
+ * - Administrative tools
+ * - Features that aren't tenant-specific
+ *
+ * 2. Use checkTenantPermission() for:
+ * - Tenant-scoped operations (team management, tenant settings)
+ * - Resource access within a tenant (orders, inventory, recipes)
+ * - Organization-specific features
+ * - Most application features
+ *
+ * 3. Use checkCombinedPermission() for:
+ * - Features that require EITHER global OR tenant permissions
+ * - Mixed access scenarios (e.g., super admin OR tenant owner)
+ * - Fallback permission checks
+ *
+ * EXAMPLES:
+ *
+ * // Check if user can manage platform users (global only)
+ * checkGlobalPermission(user, { requiredRole: 'admin' })
+ *
+ * // Check if user can manage tenant team (tenant only)
+ * checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
+ *
+ * // Check if user can access a feature (either global admin or tenant owner)
+ * checkCombinedPermission(user, tenantAccess, {
+ * globalRoles: ['admin', 'super_admin'],
+ * tenantRoles: ['owner']
+ * })
+ */
+
+import {
+ GLOBAL_USER_ROLES,
+ TENANT_ROLES,
+ ROLE_HIERARCHY,
+ hasGlobalRole,
+ hasTenantRole,
+ type GlobalUserRole,
+ type TenantRole,
+ type Role
+} from '../types/roles';
+
+/**
+ * User object structure (from Auth service)
+ */
+export interface User {
+ id: string;
+ email: string;
+ role: GlobalUserRole;
+ full_name?: string;
+ is_active: boolean;
+}
+
+/**
+ * Tenant access object structure (from Tenant service)
+ */
+export interface TenantAccess {
+ has_access: boolean;
+ role: TenantRole;
+ permissions: string[];
+}
+
+/**
+ * Permission check options for global permissions
+ */
+export interface GlobalPermissionOptions {
+ requiredRole: GlobalUserRole;
+ allowHigherRoles?: boolean; // Default: true
+}
+
+/**
+ * Permission check options for tenant permissions
+ */
+export interface TenantPermissionOptions {
+ requiredRole?: TenantRole;
+ requiredPermission?: string;
+ allowHigherRoles?: boolean; // Default: true
+}
+
+/**
+ * Permission check options for combined (global + tenant) permissions
+ */
+export interface CombinedPermissionOptions {
+ globalRoles?: GlobalUserRole[];
+ tenantRoles?: TenantRole[];
+ tenantPermissions?: string[];
+ requireBoth?: boolean; // Default: false (OR logic), true for AND logic
+}
+
+/**
+ * Check if a user has a specific global permission
+ *
+ * @param user - User object from auth store
+ * @param options - Permission requirements
+ * @returns true if user has the required global permission
+ *
+ * @example
+ * // Check if user is an admin
+ * checkGlobalPermission(user, { requiredRole: 'admin' })
+ *
+ * // Check if user is exactly a manager (no higher roles)
+ * checkGlobalPermission(user, { requiredRole: 'manager', allowHigherRoles: false })
+ */
+export function checkGlobalPermission(
+ user: User | null | undefined,
+ options: GlobalPermissionOptions
+): boolean {
+ if (!user || !user.is_active) return false;
+
+ const { requiredRole, allowHigherRoles = true } = options;
+
+ if (allowHigherRoles) {
+ // Check if user has the required role or higher
+ return hasGlobalRole(user.role, requiredRole);
+ } else {
+ // Check for exact role match
+ return user.role === requiredRole;
+ }
+}
+
+/**
+ * Check if a user has a specific tenant permission
+ *
+ * @param tenantAccess - Tenant access object from tenant store
+ * @param options - Permission requirements
+ * @returns true if user has the required tenant permission
+ *
+ * @example
+ * // Check if user is a tenant owner
+ * checkTenantPermission(tenantAccess, { requiredRole: 'owner' })
+ *
+ * // Check if user has a specific permission
+ * checkTenantPermission(tenantAccess, { requiredPermission: 'manage_team' })
+ *
+ * // Check if user is exactly an admin (no higher roles)
+ * checkTenantPermission(tenantAccess, { requiredRole: 'admin', allowHigherRoles: false })
+ */
+export function checkTenantPermission(
+ tenantAccess: TenantAccess | null | undefined,
+ options: TenantPermissionOptions
+): boolean {
+ if (!tenantAccess || !tenantAccess.has_access) return false;
+
+ const { requiredRole, requiredPermission, allowHigherRoles = true } = options;
+
+ // Check role-based permission
+ if (requiredRole) {
+ if (allowHigherRoles) {
+ if (!hasTenantRole(tenantAccess.role, requiredRole)) {
+ return false;
+ }
+ } else {
+ if (tenantAccess.role !== requiredRole) {
+ return false;
+ }
+ }
+ }
+
+ // Check specific permission
+ if (requiredPermission) {
+ if (!tenantAccess.permissions?.includes(requiredPermission)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Check combined global and tenant permissions
+ *
+ * @param user - User object from auth store
+ * @param tenantAccess - Tenant access object from tenant store
+ * @param options - Permission requirements
+ * @returns true if user meets the permission criteria
+ *
+ * @example
+ * // Check if user is either global admin OR tenant owner (OR logic)
+ * checkCombinedPermission(user, tenantAccess, {
+ * globalRoles: ['admin', 'super_admin'],
+ * tenantRoles: ['owner']
+ * })
+ *
+ * // Check if user is global admin AND tenant member (AND logic)
+ * checkCombinedPermission(user, tenantAccess, {
+ * globalRoles: ['admin'],
+ * tenantRoles: ['member', 'admin', 'owner'],
+ * requireBoth: true
+ * })
+ */
+export function checkCombinedPermission(
+ user: User | null | undefined,
+ tenantAccess: TenantAccess | null | undefined,
+ options: CombinedPermissionOptions
+): boolean {
+ const {
+ globalRoles = [],
+ tenantRoles = [],
+ tenantPermissions = [],
+ requireBoth = false
+ } = options;
+
+ // Check global roles
+ const hasGlobalAccess = globalRoles.length === 0 || (
+ user?.is_active &&
+ globalRoles.some(role => hasGlobalRole(user.role, role))
+ );
+
+ // Check tenant roles
+ const hasTenantRoleAccess = tenantRoles.length === 0 || (
+ tenantAccess?.has_access &&
+ tenantRoles.some(role => hasTenantRole(tenantAccess.role, role))
+ );
+
+ // Check tenant permissions
+ const hasTenantPermissionAccess = tenantPermissions.length === 0 || (
+ tenantAccess?.has_access &&
+ tenantPermissions.some(perm => tenantAccess.permissions?.includes(perm))
+ );
+
+ // Combine tenant role and permission checks (must pass at least one)
+ const hasTenantAccess = hasTenantRoleAccess || hasTenantPermissionAccess;
+
+ if (requireBoth) {
+ // AND logic: must have both global and tenant access
+ return hasGlobalAccess && hasTenantAccess;
+ } else {
+ // OR logic: must have either global or tenant access
+ return hasGlobalAccess || hasTenantAccess;
+ }
+}
+
+/**
+ * Check if user can manage team members
+ *
+ * @param user - User object
+ * @param tenantAccess - Tenant access object
+ * @returns true if user can manage team
+ *
+ * @example
+ * canManageTeam(user, tenantAccess)
+ */
+export function canManageTeam(
+ user: User | null | undefined,
+ tenantAccess: TenantAccess | null | undefined
+): boolean {
+ return checkCombinedPermission(user, tenantAccess, {
+ globalRoles: [GLOBAL_USER_ROLES.ADMIN, GLOBAL_USER_ROLES.SUPER_ADMIN],
+ tenantRoles: [TENANT_ROLES.OWNER, TENANT_ROLES.ADMIN]
+ });
+}
+
+/**
+ * Check if user is tenant owner
+ *
+ * @param user - User object
+ * @param tenantAccess - Tenant access object
+ * @returns true if user is owner
+ *
+ * @example
+ * isTenantOwner(user, tenantAccess)
+ */
+export function isTenantOwner(
+ user: User | null | undefined,
+ tenantAccess: TenantAccess | null | undefined
+): boolean {
+ return checkCombinedPermission(user, tenantAccess, {
+ globalRoles: [GLOBAL_USER_ROLES.SUPER_ADMIN], // Super admin can act as owner
+ tenantRoles: [TENANT_ROLES.OWNER]
+ });
+}
+
+/**
+ * Check if user can perform administrative actions
+ *
+ * @param user - User object
+ * @returns true if user has admin access
+ *
+ * @example
+ * canPerformAdminActions(user)
+ */
+export function canPerformAdminActions(
+ user: User | null | undefined
+): boolean {
+ return checkGlobalPermission(user, {
+ requiredRole: GLOBAL_USER_ROLES.ADMIN
+ });
+}
+
+/**
+ * Get user's effective permissions for a tenant
+ *
+ * @param user - User object
+ * @param tenantAccess - Tenant access object
+ * @returns Object with permission flags
+ *
+ * @example
+ * const perms = getEffectivePermissions(user, tenantAccess)
+ * if (perms.canManageTeam) { ... }
+ */
+export function getEffectivePermissions(
+ user: User | null | undefined,
+ tenantAccess: TenantAccess | null | undefined
+) {
+ return {
+ // Global permissions
+ isGlobalAdmin: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.ADMIN }),
+ isSuperAdmin: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.SUPER_ADMIN }),
+ isManager: checkGlobalPermission(user, { requiredRole: GLOBAL_USER_ROLES.MANAGER }),
+
+ // Tenant permissions
+ isTenantOwner: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.OWNER }),
+ isTenantAdmin: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.ADMIN }),
+ isTenantMember: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.MEMBER }),
+ isTenantViewer: checkTenantPermission(tenantAccess, { requiredRole: TENANT_ROLES.VIEWER }),
+
+ // Combined permissions
+ canManageTeam: canManageTeam(user, tenantAccess),
+ canTransferOwnership: isTenantOwner(user, tenantAccess),
+ canPerformAdminActions: canPerformAdminActions(user),
+
+ // Access flags
+ hasGlobalAccess: !!user?.is_active,
+ hasTenantAccess: !!tenantAccess?.has_access,
+ };
+}
+
+/**
+ * Permission validation error types
+ */
+export class PermissionError extends Error {
+ constructor(message: string, public readonly requiredPermissions: string[]) {
+ super(message);
+ this.name = 'PermissionError';
+ }
+}
+
+/**
+ * Assert that user has required permissions, throw error if not
+ *
+ * @param user - User object
+ * @param tenantAccess - Tenant access object
+ * @param options - Permission requirements
+ * @throws PermissionError if user lacks required permissions
+ *
+ * @example
+ * assertPermission(user, tenantAccess, {
+ * tenantRoles: ['owner'],
+ * errorMessage: 'Only tenant owners can perform this action'
+ * })
+ */
+export function assertPermission(
+ user: User | null | undefined,
+ tenantAccess: TenantAccess | null | undefined,
+ options: CombinedPermissionOptions & { errorMessage?: string }
+): void {
+ const hasPermission = checkCombinedPermission(user, tenantAccess, options);
+
+ if (!hasPermission) {
+ const requiredPerms = [
+ ...(options.globalRoles || []),
+ ...(options.tenantRoles || []),
+ ...(options.tenantPermissions || [])
+ ];
+
+ throw new PermissionError(
+ options.errorMessage || 'You do not have permission to perform this action',
+ requiredPerms
+ );
+ }
+}
diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py
index 02bcb361..c3663e3f 100644
--- a/gateway/app/routes/tenant.py
+++ b/gateway/app/routes/tenant.py
@@ -141,15 +141,40 @@ async def proxy_tenant_external(request: Request, tenant_id: str = Path(...), pa
target_path = f"/api/v1/tenants/{tenant_id}/external/{path}".rstrip("/")
return await _proxy_to_external_service(request, target_path)
+# Service-specific analytics routes (must come BEFORE the general analytics route)
+@router.api_route("/{tenant_id}/procurement/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+async def proxy_tenant_procurement_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
+ """Proxy tenant procurement analytics requests to procurement service"""
+ target_path = f"/api/v1/tenants/{tenant_id}/procurement/analytics/{path}".rstrip("/")
+ return await _proxy_to_procurement_service(request, target_path, tenant_id=tenant_id)
+
+@router.api_route("/{tenant_id}/inventory/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+async def proxy_tenant_inventory_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
+ """Proxy tenant inventory analytics requests to inventory service"""
+ target_path = f"/api/v1/tenants/{tenant_id}/inventory/analytics/{path}".rstrip("/")
+ return await _proxy_to_inventory_service(request, target_path, tenant_id=tenant_id)
+
+@router.api_route("/{tenant_id}/production/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+async def proxy_tenant_production_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
+ """Proxy tenant production analytics requests to production service"""
+ target_path = f"/api/v1/tenants/{tenant_id}/production/analytics/{path}".rstrip("/")
+ return await _proxy_to_production_service(request, target_path, tenant_id=tenant_id)
+
+@router.api_route("/{tenant_id}/sales/analytics/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
+async def proxy_tenant_sales_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
+ """Proxy tenant sales analytics requests to sales service"""
+ target_path = f"/api/v1/tenants/{tenant_id}/sales/analytics/{path}".rstrip("/")
+ return await _proxy_to_sales_service(request, target_path)
+
@router.api_route("/{tenant_id}/analytics/{path:path}", methods=["GET", "POST", "OPTIONS"])
async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
- """Proxy tenant analytics requests to sales service"""
+ """Proxy tenant analytics requests to sales service (fallback for non-service-specific analytics)"""
target_path = f"/api/v1/tenants/{tenant_id}/analytics/{path}".rstrip("/")
return await _proxy_to_sales_service(request, target_path)
@router.api_route("/{tenant_id}/onboarding/{path:path}", methods=["GET", "POST", "OPTIONS"])
-async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), path: str = ""):
- """Proxy tenant analytics requests to sales service"""
+async def proxy_tenant_onboarding(request: Request, tenant_id: str = Path(...), path: str = ""):
+ """Proxy tenant onboarding requests to sales service"""
target_path = f"/api/v1/tenants/{tenant_id}/onboarding/{path}".rstrip("/")
return await _proxy_to_sales_service(request, target_path)
diff --git a/services/demo_session/app/api/demo_accounts.py b/services/demo_session/app/api/demo_accounts.py
index 2586fc5b..add3ac38 100644
--- a/services/demo_session/app/api/demo_accounts.py
+++ b/services/demo_session/app/api/demo_accounts.py
@@ -36,9 +36,9 @@ async def get_demo_accounts():
else "Punto de venta con obrador central"
),
"features": (
- ["Gestión de Producción", "Recetas", "Inventario", "Previsión de Demanda", "Ventas"]
+ ["Gestión de Producción", "Recetas", "Inventario", "Ventas", "Previsión de Demanda"]
if account_type == "individual_bakery"
- else ["Gestión de Proveedores", "Inventario", "Ventas", "Pedidos", "Previsión"]
+ else ["Gestión de Proveedores", "Pedidos", "Inventario", "Ventas", "Previsión de Demanda"]
),
"business_model": (
"Producción Local" if account_type == "individual_bakery" else "Obrador Central + Punto de Venta"
diff --git a/services/forecasting/app/api/scenario_operations.py b/services/forecasting/app/api/scenario_operations.py
index d3df18fb..d631ec4f 100644
--- a/services/forecasting/app/api/scenario_operations.py
+++ b/services/forecasting/app/api/scenario_operations.py
@@ -112,7 +112,13 @@ async def simulate_scenario(
tenant_id=tenant_id,
request=forecast_request
)
- baseline_forecasts.extend(multi_day_result.get("forecasts", []))
+ # Convert forecast dictionaries to ForecastResponse objects
+ forecast_dicts = multi_day_result.get("forecasts", [])
+ for forecast_dict in forecast_dicts:
+ if isinstance(forecast_dict, dict):
+ baseline_forecasts.append(ForecastResponse(**forecast_dict))
+ else:
+ baseline_forecasts.append(forecast_dict)
# Step 2: Apply scenario adjustments to generate scenario forecasts
scenario_forecasts = await _apply_scenario_adjustments(
diff --git a/services/procurement/app/api/analytics.py b/services/procurement/app/api/analytics.py
new file mode 100644
index 00000000..e31d2d5b
--- /dev/null
+++ b/services/procurement/app/api/analytics.py
@@ -0,0 +1,82 @@
+# services/procurement/app/api/analytics.py
+"""
+Procurement Analytics API - Reporting, statistics, and insights
+Professional+ tier subscription required
+"""
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Path
+from typing import Optional, Dict, Any
+from uuid import UUID
+from datetime import datetime
+import structlog
+
+from app.services.procurement_service import ProcurementService
+from shared.routing import RouteBuilder
+from shared.auth.access_control import analytics_tier_required
+from shared.auth.decorators import get_current_user_dep
+from app.core.database import get_db
+from app.core.config import settings
+from sqlalchemy.ext.asyncio import AsyncSession
+
+route_builder = RouteBuilder('procurement')
+router = APIRouter(tags=["procurement-analytics"])
+logger = structlog.get_logger()
+
+
+def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
+ """Dependency injection for ProcurementService"""
+ return ProcurementService(db, settings)
+
+
+@router.get(
+ route_builder.build_analytics_route("procurement")
+)
+@analytics_tier_required
+async def get_procurement_analytics(
+ tenant_id: UUID = Path(..., description="Tenant ID"),
+ start_date: Optional[datetime] = Query(None, description="Start date filter"),
+ end_date: Optional[datetime] = Query(None, description="End date filter"),
+ current_user: Dict[str, Any] = Depends(get_current_user_dep),
+ procurement_service: ProcurementService = Depends(get_procurement_service)
+):
+ """Get procurement analytics dashboard for a tenant (Professional+ tier required)"""
+ try:
+ # Call the service method to get actual analytics data
+ analytics_data = await procurement_service.get_procurement_analytics(
+ tenant_id=tenant_id,
+ start_date=start_date,
+ end_date=end_date
+ )
+
+ logger.info("Retrieved procurement analytics", tenant_id=tenant_id)
+ return analytics_data
+
+ except Exception as e:
+ logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
+ raise HTTPException(status_code=500, detail=f"Failed to get procurement analytics: {str(e)}")
+
+
+@router.get(
+ route_builder.build_analytics_route("procurement/trends")
+)
+@analytics_tier_required
+async def get_procurement_trends(
+ tenant_id: UUID = Path(..., description="Tenant ID"),
+ days: int = Query(7, description="Number of days to retrieve trends for", ge=1, le=90),
+ current_user: Dict[str, Any] = Depends(get_current_user_dep),
+ procurement_service: ProcurementService = Depends(get_procurement_service)
+):
+ """Get procurement time-series trends for charts (Professional+ tier required)"""
+ try:
+ # Call the service method to get trends data
+ trends_data = await procurement_service.get_procurement_trends(
+ tenant_id=tenant_id,
+ days=days
+ )
+
+ logger.info("Retrieved procurement trends", tenant_id=tenant_id, days=days)
+ return trends_data
+
+ except Exception as e:
+ logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
+ raise HTTPException(status_code=500, detail=f"Failed to get procurement trends: {str(e)}")
diff --git a/services/procurement/app/main.py b/services/procurement/app/main.py
index 4f6b3e7f..72663116 100644
--- a/services/procurement/app/main.py
+++ b/services/procurement/app/main.py
@@ -94,11 +94,13 @@ service.setup_standard_endpoints()
from app.api.procurement_plans import router as procurement_plans_router
from app.api.purchase_orders import router as purchase_orders_router
from app.api import replenishment # Enhanced Replenishment Planning Routes
+from app.api import analytics # Procurement Analytics Routes
from app.api import internal_demo
service.add_router(procurement_plans_router)
service.add_router(purchase_orders_router)
service.add_router(replenishment.router, prefix="/api/v1/tenants/{tenant_id}", tags=["replenishment"])
+service.add_router(analytics.router, tags=["analytics"]) # RouteBuilder already includes full path
service.add_router(internal_demo.router)
diff --git a/services/procurement/app/repositories/procurement_plan_repository.py b/services/procurement/app/repositories/procurement_plan_repository.py
index eef76870..e4fe0e38 100644
--- a/services/procurement/app/repositories/procurement_plan_repository.py
+++ b/services/procurement/app/repositories/procurement_plan_repository.py
@@ -90,6 +90,30 @@ class ProcurementPlanRepository(BaseRepository):
result = await self.db.execute(stmt)
return result.scalars().all()
+ async def get_plans_by_tenant(
+ self,
+ tenant_id: uuid.UUID,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None
+ ) -> List[ProcurementPlan]:
+ """Get all procurement plans for a tenant with optional date filters"""
+ conditions = [ProcurementPlan.tenant_id == tenant_id]
+
+ if start_date:
+ conditions.append(ProcurementPlan.created_at >= start_date)
+ if end_date:
+ conditions.append(ProcurementPlan.created_at <= end_date)
+
+ stmt = (
+ select(ProcurementPlan)
+ .where(and_(*conditions))
+ .order_by(desc(ProcurementPlan.created_at))
+ .options(selectinload(ProcurementPlan.requirements))
+ )
+
+ result = await self.db.execute(stmt)
+ return result.scalars().all()
+
async def update_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[ProcurementPlan]:
"""Update procurement plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
@@ -204,3 +228,27 @@ class ProcurementRequirementRepository(BaseRepository):
count = result.scalar() or 0
return f"REQ-{count + 1:05d}"
+
+ async def get_requirements_by_tenant(
+ self,
+ tenant_id: uuid.UUID,
+ start_date: Optional[datetime] = None,
+ end_date: Optional[datetime] = None
+ ) -> List[ProcurementRequirement]:
+ """Get all procurement requirements for a tenant with optional date filters"""
+ conditions = [ProcurementPlan.tenant_id == tenant_id]
+
+ if start_date:
+ conditions.append(ProcurementRequirement.created_at >= start_date)
+ if end_date:
+ conditions.append(ProcurementRequirement.created_at <= end_date)
+
+ stmt = (
+ select(ProcurementRequirement)
+ .join(ProcurementPlan)
+ .where(and_(*conditions))
+ .order_by(desc(ProcurementRequirement.created_at))
+ )
+
+ result = await self.db.execute(stmt)
+ return result.scalars().all()
diff --git a/services/procurement/app/services/procurement_service.py b/services/procurement/app/services/procurement_service.py
index c9cd6971..5b883774 100644
--- a/services/procurement/app/services/procurement_service.py
+++ b/services/procurement/app/services/procurement_service.py
@@ -100,15 +100,19 @@ class ProcurementService:
# Initialize Recipe Explosion Service
self.recipe_explosion_service = RecipeExplosionService(
- config=config,
recipes_client=self.recipes_client,
inventory_client=self.inventory_client
)
# Initialize Smart Calculator (keep for backward compatibility)
self.smart_calculator = SmartProcurementCalculator(
- inventory_client=self.inventory_client,
- forecast_client=self.forecast_client
+ procurement_settings={
+ 'use_reorder_rules': True,
+ 'economic_rounding': True,
+ 'respect_storage_limits': True,
+ 'use_supplier_minimums': True,
+ 'optimize_price_tiers': True
+ }
)
# NEW: Initialize advanced planning services
@@ -351,6 +355,325 @@ class ProcurementService:
errors=[str(e)]
)
+ async def get_procurement_analytics(self, tenant_id: uuid.UUID, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None):
+ """
+ Get procurement analytics dashboard data with real supplier data and trends
+ """
+ try:
+ logger.info("Retrieving procurement analytics", tenant_id=tenant_id)
+
+ # Set default date range if not provided
+ if not end_date:
+ end_date = datetime.now()
+ if not start_date:
+ start_date = end_date - timedelta(days=30)
+
+ # Get procurement plans summary
+ plans = await self.plan_repo.get_plans_by_tenant(tenant_id, start_date, end_date)
+ total_plans = len(plans)
+
+ # Calculate summary metrics
+ total_estimated_cost = sum(float(plan.total_estimated_cost or 0) for plan in plans)
+ total_approved_cost = sum(float(plan.total_approved_cost or 0) for plan in plans)
+ cost_variance = total_approved_cost - total_estimated_cost
+
+ # Get requirements for performance metrics
+ requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
+
+ # Calculate performance metrics
+ fulfilled_requirements = [r for r in requirements if r.status == 'received']
+ on_time_deliveries = [r for r in fulfilled_requirements if r.delivery_status == 'delivered']
+
+ fulfillment_rate = len(fulfilled_requirements) / len(requirements) if requirements else 0
+ on_time_rate = len(on_time_deliveries) / len(fulfilled_requirements) if fulfilled_requirements else 0
+
+ # Calculate cost accuracy
+ cost_accuracy = 0
+ if requirements:
+ cost_variance_items = [r for r in requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
+ if cost_variance_items:
+ cost_accuracy = 1.0 - (sum(
+ abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
+ for r in cost_variance_items
+ ) / len(cost_variance_items))
+
+ # ============================================================
+ # TREND CALCULATIONS (7-day comparison)
+ # ============================================================
+ trend_start = end_date - timedelta(days=7)
+ previous_period_end = trend_start
+ previous_period_start = previous_period_end - timedelta(days=7)
+
+ # Get previous period data
+ prev_requirements = await self.requirement_repo.get_requirements_by_tenant(
+ tenant_id, previous_period_start, previous_period_end
+ )
+
+ # Calculate previous period metrics
+ prev_fulfilled = [r for r in prev_requirements if r.status == 'received']
+ prev_on_time = [r for r in prev_fulfilled if r.delivery_status == 'delivered']
+
+ prev_fulfillment_rate = len(prev_fulfilled) / len(prev_requirements) if prev_requirements else 0
+ prev_on_time_rate = len(prev_on_time) / len(prev_fulfilled) if prev_fulfilled else 0
+
+ prev_cost_accuracy = 0
+ if prev_requirements:
+ prev_cost_items = [r for r in prev_requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
+ if prev_cost_items:
+ prev_cost_accuracy = 1.0 - (sum(
+ abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
+ for r in prev_cost_items
+ ) / len(prev_cost_items))
+
+ # Calculate trend percentages
+ fulfillment_trend = self._calculate_trend_percentage(fulfillment_rate, prev_fulfillment_rate)
+ on_time_trend = self._calculate_trend_percentage(on_time_rate, prev_on_time_rate)
+ cost_variance_trend = self._calculate_trend_percentage(cost_accuracy, prev_cost_accuracy)
+
+ # Plan status distribution
+ status_counts = {}
+ for plan in plans:
+ status = plan.status
+ status_counts[status] = status_counts.get(status, 0) + 1
+
+ plan_status_distribution = [
+ {"status": status, "count": count}
+ for status, count in status_counts.items()
+ ]
+
+ # ============================================================
+ # CRITICAL REQUIREMENTS with REAL INVENTORY DATA
+ # ============================================================
+ try:
+ inventory_items = await self.inventory_client.get_all_ingredients(str(tenant_id))
+ inventory_map = {str(item.get('id')): item for item in inventory_items}
+
+ low_stock_count = 0
+ for req in requirements:
+ ingredient_id = str(req.ingredient_id)
+ if ingredient_id in inventory_map:
+ inv_item = inventory_map[ingredient_id]
+ current_stock = float(inv_item.get('quantity_available', 0))
+ reorder_point = float(inv_item.get('reorder_point', 0))
+ if current_stock <= reorder_point:
+ low_stock_count += 1
+ except Exception as e:
+ logger.warning("Failed to get inventory data for critical requirements", error=str(e))
+ low_stock_count = len([r for r in requirements if r.priority == 'high'])
+
+ critical_requirements = {
+ "low_stock": low_stock_count,
+ "overdue": len([r for r in requirements if r.status == 'pending' and r.required_by_date < datetime.now().date()]),
+ "high_priority": len([r for r in requirements if r.priority == 'high'])
+ }
+
+ # Recent plans
+ recent_plans = []
+ for plan in sorted(plans, key=lambda x: x.created_at, reverse=True)[:5]:
+ recent_plans.append({
+ "id": str(plan.id),
+ "plan_number": plan.plan_number,
+ "plan_date": plan.plan_date.isoformat() if plan.plan_date else None,
+ "status": plan.status,
+ "total_requirements": plan.total_requirements or 0,
+ "total_estimated_cost": float(plan.total_estimated_cost or 0),
+ "created_at": plan.created_at.isoformat() if plan.created_at else None
+ })
+
+ # ============================================================
+ # SUPPLIER PERFORMANCE with REAL SUPPLIER DATA
+ # ============================================================
+ supplier_performance = []
+ supplier_reqs = {}
+ for req in requirements:
+ if req.preferred_supplier_id:
+ supplier_id = str(req.preferred_supplier_id)
+ if supplier_id not in supplier_reqs:
+ supplier_reqs[supplier_id] = []
+ supplier_reqs[supplier_id].append(req)
+
+ # Fetch real supplier data
+ try:
+ suppliers_data = await self.suppliers_client.get_all_suppliers(str(tenant_id))
+ suppliers_map = {str(s.get('id')): s for s in suppliers_data}
+
+ for supplier_id, reqs in supplier_reqs.items():
+ fulfilled = len([r for r in reqs if r.status == 'received'])
+ on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
+
+ # Get real supplier info
+ supplier_info = suppliers_map.get(supplier_id, {})
+ supplier_name = supplier_info.get('name', f'Unknown Supplier')
+
+ # Use real quality rating from supplier data
+ quality_score = supplier_info.get('quality_rating', 0)
+ delivery_rating = supplier_info.get('delivery_rating', 0)
+
+ supplier_performance.append({
+ "id": supplier_id,
+ "name": supplier_name,
+ "total_orders": len(reqs),
+ "fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
+ "on_time_rate": on_time / fulfilled if fulfilled else 0,
+ "quality_score": quality_score
+ })
+ except Exception as e:
+ logger.warning("Failed to get supplier data, using fallback", error=str(e))
+ for supplier_id, reqs in supplier_reqs.items():
+ fulfilled = len([r for r in reqs if r.status == 'received'])
+ on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
+ supplier_performance.append({
+ "id": supplier_id,
+ "name": f"Supplier {supplier_id[:8]}...",
+ "total_orders": len(reqs),
+ "fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
+ "on_time_rate": on_time / fulfilled if fulfilled else 0,
+ "quality_score": 0
+ })
+
+ # Cost by category
+ cost_by_category = []
+ category_costs = {}
+ for req in requirements:
+ category = req.product_category or "Uncategorized"
+ category_costs[category] = category_costs.get(category, 0) + float(req.estimated_total_cost or 0)
+
+ for category, amount in category_costs.items():
+ cost_by_category.append({
+ "name": category,
+ "amount": amount
+ })
+
+ # Quality metrics
+ quality_reqs = [r for r in requirements if hasattr(r, 'quality_rating') and r.quality_rating]
+ avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
+ high_quality_count = len([r for r in quality_reqs if r.quality_rating >= 4.0])
+ low_quality_count = len([r for r in quality_reqs if r.quality_rating <= 2.0])
+
+ analytics_data = {
+ "summary": {
+ "total_plans": total_plans,
+ "total_estimated_cost": total_estimated_cost,
+ "total_approved_cost": total_approved_cost,
+ "cost_variance": cost_variance
+ },
+ "performance_metrics": {
+ "average_fulfillment_rate": fulfillment_rate,
+ "average_on_time_delivery": on_time_rate,
+ "cost_accuracy": cost_accuracy,
+ "supplier_performance": avg_quality if quality_reqs else 0,
+ "fulfillment_trend": fulfillment_trend,
+ "on_time_trend": on_time_trend,
+ "cost_variance_trend": cost_variance_trend
+ },
+ "plan_status_distribution": plan_status_distribution,
+ "critical_requirements": critical_requirements,
+ "recent_plans": recent_plans,
+ "supplier_performance": supplier_performance,
+ "cost_by_category": cost_by_category,
+ "quality_metrics": {
+ "avg_score": avg_quality,
+ "high_quality_count": high_quality_count,
+ "low_quality_count": low_quality_count
+ }
+ }
+
+ return analytics_data
+
+ except Exception as e:
+ logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
+ raise
+
+ def _calculate_trend_percentage(self, current_value: float, previous_value: float) -> float:
+ """
+ Calculate percentage change between current and previous values
+ Returns percentage change (e.g., 0.05 for 5% increase, -0.03 for 3% decrease)
+ """
+ if previous_value == 0:
+ return 0.0 if current_value == 0 else 1.0
+
+ change = ((current_value - previous_value) / previous_value)
+ return round(change, 4)
+
+ async def get_procurement_trends(self, tenant_id: uuid.UUID, days: int = 7):
+ """
+ Get time-series procurement trends for charts (last N days)
+ Returns daily metrics for performance and quality trends
+ """
+ try:
+ logger.info("Retrieving procurement trends", tenant_id=tenant_id, days=days)
+
+ end_date = datetime.now()
+ start_date = end_date - timedelta(days=days)
+
+ # Get requirements for the period
+ requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
+
+ # Group requirements by day
+ daily_data = {}
+ for day_offset in range(days):
+ day_date = (start_date + timedelta(days=day_offset)).date()
+ daily_data[day_date] = {
+ 'date': day_date.isoformat(),
+ 'requirements': [],
+ 'fulfillment_rate': 0,
+ 'on_time_rate': 0,
+ 'quality_score': 0
+ }
+
+ # Assign requirements to days based on creation date
+ for req in requirements:
+ req_date = req.created_at.date() if req.created_at else None
+ if req_date and req_date in daily_data:
+ daily_data[req_date]['requirements'].append(req)
+
+ # Calculate daily metrics
+ performance_trend = []
+ quality_trend = []
+
+ for day_date in sorted(daily_data.keys()):
+ day_reqs = daily_data[day_date]['requirements']
+
+ if day_reqs:
+ # Calculate fulfillment rate
+ fulfilled = [r for r in day_reqs if r.status == 'received']
+ fulfillment_rate = len(fulfilled) / len(day_reqs) if day_reqs else 0
+
+ # Calculate on-time rate
+ on_time = [r for r in fulfilled if r.delivery_status == 'delivered']
+ on_time_rate = len(on_time) / len(fulfilled) if fulfilled else 0
+
+ # Calculate quality score
+ quality_reqs = [r for r in day_reqs if hasattr(r, 'quality_rating') and r.quality_rating]
+ avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
+ else:
+ fulfillment_rate = 0
+ on_time_rate = 0
+ avg_quality = 0
+
+ performance_trend.append({
+ 'date': day_date.isoformat(),
+ 'fulfillment_rate': round(fulfillment_rate, 4),
+ 'on_time_rate': round(on_time_rate, 4)
+ })
+
+ quality_trend.append({
+ 'date': day_date.isoformat(),
+ 'quality_score': round(avg_quality, 2)
+ })
+
+ return {
+ 'performance_trend': performance_trend,
+ 'quality_trend': quality_trend,
+ 'period_days': days,
+ 'start_date': start_date.date().isoformat(),
+ 'end_date': end_date.date().isoformat()
+ }
+
+ except Exception as e:
+ logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
+ raise
+
# ============================================================
# Helper Methods
# ============================================================
diff --git a/services/tenant/app/models/tenants.py b/services/tenant/app/models/tenants.py
index 508719d1..b3db0250 100644
--- a/services/tenant/app/models/tenants.py
+++ b/services/tenant/app/models/tenants.py
@@ -84,15 +84,39 @@ class Tenant(Base):
return f""
class TenantMember(Base):
- """Tenant membership model for team access"""
+ """
+ Tenant membership model for team access.
+
+ This model represents TENANT-SPECIFIC roles, which are distinct from global user roles.
+
+ TENANT ROLES (stored here):
+ - owner: Full control of the tenant, can transfer ownership, manage all aspects
+ - admin: Tenant administrator, can manage team members and most operations
+ - member: Standard team member, regular operational access
+ - viewer: Read-only observer, view-only access to tenant data
+
+ ROLE MAPPING TO GLOBAL ROLES:
+ When users are created through tenant management (pilot phase), their tenant role
+ is mapped to a global user role in the Auth service:
+ - tenant 'admin' → global 'admin' (system-wide admin access)
+ - tenant 'member' → global 'manager' (management-level access)
+ - tenant 'viewer' → global 'user' (basic user access)
+ - tenant 'owner' → No automatic global role (owner is tenant-specific)
+
+ This mapping is implemented in app/api/tenant_members.py lines 68-76.
+
+ Note: user_id is a cross-service reference (no FK) to avoid circular dependencies.
+ User enrichment is handled at the service layer via Auth service calls.
+ """
__tablename__ = "tenant_members"
-
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference
-
+
# Role and permissions specific to this tenant
- role = Column(String(50), default="member") # owner, admin, member, viewer
+ # Valid values: 'owner', 'admin', 'member', 'viewer'
+ role = Column(String(50), default="member")
permissions = Column(Text) # JSON string of permissions
# Status
diff --git a/todo.md b/todo.md
new file mode 100644
index 00000000..cc44b425
--- /dev/null
+++ b/todo.md
@@ -0,0 +1,6 @@
+# Analytics API Fix Todo List
+
+- [x] Identify current frontend API calls that need to be updated
+- [ ] Update gateway routing to properly handle analytics requests
+- [ ] Verify backend procurement service analytics endpoint is working
+- [ ] Test the complete API flow