REFACTOR ALL APIs fix 1

This commit is contained in:
Urtzi Alfaro
2025-10-07 07:15:07 +02:00
parent 38fb98bc27
commit 7c72f83c51
47 changed files with 1821 additions and 270 deletions

View File

@@ -35,12 +35,24 @@ docker_build(
context='.', context='.',
dockerfile='./gateway/Dockerfile', dockerfile='./gateway/Dockerfile',
live_update=[ live_update=[
# Fall back to full rebuild if Dockerfile or requirements change
fall_back_on(['./gateway/Dockerfile', './gateway/requirements.txt']),
# Sync Python code changes # Sync Python code changes
sync('./gateway', '/app'), sync('./gateway', '/app'),
sync('./shared', '/app/shared'), sync('./shared', '/app/shared'),
# Restart on Python file changes # Restart on Python file changes
run('kill -HUP 1', trigger=['./gateway/**/*.py', './shared/**/*.py']), run('kill -HUP 1', trigger=['./gateway/**/*.py', './shared/**/*.py']),
],
# Ignore common patterns that don't require rebuilds
ignore=[
'.git',
'**/__pycache__',
'**/*.pyc',
'**/.pytest_cache',
'**/node_modules',
'**/.DS_Store'
] ]
) )
@@ -55,6 +67,10 @@ def build_python_service(service_name, service_path):
context='.', context='.',
dockerfile='./services/' + service_path + '/Dockerfile', dockerfile='./services/' + service_path + '/Dockerfile',
live_update=[ live_update=[
# Fall back to full image build if Dockerfile or requirements change
fall_back_on(['./services/' + service_path + '/Dockerfile',
'./services/' + service_path + '/requirements.txt']),
# Sync service code # Sync service code
sync('./services/' + service_path, '/app'), sync('./services/' + service_path, '/app'),
@@ -74,6 +90,15 @@ def build_python_service(service_name, service_path):
'./services/' + service_path + '/**/*.py', './services/' + service_path + '/**/*.py',
'./shared/**/*.py' './shared/**/*.py'
]), ]),
],
# Ignore common patterns that don't require rebuilds
ignore=[
'.git',
'**/__pycache__',
'**/*.pyc',
'**/.pytest_cache',
'**/node_modules',
'**/.DS_Store'
] ]
) )
@@ -263,7 +288,35 @@ k8s_resource('frontend',
# ============================================================================= # =============================================================================
# Update check interval - how often Tilt checks for file changes # Update check interval - how often Tilt checks for file changes
update_settings(max_parallel_updates=3, k8s_upsert_timeout_secs=60) update_settings(
max_parallel_updates=3,
k8s_upsert_timeout_secs=60
)
# Watch settings - configure file watching behavior
watch_settings(
# Ignore patterns that should never trigger rebuilds
ignore=[
'.git/**',
'**/__pycache__/**',
'**/*.pyc',
'**/.pytest_cache/**',
'**/node_modules/**',
'**/.DS_Store',
'**/*.swp',
'**/*.swo',
'**/.venv/**',
'**/venv/**',
'**/.mypy_cache/**',
'**/.ruff_cache/**',
'**/.tox/**',
'**/htmlcov/**',
'**/.coverage',
'**/dist/**',
'**/build/**',
'**/*.egg-info/**'
]
)
# Optimize for local development # Optimize for local development
# - Automatically stream logs from services with errors # - Automatically stream logs from services with errors

View File

@@ -26,6 +26,10 @@ import {
GetForecastsParams, GetForecastsParams,
ForecastingHealthResponse, ForecastingHealthResponse,
MultiDayForecastResponse, MultiDayForecastResponse,
ScenarioSimulationRequest,
ScenarioSimulationResponse,
ScenarioComparisonRequest,
ScenarioComparisonResponse,
} from '../types/forecasting'; } from '../types/forecasting';
export class ForecastingService { export class ForecastingService {
@@ -258,6 +262,43 @@ export class ForecastingService {
); );
} }
// ===================================================================
// SCENARIO SIMULATION - PROFESSIONAL/ENTERPRISE ONLY
// Backend: services/forecasting/app/api/scenario_operations.py
// ===================================================================
/**
* Run a "what-if" scenario simulation on forecasts
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-simulation
*
* **PROFESSIONAL/ENTERPRISE ONLY**
*/
async simulateScenario(
tenantId: string,
request: ScenarioSimulationRequest
): Promise<ScenarioSimulationResponse> {
return apiClient.post<ScenarioSimulationResponse, ScenarioSimulationRequest>(
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-simulation`,
request
);
}
/**
* Compare multiple scenario simulations
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-comparison
*
* **PROFESSIONAL/ENTERPRISE ONLY**
*/
async compareScenarios(
tenantId: string,
request: ScenarioComparisonRequest
): Promise<ScenarioComparisonResponse> {
return apiClient.post<ScenarioComparisonResponse, ScenarioComparisonRequest>(
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-comparison`,
request
);
}
// =================================================================== // ===================================================================
// Health Check // Health Check
// =================================================================== // ===================================================================

View File

