From 7c72f83c513071fc3c8e4d20a3058f610bf61bf7 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Tue, 7 Oct 2025 07:15:07 +0200 Subject: [PATCH] REFACTOR ALL APIs fix 1 --- Tiltfile | 55 +- frontend/src/api/services/forecasting.ts | 41 ++ frontend/src/api/services/orders.ts | 70 +-- frontend/src/api/services/recipes.ts | 4 +- frontend/src/api/services/sales.ts | 2 +- frontend/src/api/services/subscription.ts | 12 +- frontend/src/api/services/training.ts | 2 +- frontend/src/api/types/forecasting.ts | 164 +++++- frontend/src/api/types/training.ts | 2 +- .../domain/forecasting/ModelDetailsModal.tsx | 4 +- .../src/components/layout/Sidebar/Sidebar.tsx | 1 + frontend/src/locales/en/common.json | 1 + frontend/src/locales/es/common.json | 1 + frontend/src/locales/eu/common.json | 1 + .../ScenarioSimulationPage.tsx | 556 ++++++++++++++++++ .../operations/inventory/InventoryPage.tsx | 15 + .../src/pages/app/settings/team/TeamPage.tsx | 33 +- frontend/src/router/AppRouter.tsx | 23 +- frontend/src/router/routes.config.ts | 12 + gateway/app/main.py | 198 ++----- gateway/app/middleware/auth.py | 2 +- gateway/app/middleware/demo_middleware.py | 17 +- gateway/app/routes/demo.py | 2 +- gateway/app/routes/pos.py | 89 +++ gateway/app/routes/subscription.py | 4 +- gateway/app/routes/tenant.py | 14 + .../kubernetes/base/ingress-https.yaml | 6 +- .../kubernetes/overlays/dev/dev-ingress.yaml | 4 +- .../app/repositories/onboarding_repository.py | 42 +- services/auth/app/services/auth_service.py | 29 +- .../demo_session/app/api/demo_operations.py | 2 +- .../demo_session/app/api/demo_sessions.py | 2 +- .../demo_session/app/models/demo_session.py | 1 + .../app/services/session_manager.py | 6 +- .../app/api/scenario_operations.py | 421 +++++++++++++ services/forecasting/app/main.py | 3 +- services/forecasting/app/schemas/forecasts.py | 163 +++++ services/inventory/app/api/dashboard.py | 2 +- .../app/api/food_safety_operations.py | 1 - services/orders/app/api/order_operations.py | 2 +- .../orders/app/api/procurement_operations.py | 16 +- services/recipes/app/api/recipe_operations.py | 3 + .../suppliers/app/api/supplier_operations.py | 2 +- services/tenant/app/api/tenant_operations.py | 46 +- .../training/app/api/training_operations.py | 12 + shared/clients/auth_client.py | 2 +- shared/config/base.py | 1 + 47 files changed, 1821 insertions(+), 270 deletions(-) create mode 100644 frontend/src/pages/app/analytics/scenario-simulation/ScenarioSimulationPage.tsx create mode 100644 gateway/app/routes/pos.py create mode 100644 services/forecasting/app/api/scenario_operations.py diff --git a/Tiltfile b/Tiltfile index acdf36d7..ba0d40cc 100644 --- a/Tiltfile +++ b/Tiltfile @@ -35,12 +35,24 @@ docker_build( context='.', dockerfile='./gateway/Dockerfile', 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('./gateway', '/app'), sync('./shared', '/app/shared'), # Restart on Python file changes 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='.', dockerfile='./services/' + service_path + '/Dockerfile', 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('./services/' + service_path, '/app'), @@ -74,6 +90,15 @@ def build_python_service(service_name, service_path): './services/' + service_path + '/**/*.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_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 # - Automatically stream logs from services with errors diff --git a/frontend/src/api/services/forecasting.ts b/frontend/src/api/services/forecasting.ts index b0b10cc6..9cb2a968 100644 --- a/frontend/src/api/services/forecasting.ts +++ b/frontend/src/api/services/forecasting.ts @@ -26,6 +26,10 @@ import { GetForecastsParams, ForecastingHealthResponse, MultiDayForecastResponse, + ScenarioSimulationRequest, + ScenarioSimulationResponse, + ScenarioComparisonRequest, + ScenarioComparisonResponse, } from '../types/forecasting'; 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 { + return apiClient.post( + `${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 { + return apiClient.post( + `${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-comparison`, + request + ); + } + // =================================================================== // Health Check // =================================================================== diff --git a/frontend/src/api/services/orders.ts b/frontend/src/api/services/orders.ts index ca68e413..99ace6a5 100644 --- a/frontend/src/api/services/orders.ts +++ b/frontend/src/api/services/orders.ts @@ -215,35 +215,35 @@ export class OrdersService { /** * 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 { - return apiClient.get(`/tenants/${tenantId}/orders/procurement/plans/current`); + return apiClient.get(`/tenants/${tenantId}/orders/operations/procurement/plans/current`); } /** * 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 { - return apiClient.get(`/tenants/${tenantId}/orders/procurement/plans/date/${planDate}`); + return apiClient.get(`/tenants/${tenantId}/orders/operations/procurement/plans/date/${planDate}`); } /** * 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 { - return apiClient.get(`/tenants/${tenantId}/orders/procurement/plans/id/${planId}`); + return apiClient.get(`/tenants/${tenantId}/orders/operations/procurement/plans/id/${planId}`); } /** * 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 { const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params; - + const queryParams = new URLSearchParams({ limit: limit.toString(), offset: offset.toString(), @@ -254,29 +254,29 @@ export class OrdersService { if (end_date) queryParams.append('end_date', end_date); return apiClient.get( - `/tenants/${tenant_id}/orders/procurement/plans?${queryParams.toString()}` + `/tenants/${tenant_id}/orders/operations/procurement/plans?${queryParams.toString()}` ); } /** * 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 { - return apiClient.post(`/tenants/${tenantId}/orders/procurement/plans/generate`, request); + return apiClient.post(`/tenants/${tenantId}/orders/operations/procurement/plans/generate`, request); } /** * 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 { const { tenant_id, plan_id, status } = params; - + const queryParams = new URLSearchParams({ status }); return apiClient.put( - `/tenants/${tenant_id}/orders/procurement/plans/${plan_id}/status?${queryParams.toString()}`, + `/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/status?${queryParams.toString()}`, {} ); } @@ -291,45 +291,45 @@ export class OrdersService { /** * 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 { const { tenant_id, plan_id, status, priority } = params; - + const queryParams = new URLSearchParams(); if (status) queryParams.append('status', status); 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(url); } /** * 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 { - return apiClient.get(`/tenants/${tenantId}/orders/procurement/requirements/critical`); + return apiClient.get(`/tenants/${tenantId}/orders/operations/procurement/requirements/critical`); } /** * 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 }> { 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 /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 }> { - 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 - * 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 { return apiClient.post( - `/tenants/${tenantId}/orders/procurement/plans/${planId}/recalculate`, + `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/recalculate`, {} ); } /** * 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 { return apiClient.post( - `/tenants/${tenantId}/orders/procurement/plans/${planId}/approve`, + `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/approve`, request || {} ); } /** * 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 { return apiClient.post( - `/tenants/${tenantId}/orders/procurement/plans/${planId}/reject`, + `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/reject`, request || {} ); } /** * 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 { return apiClient.post( - `/tenants/${tenantId}/orders/procurement/plans/${planId}/create-purchase-orders`, + `/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/create-purchase-orders`, { auto_approve: autoApprove } ); } /** * 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( tenantId: string, @@ -391,14 +391,14 @@ export class OrdersService { request: LinkRequirementToPORequest ): 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 }>( - `/tenants/${tenantId}/orders/procurement/requirements/${requirementId}/link-purchase-order`, + `/tenants/${tenantId}/orders/operations/procurement/requirements/${requirementId}/link-purchase-order`, request ); } /** * 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( tenantId: string, @@ -406,7 +406,7 @@ export class OrdersService { request: UpdateDeliveryStatusRequest ): Promise<{ 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 ); } diff --git a/frontend/src/api/services/recipes.ts b/frontend/src/api/services/recipes.ts index 3d90f459..f731df07 100644 --- a/frontend/src/api/services/recipes.ts +++ b/frontend/src/api/services/recipes.ts @@ -188,10 +188,10 @@ export class RecipesService { /** * Get recipe statistics for dashboard - * GET /tenants/{tenant_id}/recipes/statistics/dashboard + * GET /tenants/{tenant_id}/recipes/dashboard/statistics */ async getRecipeStatistics(tenantId: string): Promise { - return apiClient.get(`${this.baseUrl}/${tenantId}/recipes/statistics/dashboard`); + return apiClient.get(`${this.baseUrl}/${tenantId}/recipes/dashboard/statistics`); } /** diff --git a/frontend/src/api/services/sales.ts b/frontend/src/api/services/sales.ts index add6c8b8..9fdd636e 100644 --- a/frontend/src/api/services/sales.ts +++ b/frontend/src/api/services/sales.ts @@ -100,7 +100,7 @@ export class SalesService { } async getProductCategories(tenantId: string): Promise { - return apiClient.get(`${this.baseUrl}/${tenantId}/sales/sales/categories`); + return apiClient.get(`${this.baseUrl}/${tenantId}/sales/categories`); } // =================================================================== diff --git a/frontend/src/api/services/subscription.ts b/frontend/src/api/services/subscription.ts index 7c73a51f..e8639511 100644 --- a/frontend/src/api/services/subscription.ts +++ b/frontend/src/api/services/subscription.ts @@ -89,27 +89,27 @@ export class SubscriptionService { } async validatePlanUpgrade(tenantId: string, planKey: string): Promise { - return apiClient.get(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`); + return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/validate-upgrade/${planKey}`); } async upgradePlan(tenantId: string, planKey: string): Promise { - return apiClient.post(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {}); + return apiClient.post(`${this.baseUrl}/subscriptions/${tenantId}/upgrade?new_plan=${planKey}`, {}); } 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 }> { - 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 }> { - 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 }> { - return apiClient.get(`${this.baseUrl}/${tenantId}/features/${featureName}`); + return apiClient.get(`${this.baseUrl}/subscriptions/${tenantId}/features/${featureName}`); } formatPrice(amount: number): string { diff --git a/frontend/src/api/services/training.ts b/frontend/src/api/services/training.ts index 435c64d7..67e71b7a 100644 --- a/frontend/src/api/services/training.ts +++ b/frontend/src/api/services/training.ts @@ -173,7 +173,7 @@ class TrainingService { */ getTrainingWebSocketUrl(tenantId: string, jobId: string): string { 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`; } /** diff --git a/frontend/src/api/types/forecasting.ts b/frontend/src/api/types/forecasting.ts index 24a596fe..fe0e1e28 100644 --- a/frontend/src/api/types/forecasting.ts +++ b/frontend/src/api/types/forecasting.ts @@ -90,7 +90,7 @@ export interface ForecastResponse { // Metadata created_at: string; // ISO datetime string processing_time_ms?: number | null; - features_used?: Record | null; + features?: Record | null; } /** @@ -260,3 +260,165 @@ export interface PredictionsPerformanceParams { export interface MessageResponse { 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 | 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; +} + +/** + * 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>; + best_case_scenario_id: string; + worst_case_scenario_id: string; + recommended_action: string; +} diff --git a/frontend/src/api/types/training.ts b/frontend/src/api/types/training.ts index 980c5e07..c738c6ec 100644 --- a/frontend/src/api/types/training.ts +++ b/frontend/src/api/types/training.ts @@ -401,7 +401,7 @@ export interface ModelMetricsResponse { rmse: number; // Root Mean Square Error r2_score: number; training_samples: number; - features_used: string[]; + features?: string[]; // Features used by the model model_type: string; created_at?: string | null; // ISO datetime string last_used_at?: string | null; // ISO datetime string diff --git a/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx b/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx index 4df67a9b..a57aaa98 100644 --- a/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx +++ b/frontend/src/components/domain/forecasting/ModelDetailsModal.tsx @@ -247,7 +247,7 @@ const ModelDetailsModal: React.FC = ({ { label: "Información que Analiza", value: (() => { - const features = ((model as any).features_used || model.features_used || []); + const features = ((model as any).features || []); const featureCount = features.length; if (featureCount === 0) { @@ -338,7 +338,7 @@ const ModelDetailsModal: React.FC = ({ }, { 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" : "Este modelo ha aprendido tus patrones regulares de ventas", span: 2 diff --git a/frontend/src/components/layout/Sidebar/Sidebar.tsx b/frontend/src/components/layout/Sidebar/Sidebar.tsx index 42d5f62a..33f0b4bf 100644 --- a/frontend/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar/Sidebar.tsx @@ -171,6 +171,7 @@ export const Sidebar = forwardRef(({ '/app/database/inventory': 'navigation.inventory', '/app/analytics': 'navigation.analytics', '/app/analytics/forecasting': 'navigation.forecasting', + '/app/analytics/scenario-simulation': 'navigation.scenario_simulation', '/app/analytics/sales': 'navigation.sales', '/app/analytics/performance': 'navigation.performance', '/app/ai': 'navigation.insights', diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 819197c7..5bef6b97 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -11,6 +11,7 @@ "pos": "Point of Sale", "analytics": "Analytics", "forecasting": "Forecasting", + "scenario_simulation": "Scenario Simulation", "sales": "Sales", "performance": "Performance", "insights": "AI Insights", diff --git a/frontend/src/locales/es/common.json b/frontend/src/locales/es/common.json index d850c850..1dc5799c 100644 --- a/frontend/src/locales/es/common.json +++ b/frontend/src/locales/es/common.json @@ -11,6 +11,7 @@ "pos": "Punto de Venta", "analytics": "Análisis", "forecasting": "Predicción", + "scenario_simulation": "Simulación de Escenarios", "sales": "Ventas", "performance": "Rendimiento", "insights": "Insights IA", diff --git a/frontend/src/locales/eu/common.json b/frontend/src/locales/eu/common.json index 4f56c2b4..d1dc5aaa 100644 --- a/frontend/src/locales/eu/common.json +++ b/frontend/src/locales/eu/common.json @@ -11,6 +11,7 @@ "pos": "Salmenta-puntua", "analytics": "Analisiak", "forecasting": "Aurreikuspenak", + "scenario_simulation": "Agertoki-simulazioa", "sales": "Salmentak", "performance": "Errendimendua", "insights": "AA ikuspegiak", diff --git a/frontend/src/pages/app/analytics/scenario-simulation/ScenarioSimulationPage.tsx b/frontend/src/pages/app/analytics/scenario-simulation/ScenarioSimulationPage.tsx new file mode 100644 index 00000000..99fc5ec4 --- /dev/null +++ b/frontend/src/pages/app/analytics/scenario-simulation/ScenarioSimulationPage.tsx @@ -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.WEATHER); + const [isSimulating, setIsSimulating] = useState(false); + const [simulationResult, setSimulationResult] = useState(null); + const [error, setError] = useState(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([]); + + // Scenario-specific parameters + const [weatherParams, setWeatherParams] = useState({ + temperature_change: 15, + weather_type: 'heatwave', + }); + const [competitionParams, setCompetitionParams] = useState({ + new_competitors: 1, + distance_km: 0.5, + estimated_market_share_loss: 0.15, + }); + const [eventParams, setEventParams] = useState({ + event_type: 'festival', + expected_attendance: 5000, + distance_km: 1.0, + duration_days: 3, + }); + const [pricingParams, setPricingParams] = useState({ + price_change_percent: 10, + }); + const [promotionParams, setPromotionParams] = useState({ + 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 ( +
+ + + {error && ( +
+ +
+

{error}

+
+
+ )} + +
+ {/* Left Column: Configuration */} +
+ +
+
+

+ {t('analytics.scenario_simulation.configure', 'Configure Scenario')} +

+ + {/* Scenario Name */} +
+ + 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" + /> +
+ + {/* Date Range */} +
+
+ + setStartDate(e.target.value)} + className="w-full px-3 py-2 border rounded-lg" + min={new Date().toISOString().split('T')[0]} + /> +
+
+ + setDurationDays(parseInt(e.target.value) || 7)} + min={1} + max={30} + className="w-full px-3 py-2 border rounded-lg" + /> +
+
+
+ +
+