@@ -215,31 +215,31 @@ export class OrdersService {
/** /**
* Get current procurement plan for today * Get current procurement plan for today
* GET /tenants/{tenant_id}/orders/procurement/plans/current * GET /tenants/{tenant_id}/orders/operations/procurement/plans/current
*/ */
static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> { static async getCurrentProcurementPlan(tenantId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/current`); return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/current`);
} }
/** /**
* Get procurement plan by specific date * Get procurement plan by specific date
* GET /tenants/{tenant_id}/orders/procurement/plans/date/{plan_date} * GET /tenants/{tenant_id}/orders/operations/procurement/plans/date/{plan_date}
*/ */
static async getProcurementPlanByDate(tenantId: string, planDate: string): Promise<ProcurementPlanResponse | null> { static async getProcurementPlanByDate(tenantId: string, planDate: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/date/${planDate}`); return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/date/${planDate}`);
} }
/** /**
* Get procurement plan by ID * Get procurement plan by ID
* GET /tenants/{tenant_id}/orders/procurement/plans/id/{plan_id} * GET /tenants/{tenant_id}/orders/operations/procurement/plans/id/{plan_id}
*/ */
static async getProcurementPlanById(tenantId: string, planId: string): Promise<ProcurementPlanResponse | null> { static async getProcurementPlanById(tenantId: string, planId: string): Promise<ProcurementPlanResponse | null> {
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/id/${planId}`); return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/id/${planId}`);
} }
/** /**
* List procurement plans with filtering * List procurement plans with filtering
* GET /tenants/{tenant_id}/orders/procurement/plans/ * GET /tenants/{tenant_id}/orders/operations/procurement/plans/
*/ */
static async getProcurementPlans(params: GetProcurementPlansParams): Promise<PaginatedProcurementPlans> { static async getProcurementPlans(params: GetProcurementPlansParams): Promise<PaginatedProcurementPlans> {
const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params; const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params;
@@ -254,21 +254,21 @@ export class OrdersService {
if (end_date) queryParams.append('end_date', end_date); if (end_date) queryParams.append('end_date', end_date);
return apiClient.get<PaginatedProcurementPlans>( return apiClient.get<PaginatedProcurementPlans>(
`/tenants/${tenant_id}/orders/procurement/plans?${queryParams.toString()}` `/tenants/${tenant_id}/orders/operations/procurement/plans?${queryParams.toString()}`
); );
} }
/** /**
* Generate a new procurement plan * Generate a new procurement plan
* POST /tenants/{tenant_id}/orders/procurement/plans/generate * POST /tenants/{tenant_id}/orders/operations/procurement/plans/generate
*/ */
static async generateProcurementPlan(tenantId: string, request: GeneratePlanRequest): Promise<GeneratePlanResponse> { static async generateProcurementPlan(tenantId: string, request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/procurement/plans/generate`, request); return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/operations/procurement/plans/generate`, request);
} }
/** /**
* Update procurement plan status * Update procurement plan status
* PUT /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/status * PUT /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/status
*/ */
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> { static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
const { tenant_id, plan_id, status } = params; const { tenant_id, plan_id, status } = params;
@@ -276,7 +276,7 @@ export class OrdersService {
const queryParams = new URLSearchParams({ status }); const queryParams = new URLSearchParams({ status });
return apiClient.put<ProcurementPlanResponse>( return apiClient.put<ProcurementPlanResponse>(
`/tenants/${tenant_id}/orders/procurement/plans/${plan_id}/status?${queryParams.toString()}`, `/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
{} {}
); );
} }
@@ -291,7 +291,7 @@ export class OrdersService {
/** /**
* Get requirements for a specific plan * Get requirements for a specific plan
* GET /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/requirements * GET /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/requirements
*/ */
static async getPlanRequirements(params: GetPlanRequirementsParams): Promise<ProcurementRequirementResponse[]> { static async getPlanRequirements(params: GetPlanRequirementsParams): Promise<ProcurementRequirementResponse[]> {
const { tenant_id, plan_id, status, priority } = params; const { tenant_id, plan_id, status, priority } = params;
@@ -300,36 +300,36 @@ export class OrdersService {
if (status) queryParams.append('status', status); if (status) queryParams.append('status', status);
if (priority) queryParams.append('priority', priority); if (priority) queryParams.append('priority', priority);
const url = `/tenants/${tenant_id}/orders/procurement/plans/${plan_id}/requirements${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; const url = `/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/requirements${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
return apiClient.get<ProcurementRequirementResponse[]>(url); return apiClient.get<ProcurementRequirementResponse[]>(url);
} }
/** /**
* Get critical requirements across all plans * Get critical requirements across all plans
* GET /tenants/{tenant_id}/orders/procurement/requirements/critical * GET /tenants/{tenant_id}/orders/operations/procurement/requirements/critical
*/ */
static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> { static async getCriticalRequirements(tenantId: string): Promise<ProcurementRequirementResponse[]> {
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/procurement/requirements/critical`); return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/operations/procurement/requirements/critical`);
} }
/** /**
* Trigger daily scheduler manually * Trigger daily scheduler manually
* POST /tenants/{tenant_id}/orders/procurement/scheduler/trigger * POST /tenants/{tenant_id}/orders/operations/procurement/scheduler/trigger
*/ */
static async triggerDailyScheduler(tenantId: string): Promise<{ success: boolean; message: string; tenant_id: string }> { static async triggerDailyScheduler(tenantId: string): Promise<{ success: boolean; message: string; tenant_id: string }> {
return apiClient.post<{ success: boolean; message: string; tenant_id: string }>( return apiClient.post<{ success: boolean; message: string; tenant_id: string }>(
`/tenants/${tenantId}/orders/procurement/scheduler/trigger`, `/tenants/${tenantId}/orders/operations/procurement/scheduler/trigger`,
{} {}
); );
} }
/** /**
* Get procurement service health * Get procurement service health
* GET /tenants/{tenant_id}/orders/procurement/health * GET /tenants/{tenant_id}/orders/base/procurement/health
*/ */
static async getProcurementHealth(tenantId: string): Promise<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }> { static async getProcurementHealth(tenantId: string): Promise<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }> {
return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/orders/procurement/health`); return apiClient.get<{ status: string; service: string; procurement_enabled: boolean; timestamp: string }>(`/tenants/${tenantId}/orders/base/procurement/health`);
} }
// =================================================================== // ===================================================================
@@ -339,51 +339,51 @@ export class OrdersService {
/** /**
* Recalculate an existing procurement plan * Recalculate an existing procurement plan
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/recalculate * POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/recalculate
*/ */
static async recalculateProcurementPlan(tenantId: string, planId: string): Promise<GeneratePlanResponse> { static async recalculateProcurementPlan(tenantId: string, planId: string): Promise<GeneratePlanResponse> {
return apiClient.post<GeneratePlanResponse>( return apiClient.post<GeneratePlanResponse>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/recalculate`, `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/recalculate`,
{} {}
); );
} }
/** /**
* Approve a procurement plan with notes * Approve a procurement plan with notes
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/approve * POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/approve
*/ */
static async approveProcurementPlan(tenantId: string, planId: string, request?: ApprovalRequest): Promise<ProcurementPlanResponse> { static async approveProcurementPlan(tenantId: string, planId: string, request?: ApprovalRequest): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>( return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/approve`, `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/approve`,
request || {} request || {}
); );
} }
/** /**
* Reject a procurement plan with notes * Reject a procurement plan with notes
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/reject * POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/reject
*/ */
static async rejectProcurementPlan(tenantId: string, planId: string, request?: RejectionRequest): Promise<ProcurementPlanResponse> { static async rejectProcurementPlan(tenantId: string, planId: string, request?: RejectionRequest): Promise<ProcurementPlanResponse> {
return apiClient.post<ProcurementPlanResponse>( return apiClient.post<ProcurementPlanResponse>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/reject`, `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/reject`,
request || {} request || {}
); );
} }
/** /**
* Create purchase orders automatically from procurement plan * Create purchase orders automatically from procurement plan
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/create-purchase-orders * POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/create-purchase-orders
*/ */
static async createPurchaseOrdersFromPlan(tenantId: string, planId: string, autoApprove: boolean = false): Promise<CreatePOsResult> { static async createPurchaseOrdersFromPlan(tenantId: string, planId: string, autoApprove: boolean = false): Promise<CreatePOsResult> {
return apiClient.post<CreatePOsResult>( return apiClient.post<CreatePOsResult>(
`/tenants/${tenantId}/orders/procurement/plans/${planId}/create-purchase-orders`, `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/create-purchase-orders`,
{ auto_approve: autoApprove } { auto_approve: autoApprove }
); );
} }
/** /**
* Link a procurement requirement to a purchase order * Link a procurement requirement to a purchase order
* POST /tenants/{tenant_id}/orders/procurement/requirements/{requirement_id}/link-purchase-order * POST /tenants/{tenant_id}/orders/operations/procurement/requirements/{requirement_id}/link-purchase-order
*/ */
static async linkRequirementToPurchaseOrder( static async linkRequirementToPurchaseOrder(
tenantId: string, tenantId: string,
@@ -391,14 +391,14 @@ export class OrdersService {
request: LinkRequirementToPORequest request: LinkRequirementToPORequest
): Promise<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }> { ): Promise<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }> {
return apiClient.post<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }>( return apiClient.post<{ success: boolean; message: string; requirement_id: string; purchase_order_id: string }>(
`/tenants/${tenantId}/orders/procurement/requirements/${requirementId}/link-purchase-order`, `/tenants/${tenantId}/orders/operations/procurement/requirements/${requirementId}/link-purchase-order`,
request request
); );
} }
/** /**
* Update delivery status for a requirement * Update delivery status for a requirement
* PUT /tenants/{tenant_id}/orders/procurement/requirements/{requirement_id}/delivery-status * PUT /tenants/{tenant_id}/orders/operations/procurement/requirements/{requirement_id}/delivery-status
*/ */
static async updateRequirementDeliveryStatus( static async updateRequirementDeliveryStatus(
tenantId: string, tenantId: string,
@@ -406,7 +406,7 @@ export class OrdersService {
request: UpdateDeliveryStatusRequest request: UpdateDeliveryStatusRequest
): Promise<{ success: boolean; message: string; requirement_id: string; delivery_status: string }> { ): Promise<{ success: boolean; message: string; requirement_id: string; delivery_status: string }> {
return apiClient.put<{ success: boolean; message: string; requirement_id: string; delivery_status: string }>( return apiClient.put<{ success: boolean; message: string; requirement_id: string; delivery_status: string }>(
`/tenants/${tenantId}/orders/procurement/requirements/${requirementId}/delivery-status`, `/tenants/${tenantId}/orders/operations/procurement/requirements/${requirementId}/delivery-status`,
request request
); );
} }

View File

@@ -188,10 +188,10 @@ export class RecipesService {
/** /**
* Get recipe statistics for dashboard * Get recipe statistics for dashboard
* GET /tenants/{tenant_id}/recipes/statistics/dashboard * GET /tenants/{tenant_id}/recipes/dashboard/statistics
*/ */
async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> { async getRecipeStatistics(tenantId: string): Promise<RecipeStatisticsResponse> {
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/statistics/dashboard`); return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/dashboard/statistics`);
} }
/** /**

View File

@@ -100,7 +100,7 @@ export class SalesService {
} }
async getProductCategories(tenantId: string): Promise<string[]> { async getProductCategories(tenantId: string): Promise<string[]> {
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/sales/categories`); return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/categories`);
} }
// =================================================================== // ===================================================================

View File

@@ -89,27 +89,27 @@ export class SubscriptionService {
} }
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> { async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`); return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/subscriptions/${tenantId}/validate-upgrade/${planKey}`);
} }
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> { async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {}); return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/subscriptions/${tenantId}/upgrade?new_plan=${planKey}`, {});
} }
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-location`); return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-location`);
} }
async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { async canAddProduct(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-product`); return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-product`);
} }
async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> { async canAddUser(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/can-add-user`); return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/can-add-user`);
} }
async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> { async hasFeature(tenantId: string, featureName: string): Promise<{ has_feature: boolean; feature_value?: any; plan?: string; reason?: string }> {
return apiClient.get(`${this.baseUrl}/${tenantId}/features/${featureName}`); return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/features/${featureName}`);
} }
formatPrice(amount: number): string { formatPrice(amount: number): string {

View File

@@ -173,7 +173,7 @@ class TrainingService {
*/ */
getTrainingWebSocketUrl(tenantId: string, jobId: string): string { getTrainingWebSocketUrl(tenantId: string, jobId: string): string {
const baseWsUrl = apiClient.getAxiosInstance().defaults.baseURL?.replace(/^http/, 'ws'); const baseWsUrl = apiClient.getAxiosInstance().defaults.baseURL?.replace(/^http/, 'ws');
return `${baseWsUrl}/ws/tenants/${tenantId}/training/jobs/${jobId}/live`; return `${baseWsUrl}/tenants/${tenantId}/training/jobs/${jobId}/live`;
} }
/** /**

View File

@@ -90,7 +90,7 @@ export interface ForecastResponse {
// Metadata // Metadata
created_at: string; // ISO datetime string created_at: string; // ISO datetime string
processing_time_ms?: number | null; processing_time_ms?: number | null;
features_used?: Record<string, any> | null; features?: Record<string, any> | null;
} }
/** /**
@@ -260,3 +260,165 @@ export interface PredictionsPerformanceParams {
export interface MessageResponse { export interface MessageResponse {
message: string; message: string;
} }
// ================================================================
// SCENARIO SIMULATION TYPES - PROFESSIONAL/ENTERPRISE ONLY
// ================================================================
/**
* Types of scenarios available for simulation
* Backend: ScenarioType enum in schemas/forecasts.py (lines 114-123)
*/
export enum ScenarioType {
WEATHER = 'weather',
COMPETITION = 'competition',
EVENT = 'event',
PRICING = 'pricing',
PROMOTION = 'promotion',
HOLIDAY = 'holiday',
SUPPLY_DISRUPTION = 'supply_disruption',
CUSTOM = 'custom'
}
/**
* Weather scenario parameters
* Backend: WeatherScenario in schemas/forecasts.py (lines 126-130)
*/
export interface WeatherScenario {
temperature_change?: number | null; // Temperature change in °C (-30 to +30)
precipitation_change?: number | null; // Precipitation change in mm (0-100)
weather_type?: string | null; // Weather type (heatwave, cold_snap, rainy, etc.)
}
/**
* Competition scenario parameters
* Backend: CompetitionScenario in schemas/forecasts.py (lines 133-137)
*/
export interface CompetitionScenario {
new_competitors: number; // Number of new competitors (1-10)
distance_km: number; // Distance from location in km (0.1-10)
estimated_market_share_loss: number; // Estimated market share loss (0-0.5)
}
/**
* Event scenario parameters
* Backend: EventScenario in schemas/forecasts.py (lines 140-145)
*/
export interface EventScenario {
event_type: string; // Type of event (festival, sports, concert, etc.)
expected_attendance: number; // Expected attendance
distance_km: number; // Distance from location in km (0-50)
duration_days: number; // Duration in days (1-30)
}
/**
* Pricing scenario parameters
* Backend: PricingScenario in schemas/forecasts.py (lines 148-151)
*/
export interface PricingScenario {
price_change_percent: number; // Price change percentage (-50 to +100)
affected_products?: string[] | null; // List of affected product IDs
}
/**
* Promotion scenario parameters
* Backend: PromotionScenario in schemas/forecasts.py (lines 154-158)
*/
export interface PromotionScenario {
discount_percent: number; // Discount percentage (0-75)
promotion_type: string; // Type of promotion (bogo, discount, bundle, etc.)
expected_traffic_increase: number; // Expected traffic increase (0-2.0 = 0-200%)
}
/**
* Request schema for scenario simulation
* Backend: ScenarioSimulationRequest in schemas/forecasts.py (lines 161-189)
*/
export interface ScenarioSimulationRequest {
scenario_name: string; // Name for this scenario (3-200 chars)
scenario_type: ScenarioType;
inventory_product_ids: string[]; // Products to simulate (min 1)
start_date: string; // ISO date string
duration_days?: number; // Default: 7, range: 1-30
// Scenario-specific parameters (provide based on scenario_type)
weather_params?: WeatherScenario | null;
competition_params?: CompetitionScenario | null;
event_params?: EventScenario | null;
pricing_params?: PricingScenario | null;
promotion_params?: PromotionScenario | null;
// Custom scenario parameters
custom_multipliers?: Record<string, number> | null;
// Comparison settings
include_baseline?: boolean; // Default: true
}
/**
* Impact of scenario on a specific product
* Backend: ScenarioImpact in schemas/forecasts.py (lines 192-199)
*/
export interface ScenarioImpact {
inventory_product_id: string;
baseline_demand: number;
simulated_demand: number;
demand_change_percent: number;
confidence_range: [number, number];
impact_factors: Record<string, any>;
}
/**
* Response schema for scenario simulation
* Backend: ScenarioSimulationResponse in schemas/forecasts.py (lines 202-256)
*/
export interface ScenarioSimulationResponse {
id: string;
tenant_id: string;
scenario_name: string;
scenario_type: ScenarioType;
// Simulation parameters
start_date: string; // ISO date string
end_date: string; // ISO date string
duration_days: number;
// Results
baseline_forecasts?: ForecastResponse[] | null;
scenario_forecasts: ForecastResponse[];
// Impact summary
total_baseline_demand: number;
total_scenario_demand: number;
overall_impact_percent: number;
product_impacts: ScenarioImpact[];
// Insights and recommendations
insights: string[];
recommendations: string[];
risk_level: string; // low, medium, high
// Metadata
created_at: string; // ISO datetime string
processing_time_ms: number;
}
/**
* Request to compare multiple scenarios
* Backend: ScenarioComparisonRequest in schemas/forecasts.py (lines 259-261)
*/
export interface ScenarioComparisonRequest {
scenario_ids: string[]; // 2-5 scenario IDs to compare
}
/**
* Response comparing multiple scenarios
* Backend: ScenarioComparisonResponse in schemas/forecasts.py (lines 264-270)
*/
export interface ScenarioComparisonResponse {
scenarios: ScenarioSimulationResponse[];
comparison_matrix: Record<string, Record<string, any>>;
best_case_scenario_id: string;
worst_case_scenario_id: string;
recommended_action: string;
}

View File

@@ -401,7 +401,7 @@ export interface ModelMetricsResponse {
rmse: number; // Root Mean Square Error rmse: number; // Root Mean Square Error
r2_score: number; r2_score: number;
training_samples: number; training_samples: number;
features_used: string[]; features?: string[]; // Features used by the model
model_type: string; model_type: string;
created_at?: string | null; // ISO datetime string created_at?: string | null; // ISO datetime string
last_used_at?: string | null; // ISO datetime string last_used_at?: string | null; // ISO datetime string

View File

@@ -247,7 +247,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
{ {
label: "Información que Analiza", label: "Información que Analiza",
value: (() => { value: (() => {
const features = ((model as any).features_used || model.features_used || []); const features = ((model as any).features || []);
const featureCount = features.length; const featureCount = features.length;
if (featureCount === 0) { if (featureCount === 0) {
@@ -338,7 +338,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
}, },
{ {
label: "Patrones descubiertos", label: "Patrones descubiertos",
value: ((model as any).features_used || model.features_used || []).some((f: string) => f.toLowerCase().includes('weekend')) value: ((model as any).features || []).some((f: string) => f.toLowerCase().includes('weekend'))
? "Tu negocio muestra patrones diferentes entre días de semana y fines de semana" ? "Tu negocio muestra patrones diferentes entre días de semana y fines de semana"
: "Este modelo ha aprendido tus patrones regulares de ventas", : "Este modelo ha aprendido tus patrones regulares de ventas",
span: 2 span: 2

View File

@@ -171,6 +171,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
'/app/database/inventory': 'navigation.inventory', '/app/database/inventory': 'navigation.inventory',
'/app/analytics': 'navigation.analytics', '/app/analytics': 'navigation.analytics',
'/app/analytics/forecasting': 'navigation.forecasting', '/app/analytics/forecasting': 'navigation.forecasting',
'/app/analytics/scenario-simulation': 'navigation.scenario_simulation',
'/app/analytics/sales': 'navigation.sales', '/app/analytics/sales': 'navigation.sales',
'/app/analytics/performance': 'navigation.performance', '/app/analytics/performance': 'navigation.performance',
'/app/ai': 'navigation.insights', '/app/ai': 'navigation.insights',

View File

@@ -11,6 +11,7 @@
"pos": "Point of Sale", "pos": "Point of Sale",
"analytics": "Analytics", "analytics": "Analytics",
"forecasting": "Forecasting", "forecasting": "Forecasting",
"scenario_simulation": "Scenario Simulation",
"sales": "Sales", "sales": "Sales",
"performance": "Performance", "performance": "Performance",
"insights": "AI Insights", "insights": "AI Insights",

View File

@@ -11,6 +11,7 @@
"pos": "Punto de Venta", "pos": "Punto de Venta",
"analytics": "Análisis", "analytics": "Análisis",
"forecasting": "Predicción", "forecasting": "Predicción",
"scenario_simulation": "Simulación de Escenarios",
"sales": "Ventas", "sales": "Ventas",
"performance": "Rendimiento", "performance": "Rendimiento",
"insights": "Insights IA", "insights": "Insights IA",

View File

@@ -11,6 +11,7 @@
"pos": "Salmenta-puntua", "pos": "Salmenta-puntua",
"analytics": "Analisiak", "analytics": "Analisiak",
"forecasting": "Aurreikuspenak", "forecasting": "Aurreikuspenak",
"scenario_simulation": "Agertoki-simulazioa",
"sales": "Salmentak", "sales": "Salmentak",
"performance": "Errendimendua", "performance": "Errendimendua",
"insights": "AA ikuspegiak", "insights": "AA ikuspegiak",

View File

@@ -0,0 +1,556 @@
/**
* Scenario Simulation Page - PROFESSIONAL/ENTERPRISE ONLY
*
* Interactive "what-if" analysis tool for strategic planning
* Allows users to test different scenarios and see potential impacts on demand
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useTenantStore } from '../../../../stores';
import { forecastingService } from '../../../../api/services/forecasting';
import {
ScenarioType,
ScenarioSimulationRequest,
ScenarioSimulationResponse,
WeatherScenario,
CompetitionScenario,
EventScenario,
PricingScenario,
PromotionScenario,
} from '../../../../api/types/forecasting';
import {
Card,
Button,
Badge,
} from '../../../../components/ui';
import {
CloudRain,
Sun,
Users,
Calendar,
Tag,
TrendingUp,
AlertTriangle,
CheckCircle,
Lightbulb,
BarChart3,
ArrowUpRight,
ArrowDownRight,
Play,
Sparkles,
} from 'lucide-react';
import { PageHeader } from '../../../../components/layout';
export const ScenarioSimulationPage: React.FC = () => {
const { t } = useTranslation();
const currentTenant = useTenantStore((state) => state.currentTenant);
const [selectedScenarioType, setSelectedScenarioType] = useState<ScenarioType>(ScenarioType.WEATHER);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationResult, setSimulationResult] = useState<ScenarioSimulationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
// Form state
const [scenarioName, setScenarioName] = useState('');
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [durationDays, setDurationDays] = useState(7);
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
// Scenario-specific parameters
const [weatherParams, setWeatherParams] = useState<WeatherScenario>({
temperature_change: 15,
weather_type: 'heatwave',
});
const [competitionParams, setCompetitionParams] = useState<CompetitionScenario>({
new_competitors: 1,
distance_km: 0.5,
estimated_market_share_loss: 0.15,
});
const [eventParams, setEventParams] = useState<EventScenario>({
event_type: 'festival',
expected_attendance: 5000,
distance_km: 1.0,
duration_days: 3,
});
const [pricingParams, setPricingParams] = useState<PricingScenario>({
price_change_percent: 10,
});
const [promotionParams, setPromotionParams] = useState<PromotionScenario>({
discount_percent: 20,
promotion_type: 'discount',
expected_traffic_increase: 0.3,
});
const handleSimulate = async () => {
if (!currentTenant?.id) return;
if (!scenarioName || selectedProducts.length === 0) {
setError('Please provide a scenario name and select at least one product');
return;
}
setIsSimulating(true);
setError(null);
try {
const request: ScenarioSimulationRequest = {
scenario_name: scenarioName,
scenario_type: selectedScenarioType,
inventory_product_ids: selectedProducts,
start_date: startDate,
duration_days: durationDays,
include_baseline: true,
};
// Add scenario-specific parameters
switch (selectedScenarioType) {
case ScenarioType.WEATHER:
request.weather_params = weatherParams;
break;
case ScenarioType.COMPETITION:
request.competition_params = competitionParams;
break;
case ScenarioType.EVENT:
request.event_params = eventParams;
break;
case ScenarioType.PRICING:
request.pricing_params = pricingParams;
break;
case ScenarioType.PROMOTION:
request.promotion_params = promotionParams;
break;
}
const result = await forecastingService.simulateScenario(currentTenant.id, request);
setSimulationResult(result);
} catch (err: any) {
console.error('Simulation error:', err);
if (err.response?.status === 402) {
setError('This feature requires a Professional or Enterprise subscription. Please upgrade your plan to access scenario simulation tools.');
} else {
setError(err.response?.data?.detail || 'Failed to run scenario simulation');
}
} finally {
setIsSimulating(false);
}
};
const scenarioIcons = {
[ScenarioType.WEATHER]: CloudRain,
[ScenarioType.COMPETITION]: Users,
[ScenarioType.EVENT]: Calendar,
[ScenarioType.PRICING]: Tag,
[ScenarioType.PROMOTION]: TrendingUp,
[ScenarioType.HOLIDAY]: Calendar,
[ScenarioType.SUPPLY_DISRUPTION]: AlertTriangle,
[ScenarioType.CUSTOM]: Sparkles,
};
const getRiskLevelColor = (riskLevel: string) => {
switch (riskLevel) {
case 'high':
return 'error';
case 'medium':
return 'warning';
case 'low':
return 'success';
default:
return 'default';
}
};
return (
<div className="space-y-6">
<PageHeader
title={t('analytics.scenario_simulation.title', 'Scenario Simulation')}
subtitle={t('analytics.scenario_simulation.subtitle', 'Test "what-if" scenarios to optimize your planning')}
icon={Sparkles}
status={{
text: t('subscription.professional_enterprise', 'Professional/Enterprise'),
variant: 'primary'
}}
/>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm text-red-800">{error}</p>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column: Configuration */}
<div className="space-y-6">
<Card>
<div className="p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">
{t('analytics.scenario_simulation.configure', 'Configure Scenario')}
</h3>
{/* Scenario Name */}
<div className="space-y-2">
<label className="text-sm font-medium">
{t('analytics.scenario_simulation.scenario_name', 'Scenario Name')}
</label>
<input
type="text"
value={scenarioName}
onChange={(e) => setScenarioName(e.target.value)}
placeholder={t('analytics.scenario_simulation.scenario_name_placeholder', 'e.g., Summer Heatwave Impact')}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
{/* Date Range */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t('analytics.scenario_simulation.start_date', 'Start Date')}
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
min={new Date().toISOString().split('T')[0]}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t('analytics.scenario_simulation.duration', 'Duration (days)')}
</label>
<input
type="number"
value={durationDays}
onChange={(e) => setDurationDays(parseInt(e.target.value) || 7)}
min={1}
max={30}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium mb-3">
{t('analytics.scenario_simulation.scenario_type', 'Scenario Type')}
</h4>
<div className="grid grid-cols-2 gap-3">
{Object.values(ScenarioType).map((type) => {
const Icon = scenarioIcons[type];
return (
<button
key={type}
onClick={() => setSelectedScenarioType(type)}
className={`p-3 border rounded-lg flex items-center gap-2 transition-all ${
selectedScenarioType === type
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Icon className="w-4 h-4" />
<span className="text-sm capitalize">{type.replace('_', ' ')}</span>
</button>
);
})}
</div>
</div>
{/* Scenario-Specific Parameters */}
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-3">
{t('analytics.scenario_simulation.parameters', 'Parameters')}
</h4>
{selectedScenarioType === ScenarioType.WEATHER && (
<div className="space-y-3">
<div>
<label className="text-sm">Temperature Change (°C)</label>
<input
type="number"
value={weatherParams.temperature_change || 0}
onChange={(e) => setWeatherParams({ ...weatherParams, temperature_change: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={-30}
max={30}
/>
</div>
<div>
<label className="text-sm">Weather Type</label>
<select
value={weatherParams.weather_type || 'heatwave'}
onChange={(e) => setWeatherParams({ ...weatherParams, weather_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg mt-1"
>
<option value="heatwave">Heatwave</option>
<option value="cold_snap">Cold Snap</option>
<option value="rainy">Rainy</option>
<option value="stormy">Stormy</option>
</select>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.COMPETITION && (
<div className="space-y-3">
<div>
<label className="text-sm">New Competitors</label>
<input
type="number"
value={competitionParams.new_competitors}
onChange={(e) => setCompetitionParams({ ...competitionParams, new_competitors: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={1}
max={10}
/>
</div>
<div>
<label className="text-sm">Distance (km)</label>
<input
type="number"
step="0.1"
value={competitionParams.distance_km}
onChange={(e) => setCompetitionParams({ ...competitionParams, distance_km: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0.1}
max={10}
/>
</div>
<div>
<label className="text-sm">Est. Market Share Loss (%)</label>
<input
type="number"
value={competitionParams.estimated_market_share_loss * 100}
onChange={(e) => setCompetitionParams({ ...competitionParams, estimated_market_share_loss: parseFloat(e.target.value) / 100 })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0}
max={50}
/>
</div>
</div>
)}
{selectedScenarioType === ScenarioType.PROMOTION && (
<div className="space-y-3">
<div>
<label className="text-sm">Discount (%)</label>
<input
type="number"
value={promotionParams.discount_percent}
onChange={(e) => setPromotionParams({ ...promotionParams, discount_percent: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0}
max={75}
/>
</div>
<div>
<label className="text-sm">Expected Traffic Increase (%)</label>
<input
type="number"
value={promotionParams.expected_traffic_increase * 100}
onChange={(e) => setPromotionParams({ ...promotionParams, expected_traffic_increase: parseFloat(e.target.value) / 100 })}
className="w-full px-3 py-2 border rounded-lg mt-1"
min={0}
max={200}
/>
</div>
</div>
)}
</div>
<Button
onClick={handleSimulate}
disabled={isSimulating || !scenarioName}
className="w-full"
size="lg"
>
<Play className="w-4 h-4 mr-2" />
{isSimulating ? t('common.simulating', 'Simulating...') : t('common.run_simulation', 'Run Simulation')}
</Button>
</div>
</Card>
{/* Quick Examples */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
{t('analytics.scenario_simulation.quick_examples', 'Quick Examples')}
</h3>
<div className="space-y-2">
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.WEATHER);
setScenarioName('Summer Heatwave Next Week');
setWeatherParams({ temperature_change: 15, weather_type: 'heatwave' });
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
>
<Sun className="w-4 h-4 inline mr-2" />
What if a heatwave hits next week?
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.COMPETITION);
setScenarioName('New Bakery Opening Nearby');
setCompetitionParams({ new_competitors: 1, distance_km: 0.3, estimated_market_share_loss: 0.2 });
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
>
<Users className="w-4 h-4 inline mr-2" />
How would a new competitor affect sales?
</button>
<button
onClick={() => {
setSelectedScenarioType(ScenarioType.PROMOTION);
setScenarioName('Weekend Flash Sale');
setPromotionParams({ discount_percent: 25, promotion_type: 'flash_sale', expected_traffic_increase: 0.5 });
}}
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
>
<Tag className="w-4 h-4 inline mr-2" />
Impact of a 25% weekend promotion?
</button>
</div>
</div>
</Card>
</div>
{/* Right Column: Results */}
<div className="space-y-6">
{simulationResult ? (
<>
{/* Impact Summary */}
<Card>
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">{simulationResult.scenario_name}</h3>
<p className="text-sm text-gray-500 capitalize">{simulationResult.scenario_type.replace('_', ' ')}</p>
</div>
<Badge variant={getRiskLevelColor(simulationResult.risk_level)}>
{simulationResult.risk_level} risk
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm text-gray-500 mb-1">Baseline Demand</div>
<div className="text-2xl font-bold">{Math.round(simulationResult.total_baseline_demand)}</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<div className="text-sm text-gray-500 mb-1">Scenario Demand</div>
<div className="text-2xl font-bold">{Math.round(simulationResult.total_scenario_demand)}</div>
</div>
</div>
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Overall Impact</span>
<div className="flex items-center gap-2">
{simulationResult.overall_impact_percent > 0 ? (
<ArrowUpRight className="w-5 h-5 text-green-500" />
) : (
<ArrowDownRight className="w-5 h-5 text-red-500" />
)}
<span className={`text-2xl font-bold ${
simulationResult.overall_impact_percent > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{simulationResult.overall_impact_percent > 0 ? '+' : ''}
{simulationResult.overall_impact_percent.toFixed(1)}%
</span>
</div>
</div>
</div>
</div>
</Card>
{/* Insights */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Lightbulb className="w-4 h-4" />
{t('analytics.scenario_simulation.insights', 'Key Insights')}
</h3>
<div className="space-y-2">
{simulationResult.insights.map((insight, index) => (
<div key={index} className="flex items-start gap-2 text-sm">
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<span>{insight}</span>
</div>
))}
</div>
</div>
</Card>
{/* Recommendations */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{t('analytics.scenario_simulation.recommendations', 'Recommendations')}
</h3>
<div className="space-y-2">
{simulationResult.recommendations.map((recommendation, index) => (
<div key={index} className="flex items-start gap-2 text-sm p-3 bg-blue-50 rounded-lg">
<span className="font-medium text-blue-600">{index + 1}.</span>
<span>{recommendation}</span>
</div>
))}
</div>
</div>
</Card>
{/* Product Impacts */}
{simulationResult.product_impacts.length > 0 && (
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
{t('analytics.scenario_simulation.product_impacts', 'Product Impacts')}
</h3>
<div className="space-y-3">
{simulationResult.product_impacts.map((impact, index) => (
<div key={index} className="p-3 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">{impact.inventory_product_id}</span>
<span className={`text-sm font-bold ${
impact.demand_change_percent > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{impact.demand_change_percent > 0 ? '+' : ''}
{impact.demand_change_percent.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Baseline: {Math.round(impact.baseline_demand)}</span>
<span></span>
<span>Scenario: {Math.round(impact.simulated_demand)}</span>
</div>
</div>
))}
</div>
</div>
</Card>
)}
</>
) : (
<Card>
<div className="p-12 text-center text-gray-400">
<Sparkles className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">
{t('analytics.scenario_simulation.no_results', 'Configure and run a scenario to see results')}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
};
export default ScenarioSimulationPage;

View File

@@ -17,6 +17,7 @@ import AddStockModal from '../../../../components/domain/inventory/AddStockModal
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory'; import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId'; import { useTenantId } from '../../../../hooks/useTenantId';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory'; import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
import { subscriptionService } from '../../../../api/services/subscription';
const InventoryPage: React.FC = () => { const InventoryPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -312,7 +313,21 @@ const InventoryPage: React.FC = () => {
// Handle creating a new ingredient // Handle creating a new ingredient
const handleCreateIngredient = async (ingredientData: IngredientCreate) => { const handleCreateIngredient = async (ingredientData: IngredientCreate) => {
if (!tenantId) {
throw new Error('No tenant ID available');
}
try { try {
// Check subscription limits before creating
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'inventory_items', 1);
if (!usageCheck.allowed) {
throw new Error(
usageCheck.message ||
`Has alcanzado el límite de ${usageCheck.limit} ingredientes para tu plan. Actualiza tu suscripción para agregar más.`
);
}
await createIngredientMutation.mutateAsync({ await createIngredientMutation.mutateAsync({
tenantId, tenantId,
ingredientData ingredientData

View File

@@ -10,6 +10,7 @@ import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store'; import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast'; import { useToast } from '../../../../hooks/ui/useToast';
import { TENANT_ROLES } from '../../../../types/roles'; import { TENANT_ROLES } from '../../../../types/roles';
import { subscriptionService } from '../../../../api/services/subscription';
const TeamPage: React.FC = () => { const TeamPage: React.FC = () => {
const { t } = useTranslation(['settings']); const { t } = useTranslation(['settings']);
@@ -448,19 +449,35 @@ const TeamPage: React.FC = () => {
onAddMember={async (userData) => { onAddMember={async (userData) => {
if (!tenantId) return Promise.reject('No tenant ID available'); if (!tenantId) return Promise.reject('No tenant ID available');
return addMemberMutation.mutateAsync({ try {
tenantId, // Check subscription limits before adding member
userId: userData.userId, const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1);
role: userData.role,
}).then(() => { if (!usageCheck.allowed) {
const errorMessage = usageCheck.message ||
`Has alcanzado el límite de ${usageCheck.limit} usuarios para tu plan. Actualiza tu suscripción para agregar más miembros.`;
addToast(errorMessage, { type: 'error' });
throw new Error(errorMessage);
}
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId,
role: userData.role,
});
addToast('Miembro agregado exitosamente', { type: 'success' }); addToast('Miembro agregado exitosamente', { type: 'success' });
setShowAddForm(false); setShowAddForm(false);
setSelectedUserToAdd(''); setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER); setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
}).catch((error) => { } catch (error) {
if ((error as Error).message.includes('límite')) {
// Limit error already toasted above
throw error;
}
addToast('Error al agregar miembro', { type: 'error' }); addToast('Error al agregar miembro', { type: 'error' });
throw error; throw error;
}); }
}} }}
availableUsers={availableUsers} availableUsers={availableUsers}
/> />

View File

@@ -26,6 +26,7 @@ const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/
const ProcurementAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProcurementAnalyticsPage')); const ProcurementAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProcurementAnalyticsPage'));
const ForecastingPage = React.lazy(() => import('../pages/app/analytics/forecasting/ForecastingPage')); const ForecastingPage = React.lazy(() => import('../pages/app/analytics/forecasting/ForecastingPage'));
const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales-analytics/SalesAnalyticsPage')); const SalesAnalyticsPage = React.lazy(() => import('../pages/app/analytics/sales-analytics/SalesAnalyticsPage'));
const ScenarioSimulationPage = React.lazy(() => import('../pages/app/analytics/scenario-simulation/ScenarioSimulationPage'));
const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage')); const AIInsightsPage = React.lazy(() => import('../pages/app/analytics/ai-insights/AIInsightsPage'));
const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage')); const PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage'));
@@ -258,6 +259,16 @@ export const AppRouter: React.FC = () => {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/app/analytics/scenario-simulation"
element={
<ProtectedRoute>
<AppShell>
<ScenarioSimulationPage />
</AppShell>
</ProtectedRoute>
}
/>
<Route <Route
path="/app/analytics/ai-insights" path="/app/analytics/ai-insights"
element={ element={

View File

@@ -337,6 +337,18 @@ export const routesConfig: RouteConfig[] = [
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
{
path: '/app/analytics/scenario-simulation',
name: 'ScenarioSimulation',
component: 'ScenarioSimulationPage',
title: 'Simulación de Escenarios',
icon: 'forecasting',
requiresAuth: true,
requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
requiredAnalyticsLevel: 'predictive',
showInNavigation: true,
showInBreadcrumbs: true,
},
{ {
path: '/app/analytics/ai-insights', path: '/app/analytics/ai-insights',
name: 'AIInsights', name: 'AIInsights',

View File

@@ -21,7 +21,7 @@ from app.middleware.logging import LoggingMiddleware
from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.rate_limit import RateLimitMiddleware
from app.middleware.subscription import SubscriptionMiddleware from app.middleware.subscription import SubscriptionMiddleware
from app.middleware.demo_middleware import DemoMiddleware from app.middleware.demo_middleware import DemoMiddleware
from app.routes import auth, tenant, notification, nominatim, user, subscription, demo from app.routes import auth, tenant, notification, nominatim, user, subscription, demo, pos
from shared.monitoring.logging import setup_logging from shared.monitoring.logging import setup_logging
from shared.monitoring.metrics import MetricsCollector from shared.monitoring.metrics import MetricsCollector
@@ -71,6 +71,7 @@ app.include_router(tenant.router, prefix="/api/v1/tenants", tags=["tenants"])
app.include_router(subscription.router, prefix="/api/v1", tags=["subscriptions"]) app.include_router(subscription.router, prefix="/api/v1", tags=["subscriptions"])
app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) app.include_router(notification.router, prefix="/api/v1/notifications", tags=["notifications"])
app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"]) app.include_router(nominatim.router, prefix="/api/v1/nominatim", tags=["location"])
app.include_router(pos.router, prefix="/api/v1/pos", tags=["pos"])
app.include_router(demo.router, prefix="/api/v1", tags=["demo"]) app.include_router(demo.router, prefix="/api/v1", tags=["demo"])
@@ -251,203 +252,106 @@ async def events_stream(request: Request, tenant_id: str):
# WEBSOCKET ROUTING FOR TRAINING SERVICE # WEBSOCKET ROUTING FOR TRAINING SERVICE
# ================================================================ # ================================================================
@app.websocket("/api/v1/ws/tenants/{tenant_id}/training/jobs/{job_id}/live") @app.websocket("/api/v1/tenants/{tenant_id}/training/jobs/{job_id}/live")
async def websocket_training_progress(websocket: WebSocket, tenant_id: str, job_id: str): async def websocket_training_progress(websocket: WebSocket, tenant_id: str, job_id: str):
"""WebSocket proxy that forwards connections directly to training service with enhanced token validation""" """
await websocket.accept() WebSocket proxy that forwards connections directly to training service.
Acts as a pure proxy - does NOT handle websocket logic, just forwards to training service.
# Get token from query params All auth, message handling, and business logic is in the training service.
"""
# Get token from query params (required for training service authentication)
token = websocket.query_params.get("token") token = websocket.query_params.get("token")
if not token: if not token:
logger.warning(f"WebSocket connection rejected - missing token for job {job_id}") logger.warning(f"WebSocket proxy rejected - missing token for job {job_id}")
await websocket.accept()
await websocket.close(code=1008, reason="Authentication token required") await websocket.close(code=1008, reason="Authentication token required")
return return
# Validate token using auth middleware # Accept the connection immediately
from app.middleware.auth import jwt_handler await websocket.accept()
try:
payload = jwt_handler.verify_token(token)
if not payload:
logger.warning(f"WebSocket connection rejected - invalid token for job {job_id}")
await websocket.close(code=1008, reason="Invalid authentication token")
return
# Check token expiration logger.info(f"Gateway proxying WebSocket to training service for job {job_id}, tenant {tenant_id}")
import time
if payload.get('exp', 0) < time.time():
logger.warning(f"WebSocket connection rejected - expired token for job {job_id}")
await websocket.close(code=1008, reason="Token expired")
return
logger.info(f"WebSocket token validated for user {payload.get('email', 'unknown')}") # Build WebSocket URL to training service - forward to the exact same path
except Exception as e:
logger.warning(f"WebSocket token validation failed for job {job_id}: {e}")
await websocket.close(code=1008, reason="Token validation failed")
return
logger.info(f"Proxying WebSocket connection to training service for job {job_id}, tenant {tenant_id}")
# Build WebSocket URL to training service
training_service_base = settings.TRAINING_SERVICE_URL.rstrip('/') training_service_base = settings.TRAINING_SERVICE_URL.rstrip('/')
training_ws_url = training_service_base.replace('http://', 'ws://').replace('https://', 'wss://') training_ws_url = training_service_base.replace('http://', 'ws://').replace('https://', 'wss://')
training_ws_url = f"{training_ws_url}/api/v1/tenants/{tenant_id}/training/jobs/{job_id}/live?token={token}" training_ws_url = f"{training_ws_url}/api/v1/tenants/{tenant_id}/training/jobs/{job_id}/live?token={token}"
training_ws = None training_ws = None
heartbeat_task = None
try: try:
# Connect to training service WebSocket with proper timeout configuration # Connect to training service WebSocket
import websockets import websockets
# Configure timeouts to coordinate with frontend (30s heartbeat) and training service
# DISABLE gateway-level ping to avoid dual-ping conflicts - let frontend handle ping/pong
training_ws = await websockets.connect( training_ws = await websockets.connect(
training_ws_url, training_ws_url,
ping_interval=None, # DISABLED: Let frontend handle ping/pong via message forwarding ping_interval=None, # Let training service handle heartbeat
ping_timeout=None, # DISABLED: No independent ping mechanism ping_timeout=None,
close_timeout=15, # Reasonable close timeout close_timeout=10,
max_size=2**20, # 1MB max message size open_timeout=30, # Allow time for training service to setup
max_queue=32 # Max queued messages max_size=2**20,
max_queue=32
) )
logger.info(f"Connected to training service WebSocket for job {job_id} with gateway ping DISABLED (frontend handles ping/pong)") logger.info(f"Gateway connected to training service WebSocket for job {job_id}")
# Track connection state properly due to FastAPI WebSocket state propagation bug
connection_alive = True
last_activity = asyncio.get_event_loop().time()
async def check_connection_health():
"""Monitor connection health based on activity timestamps only - no WebSocket interference"""
nonlocal connection_alive, last_activity
while connection_alive:
try:
await asyncio.sleep(30) # Check every 30 seconds (aligned with frontend heartbeat)
current_time = asyncio.get_event_loop().time()
# Check if we haven't received any activity for too long
# Frontend sends ping every 30s, so 90s = 3 missed pings before considering dead
if current_time - last_activity > 90:
logger.warning(f"No frontend activity for 90s on job {job_id} - connection may be dead")
# Don't forcibly close - let the forwarding loops handle actual connection issues
# This is just monitoring/logging now
else:
logger.debug(f"Connection health OK for job {job_id} - last activity {int(current_time - last_activity)}s ago")
except Exception as e:
logger.error(f"Connection health monitoring error for job {job_id}: {e}")
break
async def forward_to_training(): async def forward_to_training():
"""Forward messages from frontend to training service with proper error handling""" """Forward messages from frontend to training service"""
nonlocal connection_alive, last_activity
try: try:
while connection_alive and training_ws and training_ws.open: while training_ws and training_ws.open:
try: data = await websocket.receive()
# Use longer timeout to avoid conflicts with frontend 30s heartbeat
# Frontend sends ping every 30s, so we need to allow for some latency
data = await asyncio.wait_for(websocket.receive(), timeout=45.0)
last_activity = asyncio.get_event_loop().time()
# Handle different message types if data.get("type") == "websocket.receive":
if data.get("type") == "websocket.receive": if "text" in data:
if "text" in data: await training_ws.send(data["text"])
message = data["text"] logger.debug(f"Gateway forwarded frontend->training: {data['text'][:100]}")
# Forward text messages to training service elif "bytes" in data:
await training_ws.send(message) await training_ws.send(data["bytes"])
logger.debug(f"Forwarded message to training service for job {job_id}: {message[:100]}...") elif data.get("type") == "websocket.disconnect":
elif "bytes" in data: logger.info(f"Frontend disconnected for job {job_id}")
# Forward binary messages if needed
await training_ws.send(data["bytes"])
# Ping/pong frames are automatically handled by Starlette/FastAPI
except asyncio.TimeoutError:
# No message received in 45 seconds, continue loop
# This allows for frontend 30s heartbeat + network latency + processing time
continue
except Exception as e:
logger.error(f"Error receiving from frontend for job {job_id}: {e}")
connection_alive = False
break break
except Exception as e: except Exception as e:
logger.error(f"Error in forward_to_training for job {job_id}: {e}") logger.error(f"Error forwarding frontend->training for job {job_id}: {e}")
connection_alive = False
async def forward_to_frontend(): async def forward_to_frontend():
"""Forward messages from training service to frontend with proper error handling""" """Forward messages from training service to frontend"""
nonlocal connection_alive, last_activity
try: try:
while connection_alive and training_ws and training_ws.open: while training_ws and training_ws.open:
try: message = await training_ws.recv()
# Use coordinated timeout - training service expects messages every 60s await websocket.send_text(message)
# This should be longer than training service timeout to avoid premature closure logger.debug(f"Gateway forwarded training->frontend: {message[:100]}")
message = await asyncio.wait_for(training_ws.recv(), timeout=75.0)
last_activity = asyncio.get_event_loop().time()
# Forward the message to frontend
await websocket.send_text(message)
logger.debug(f"Forwarded message to frontend for job {job_id}: {message[:100]}...")
except asyncio.TimeoutError:
# No message received in 75 seconds, continue loop
# Training service sends heartbeats, so this indicates potential issues
continue
except Exception as e:
logger.error(f"Error receiving from training service for job {job_id}: {e}")
connection_alive = False
break
except Exception as e: except Exception as e:
logger.error(f"Error in forward_to_frontend for job {job_id}: {e}") logger.error(f"Error forwarding training->frontend for job {job_id}: {e}")
connection_alive = False
# Start connection health monitoring # Run both forwarding tasks concurrently
heartbeat_task = asyncio.create_task(check_connection_health()) await asyncio.gather(
forward_to_training(),
# Run both forwarding tasks concurrently with proper error handling forward_to_frontend(),
try: return_exceptions=True
await asyncio.gather( )
forward_to_training(),
forward_to_frontend(),
return_exceptions=True
)
except Exception as e:
logger.error(f"Error in WebSocket forwarding tasks for job {job_id}: {e}")
finally:
connection_alive = False
except websockets.exceptions.ConnectionClosedError as e: except websockets.exceptions.ConnectionClosedError as e:
logger.warning(f"Training service WebSocket connection closed for job {job_id}: {e}") logger.warning(f"Training service WebSocket closed for job {job_id}: {e}")
except websockets.exceptions.WebSocketException as e: except websockets.exceptions.WebSocketException as e:
logger.error(f"WebSocket exception for job {job_id}: {e}") logger.error(f"WebSocket exception for job {job_id}: {e}")
except Exception as e: except Exception as e:
logger.error(f"WebSocket proxy error for job {job_id}: {e}") logger.error(f"WebSocket proxy error for job {job_id}: {e}")
finally: finally:
# Cleanup # Cleanup
if heartbeat_task and not heartbeat_task.done():
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
if training_ws and not training_ws.closed: if training_ws and not training_ws.closed:
try: try:
await training_ws.close() await training_ws.close()
logger.info(f"Closed training service WebSocket for job {job_id}")
except Exception as e: except Exception as e:
logger.warning(f"Error closing training service WebSocket for job {job_id}: {e}") logger.warning(f"Error closing training service WebSocket for job {job_id}: {e}")
try: try:
if not websocket.client_state.name == 'DISCONNECTED': if not websocket.client_state.name == 'DISCONNECTED':
await websocket.close(code=1000, reason="Proxy connection closed") await websocket.close(code=1000, reason="Proxy closed")
except Exception as e: except Exception as e:
logger.warning(f"Error closing frontend WebSocket for job {job_id}: {e}") logger.warning(f"Error closing frontend WebSocket for job {job_id}: {e}")
logger.info(f"WebSocket proxy cleanup completed for job {job_id}") logger.info(f"Gateway WebSocket proxy cleanup completed for job {job_id}")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -36,7 +36,7 @@ PUBLIC_ROUTES = [
"/api/v1/nominatim/search", "/api/v1/nominatim/search",
"/api/v1/plans", "/api/v1/plans",
"/api/v1/demo/accounts", "/api/v1/demo/accounts",
"/api/v1/demo/session/create" "/api/v1/demo/sessions"
] ]
class AuthMiddleware(BaseHTTPMiddleware): class AuthMiddleware(BaseHTTPMiddleware):

View File

@@ -77,10 +77,9 @@ class DemoMiddleware(BaseHTTPMiddleware):
# Skip demo middleware for demo service endpoints # Skip demo middleware for demo service endpoints
demo_service_paths = [ demo_service_paths = [
"/api/v1/demo/accounts", "/api/v1/demo/accounts",
"/api/v1/demo/session/create", "/api/v1/demo/sessions",
"/api/v1/demo/session/extend",
"/api/v1/demo/session/destroy",
"/api/v1/demo/stats", "/api/v1/demo/stats",
"/api/v1/demo/operations",
] ]
if any(request.url.path.startswith(path) or request.url.path == path for path in demo_service_paths): if any(request.url.path.startswith(path) or request.url.path == path for path in demo_service_paths):
@@ -204,7 +203,7 @@ class DemoMiddleware(BaseHTTPMiddleware):
try: try:
async with httpx.AsyncClient(timeout=5.0) as client: async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get( response = await client.get(
f"{self.demo_session_url}/api/demo/session/{session_id}" f"{self.demo_session_url}/api/v1/demo/sessions/{session_id}"
) )
if response.status_code == 200: if response.status_code == 200:
return response.json() return response.json()
@@ -215,13 +214,9 @@ class DemoMiddleware(BaseHTTPMiddleware):
async def _update_session_activity(self, session_id: str): async def _update_session_activity(self, session_id: str):
"""Update session activity timestamp""" """Update session activity timestamp"""
try: # Note: Activity tracking is handled by the demo service internally
async with httpx.AsyncClient(timeout=2.0) as client: # No explicit endpoint needed - activity is updated on session access
await client.post( pass
f"{self.demo_session_url}/api/demo/session/{session_id}/activity"
)
except Exception as e:
logger.debug("Failed to update activity", session_id=session_id, error=str(e))
def _check_blocked_path(self, path: str) -> Optional[dict]: def _check_blocked_path(self, path: str) -> Optional[dict]:
"""Check if path is explicitly blocked for demo accounts""" """Check if path is explicitly blocked for demo accounts"""

View File

@@ -22,7 +22,7 @@ async def proxy_demo_service(path: str, request: Request):
""" """
# Build the target URL # Build the target URL
demo_service_url = settings.DEMO_SESSION_SERVICE_URL.rstrip('/') demo_service_url = settings.DEMO_SESSION_SERVICE_URL.rstrip('/')
target_url = f"{demo_service_url}/api/demo/{path}" target_url = f"{demo_service_url}/api/v1/demo/{path}"
# Get request body # Get request body
body = None body = None

89
gateway/app/routes/pos.py Normal file
View File

@@ -0,0 +1,89 @@
"""
POS routes for API Gateway - Global POS endpoints
"""
from fastapi import APIRouter, Request, Response, HTTPException
from fastapi.responses import JSONResponse
import httpx
import logging
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
# ================================================================
# GLOBAL POS ENDPOINTS (No tenant context required)
# ================================================================
@router.api_route("/supported-systems", methods=["GET", "OPTIONS"])
async def proxy_supported_systems(request: Request):
"""Proxy supported POS systems request to POS service"""
target_path = "/api/v1/pos/supported-systems"
return await _proxy_to_pos_service(request, target_path)
# ================================================================
# PROXY HELPER FUNCTIONS
# ================================================================
async def _proxy_to_pos_service(request: Request, target_path: str):
"""Proxy request to POS service"""
# Handle OPTIONS requests directly for CORS
if request.method == "OPTIONS":
return Response(
status_code=200,
headers={
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
"Access-Control-Allow-Credentials": "true",
"Access-Control-Max-Age": "86400"
}
)
try:
url = f"{settings.POS_SERVICE_URL}{target_path}"
# Forward headers
headers = dict(request.headers)
headers.pop("host", None)
# Add query parameters
params = dict(request.query_params)
timeout_config = httpx.Timeout(
connect=30.0,
read=60.0,
write=30.0,
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout_config) as client:
response = await client.request(
method=request.method,
url=url,
headers=headers,
params=params
)
# Handle different response types
if response.headers.get("content-type", "").startswith("application/json"):
try:
content = response.json()
except:
content = {"message": "Invalid JSON response from service"}
else:
content = response.text
return JSONResponse(
status_code=response.status_code,
content=content
)
except Exception as e:
logger.error(f"Unexpected error proxying to POS service {target_path}: {e}")
raise HTTPException(
status_code=500,
detail="Internal gateway error"
)

View File

@@ -17,10 +17,10 @@ router = APIRouter()
# SUBSCRIPTION ENDPOINTS - Direct routing to tenant service # SUBSCRIPTION ENDPOINTS - Direct routing to tenant service
# ================================================================ # ================================================================
@router.api_route("/subscriptions/{tenant_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) @router.api_route("/tenants/subscriptions/{tenant_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_subscription_endpoints(request: Request, tenant_id: str = Path(...), path: str = ""): async def proxy_subscription_endpoints(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy subscription requests directly to tenant service""" """Proxy subscription requests directly to tenant service"""
target_path = f"/api/v1/subscriptions/{tenant_id}/{path}".rstrip("/") target_path = f"/api/v1/tenants/subscriptions/{tenant_id}/{path}".rstrip("/")
return await _proxy_to_tenant_service(request, target_path) return await _proxy_to_tenant_service(request, target_path)
@router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"]) @router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"])

View File

@@ -300,6 +300,16 @@ async def proxy_tenant_recipes_with_path(request: Request, tenant_id: str = Path
target_path = f"/api/v1/tenants/{tenant_id}/recipes/{path}".rstrip("/") target_path = f"/api/v1/tenants/{tenant_id}/recipes/{path}".rstrip("/")
return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id) return await _proxy_to_recipes_service(request, target_path, tenant_id=tenant_id)
# ================================================================
# TENANT-SCOPED POS SERVICE ENDPOINTS
# ================================================================
@router.api_route("/{tenant_id}/pos/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
async def proxy_tenant_pos(request: Request, tenant_id: str = Path(...), path: str = ""):
"""Proxy tenant POS requests to POS service"""
target_path = f"/api/v1/tenants/{tenant_id}/pos/{path}".rstrip("/")
return await _proxy_to_pos_service(request, target_path, tenant_id=tenant_id)
# ================================================================ # ================================================================
# PROXY HELPER FUNCTIONS # PROXY HELPER FUNCTIONS
# ================================================================ # ================================================================
@@ -348,6 +358,10 @@ async def _proxy_to_recipes_service(request: Request, target_path: str, tenant_i
"""Proxy request to recipes service""" """Proxy request to recipes service"""
return await _proxy_request(request, target_path, settings.RECIPES_SERVICE_URL, tenant_id=tenant_id) return await _proxy_request(request, target_path, settings.RECIPES_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_to_pos_service(request: Request, target_path: str, tenant_id: str = None):
"""Proxy request to POS service"""
return await _proxy_request(request, target_path, settings.POS_SERVICE_URL, tenant_id=tenant_id)
async def _proxy_request(request: Request, target_path: str, service_url: str, tenant_id: str = None): async def _proxy_request(request: Request, target_path: str, service_url: str, tenant_id: str = None):
"""Generic proxy function with enhanced error handling""" """Generic proxy function with enhanced error handling"""

View File

@@ -12,12 +12,14 @@ metadata:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true" nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: "10m" nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "600" nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
# SSE-specific configuration for long-lived connections # SSE and WebSocket configuration for long-lived connections
nginx.ingress.kubernetes.io/proxy-buffering: "off" nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1" nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/upstream-keepalive-timeout: "3600" nginx.ingress.kubernetes.io/upstream-keepalive-timeout: "3600"
# WebSocket upgrade support
nginx.ingress.kubernetes.io/websocket-services: "gateway-service"
# CORS configuration for HTTPS and local development # CORS configuration for HTTPS and local development
nginx.ingress.kubernetes.io/enable-cors: "true" nginx.ingress.kubernetes.io/enable-cors: "true"
nginx.ingress.kubernetes.io/cors-allow-origin: "https://bakery-ia.local,https://api.bakery-ia.local,https://monitoring.bakery-ia.local,https://localhost" nginx.ingress.kubernetes.io/cors-allow-origin: "https://bakery-ia.local,https://api.bakery-ia.local,https://monitoring.bakery-ia.local,https://localhost"

View File

@@ -11,7 +11,7 @@ metadata:
nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type, Authorization, X-Requested-With, Accept, Origin, Cache-Control" nginx.ingress.kubernetes.io/cors-allow-headers: "Content-Type, Authorization, X-Requested-With, Accept, Origin, Cache-Control"
nginx.ingress.kubernetes.io/cors-allow-credentials: "true" nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
nginx.ingress.kubernetes.io/enable-cors: "true" nginx.ingress.kubernetes.io/enable-cors: "true"
# Development and SSE-specific annotations # Development, SSE and WebSocket annotations
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "600" nginx.ingress.kubernetes.io/proxy-connect-timeout: "600"
nginx.ingress.kubernetes.io/proxy-body-size: "10m" nginx.ingress.kubernetes.io/proxy-body-size: "10m"
@@ -19,6 +19,8 @@ metadata:
nginx.ingress.kubernetes.io/proxy-buffering: "off" nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-http-version: "1.1" nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/upstream-keepalive-timeout: "3600" nginx.ingress.kubernetes.io/upstream-keepalive-timeout: "3600"
# WebSocket upgrade support
nginx.ingress.kubernetes.io/websocket-services: "gateway-service"
spec: spec:
ingressClassName: nginx ingressClassName: nginx
rules: rules:

View File

@@ -55,9 +55,18 @@ class OnboardingRepository:
user_id: str, user_id: str,
step_name: str, step_name: str,
completed: bool, completed: bool,
step_data: Dict[str, Any] = None step_data: Dict[str, Any] = None,
auto_commit: bool = True
) -> UserOnboardingProgress: ) -> UserOnboardingProgress:
"""Insert or update a user's onboarding step""" """Insert or update a user's onboarding step
Args:
user_id: User ID
step_name: Name of the step
completed: Whether the step is completed
step_data: Additional data for the step
auto_commit: Whether to auto-commit (set to False when used within UnitOfWork)
"""
try: try:
completed_at = datetime.now(timezone.utc) if completed else None completed_at = datetime.now(timezone.utc) if completed else None
step_data = step_data or {} step_data = step_data or {}
@@ -86,13 +95,20 @@ class OnboardingRepository:
# Return the updated record # Return the updated record
stmt = stmt.returning(UserOnboardingProgress) stmt = stmt.returning(UserOnboardingProgress)
result = await self.db.execute(stmt) result = await self.db.execute(stmt)
await self.db.commit()
# Only commit if auto_commit is True (not within a UnitOfWork)
if auto_commit:
await self.db.commit()
else:
# Flush to ensure the statement is executed
await self.db.flush()
return result.scalars().first() return result.scalars().first()
except Exception as e: except Exception as e:
logger.error(f"Error upserting step {step_name} for user {user_id}: {e}") logger.error(f"Error upserting step {step_name} for user {user_id}: {e}")
await self.db.rollback() if auto_commit:
await self.db.rollback()
raise raise
async def get_user_summary(self, user_id: str) -> Optional[UserOnboardingSummary]: async def get_user_summary(self, user_id: str) -> Optional[UserOnboardingSummary]:

View File

@@ -109,15 +109,14 @@ class EnhancedAuthService:
await token_repo.create_token(token_data) await token_repo.create_token(token_data)
# Commit transaction # Store subscription plan selection in onboarding progress BEFORE committing
await uow.commit() # This ensures it's part of the same transaction
# Store subscription plan selection in onboarding progress for later retrieval
if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id: if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id:
try: try:
from app.repositories.onboarding_repository import OnboardingRepository from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress from app.models.onboarding import UserOnboardingProgress
# Use upsert_user_step instead of save_step_data to avoid double commits
onboarding_repo = OnboardingRepository(db_session) onboarding_repo = OnboardingRepository(db_session)
plan_data = { plan_data = {
"subscription_plan": user_data.subscription_plan or "starter", "subscription_plan": user_data.subscription_plan or "starter",
@@ -126,17 +125,29 @@ class EnhancedAuthService:
"saved_at": datetime.now(timezone.utc).isoformat() "saved_at": datetime.now(timezone.utc).isoformat()
} }
await onboarding_repo.save_step_data( # Create the onboarding step record with plan data
str(new_user.id), # Note: We use completed=True to mark user_registered as complete
"user_registered", # auto_commit=False to let UnitOfWork handle the commit
plan_data await onboarding_repo.upsert_user_step(
user_id=str(new_user.id),
step_name="user_registered",
completed=True,
step_data=plan_data,
auto_commit=False
) )
logger.info("Subscription plan saved to onboarding progress", logger.info("Subscription plan saved to onboarding progress",
user_id=new_user.id, user_id=new_user.id,
plan=user_data.subscription_plan) plan=user_data.subscription_plan)
except Exception as e: except Exception as e:
logger.warning("Failed to save subscription plan to onboarding progress", error=str(e)) logger.error("Failed to save subscription plan to onboarding progress",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if onboarding data can't be saved
raise
# Commit transaction (includes user, tokens, and onboarding data)
await uow.commit()
# Publish registration event (non-blocking) # Publish registration event (non-blocking)
try: try:

View File

@@ -50,7 +50,7 @@ async def extend_demo_session(
"status": session.status.value, "status": session.status.value,
"created_at": session.created_at, "created_at": session.created_at,
"expires_at": session.expires_at, "expires_at": session.expires_at,
"demo_config": session.metadata.get("demo_config", {}), "demo_config": session.session_metadata.get("demo_config", {}),
"session_token": session_token "session_token": session_token
} }

View File

@@ -82,7 +82,7 @@ async def create_demo_session(
"status": session.status.value, "status": session.status.value,
"created_at": session.created_at, "created_at": session.created_at,
"expires_at": session.expires_at, "expires_at": session.expires_at,
"demo_config": session.metadata.get("demo_config", {}), "demo_config": session.session_metadata.get("demo_config", {}),
"session_token": session_token "session_token": session_token
} }

View File

@@ -59,6 +59,7 @@ class DemoSession(Base):
return { return {
"id": str(self.id), "id": str(self.id),
"session_id": self.session_id, "session_id": self.session_id,
"user_id": str(self.user_id) if self.user_id else None,
"virtual_tenant_id": str(self.virtual_tenant_id), "virtual_tenant_id": str(self.virtual_tenant_id),
"base_demo_tenant_id": str(self.base_demo_tenant_id), "base_demo_tenant_id": str(self.base_demo_tenant_id),
"demo_account_type": self.demo_account_type, "demo_account_type": self.demo_account_type,

View File

@@ -73,7 +73,7 @@ class DemoSessionManager:
last_activity_at=datetime.now(timezone.utc), last_activity_at=datetime.now(timezone.utc),
data_cloned=False, data_cloned=False,
redis_populated=False, redis_populated=False,
metadata={ session_metadata={
"demo_config": demo_config, "demo_config": demo_config,
"extension_count": 0 "extension_count": 0
} }
@@ -133,7 +133,7 @@ class DemoSessionManager:
raise ValueError(f"Cannot extend {session.status.value} session") raise ValueError(f"Cannot extend {session.status.value} session")
# Check extension limit # Check extension limit
extension_count = session.metadata.get("extension_count", 0) extension_count = session.session_metadata.get("extension_count", 0)
if extension_count >= settings.DEMO_SESSION_MAX_EXTENSIONS: if extension_count >= settings.DEMO_SESSION_MAX_EXTENSIONS:
raise ValueError(f"Maximum extensions ({settings.DEMO_SESSION_MAX_EXTENSIONS}) reached") raise ValueError(f"Maximum extensions ({settings.DEMO_SESSION_MAX_EXTENSIONS}) reached")
@@ -144,7 +144,7 @@ class DemoSessionManager:
session.expires_at = new_expires_at session.expires_at = new_expires_at
session.last_activity_at = datetime.now(timezone.utc) session.last_activity_at = datetime.now(timezone.utc)
session.metadata["extension_count"] = extension_count + 1 session.session_metadata["extension_count"] = extension_count + 1
await self.db.commit() await self.db.commit()
await self.db.refresh(session) await self.db.refresh(session)

View File

@@ -0,0 +1,421 @@
"""
Scenario Simulation Operations API - PROFESSIONAL/ENTERPRISE ONLY
Business operations for "what-if" scenario testing and strategic planning
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Path, Request
from typing import List, Dict, Any
from datetime import date, datetime, timedelta
import uuid
from app.schemas.forecasts import (
ScenarioSimulationRequest,
ScenarioSimulationResponse,
ScenarioComparisonRequest,
ScenarioComparisonResponse,
ScenarioType,
ScenarioImpact,
ForecastResponse,
ForecastRequest
)
from app.services.forecasting_service import EnhancedForecastingService
from shared.auth.decorators import get_current_user_dep
from shared.database.base import create_database_manager
from shared.monitoring.decorators import track_execution_time
from shared.monitoring.metrics import get_metrics_collector
from app.core.config import settings
from shared.routing import RouteBuilder
from shared.auth.access_control import require_user_role
route_builder = RouteBuilder('forecasting')
logger = structlog.get_logger()
router = APIRouter(tags=["scenario-simulation"])
def get_enhanced_forecasting_service():
"""Dependency injection for EnhancedForecastingService"""
database_manager = create_database_manager(settings.DATABASE_URL, "forecasting-service")
return EnhancedForecastingService(database_manager)
@router.post(
route_builder.build_analytics_route("scenario-simulation"),
response_model=ScenarioSimulationResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@track_execution_time("scenario_simulation_duration_seconds", "forecasting-service")
async def simulate_scenario(
request: ScenarioSimulationRequest,
tenant_id: str = Path(..., description="Tenant ID"),
request_obj: Request = None,
forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
):
"""
Run a "what-if" scenario simulation on forecasts
This endpoint allows users to test how different scenarios might impact demand:
- Weather events (heatwaves, cold snaps, rain)
- Competition (new competitors opening nearby)
- Events (festivals, concerts, sports events)
- Pricing changes
- Promotions
- Supply disruptions
**PROFESSIONAL/ENTERPRISE ONLY**
"""
metrics = get_metrics_collector(request_obj)
start_time = datetime.utcnow()
try:
logger.info("Starting scenario simulation",
tenant_id=tenant_id,
scenario_name=request.scenario_name,
scenario_type=request.scenario_type.value,
products=len(request.inventory_product_ids))
if metrics:
metrics.increment_counter(f"scenario_simulations_total")
metrics.increment_counter(f"scenario_simulations_{request.scenario_type.value}_total")
# Generate simulation ID
simulation_id = str(uuid.uuid4())
end_date = request.start_date + timedelta(days=request.duration_days - 1)
# Step 1: Generate baseline forecasts
baseline_forecasts = []
if request.include_baseline:
logger.info("Generating baseline forecasts", tenant_id=tenant_id)
for product_id in request.inventory_product_ids:
forecast_request = ForecastRequest(
inventory_product_id=product_id,
forecast_date=request.start_date,
forecast_days=request.duration_days,
location="default" # TODO: Get from tenant settings
)
multi_day_result = await forecasting_service.generate_multi_day_forecast(
tenant_id=tenant_id,
request=forecast_request
)
baseline_forecasts.extend(multi_day_result.get("forecasts", []))
# Step 2: Apply scenario adjustments to generate scenario forecasts
scenario_forecasts = await _apply_scenario_adjustments(
tenant_id=tenant_id,
request=request,
baseline_forecasts=baseline_forecasts if request.include_baseline else [],
forecasting_service=forecasting_service
)
# Step 3: Calculate impacts
product_impacts = _calculate_product_impacts(
baseline_forecasts,
scenario_forecasts,
request.inventory_product_ids
)
# Step 4: Calculate totals
total_baseline_demand = sum(f.predicted_demand for f in baseline_forecasts) if baseline_forecasts else 0
total_scenario_demand = sum(f.predicted_demand for f in scenario_forecasts)
overall_impact_percent = (
((total_scenario_demand - total_baseline_demand) / total_baseline_demand * 100)
if total_baseline_demand > 0 else 0
)
# Step 5: Generate insights and recommendations
insights, recommendations, risk_level = _generate_insights(
request.scenario_type,
request,
product_impacts,
overall_impact_percent
)
# Calculate processing time
processing_time_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
if metrics:
metrics.increment_counter("scenario_simulations_success_total")
metrics.observe_histogram("scenario_simulation_processing_time_ms", processing_time_ms)
logger.info("Scenario simulation completed successfully",
tenant_id=tenant_id,
simulation_id=simulation_id,
overall_impact=f"{overall_impact_percent:.2f}%",
processing_time_ms=processing_time_ms)
return ScenarioSimulationResponse(
id=simulation_id,
tenant_id=tenant_id,
scenario_name=request.scenario_name,
scenario_type=request.scenario_type,
start_date=request.start_date,
end_date=end_date,
duration_days=request.duration_days,
baseline_forecasts=baseline_forecasts if request.include_baseline else None,
scenario_forecasts=scenario_forecasts,
total_baseline_demand=total_baseline_demand,
total_scenario_demand=total_scenario_demand,
overall_impact_percent=overall_impact_percent,
product_impacts=product_impacts,
insights=insights,
recommendations=recommendations,
risk_level=risk_level,
created_at=datetime.utcnow(),
processing_time_ms=processing_time_ms
)
except ValueError as e:
if metrics:
metrics.increment_counter("scenario_simulation_validation_errors_total")
logger.error("Scenario simulation validation error", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
if metrics:
metrics.increment_counter("scenario_simulations_errors_total")
logger.error("Scenario simulation failed", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Scenario simulation failed"
)
async def _apply_scenario_adjustments(
tenant_id: str,
request: ScenarioSimulationRequest,
baseline_forecasts: List[ForecastResponse],
forecasting_service: EnhancedForecastingService
) -> List[ForecastResponse]:
"""
Apply scenario-specific adjustments to forecasts
"""
scenario_forecasts = []
# If no baseline, generate fresh forecasts
if not baseline_forecasts:
for product_id in request.inventory_product_ids:
forecast_request = ForecastRequest(
inventory_product_id=product_id,
forecast_date=request.start_date,
forecast_days=request.duration_days,
location="default"
)
multi_day_result = await forecasting_service.generate_multi_day_forecast(
tenant_id=tenant_id,
request=forecast_request
)
baseline_forecasts = multi_day_result.get("forecasts", [])
# Apply multipliers based on scenario type
for forecast in baseline_forecasts:
adjusted_forecast = forecast.copy()
multiplier = _get_scenario_multiplier(request)
# Adjust predicted demand
adjusted_forecast.predicted_demand *= multiplier
adjusted_forecast.confidence_lower *= multiplier
adjusted_forecast.confidence_upper *= multiplier
scenario_forecasts.append(adjusted_forecast)
return scenario_forecasts
def _get_scenario_multiplier(request: ScenarioSimulationRequest) -> float:
"""
Calculate demand multiplier based on scenario type and parameters
"""
if request.scenario_type == ScenarioType.WEATHER:
if request.weather_params:
# Heatwave increases demand for cold items, decreases for hot items
if request.weather_params.temperature_change and request.weather_params.temperature_change > 10:
return 1.25 # 25% increase during heatwave
elif request.weather_params.temperature_change and request.weather_params.temperature_change < -10:
return 0.85 # 15% decrease during cold snap
elif request.weather_params.precipitation_change and request.weather_params.precipitation_change > 10:
return 0.90 # 10% decrease during heavy rain
return 1.0
elif request.scenario_type == ScenarioType.COMPETITION:
if request.competition_params:
# New competition reduces demand based on market share loss
return 1.0 - request.competition_params.estimated_market_share_loss
return 0.85 # Default 15% reduction
elif request.scenario_type == ScenarioType.EVENT:
if request.event_params:
# Events increase demand based on attendance and proximity
if request.event_params.distance_km < 1.0:
return 1.5 # 50% increase for very close events
elif request.event_params.distance_km < 5.0:
return 1.2 # 20% increase for nearby events
return 1.15 # Default 15% increase
elif request.scenario_type == ScenarioType.PRICING:
if request.pricing_params:
# Price elasticity: typically -0.5 to -2.0
# 10% price increase = 5-20% demand decrease
elasticity = -1.0 # Average elasticity
return 1.0 + (request.pricing_params.price_change_percent / 100) * elasticity
return 1.0
elif request.scenario_type == ScenarioType.PROMOTION:
if request.promotion_params:
# Promotions increase traffic and conversion
traffic_boost = 1.0 + request.promotion_params.expected_traffic_increase
discount_boost = 1.0 + (request.promotion_params.discount_percent / 100) * 0.5
return traffic_boost * discount_boost
return 1.3 # Default 30% increase
elif request.scenario_type == ScenarioType.SUPPLY_DISRUPTION:
return 0.6 # 40% reduction due to limited supply
elif request.scenario_type == ScenarioType.CUSTOM:
if request.custom_multipliers and 'demand' in request.custom_multipliers:
return request.custom_multipliers['demand']
return 1.0
return 1.0
def _calculate_product_impacts(
baseline_forecasts: List[ForecastResponse],
scenario_forecasts: List[ForecastResponse],
product_ids: List[str]
) -> List[ScenarioImpact]:
"""
Calculate per-product impact of the scenario
"""
impacts = []
for product_id in product_ids:
baseline_total = sum(
f.predicted_demand for f in baseline_forecasts
if f.inventory_product_id == product_id
)
scenario_total = sum(
f.predicted_demand for f in scenario_forecasts
if f.inventory_product_id == product_id
)
if baseline_total > 0:
change_percent = ((scenario_total - baseline_total) / baseline_total) * 100
else:
change_percent = 0
# Get confidence ranges
scenario_product_forecasts = [
f for f in scenario_forecasts if f.inventory_product_id == product_id
]
avg_lower = sum(f.confidence_lower for f in scenario_product_forecasts) / len(scenario_product_forecasts) if scenario_product_forecasts else 0
avg_upper = sum(f.confidence_upper for f in scenario_product_forecasts) / len(scenario_product_forecasts) if scenario_product_forecasts else 0
impacts.append(ScenarioImpact(
inventory_product_id=product_id,
baseline_demand=baseline_total,
simulated_demand=scenario_total,
demand_change_percent=change_percent,
confidence_range=(avg_lower, avg_upper),
impact_factors={"primary_driver": "scenario_adjustment"}
))
return impacts
def _generate_insights(
scenario_type: ScenarioType,
request: ScenarioSimulationRequest,
impacts: List[ScenarioImpact],
overall_impact: float
) -> tuple[List[str], List[str], str]:
"""
Generate AI-powered insights and recommendations
"""
insights = []
recommendations = []
risk_level = "low"
# Determine risk level
if abs(overall_impact) > 30:
risk_level = "high"
elif abs(overall_impact) > 15:
risk_level = "medium"
# Generate scenario-specific insights
if scenario_type == ScenarioType.WEATHER:
if request.weather_params:
if request.weather_params.temperature_change and request.weather_params.temperature_change > 10:
insights.append(f"Heatwave of +{request.weather_params.temperature_change}°C expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Increase inventory of cold beverages and refrigerated items")
recommendations.append("Extend operating hours to capture increased evening traffic")
elif request.weather_params.temperature_change and request.weather_params.temperature_change < -10:
insights.append(f"Cold snap of {request.weather_params.temperature_change}°C expected to decrease demand by {abs(overall_impact):.1f}%")
recommendations.append("Increase production of warm comfort foods")
recommendations.append("Reduce inventory of cold items")
elif scenario_type == ScenarioType.COMPETITION:
insights.append(f"New competitor expected to reduce demand by {abs(overall_impact):.1f}%")
recommendations.append("Consider launching loyalty program to retain customers")
recommendations.append("Differentiate with unique product offerings")
recommendations.append("Focus on customer service excellence")
elif scenario_type == ScenarioType.EVENT:
insights.append(f"Local event expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Increase staffing for the event period")
recommendations.append("Stock additional inventory of popular items")
recommendations.append("Consider event-specific promotions")
elif scenario_type == ScenarioType.PRICING:
if overall_impact < 0:
insights.append(f"Price increase expected to reduce demand by {abs(overall_impact):.1f}%")
recommendations.append("Consider smaller price increases")
recommendations.append("Communicate value proposition to customers")
else:
insights.append(f"Price decrease expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Ensure adequate inventory to meet increased demand")
elif scenario_type == ScenarioType.PROMOTION:
insights.append(f"Promotion expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Stock additional inventory before promotion starts")
recommendations.append("Increase staffing during promotion period")
recommendations.append("Prepare marketing materials and signage")
# Add product-specific insights
high_impact_products = [
impact for impact in impacts
if abs(impact.demand_change_percent) > 20
]
if high_impact_products:
insights.append(f"{len(high_impact_products)} products show significant impact (>20% change)")
# Add general recommendation
if risk_level == "high":
recommendations.append("⚠️ High-impact scenario - review and adjust operational plans immediately")
elif risk_level == "medium":
recommendations.append("Monitor situation closely and prepare contingency plans")
return insights, recommendations, risk_level
@router.post(
route_builder.build_analytics_route("scenario-comparison"),
response_model=ScenarioComparisonResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def compare_scenarios(
request: ScenarioComparisonRequest,
tenant_id: str = Path(..., description="Tenant ID")
):
"""
Compare multiple scenario simulations
**PROFESSIONAL/ENTERPRISE ONLY**
"""
# TODO: Implement scenario comparison
# This would retrieve saved scenarios and compare them
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Scenario comparison not yet implemented"
)

View File

@@ -15,7 +15,7 @@ from app.services.forecasting_alert_service import ForecastingAlertService
from shared.service_base import StandardFastAPIService from shared.service_base import StandardFastAPIService
# Import API routers # Import API routers
from app.api import forecasts, forecasting_operations, analytics from app.api import forecasts, forecasting_operations, analytics, scenario_operations
class ForecastingService(StandardFastAPIService): class ForecastingService(StandardFastAPIService):
@@ -166,6 +166,7 @@ service.setup_custom_endpoints()
service.add_router(forecasts.router) service.add_router(forecasts.router)
service.add_router(forecasting_operations.router) service.add_router(forecasting_operations.router)
service.add_router(analytics.router) service.add_router(analytics.router)
service.add_router(scenario_operations.router)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@@ -107,3 +107,166 @@ class MultiDayForecastResponse(BaseModel):
processing_time_ms: int = Field(..., description="Total processing time") processing_time_ms: int = Field(..., description="Total processing time")
# ================================================================
# SCENARIO SIMULATION SCHEMAS - PROFESSIONAL/ENTERPRISE ONLY
# ================================================================
class ScenarioType(str, Enum):
"""Types of scenarios available for simulation"""
WEATHER = "weather" # Weather impact (heatwave, cold snap, rain, etc.)
COMPETITION = "competition" # New competitor opening nearby
EVENT = "event" # Local event (festival, sports, concert, etc.)
PRICING = "pricing" # Price changes
PROMOTION = "promotion" # Promotional campaigns
HOLIDAY = "holiday" # Holiday periods
SUPPLY_DISRUPTION = "supply_disruption" # Supply chain issues
CUSTOM = "custom" # Custom user-defined scenario
class WeatherScenario(BaseModel):
"""Weather scenario parameters"""
temperature_change: Optional[float] = Field(None, ge=-30, le=30, description="Temperature change in °C")
precipitation_change: Optional[float] = Field(None, ge=0, le=100, description="Precipitation change in mm")
weather_type: Optional[str] = Field(None, description="Weather type (heatwave, cold_snap, rainy, etc.)")
class CompetitionScenario(BaseModel):
"""Competition scenario parameters"""
new_competitors: int = Field(1, ge=1, le=10, description="Number of new competitors")
distance_km: float = Field(0.5, ge=0.1, le=10, description="Distance from location in km")
estimated_market_share_loss: float = Field(0.1, ge=0, le=0.5, description="Estimated market share loss (0-50%)")
class EventScenario(BaseModel):
"""Event scenario parameters"""
event_type: str = Field(..., description="Type of event (festival, sports, concert, etc.)")
expected_attendance: int = Field(..., ge=0, description="Expected attendance")
distance_km: float = Field(0.5, ge=0, le=50, description="Distance from location in km")
duration_days: int = Field(1, ge=1, le=30, description="Duration in days")
class PricingScenario(BaseModel):
"""Pricing scenario parameters"""
price_change_percent: float = Field(..., ge=-50, le=100, description="Price change percentage")
affected_products: Optional[List[str]] = Field(None, description="List of affected product IDs")
class PromotionScenario(BaseModel):
"""Promotion scenario parameters"""
discount_percent: float = Field(..., ge=0, le=75, description="Discount percentage")
promotion_type: str = Field(..., description="Type of promotion (bogo, discount, bundle, etc.)")
expected_traffic_increase: float = Field(0.2, ge=0, le=2, description="Expected traffic increase (0-200%)")
class ScenarioSimulationRequest(BaseModel):
"""Request schema for scenario simulation - PROFESSIONAL/ENTERPRISE ONLY"""
scenario_name: str = Field(..., min_length=3, max_length=200, description="Name for this scenario")
scenario_type: ScenarioType = Field(..., description="Type of scenario to simulate")
inventory_product_ids: List[str] = Field(..., min_items=1, description="Products to simulate")
start_date: date = Field(..., description="Simulation start date")
duration_days: int = Field(7, ge=1, le=30, description="Simulation duration in days")
# Scenario-specific parameters (one should be provided based on scenario_type)
weather_params: Optional[WeatherScenario] = None
competition_params: Optional[CompetitionScenario] = None
event_params: Optional[EventScenario] = None
pricing_params: Optional[PricingScenario] = None
promotion_params: Optional[PromotionScenario] = None
# Custom scenario parameters
custom_multipliers: Optional[Dict[str, float]] = Field(
None,
description="Custom multipliers for baseline forecast (e.g., {'demand': 1.2, 'traffic': 0.8})"
)
# Comparison settings
include_baseline: bool = Field(True, description="Include baseline forecast for comparison")
@validator('start_date')
def validate_start_date(cls, v):
if v < date.today():
raise ValueError("Simulation start date cannot be in the past")
return v
class ScenarioImpact(BaseModel):
"""Impact of scenario on a specific product"""
inventory_product_id: str
baseline_demand: float
simulated_demand: float
demand_change_percent: float
confidence_range: tuple[float, float]
impact_factors: Dict[str, Any] # Breakdown of what drove the change
class ScenarioSimulationResponse(BaseModel):
"""Response schema for scenario simulation"""
id: str = Field(..., description="Simulation ID")
tenant_id: str
scenario_name: str
scenario_type: ScenarioType
# Simulation parameters
start_date: date
end_date: date
duration_days: int
# Results
baseline_forecasts: Optional[List[ForecastResponse]] = Field(
None,
description="Baseline forecasts (if requested)"
)
scenario_forecasts: List[ForecastResponse] = Field(..., description="Forecasts with scenario applied")
# Impact summary
total_baseline_demand: float
total_scenario_demand: float
overall_impact_percent: float
product_impacts: List[ScenarioImpact]
# Insights and recommendations
insights: List[str] = Field(..., description="AI-generated insights about the scenario")
recommendations: List[str] = Field(..., description="Actionable recommendations")
risk_level: str = Field(..., description="Risk level: low, medium, high")
# Metadata
created_at: datetime
processing_time_ms: int
class Config:
json_schema_extra = {
"example": {
"id": "scenario_123",
"tenant_id": "tenant_456",
"scenario_name": "Summer Heatwave Impact",
"scenario_type": "weather",
"overall_impact_percent": 15.5,
"insights": [
"Cold beverages expected to increase by 45%",
"Bread products may decrease by 8% due to reduced appetite",
"Ice cream demand projected to surge by 120%"
],
"recommendations": [
"Increase cold beverage inventory by 40%",
"Reduce bread production by 10%",
"Stock additional ice cream varieties"
],
"risk_level": "medium"
}
}
class ScenarioComparisonRequest(BaseModel):
"""Request to compare multiple scenarios"""
scenario_ids: List[str] = Field(..., min_items=2, max_items=5, description="Scenario IDs to compare")
class ScenarioComparisonResponse(BaseModel):
"""Response comparing multiple scenarios"""
scenarios: List[ScenarioSimulationResponse]
comparison_matrix: Dict[str, Dict[str, Any]]
best_case_scenario_id: str
worst_case_scenario_id: str
recommended_action: str

View File

@@ -116,7 +116,7 @@ async def get_food_safety_dashboard(
route_builder.build_dashboard_route("analytics"), route_builder.build_dashboard_route("analytics"),
response_model=InventoryAnalytics response_model=InventoryAnalytics
) )
@analytics_tier_required
async def get_inventory_analytics( async def get_inventory_analytics(
tenant_id: UUID = Path(...), tenant_id: UUID = Path(...),
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"), days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),

View File

@@ -89,7 +89,6 @@ async def acknowledge_alert(
route_builder.build_analytics_route("food-safety-metrics"), route_builder.build_analytics_route("food-safety-metrics"),
response_model=FoodSafetyMetrics response_model=FoodSafetyMetrics
) )
@analytics_tier_required
async def get_food_safety_metrics( async def get_food_safety_metrics(
tenant_id: UUID = Path(...), tenant_id: UUID = Path(...),
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"), days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),

View File

@@ -60,7 +60,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService:
# ===== Dashboard and Analytics Endpoints ===== # ===== Dashboard and Analytics Endpoints =====
@router.get( @router.get(
route_builder.build_base_route("dashboard-summary"), route_builder.build_operations_route("dashboard-summary"),
response_model=OrdersDashboardSummary response_model=OrdersDashboardSummary
) )
async def get_dashboard_summary( async def get_dashboard_summary(

View File

@@ -110,6 +110,7 @@ async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> Procure
@monitor_performance("get_current_procurement_plan") @monitor_performance("get_current_procurement_plan")
async def get_current_procurement_plan( async def get_current_procurement_plan(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -137,6 +138,7 @@ async def get_current_procurement_plan(
async def get_procurement_plan_by_date( async def get_procurement_plan_by_date(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_date: date, plan_date: date,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -168,7 +170,7 @@ async def list_procurement_plans(
end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"),
limit: int = Query(50, ge=1, le=100, description="Number of plans to return"), limit: int = Query(50, ge=1, le=100, description="Number of plans to return"),
offset: int = Query(0, ge=0, description="Number of plans to skip"), offset: int = Query(0, ge=0, description="Number of plans to skip"),
# tenant_access: TenantAccess = Depends(get_current_tenant), current_user: Dict[str, Any] = Depends(get_current_user_dep),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
""" """
@@ -226,6 +228,7 @@ async def list_procurement_plans(
async def generate_procurement_plan( async def generate_procurement_plan(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
request: GeneratePlanRequest, request: GeneratePlanRequest,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -272,6 +275,7 @@ async def update_procurement_plan_status(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"), status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -314,6 +318,7 @@ async def update_procurement_plan_status(
async def get_procurement_plan_by_id( async def get_procurement_plan_by_id(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -354,6 +359,7 @@ async def get_procurement_plan_by_id(
@monitor_performance("get_procurement_dashboard") @monitor_performance("get_procurement_dashboard")
async def get_procurement_dashboard( async def get_procurement_dashboard(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -392,6 +398,7 @@ async def get_plan_requirements(
plan_id: uuid.UUID, plan_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by requirement status"), status: Optional[str] = Query(None, description="Filter by requirement status"),
priority: Optional[str] = Query(None, description="Filter by priority level"), priority: Optional[str] = Query(None, description="Filter by priority level"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -436,6 +443,7 @@ async def get_plan_requirements(
@monitor_performance("get_critical_requirements") @monitor_performance("get_critical_requirements")
async def get_critical_requirements( async def get_critical_requirements(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -469,6 +477,7 @@ async def get_critical_requirements(
async def recalculate_procurement_plan( async def recalculate_procurement_plan(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -514,6 +523,7 @@ async def link_requirement_to_purchase_order(
purchase_order_number: str, purchase_order_number: str,
ordered_quantity: float, ordered_quantity: float,
expected_delivery_date: Optional[date] = None, expected_delivery_date: Optional[date] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -572,6 +582,7 @@ async def update_requirement_delivery_status(
received_quantity: Optional[float] = None, received_quantity: Optional[float] = None,
actual_delivery_date: Optional[date] = None, actual_delivery_date: Optional[date] = None,
quality_rating: Optional[float] = None, quality_rating: Optional[float] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -627,6 +638,7 @@ async def approve_procurement_plan(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
approval_notes: Optional[str] = None, approval_notes: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -683,6 +695,7 @@ async def reject_procurement_plan(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
rejection_notes: Optional[str] = None, rejection_notes: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):
@@ -739,6 +752,7 @@ async def create_purchase_orders_from_plan(
tenant_id: uuid.UUID, tenant_id: uuid.UUID,
plan_id: uuid.UUID, plan_id: uuid.UUID,
auto_approve: bool = False, auto_approve: bool = False,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant), tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service) procurement_service: ProcurementService = Depends(get_procurement_service)
): ):

View File

@@ -18,6 +18,7 @@ from ..schemas.recipes import (
) )
from shared.routing import RouteBuilder, RouteCategory from shared.routing import RouteBuilder, RouteCategory
from shared.auth.access_control import require_user_role from shared.auth.access_control import require_user_role
from shared.auth.decorators import get_current_user_dep
route_builder = RouteBuilder('recipes') route_builder = RouteBuilder('recipes')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -148,8 +149,10 @@ async def check_recipe_feasibility(
route_builder.build_dashboard_route("statistics"), route_builder.build_dashboard_route("statistics"),
response_model=RecipeStatisticsResponse response_model=RecipeStatisticsResponse
) )
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_recipe_statistics( async def get_recipe_statistics(
tenant_id: UUID, tenant_id: UUID,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get recipe statistics for dashboard""" """Get recipe statistics for dashboard"""

View File

@@ -36,7 +36,7 @@ logger = structlog.get_logger()
# ===== Supplier Operations ===== # ===== Supplier Operations =====
@router.get(route_builder.build_operations_route("suppliers/statistics"), response_model=SupplierStatistics) @router.get(route_builder.build_operations_route("statistics"), response_model=SupplierStatistics)
async def get_supplier_statistics( async def get_supplier_statistics(
tenant_id: str = Path(..., description="Tenant ID"), tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)

View File

@@ -587,16 +587,48 @@ async def upgrade_subscription_plan(
detail=validation.get("reason", "Cannot upgrade to this plan") detail=validation.get("reason", "Cannot upgrade to this plan")
) )
# TODO: Implement actual plan upgrade logic # Actually update the subscription plan in the database
# This would involve: from app.core.config import settings
# 1. Update subscription in database from app.repositories.subscription_repository import SubscriptionRepository
# 2. Process payment changes from app.models.tenants import Subscription
# 3. Update billing cycle from shared.database.base import create_database_manager
# 4. Send notifications
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async with database_manager.get_session() as session:
subscription_repo = SubscriptionRepository(Subscription, session)
# Get the active subscription for this tenant
active_subscription = await subscription_repo.get_active_subscription(str(tenant_id))
if not active_subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription found for this tenant"
)
# Update the subscription plan
updated_subscription = await subscription_repo.update_subscription_plan(
str(active_subscription.id),
new_plan
)
# Commit the changes
await session.commit()
logger.info("Subscription plan upgraded successfully",
tenant_id=str(tenant_id),
subscription_id=str(active_subscription.id),
old_plan=active_subscription.plan,
new_plan=new_plan,
user_id=current_user["user_id"])
return { return {
"success": True, "success": True,
"message": f"Plan upgrade to {new_plan} initiated", "message": f"Plan successfully upgraded to {new_plan}",
"old_plan": active_subscription.plan,
"new_plan": new_plan,
"new_monthly_price": updated_subscription.monthly_price,
"validation": validation "validation": validation
} }

View File

@@ -478,6 +478,18 @@ async def training_progress_websocket(
await connection_manager.connect(websocket, job_id, connection_id) await connection_manager.connect(websocket, job_id, connection_id)
logger.info(f"WebSocket connection established for job {job_id}, user {user_id}") logger.info(f"WebSocket connection established for job {job_id}, user {user_id}")
# Send immediate connection confirmation to prevent gateway timeout
try:
await websocket.send_json({
"type": "connected",
"job_id": job_id,
"message": "WebSocket connection established",
"timestamp": str(datetime.now())
})
logger.debug(f"Sent connection confirmation for job {job_id}")
except Exception as e:
logger.error(f"Failed to send connection confirmation for job {job_id}: {e}")
consumer_task = None consumer_task = None
training_completed = False training_completed = False

View File

@@ -21,7 +21,7 @@ class AuthServiceClient(BaseServiceClient):
def get_service_base_path(self) -> str: def get_service_base_path(self) -> str:
"""Return the base path for auth service APIs""" """Return the base path for auth service APIs"""
return "/api/v1/users" return "/api/v1/auth"
async def get_user_onboarding_progress(self, user_id: str) -> Optional[Dict[str, Any]]: async def get_user_onboarding_progress(self, user_id: str) -> Optional[Dict[str, Any]]:
""" """

View File

@@ -166,6 +166,7 @@ class BaseServiceSettings(BaseSettings):
ORDERS_SERVICE_URL: str = os.getenv("ORDERS_SERVICE_URL", "http://bakery-orders-service:8000") ORDERS_SERVICE_URL: str = os.getenv("ORDERS_SERVICE_URL", "http://bakery-orders-service:8000")
SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://bakery-suppliers-service:8000") SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://bakery-suppliers-service:8000")
RECIPES_SERVICE_URL: str = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000") RECIPES_SERVICE_URL: str = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000")
POS_SERVICE_URL: str = os.getenv("POS_SERVICE_URL", "http://pos-service:8000")
NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080") NOMINATIM_SERVICE_URL: str = os.getenv("NOMINATIM_SERVICE_URL", "http://nominatim:8080")
DEMO_SESSION_SERVICE_URL: str = os.getenv("DEMO_SESSION_SERVICE_URL", "http://demo-session-service:8000") DEMO_SESSION_SERVICE_URL: str = os.getenv("DEMO_SESSION_SERVICE_URL", "http://demo-session-service:8000")