+ {t('analytics.scenario_simulation.scenario_type', 'Scenario Type')} +

+
+ {Object.values(ScenarioType).map((type) => { + const Icon = scenarioIcons[type]; + return ( + + ); + })} +
+
+ + {/* Scenario-Specific Parameters */} +
+

+ {t('analytics.scenario_simulation.parameters', 'Parameters')} +

+ + {selectedScenarioType === ScenarioType.WEATHER && ( +
+
+ + setWeatherParams({ ...weatherParams, temperature_change: parseFloat(e.target.value) })} + className="w-full px-3 py-2 border rounded-lg mt-1" + min={-30} + max={30} + /> +
+
+ + +
+
+ )} + + {selectedScenarioType === ScenarioType.COMPETITION && ( +
+
+ + 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} + /> +
+
+ + 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} + /> +
+
+ + 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} + /> +
+
+ )} + + {selectedScenarioType === ScenarioType.PROMOTION && ( +
+
+ + setPromotionParams({ ...promotionParams, discount_percent: parseFloat(e.target.value) })} + className="w-full px-3 py-2 border rounded-lg mt-1" + min={0} + max={75} + /> +
+
+ + 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} + /> +
+
+ )} +
+ + +
+
+ + {/* Quick Examples */} + +
+

+ + {t('analytics.scenario_simulation.quick_examples', 'Quick Examples')} +

+
+ + + +
+
+
+
+ + {/* Right Column: Results */} +
+ {simulationResult ? ( + <> + {/* Impact Summary */} + +
+
+
+

{simulationResult.scenario_name}

+

{simulationResult.scenario_type.replace('_', ' ')}

+
+ + {simulationResult.risk_level} risk + +
+ +
+
+
Baseline Demand
+
{Math.round(simulationResult.total_baseline_demand)}
+
+
+
Scenario Demand
+
{Math.round(simulationResult.total_scenario_demand)}
+
+
+ +
+
+ Overall Impact +
+ {simulationResult.overall_impact_percent > 0 ? ( + + ) : ( + + )} + 0 ? 'text-green-600' : 'text-red-600' + }`}> + {simulationResult.overall_impact_percent > 0 ? '+' : ''} + {simulationResult.overall_impact_percent.toFixed(1)}% + +
+
+
+
+
+ + {/* Insights */} + +
+

+ + {t('analytics.scenario_simulation.insights', 'Key Insights')} +

+
+ {simulationResult.insights.map((insight, index) => ( +
+ + {insight} +
+ ))} +
+
+
+ + {/* Recommendations */} + +
+

+ + {t('analytics.scenario_simulation.recommendations', 'Recommendations')} +

+
+ {simulationResult.recommendations.map((recommendation, index) => ( +
+ {index + 1}. + {recommendation} +
+ ))} +
+
+
+ + {/* Product Impacts */} + {simulationResult.product_impacts.length > 0 && ( + +
+

+ + {t('analytics.scenario_simulation.product_impacts', 'Product Impacts')} +

+
+ {simulationResult.product_impacts.map((impact, index) => ( +
+
+ {impact.inventory_product_id} + 0 ? 'text-green-600' : 'text-red-600' + }`}> + {impact.demand_change_percent > 0 ? '+' : ''} + {impact.demand_change_percent.toFixed(1)}% + +
+
+ Baseline: {Math.round(impact.baseline_demand)} + + Scenario: {Math.round(impact.simulated_demand)} +
+
+ ))} +
+
+
+ )} + + ) : ( + +
+ +

+ {t('analytics.scenario_simulation.no_results', 'Configure and run a scenario to see results')} +

+
+
+ )} +
+
+
+ ); +}; + +export default ScenarioSimulationPage; diff --git a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx index 29487e45..3f275b9c 100644 --- a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx +++ b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx @@ -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 { useTenantId } from '../../../../hooks/useTenantId'; import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory'; +import { subscriptionService } from '../../../../api/services/subscription'; const InventoryPage: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); @@ -312,7 +313,21 @@ const InventoryPage: React.FC = () => { // Handle creating a new ingredient const handleCreateIngredient = async (ingredientData: IngredientCreate) => { + if (!tenantId) { + throw new Error('No tenant ID available'); + } + 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({ tenantId, ingredientData diff --git a/frontend/src/pages/app/settings/team/TeamPage.tsx b/frontend/src/pages/app/settings/team/TeamPage.tsx index b3a3c7ed..2a937bd8 100644 --- a/frontend/src/pages/app/settings/team/TeamPage.tsx +++ b/frontend/src/pages/app/settings/team/TeamPage.tsx @@ -10,6 +10,7 @@ import { useAuthUser } from '../../../../stores/auth.store'; import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store'; import { useToast } from '../../../../hooks/ui/useToast'; import { TENANT_ROLES } from '../../../../types/roles'; +import { subscriptionService } from '../../../../api/services/subscription'; const TeamPage: React.FC = () => { const { t } = useTranslation(['settings']); @@ -447,20 +448,36 @@ const TeamPage: React.FC = () => { }} onAddMember={async (userData) => { if (!tenantId) return Promise.reject('No tenant ID available'); - - return addMemberMutation.mutateAsync({ - tenantId, - userId: userData.userId, - role: userData.role, - }).then(() => { + + try { + // Check subscription limits before adding member + const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1); + + 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' }); setShowAddForm(false); setSelectedUserToAdd(''); 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' }); throw error; - }); + } }} availableUsers={availableUsers} /> diff --git a/frontend/src/router/AppRouter.tsx b/frontend/src/router/AppRouter.tsx index 6292db80..fe511edd 100644 --- a/frontend/src/router/AppRouter.tsx +++ b/frontend/src/router/AppRouter.tsx @@ -26,6 +26,7 @@ const ProductionAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ const ProcurementAnalyticsPage = React.lazy(() => import('../pages/app/analytics/ProcurementAnalyticsPage')); const ForecastingPage = React.lazy(() => import('../pages/app/analytics/forecasting/ForecastingPage')); 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 PerformanceAnalyticsPage = React.lazy(() => import('../pages/app/analytics/performance/PerformanceAnalyticsPage')); @@ -258,25 +259,35 @@ export const AppRouter: React.FC = () => { } /> - + + + + + } + /> + - } + } /> - - } + } /> diff --git a/frontend/src/router/routes.config.ts b/frontend/src/router/routes.config.ts index c3affee7..bbe97102 100644 --- a/frontend/src/router/routes.config.ts +++ b/frontend/src/router/routes.config.ts @@ -337,6 +337,18 @@ export const routesConfig: RouteConfig[] = [ showInNavigation: 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', name: 'AIInsights', diff --git a/gateway/app/main.py b/gateway/app/main.py index 68f61a73..0e639266 100644 --- a/gateway/app/main.py +++ b/gateway/app/main.py @@ -21,7 +21,7 @@ from app.middleware.logging import LoggingMiddleware from app.middleware.rate_limit import RateLimitMiddleware from app.middleware.subscription import SubscriptionMiddleware 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.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(notification.router, prefix="/api/v1/notifications", tags=["notifications"]) 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"]) @@ -251,203 +252,106 @@ async def events_stream(request: Request, tenant_id: str): # 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): - """WebSocket proxy that forwards connections directly to training service with enhanced token validation""" - await websocket.accept() - - # Get token from query params + """ + WebSocket proxy that forwards connections directly to training service. + Acts as a pure proxy - does NOT handle websocket logic, just forwards to training service. + 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") 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") return - # Validate token using auth middleware - from app.middleware.auth import jwt_handler - 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 + # Accept the connection immediately + await websocket.accept() - # Check token expiration - 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"Gateway proxying WebSocket to training service for job {job_id}, tenant {tenant_id}") - logger.info(f"WebSocket token validated for user {payload.get('email', 'unknown')}") - - 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 + # Build WebSocket URL to training service - forward to the exact same path training_service_base = settings.TRAINING_SERVICE_URL.rstrip('/') 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 = None - heartbeat_task = None try: - # Connect to training service WebSocket with proper timeout configuration + # Connect to training service WebSocket 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_url, - ping_interval=None, # DISABLED: Let frontend handle ping/pong via message forwarding - ping_timeout=None, # DISABLED: No independent ping mechanism - close_timeout=15, # Reasonable close timeout - max_size=2**20, # 1MB max message size - max_queue=32 # Max queued messages + ping_interval=None, # Let training service handle heartbeat + ping_timeout=None, + close_timeout=10, + open_timeout=30, # Allow time for training service to setup + 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)") - - # 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 + logger.info(f"Gateway connected to training service WebSocket for job {job_id}") async def forward_to_training(): - """Forward messages from frontend to training service with proper error handling""" - nonlocal connection_alive, last_activity - + """Forward messages from frontend to training service""" try: - while connection_alive and training_ws and training_ws.open: - try: - # 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() + while training_ws and training_ws.open: + data = await websocket.receive() - # Handle different message types - if data.get("type") == "websocket.receive": - if "text" in data: - message = data["text"] - # Forward text messages to training service - await training_ws.send(message) - logger.debug(f"Forwarded message to training service for job {job_id}: {message[:100]}...") - elif "bytes" in data: - # 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 + if data.get("type") == "websocket.receive": + if "text" in data: + await training_ws.send(data["text"]) + logger.debug(f"Gateway forwarded frontend->training: {data['text'][:100]}") + elif "bytes" in data: + await training_ws.send(data["bytes"]) + elif data.get("type") == "websocket.disconnect": + logger.info(f"Frontend disconnected for job {job_id}") break - except Exception as e: - logger.error(f"Error in forward_to_training for job {job_id}: {e}") - connection_alive = False + logger.error(f"Error forwarding frontend->training for job {job_id}: {e}") async def forward_to_frontend(): - """Forward messages from training service to frontend with proper error handling""" - nonlocal connection_alive, last_activity - + """Forward messages from training service to frontend""" try: - while connection_alive and training_ws and training_ws.open: - try: - # Use coordinated timeout - training service expects messages every 60s - # This should be longer than training service timeout to avoid premature closure - 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 - + while training_ws and training_ws.open: + message = await training_ws.recv() + await websocket.send_text(message) + logger.debug(f"Gateway forwarded training->frontend: {message[:100]}") except Exception as e: - logger.error(f"Error in forward_to_frontend for job {job_id}: {e}") - connection_alive = False + logger.error(f"Error forwarding training->frontend for job {job_id}: {e}") - # Start connection health monitoring - heartbeat_task = asyncio.create_task(check_connection_health()) - - # Run both forwarding tasks concurrently with proper error handling - try: - 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 + # Run both forwarding tasks concurrently + await asyncio.gather( + forward_to_training(), + forward_to_frontend(), + return_exceptions=True + ) 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: logger.error(f"WebSocket exception for job {job_id}: {e}") except Exception as e: logger.error(f"WebSocket proxy error for job {job_id}: {e}") finally: # 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: try: await training_ws.close() + logger.info(f"Closed training service WebSocket for job {job_id}") except Exception as e: logger.warning(f"Error closing training service WebSocket for job {job_id}: {e}") try: 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: 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__": import uvicorn diff --git a/gateway/app/middleware/auth.py b/gateway/app/middleware/auth.py index 6f01d948..42969bbc 100644 --- a/gateway/app/middleware/auth.py +++ b/gateway/app/middleware/auth.py @@ -36,7 +36,7 @@ PUBLIC_ROUTES = [ "/api/v1/nominatim/search", "/api/v1/plans", "/api/v1/demo/accounts", - "/api/v1/demo/session/create" + "/api/v1/demo/sessions" ] class AuthMiddleware(BaseHTTPMiddleware): diff --git a/gateway/app/middleware/demo_middleware.py b/gateway/app/middleware/demo_middleware.py index 1bf18eb4..dbefc3b5 100644 --- a/gateway/app/middleware/demo_middleware.py +++ b/gateway/app/middleware/demo_middleware.py @@ -77,10 +77,9 @@ class DemoMiddleware(BaseHTTPMiddleware): # Skip demo middleware for demo service endpoints demo_service_paths = [ "/api/v1/demo/accounts", - "/api/v1/demo/session/create", - "/api/v1/demo/session/extend", - "/api/v1/demo/session/destroy", + "/api/v1/demo/sessions", "/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): @@ -204,7 +203,7 @@ class DemoMiddleware(BaseHTTPMiddleware): try: async with httpx.AsyncClient(timeout=5.0) as client: 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: return response.json() @@ -215,13 +214,9 @@ class DemoMiddleware(BaseHTTPMiddleware): async def _update_session_activity(self, session_id: str): """Update session activity timestamp""" - try: - async with httpx.AsyncClient(timeout=2.0) as client: - await client.post( - 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)) + # Note: Activity tracking is handled by the demo service internally + # No explicit endpoint needed - activity is updated on session access + pass def _check_blocked_path(self, path: str) -> Optional[dict]: """Check if path is explicitly blocked for demo accounts""" diff --git a/gateway/app/routes/demo.py b/gateway/app/routes/demo.py index 229933d5..8959fe47 100644 --- a/gateway/app/routes/demo.py +++ b/gateway/app/routes/demo.py @@ -22,7 +22,7 @@ async def proxy_demo_service(path: str, request: Request): """ # Build the target URL 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 body = None diff --git a/gateway/app/routes/pos.py b/gateway/app/routes/pos.py new file mode 100644 index 00000000..ae4f79f2 --- /dev/null +++ b/gateway/app/routes/pos.py @@ -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" + ) diff --git a/gateway/app/routes/subscription.py b/gateway/app/routes/subscription.py index e146ed5f..3e56576e 100644 --- a/gateway/app/routes/subscription.py +++ b/gateway/app/routes/subscription.py @@ -17,10 +17,10 @@ router = APIRouter() # 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 = ""): """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) @router.api_route("/subscriptions/plans", methods=["GET", "OPTIONS"]) diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index b5dc7440..14a2c2bc 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -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("/") 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 # ================================================================ @@ -348,6 +358,10 @@ async def _proxy_to_recipes_service(request: Request, target_path: str, tenant_i """Proxy request to recipes service""" 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): """Generic proxy function with enhanced error handling""" diff --git a/infrastructure/kubernetes/base/ingress-https.yaml b/infrastructure/kubernetes/base/ingress-https.yaml index c497565a..57f5eedd 100644 --- a/infrastructure/kubernetes/base/ingress-https.yaml +++ b/infrastructure/kubernetes/base/ingress-https.yaml @@ -12,12 +12,14 @@ metadata: nginx.ingress.kubernetes.io/force-ssl-redirect: "true" nginx.ingress.kubernetes.io/proxy-body-size: "10m" 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" - # 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-http-version: "1.1" 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 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" diff --git a/infrastructure/kubernetes/overlays/dev/dev-ingress.yaml b/infrastructure/kubernetes/overlays/dev/dev-ingress.yaml index 77a42b6f..5debb410 100644 --- a/infrastructure/kubernetes/overlays/dev/dev-ingress.yaml +++ b/infrastructure/kubernetes/overlays/dev/dev-ingress.yaml @@ -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-credentials: "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-connect-timeout: "600" 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-http-version: "1.1" nginx.ingress.kubernetes.io/upstream-keepalive-timeout: "3600" + # WebSocket upgrade support + nginx.ingress.kubernetes.io/websocket-services: "gateway-service" spec: ingressClassName: nginx rules: diff --git a/services/auth/app/repositories/onboarding_repository.py b/services/auth/app/repositories/onboarding_repository.py index ec4bef88..9543d8aa 100644 --- a/services/auth/app/repositories/onboarding_repository.py +++ b/services/auth/app/repositories/onboarding_repository.py @@ -51,17 +51,26 @@ class OnboardingRepository: return None async def upsert_user_step( - self, - user_id: str, - step_name: str, - completed: bool, - step_data: Dict[str, Any] = None + self, + user_id: str, + step_name: str, + completed: bool, + step_data: Dict[str, Any] = None, + auto_commit: bool = True ) -> 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: completed_at = datetime.now(timezone.utc) if completed else None step_data = step_data or {} - + # Use PostgreSQL UPSERT (INSERT ... ON CONFLICT ... DO UPDATE) stmt = insert(UserOnboardingProgress).values( user_id=user_id, @@ -71,7 +80,7 @@ class OnboardingRepository: step_data=step_data, updated_at=datetime.now(timezone.utc) ) - + # On conflict, update the existing record stmt = stmt.on_conflict_do_update( index_elements=['user_id', 'step_name'], @@ -82,17 +91,24 @@ class OnboardingRepository: updated_at=stmt.excluded.updated_at ) ) - + # Return the updated record stmt = stmt.returning(UserOnboardingProgress) 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() - + except Exception as 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 async def get_user_summary(self, user_id: str) -> Optional[UserOnboardingSummary]: diff --git a/services/auth/app/services/auth_service.py b/services/auth/app/services/auth_service.py index 0f1e27e8..bc4a469f 100644 --- a/services/auth/app/services/auth_service.py +++ b/services/auth/app/services/auth_service.py @@ -108,16 +108,15 @@ class EnhancedAuthService: } await token_repo.create_token(token_data) - - # Commit transaction - await uow.commit() - # Store subscription plan selection in onboarding progress for later retrieval + # Store subscription plan selection in onboarding progress BEFORE committing + # This ensures it's part of the same transaction if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id: try: from app.repositories.onboarding_repository import OnboardingRepository from app.models.onboarding import UserOnboardingProgress + # Use upsert_user_step instead of save_step_data to avoid double commits onboarding_repo = OnboardingRepository(db_session) plan_data = { "subscription_plan": user_data.subscription_plan or "starter", @@ -126,17 +125,29 @@ class EnhancedAuthService: "saved_at": datetime.now(timezone.utc).isoformat() } - await onboarding_repo.save_step_data( - str(new_user.id), - "user_registered", - plan_data + # Create the onboarding step record with plan data + # Note: We use completed=True to mark user_registered as complete + # auto_commit=False to let UnitOfWork handle the commit + 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", user_id=new_user.id, plan=user_data.subscription_plan) 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) try: diff --git a/services/demo_session/app/api/demo_operations.py b/services/demo_session/app/api/demo_operations.py index d14aebbb..7fc3047a 100644 --- a/services/demo_session/app/api/demo_operations.py +++ b/services/demo_session/app/api/demo_operations.py @@ -50,7 +50,7 @@ async def extend_demo_session( "status": session.status.value, "created_at": session.created_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 } diff --git a/services/demo_session/app/api/demo_sessions.py b/services/demo_session/app/api/demo_sessions.py index 0a45deb4..1f042f90 100644 --- a/services/demo_session/app/api/demo_sessions.py +++ b/services/demo_session/app/api/demo_sessions.py @@ -82,7 +82,7 @@ async def create_demo_session( "status": session.status.value, "created_at": session.created_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 } diff --git a/services/demo_session/app/models/demo_session.py b/services/demo_session/app/models/demo_session.py index 86da303f..11b35eb7 100644 --- a/services/demo_session/app/models/demo_session.py +++ b/services/demo_session/app/models/demo_session.py @@ -59,6 +59,7 @@ class DemoSession(Base): return { "id": str(self.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), "base_demo_tenant_id": str(self.base_demo_tenant_id), "demo_account_type": self.demo_account_type, diff --git a/services/demo_session/app/services/session_manager.py b/services/demo_session/app/services/session_manager.py index 1ba82d4b..b9f4e1ef 100644 --- a/services/demo_session/app/services/session_manager.py +++ b/services/demo_session/app/services/session_manager.py @@ -73,7 +73,7 @@ class DemoSessionManager: last_activity_at=datetime.now(timezone.utc), data_cloned=False, redis_populated=False, - metadata={ + session_metadata={ "demo_config": demo_config, "extension_count": 0 } @@ -133,7 +133,7 @@ class DemoSessionManager: raise ValueError(f"Cannot extend {session.status.value} session") # 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: raise ValueError(f"Maximum extensions ({settings.DEMO_SESSION_MAX_EXTENSIONS}) reached") @@ -144,7 +144,7 @@ class DemoSessionManager: session.expires_at = new_expires_at 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.refresh(session) diff --git a/services/forecasting/app/api/scenario_operations.py b/services/forecasting/app/api/scenario_operations.py new file mode 100644 index 00000000..d70b8a59 --- /dev/null +++ b/services/forecasting/app/api/scenario_operations.py @@ -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" + ) diff --git a/services/forecasting/app/main.py b/services/forecasting/app/main.py index 015be933..4040f93c 100644 --- a/services/forecasting/app/main.py +++ b/services/forecasting/app/main.py @@ -15,7 +15,7 @@ from app.services.forecasting_alert_service import ForecastingAlertService from shared.service_base import StandardFastAPIService # Import API routers -from app.api import forecasts, forecasting_operations, analytics +from app.api import forecasts, forecasting_operations, analytics, scenario_operations class ForecastingService(StandardFastAPIService): @@ -166,6 +166,7 @@ service.setup_custom_endpoints() service.add_router(forecasts.router) service.add_router(forecasting_operations.router) service.add_router(analytics.router) +service.add_router(scenario_operations.router) if __name__ == "__main__": import uvicorn diff --git a/services/forecasting/app/schemas/forecasts.py b/services/forecasting/app/schemas/forecasts.py index b795d1ed..e5b65788 100644 --- a/services/forecasting/app/schemas/forecasts.py +++ b/services/forecasting/app/schemas/forecasts.py @@ -107,3 +107,166 @@ class MultiDayForecastResponse(BaseModel): 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 + + diff --git a/services/inventory/app/api/dashboard.py b/services/inventory/app/api/dashboard.py index 5c318694..d4417870 100644 --- a/services/inventory/app/api/dashboard.py +++ b/services/inventory/app/api/dashboard.py @@ -116,7 +116,7 @@ async def get_food_safety_dashboard( route_builder.build_dashboard_route("analytics"), response_model=InventoryAnalytics ) -@analytics_tier_required + async def get_inventory_analytics( tenant_id: UUID = Path(...), days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"), diff --git a/services/inventory/app/api/food_safety_operations.py b/services/inventory/app/api/food_safety_operations.py index c38902b3..9166b31c 100644 --- a/services/inventory/app/api/food_safety_operations.py +++ b/services/inventory/app/api/food_safety_operations.py @@ -89,7 +89,6 @@ async def acknowledge_alert( route_builder.build_analytics_route("food-safety-metrics"), response_model=FoodSafetyMetrics ) -@analytics_tier_required async def get_food_safety_metrics( tenant_id: UUID = Path(...), days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"), diff --git a/services/orders/app/api/order_operations.py b/services/orders/app/api/order_operations.py index b434a79a..5ff2a66a 100644 --- a/services/orders/app/api/order_operations.py +++ b/services/orders/app/api/order_operations.py @@ -60,7 +60,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService: # ===== Dashboard and Analytics Endpoints ===== @router.get( - route_builder.build_base_route("dashboard-summary"), + route_builder.build_operations_route("dashboard-summary"), response_model=OrdersDashboardSummary ) async def get_dashboard_summary( diff --git a/services/orders/app/api/procurement_operations.py b/services/orders/app/api/procurement_operations.py index fb675b1e..4da825fd 100644 --- a/services/orders/app/api/procurement_operations.py +++ b/services/orders/app/api/procurement_operations.py @@ -110,6 +110,7 @@ async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> Procure @monitor_performance("get_current_procurement_plan") async def get_current_procurement_plan( tenant_id: uuid.UUID, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -137,6 +138,7 @@ async def get_current_procurement_plan( async def get_procurement_plan_by_date( tenant_id: uuid.UUID, plan_date: date, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), 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)"), 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"), - # tenant_access: TenantAccess = Depends(get_current_tenant), + current_user: Dict[str, Any] = Depends(get_current_user_dep), procurement_service: ProcurementService = Depends(get_procurement_service) ): """ @@ -226,6 +228,7 @@ async def list_procurement_plans( async def generate_procurement_plan( tenant_id: uuid.UUID, request: GeneratePlanRequest, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -272,6 +275,7 @@ async def update_procurement_plan_status( tenant_id: uuid.UUID, plan_id: uuid.UUID, 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), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -314,6 +318,7 @@ async def update_procurement_plan_status( async def get_procurement_plan_by_id( tenant_id: uuid.UUID, plan_id: uuid.UUID, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -354,6 +359,7 @@ async def get_procurement_plan_by_id( @monitor_performance("get_procurement_dashboard") async def get_procurement_dashboard( tenant_id: uuid.UUID, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -392,6 +398,7 @@ async def get_plan_requirements( plan_id: uuid.UUID, status: Optional[str] = Query(None, description="Filter by requirement status"), 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), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -436,6 +443,7 @@ async def get_plan_requirements( @monitor_performance("get_critical_requirements") async def get_critical_requirements( tenant_id: uuid.UUID, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -469,6 +477,7 @@ async def get_critical_requirements( async def recalculate_procurement_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -514,6 +523,7 @@ async def link_requirement_to_purchase_order( purchase_order_number: str, ordered_quantity: float, expected_delivery_date: Optional[date] = None, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -572,6 +582,7 @@ async def update_requirement_delivery_status( received_quantity: Optional[float] = None, actual_delivery_date: Optional[date] = None, quality_rating: Optional[float] = None, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -627,6 +638,7 @@ async def approve_procurement_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, approval_notes: Optional[str] = None, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -683,6 +695,7 @@ async def reject_procurement_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, rejection_notes: Optional[str] = None, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): @@ -739,6 +752,7 @@ async def create_purchase_orders_from_plan( tenant_id: uuid.UUID, plan_id: uuid.UUID, auto_approve: bool = False, + current_user: Dict[str, Any] = Depends(get_current_user_dep), tenant_access: TenantAccess = Depends(get_current_tenant), procurement_service: ProcurementService = Depends(get_procurement_service) ): diff --git a/services/recipes/app/api/recipe_operations.py b/services/recipes/app/api/recipe_operations.py index 28905351..b95142ef 100644 --- a/services/recipes/app/api/recipe_operations.py +++ b/services/recipes/app/api/recipe_operations.py @@ -18,6 +18,7 @@ from ..schemas.recipes import ( ) from shared.routing import RouteBuilder, RouteCategory from shared.auth.access_control import require_user_role +from shared.auth.decorators import get_current_user_dep route_builder = RouteBuilder('recipes') logger = logging.getLogger(__name__) @@ -148,8 +149,10 @@ async def check_recipe_feasibility( route_builder.build_dashboard_route("statistics"), response_model=RecipeStatisticsResponse ) +@require_user_role(['viewer', 'member', 'admin', 'owner']) async def get_recipe_statistics( tenant_id: UUID, + current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get recipe statistics for dashboard""" diff --git a/services/suppliers/app/api/supplier_operations.py b/services/suppliers/app/api/supplier_operations.py index a0acbee3..29498abe 100644 --- a/services/suppliers/app/api/supplier_operations.py +++ b/services/suppliers/app/api/supplier_operations.py @@ -36,7 +36,7 @@ logger = structlog.get_logger() # ===== 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( tenant_id: str = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) diff --git a/services/tenant/app/api/tenant_operations.py b/services/tenant/app/api/tenant_operations.py index bdaa4d80..8da512f8 100644 --- a/services/tenant/app/api/tenant_operations.py +++ b/services/tenant/app/api/tenant_operations.py @@ -587,16 +587,48 @@ async def upgrade_subscription_plan( detail=validation.get("reason", "Cannot upgrade to this plan") ) - # TODO: Implement actual plan upgrade logic - # This would involve: - # 1. Update subscription in database - # 2. Process payment changes - # 3. Update billing cycle - # 4. Send notifications + # Actually update the subscription plan in the database + from app.core.config import settings + from app.repositories.subscription_repository import SubscriptionRepository + from app.models.tenants import Subscription + from shared.database.base import create_database_manager + + 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 { "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 } diff --git a/services/training/app/api/training_operations.py b/services/training/app/api/training_operations.py index edfebe52..948149a6 100644 --- a/services/training/app/api/training_operations.py +++ b/services/training/app/api/training_operations.py @@ -478,6 +478,18 @@ async def training_progress_websocket( await connection_manager.connect(websocket, job_id, connection_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 training_completed = False diff --git a/shared/clients/auth_client.py b/shared/clients/auth_client.py index af1b84c8..d2559b89 100644 --- a/shared/clients/auth_client.py +++ b/shared/clients/auth_client.py @@ -21,7 +21,7 @@ class AuthServiceClient(BaseServiceClient): def get_service_base_path(self) -> str: """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]]: """ diff --git a/shared/config/base.py b/shared/config/base.py index 24a1f3ad..07801c81 100644 --- a/shared/config/base.py +++ b/shared/config/base.py @@ -166,6 +166,7 @@ class BaseServiceSettings(BaseSettings): 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") 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") DEMO_SESSION_SERVICE_URL: str = os.getenv("DEMO_SESSION_SERVICE_URL", "http://demo-session-service:8000")