REFACTOR ALL APIs fix 1
This commit is contained in:
55
Tiltfile
55
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
|
||||
|
||||
@@ -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<ScenarioSimulationResponse> {
|
||||
return apiClient.post<ScenarioSimulationResponse, ScenarioSimulationRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-simulation`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare multiple scenario simulations
|
||||
* POST /tenants/{tenant_id}/forecasting/analytics/scenario-comparison
|
||||
*
|
||||
* **PROFESSIONAL/ENTERPRISE ONLY**
|
||||
*/
|
||||
async compareScenarios(
|
||||
tenantId: string,
|
||||
request: ScenarioComparisonRequest
|
||||
): Promise<ScenarioComparisonResponse> {
|
||||
return apiClient.post<ScenarioComparisonResponse, ScenarioComparisonRequest>(
|
||||
`${this.baseUrl}/${tenantId}/forecasting/analytics/scenario-comparison`,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Health Check
|
||||
// ===================================================================
|
||||
|
||||
@@ -215,31 +215,31 @@ 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<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/current`);
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/current`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan by specific date
|
||||
* GET /tenants/{tenant_id}/orders/procurement/plans/date/{plan_date}
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/date/{plan_date}
|
||||
*/
|
||||
static async getProcurementPlanByDate(tenantId: string, planDate: string): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/date/${planDate}`);
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/date/${planDate}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan by ID
|
||||
* GET /tenants/{tenant_id}/orders/procurement/plans/id/{plan_id}
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/id/{plan_id}
|
||||
*/
|
||||
static async getProcurementPlanById(tenantId: string, planId: string): Promise<ProcurementPlanResponse | null> {
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/procurement/plans/id/${planId}`);
|
||||
return apiClient.get<ProcurementPlanResponse | null>(`/tenants/${tenantId}/orders/operations/procurement/plans/id/${planId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List procurement plans with filtering
|
||||
* GET /tenants/{tenant_id}/orders/procurement/plans/
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/
|
||||
*/
|
||||
static async getProcurementPlans(params: GetProcurementPlansParams): Promise<PaginatedProcurementPlans> {
|
||||
const { tenant_id, status, start_date, end_date, limit = 50, offset = 0 } = params;
|
||||
@@ -254,21 +254,21 @@ export class OrdersService {
|
||||
if (end_date) queryParams.append('end_date', end_date);
|
||||
|
||||
return apiClient.get<PaginatedProcurementPlans>(
|
||||
`/tenants/${tenant_id}/orders/procurement/plans?${queryParams.toString()}`
|
||||
`/tenants/${tenant_id}/orders/operations/procurement/plans?${queryParams.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new procurement plan
|
||||
* POST /tenants/{tenant_id}/orders/procurement/plans/generate
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/generate
|
||||
*/
|
||||
static async generateProcurementPlan(tenantId: string, request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/procurement/plans/generate`, request);
|
||||
return apiClient.post<GeneratePlanResponse>(`/tenants/${tenantId}/orders/operations/procurement/plans/generate`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement plan status
|
||||
* PUT /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/status
|
||||
* PUT /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/status
|
||||
*/
|
||||
static async updateProcurementPlanStatus(params: UpdatePlanStatusParams): Promise<ProcurementPlanResponse> {
|
||||
const { tenant_id, plan_id, status } = params;
|
||||
@@ -276,7 +276,7 @@ export class OrdersService {
|
||||
const queryParams = new URLSearchParams({ status });
|
||||
|
||||
return apiClient.put<ProcurementPlanResponse>(
|
||||
`/tenants/${tenant_id}/orders/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
|
||||
`/tenants/${tenant_id}/orders/operations/procurement/plans/${plan_id}/status?${queryParams.toString()}`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export class OrdersService {
|
||||
|
||||
/**
|
||||
* Get requirements for a specific plan
|
||||
* GET /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/requirements
|
||||
* GET /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/requirements
|
||||
*/
|
||||
static async getPlanRequirements(params: GetPlanRequirementsParams): Promise<ProcurementRequirementResponse[]> {
|
||||
const { tenant_id, plan_id, status, priority } = params;
|
||||
@@ -300,36 +300,36 @@ export class OrdersService {
|
||||
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<ProcurementRequirementResponse[]>(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<ProcurementRequirementResponse[]> {
|
||||
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/procurement/requirements/critical`);
|
||||
return apiClient.get<ProcurementRequirementResponse[]>(`/tenants/${tenantId}/orders/operations/procurement/requirements/critical`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger daily scheduler manually
|
||||
* 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<GeneratePlanResponse> {
|
||||
return apiClient.post<GeneratePlanResponse>(
|
||||
`/tenants/${tenantId}/orders/procurement/plans/${planId}/recalculate`,
|
||||
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/recalculate`,
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a procurement plan with notes
|
||||
* POST /tenants/{tenant_id}/orders/procurement/plans/{plan_id}/approve
|
||||
* POST /tenants/{tenant_id}/orders/operations/procurement/plans/{plan_id}/approve
|
||||
*/
|
||||
static async approveProcurementPlan(tenantId: string, planId: string, request?: ApprovalRequest): Promise<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/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<ProcurementPlanResponse> {
|
||||
return apiClient.post<ProcurementPlanResponse>(
|
||||
`/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<CreatePOsResult> {
|
||||
return apiClient.post<CreatePOsResult>(
|
||||
`/tenants/${tenantId}/orders/procurement/plans/${planId}/create-purchase-orders`,
|
||||
`/tenants/${tenantId}/orders/operations/procurement/plans/${planId}/create-purchase-orders`,
|
||||
{ auto_approve: autoApprove }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<RecipeStatisticsResponse> {
|
||||
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/statistics/dashboard`);
|
||||
return apiClient.get<RecipeStatisticsResponse>(`${this.baseUrl}/${tenantId}/recipes/dashboard/statistics`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -100,7 +100,7 @@ export class SalesService {
|
||||
}
|
||||
|
||||
async getProductCategories(tenantId: string): Promise<string[]> {
|
||||
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/sales/categories`);
|
||||
return apiClient.get<string[]>(`${this.baseUrl}/${tenantId}/sales/categories`);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -89,27 +89,27 @@ export class SubscriptionService {
|
||||
}
|
||||
|
||||
async validatePlanUpgrade(tenantId: string, planKey: string): Promise<PlanUpgradeValidation> {
|
||||
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/${tenantId}/validate-upgrade/${planKey}`);
|
||||
return apiClient.get<PlanUpgradeValidation>(`${this.baseUrl}/subscriptions/${tenantId}/validate-upgrade/${planKey}`);
|
||||
}
|
||||
|
||||
async upgradePlan(tenantId: string, planKey: string): Promise<PlanUpgradeResult> {
|
||||
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/${tenantId}/upgrade?new_plan=${planKey}`, {});
|
||||
return apiClient.post<PlanUpgradeResult>(`${this.baseUrl}/subscriptions/${tenantId}/upgrade?new_plan=${planKey}`, {});
|
||||
}
|
||||
|
||||
async canAddLocation(tenantId: string): Promise<{ can_add: boolean; reason?: string; current_count?: number; max_allowed?: number }> {
|
||||
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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface ForecastResponse {
|
||||
// Metadata
|
||||
created_at: string; // ISO datetime string
|
||||
processing_time_ms?: number | null;
|
||||
features_used?: Record<string, any> | null;
|
||||
features?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,3 +260,165 @@ export interface PredictionsPerformanceParams {
|
||||
export interface MessageResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SCENARIO SIMULATION TYPES - PROFESSIONAL/ENTERPRISE ONLY
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Types of scenarios available for simulation
|
||||
* Backend: ScenarioType enum in schemas/forecasts.py (lines 114-123)
|
||||
*/
|
||||
export enum ScenarioType {
|
||||
WEATHER = 'weather',
|
||||
COMPETITION = 'competition',
|
||||
EVENT = 'event',
|
||||
PRICING = 'pricing',
|
||||
PROMOTION = 'promotion',
|
||||
HOLIDAY = 'holiday',
|
||||
SUPPLY_DISRUPTION = 'supply_disruption',
|
||||
CUSTOM = 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* Weather scenario parameters
|
||||
* Backend: WeatherScenario in schemas/forecasts.py (lines 126-130)
|
||||
*/
|
||||
export interface WeatherScenario {
|
||||
temperature_change?: number | null; // Temperature change in °C (-30 to +30)
|
||||
precipitation_change?: number | null; // Precipitation change in mm (0-100)
|
||||
weather_type?: string | null; // Weather type (heatwave, cold_snap, rainy, etc.)
|
||||
}
|
||||
|
||||
/**
|
||||
* Competition scenario parameters
|
||||
* Backend: CompetitionScenario in schemas/forecasts.py (lines 133-137)
|
||||
*/
|
||||
export interface CompetitionScenario {
|
||||
new_competitors: number; // Number of new competitors (1-10)
|
||||
distance_km: number; // Distance from location in km (0.1-10)
|
||||
estimated_market_share_loss: number; // Estimated market share loss (0-0.5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event scenario parameters
|
||||
* Backend: EventScenario in schemas/forecasts.py (lines 140-145)
|
||||
*/
|
||||
export interface EventScenario {
|
||||
event_type: string; // Type of event (festival, sports, concert, etc.)
|
||||
expected_attendance: number; // Expected attendance
|
||||
distance_km: number; // Distance from location in km (0-50)
|
||||
duration_days: number; // Duration in days (1-30)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing scenario parameters
|
||||
* Backend: PricingScenario in schemas/forecasts.py (lines 148-151)
|
||||
*/
|
||||
export interface PricingScenario {
|
||||
price_change_percent: number; // Price change percentage (-50 to +100)
|
||||
affected_products?: string[] | null; // List of affected product IDs
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotion scenario parameters
|
||||
* Backend: PromotionScenario in schemas/forecasts.py (lines 154-158)
|
||||
*/
|
||||
export interface PromotionScenario {
|
||||
discount_percent: number; // Discount percentage (0-75)
|
||||
promotion_type: string; // Type of promotion (bogo, discount, bundle, etc.)
|
||||
expected_traffic_increase: number; // Expected traffic increase (0-2.0 = 0-200%)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request schema for scenario simulation
|
||||
* Backend: ScenarioSimulationRequest in schemas/forecasts.py (lines 161-189)
|
||||
*/
|
||||
export interface ScenarioSimulationRequest {
|
||||
scenario_name: string; // Name for this scenario (3-200 chars)
|
||||
scenario_type: ScenarioType;
|
||||
inventory_product_ids: string[]; // Products to simulate (min 1)
|
||||
start_date: string; // ISO date string
|
||||
duration_days?: number; // Default: 7, range: 1-30
|
||||
|
||||
// Scenario-specific parameters (provide based on scenario_type)
|
||||
weather_params?: WeatherScenario | null;
|
||||
competition_params?: CompetitionScenario | null;
|
||||
event_params?: EventScenario | null;
|
||||
pricing_params?: PricingScenario | null;
|
||||
promotion_params?: PromotionScenario | null;
|
||||
|
||||
// Custom scenario parameters
|
||||
custom_multipliers?: Record<string, number> | null;
|
||||
|
||||
// Comparison settings
|
||||
include_baseline?: boolean; // Default: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Impact of scenario on a specific product
|
||||
* Backend: ScenarioImpact in schemas/forecasts.py (lines 192-199)
|
||||
*/
|
||||
export interface ScenarioImpact {
|
||||
inventory_product_id: string;
|
||||
baseline_demand: number;
|
||||
simulated_demand: number;
|
||||
demand_change_percent: number;
|
||||
confidence_range: [number, number];
|
||||
impact_factors: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response schema for scenario simulation
|
||||
* Backend: ScenarioSimulationResponse in schemas/forecasts.py (lines 202-256)
|
||||
*/
|
||||
export interface ScenarioSimulationResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
scenario_name: string;
|
||||
scenario_type: ScenarioType;
|
||||
|
||||
// Simulation parameters
|
||||
start_date: string; // ISO date string
|
||||
end_date: string; // ISO date string
|
||||
duration_days: number;
|
||||
|
||||
// Results
|
||||
baseline_forecasts?: ForecastResponse[] | null;
|
||||
scenario_forecasts: ForecastResponse[];
|
||||
|
||||
// Impact summary
|
||||
total_baseline_demand: number;
|
||||
total_scenario_demand: number;
|
||||
overall_impact_percent: number;
|
||||
product_impacts: ScenarioImpact[];
|
||||
|
||||
// Insights and recommendations
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
risk_level: string; // low, medium, high
|
||||
|
||||
// Metadata
|
||||
created_at: string; // ISO datetime string
|
||||
processing_time_ms: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to compare multiple scenarios
|
||||
* Backend: ScenarioComparisonRequest in schemas/forecasts.py (lines 259-261)
|
||||
*/
|
||||
export interface ScenarioComparisonRequest {
|
||||
scenario_ids: string[]; // 2-5 scenario IDs to compare
|
||||
}
|
||||
|
||||
/**
|
||||
* Response comparing multiple scenarios
|
||||
* Backend: ScenarioComparisonResponse in schemas/forecasts.py (lines 264-270)
|
||||
*/
|
||||
export interface ScenarioComparisonResponse {
|
||||
scenarios: ScenarioSimulationResponse[];
|
||||
comparison_matrix: Record<string, Record<string, any>>;
|
||||
best_case_scenario_id: string;
|
||||
worst_case_scenario_id: string;
|
||||
recommended_action: string;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -247,7 +247,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||
{
|
||||
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<ModelDetailsModalProps> = ({
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
@@ -171,6 +171,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
'/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',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"pos": "Point of Sale",
|
||||
"analytics": "Analytics",
|
||||
"forecasting": "Forecasting",
|
||||
"scenario_simulation": "Scenario Simulation",
|
||||
"sales": "Sales",
|
||||
"performance": "Performance",
|
||||
"insights": "AI Insights",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"pos": "Salmenta-puntua",
|
||||
"analytics": "Analisiak",
|
||||
"forecasting": "Aurreikuspenak",
|
||||
"scenario_simulation": "Agertoki-simulazioa",
|
||||
"sales": "Salmentak",
|
||||
"performance": "Errendimendua",
|
||||
"insights": "AA ikuspegiak",
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Scenario Simulation Page - PROFESSIONAL/ENTERPRISE ONLY
|
||||
*
|
||||
* Interactive "what-if" analysis tool for strategic planning
|
||||
* Allows users to test different scenarios and see potential impacts on demand
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTenantStore } from '../../../../stores';
|
||||
import { forecastingService } from '../../../../api/services/forecasting';
|
||||
import {
|
||||
ScenarioType,
|
||||
ScenarioSimulationRequest,
|
||||
ScenarioSimulationResponse,
|
||||
WeatherScenario,
|
||||
CompetitionScenario,
|
||||
EventScenario,
|
||||
PricingScenario,
|
||||
PromotionScenario,
|
||||
} from '../../../../api/types/forecasting';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
} from '../../../../components/ui';
|
||||
import {
|
||||
CloudRain,
|
||||
Sun,
|
||||
Users,
|
||||
Calendar,
|
||||
Tag,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Lightbulb,
|
||||
BarChart3,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
|
||||
export const ScenarioSimulationPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentTenant = useTenantStore((state) => state.currentTenant);
|
||||
|
||||
const [selectedScenarioType, setSelectedScenarioType] = useState<ScenarioType>(ScenarioType.WEATHER);
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [simulationResult, setSimulationResult] = useState<ScenarioSimulationResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [scenarioName, setScenarioName] = useState('');
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [durationDays, setDurationDays] = useState(7);
|
||||
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
|
||||
|
||||
// Scenario-specific parameters
|
||||
const [weatherParams, setWeatherParams] = useState<WeatherScenario>({
|
||||
temperature_change: 15,
|
||||
weather_type: 'heatwave',
|
||||
});
|
||||
const [competitionParams, setCompetitionParams] = useState<CompetitionScenario>({
|
||||
new_competitors: 1,
|
||||
distance_km: 0.5,
|
||||
estimated_market_share_loss: 0.15,
|
||||
});
|
||||
const [eventParams, setEventParams] = useState<EventScenario>({
|
||||
event_type: 'festival',
|
||||
expected_attendance: 5000,
|
||||
distance_km: 1.0,
|
||||
duration_days: 3,
|
||||
});
|
||||
const [pricingParams, setPricingParams] = useState<PricingScenario>({
|
||||
price_change_percent: 10,
|
||||
});
|
||||
const [promotionParams, setPromotionParams] = useState<PromotionScenario>({
|
||||
discount_percent: 20,
|
||||
promotion_type: 'discount',
|
||||
expected_traffic_increase: 0.3,
|
||||
});
|
||||
|
||||
const handleSimulate = async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
if (!scenarioName || selectedProducts.length === 0) {
|
||||
setError('Please provide a scenario name and select at least one product');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSimulating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const request: ScenarioSimulationRequest = {
|
||||
scenario_name: scenarioName,
|
||||
scenario_type: selectedScenarioType,
|
||||
inventory_product_ids: selectedProducts,
|
||||
start_date: startDate,
|
||||
duration_days: durationDays,
|
||||
include_baseline: true,
|
||||
};
|
||||
|
||||
// Add scenario-specific parameters
|
||||
switch (selectedScenarioType) {
|
||||
case ScenarioType.WEATHER:
|
||||
request.weather_params = weatherParams;
|
||||
break;
|
||||
case ScenarioType.COMPETITION:
|
||||
request.competition_params = competitionParams;
|
||||
break;
|
||||
case ScenarioType.EVENT:
|
||||
request.event_params = eventParams;
|
||||
break;
|
||||
case ScenarioType.PRICING:
|
||||
request.pricing_params = pricingParams;
|
||||
break;
|
||||
case ScenarioType.PROMOTION:
|
||||
request.promotion_params = promotionParams;
|
||||
break;
|
||||
}
|
||||
|
||||
const result = await forecastingService.simulateScenario(currentTenant.id, request);
|
||||
setSimulationResult(result);
|
||||
} catch (err: any) {
|
||||
console.error('Simulation error:', err);
|
||||
|
||||
if (err.response?.status === 402) {
|
||||
setError('This feature requires a Professional or Enterprise subscription. Please upgrade your plan to access scenario simulation tools.');
|
||||
} else {
|
||||
setError(err.response?.data?.detail || 'Failed to run scenario simulation');
|
||||
}
|
||||
} finally {
|
||||
setIsSimulating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scenarioIcons = {
|
||||
[ScenarioType.WEATHER]: CloudRain,
|
||||
[ScenarioType.COMPETITION]: Users,
|
||||
[ScenarioType.EVENT]: Calendar,
|
||||
[ScenarioType.PRICING]: Tag,
|
||||
[ScenarioType.PROMOTION]: TrendingUp,
|
||||
[ScenarioType.HOLIDAY]: Calendar,
|
||||
[ScenarioType.SUPPLY_DISRUPTION]: AlertTriangle,
|
||||
[ScenarioType.CUSTOM]: Sparkles,
|
||||
};
|
||||
|
||||
const getRiskLevelColor = (riskLevel: string) => {
|
||||
switch (riskLevel) {
|
||||
case 'high':
|
||||
return 'error';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('analytics.scenario_simulation.title', 'Scenario Simulation')}
|
||||
subtitle={t('analytics.scenario_simulation.subtitle', 'Test "what-if" scenarios to optimize your planning')}
|
||||
icon={Sparkles}
|
||||
status={{
|
||||
text: t('subscription.professional_enterprise', 'Professional/Enterprise'),
|
||||
variant: 'primary'
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column: Configuration */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t('analytics.scenario_simulation.configure', 'Configure Scenario')}
|
||||
</h3>
|
||||
|
||||
{/* Scenario Name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t('analytics.scenario_simulation.scenario_name', 'Scenario Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={scenarioName}
|
||||
onChange={(e) => setScenarioName(e.target.value)}
|
||||
placeholder={t('analytics.scenario_simulation.scenario_name_placeholder', 'e.g., Summer Heatwave Impact')}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t('analytics.scenario_simulation.start_date', 'Start Date')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t('analytics.scenario_simulation.duration', 'Duration (days)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={durationDays}
|
||||
onChange={(e) => setDurationDays(parseInt(e.target.value) || 7)}
|
||||
min={1}
|
||||
max={30}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3">
|
||||
{t('analytics.scenario_simulation.scenario_type', 'Scenario Type')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.values(ScenarioType).map((type) => {
|
||||
const Icon = scenarioIcons[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedScenarioType(type)}
|
||||
className={`p-3 border rounded-lg flex items-center gap-2 transition-all ${
|
||||
selectedScenarioType === type
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-sm capitalize">{type.replace('_', ' ')}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scenario-Specific Parameters */}
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="text-sm font-medium mb-3">
|
||||
{t('analytics.scenario_simulation.parameters', 'Parameters')}
|
||||
</h4>
|
||||
|
||||
{selectedScenarioType === ScenarioType.WEATHER && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">Temperature Change (°C)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={weatherParams.temperature_change || 0}
|
||||
onChange={(e) => setWeatherParams({ ...weatherParams, temperature_change: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
min={-30}
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Weather Type</label>
|
||||
<select
|
||||
value={weatherParams.weather_type || 'heatwave'}
|
||||
onChange={(e) => setWeatherParams({ ...weatherParams, weather_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
>
|
||||
<option value="heatwave">Heatwave</option>
|
||||
<option value="cold_snap">Cold Snap</option>
|
||||
<option value="rainy">Rainy</option>
|
||||
<option value="stormy">Stormy</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.COMPETITION && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">New Competitors</label>
|
||||
<input
|
||||
type="number"
|
||||
value={competitionParams.new_competitors}
|
||||
onChange={(e) => setCompetitionParams({ ...competitionParams, new_competitors: parseInt(e.target.value) || 1 })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Distance (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={competitionParams.distance_km}
|
||||
onChange={(e) => setCompetitionParams({ ...competitionParams, distance_km: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
min={0.1}
|
||||
max={10}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Est. Market Share Loss (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={competitionParams.estimated_market_share_loss * 100}
|
||||
onChange={(e) => setCompetitionParams({ ...competitionParams, estimated_market_share_loss: parseFloat(e.target.value) / 100 })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
min={0}
|
||||
max={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedScenarioType === ScenarioType.PROMOTION && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm">Discount (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionParams.discount_percent}
|
||||
onChange={(e) => setPromotionParams({ ...promotionParams, discount_percent: parseFloat(e.target.value) })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
min={0}
|
||||
max={75}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm">Expected Traffic Increase (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={promotionParams.expected_traffic_increase * 100}
|
||||
onChange={(e) => setPromotionParams({ ...promotionParams, expected_traffic_increase: parseFloat(e.target.value) / 100 })}
|
||||
className="w-full px-3 py-2 border rounded-lg mt-1"
|
||||
min={0}
|
||||
max={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSimulate}
|
||||
disabled={isSimulating || !scenarioName}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{isSimulating ? t('common.simulating', 'Simulating...') : t('common.run_simulation', 'Run Simulation')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Examples */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.quick_examples', 'Quick Examples')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.WEATHER);
|
||||
setScenarioName('Summer Heatwave Next Week');
|
||||
setWeatherParams({ temperature_change: 15, weather_type: 'heatwave' });
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
<Sun className="w-4 h-4 inline mr-2" />
|
||||
What if a heatwave hits next week?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.COMPETITION);
|
||||
setScenarioName('New Bakery Opening Nearby');
|
||||
setCompetitionParams({ new_competitors: 1, distance_km: 0.3, estimated_market_share_loss: 0.2 });
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
<Users className="w-4 h-4 inline mr-2" />
|
||||
How would a new competitor affect sales?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedScenarioType(ScenarioType.PROMOTION);
|
||||
setScenarioName('Weekend Flash Sale');
|
||||
setPromotionParams({ discount_percent: 25, promotion_type: 'flash_sale', expected_traffic_increase: 0.5 });
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
<Tag className="w-4 h-4 inline mr-2" />
|
||||
Impact of a 25% weekend promotion?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Results */}
|
||||
<div className="space-y-6">
|
||||
{simulationResult ? (
|
||||
<>
|
||||
{/* Impact Summary */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{simulationResult.scenario_name}</h3>
|
||||
<p className="text-sm text-gray-500 capitalize">{simulationResult.scenario_type.replace('_', ' ')}</p>
|
||||
</div>
|
||||
<Badge variant={getRiskLevelColor(simulationResult.risk_level)}>
|
||||
{simulationResult.risk_level} risk
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500 mb-1">Baseline Demand</div>
|
||||
<div className="text-2xl font-bold">{Math.round(simulationResult.total_baseline_demand)}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm text-gray-500 mb-1">Scenario Demand</div>
|
||||
<div className="text-2xl font-bold">{Math.round(simulationResult.total_scenario_demand)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Overall Impact</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{simulationResult.overall_impact_percent > 0 ? (
|
||||
<ArrowUpRight className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<span className={`text-2xl font-bold ${
|
||||
simulationResult.overall_impact_percent > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{simulationResult.overall_impact_percent > 0 ? '+' : ''}
|
||||
{simulationResult.overall_impact_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Insights */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Lightbulb className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.insights', 'Key Insights')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{simulationResult.insights.map((insight, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<span>{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recommendations */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.recommendations', 'Recommendations')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{simulationResult.recommendations.map((recommendation, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm p-3 bg-blue-50 rounded-lg">
|
||||
<span className="font-medium text-blue-600">{index + 1}.</span>
|
||||
<span>{recommendation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Product Impacts */}
|
||||
{simulationResult.product_impacts.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
{t('analytics.scenario_simulation.product_impacts', 'Product Impacts')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{simulationResult.product_impacts.map((impact, index) => (
|
||||
<div key={index} className="p-3 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">{impact.inventory_product_id}</span>
|
||||
<span className={`text-sm font-bold ${
|
||||
impact.demand_change_percent > 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{impact.demand_change_percent > 0 ? '+' : ''}
|
||||
{impact.demand_change_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>Baseline: {Math.round(impact.baseline_demand)}</span>
|
||||
<span>→</span>
|
||||
<span>Scenario: {Math.round(impact.simulated_demand)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="p-12 text-center text-gray-400">
|
||||
<Sparkles className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{t('analytics.scenario_simulation.no_results', 'Configure and run a scenario to see results')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioSimulationPage;
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
@@ -448,19 +449,35 @@ const TeamPage: React.FC = () => {
|
||||
onAddMember={async (userData) => {
|
||||
if (!tenantId) return Promise.reject('No tenant ID available');
|
||||
|
||||
return addMemberMutation.mutateAsync({
|
||||
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,
|
||||
}).then(() => {
|
||||
});
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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,6 +259,16 @@ export const AppRouter: React.FC = () => {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/analytics/scenario-simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppShell>
|
||||
<ScenarioSimulationPage />
|
||||
</AppShell>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/analytics/ai-insights"
|
||||
element={
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]}...")
|
||||
await training_ws.send(data["text"])
|
||||
logger.debug(f"Gateway forwarded frontend->training: {data['text'][: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
|
||||
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
|
||||
while training_ws and training_ws.open:
|
||||
message = await training_ws.recv()
|
||||
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
|
||||
logger.debug(f"Gateway forwarded training->frontend: {message[:100]}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error receiving from training service for job {job_id}: {e}")
|
||||
connection_alive = False
|
||||
break
|
||||
logger.error(f"Error forwarding training->frontend for job {job_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in forward_to_frontend for job {job_id}: {e}")
|
||||
connection_alive = False
|
||||
|
||||
# Start connection health monitoring
|
||||
heartbeat_task = asyncio.create_task(check_connection_health())
|
||||
|
||||
# Run both forwarding tasks concurrently with proper error handling
|
||||
try:
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_training(),
|
||||
forward_to_frontend(),
|
||||
return_exceptions=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in WebSocket forwarding tasks for job {job_id}: {e}")
|
||||
finally:
|
||||
connection_alive = False
|
||||
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
89
gateway/app/routes/pos.py
Normal file
89
gateway/app/routes/pos.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
POS routes for API Gateway - Global POS endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Response, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# ================================================================
|
||||
# GLOBAL POS ENDPOINTS (No tenant context required)
|
||||
# ================================================================
|
||||
|
||||
@router.api_route("/supported-systems", methods=["GET", "OPTIONS"])
|
||||
async def proxy_supported_systems(request: Request):
|
||||
"""Proxy supported POS systems request to POS service"""
|
||||
target_path = "/api/v1/pos/supported-systems"
|
||||
return await _proxy_to_pos_service(request, target_path)
|
||||
|
||||
# ================================================================
|
||||
# PROXY HELPER FUNCTIONS
|
||||
# ================================================================
|
||||
|
||||
async def _proxy_to_pos_service(request: Request, target_path: str):
|
||||
"""Proxy request to POS service"""
|
||||
|
||||
# Handle OPTIONS requests directly for CORS
|
||||
if request.method == "OPTIONS":
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"Access-Control-Allow-Origin": settings.CORS_ORIGINS_LIST,
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tenant-ID",
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Max-Age": "86400"
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
url = f"{settings.POS_SERVICE_URL}{target_path}"
|
||||
|
||||
# Forward headers
|
||||
headers = dict(request.headers)
|
||||
headers.pop("host", None)
|
||||
|
||||
# Add query parameters
|
||||
params = dict(request.query_params)
|
||||
|
||||
timeout_config = httpx.Timeout(
|
||||
connect=30.0,
|
||||
read=60.0,
|
||||
write=30.0,
|
||||
pool=30.0
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout_config) as client:
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=params
|
||||
)
|
||||
|
||||
# Handle different response types
|
||||
if response.headers.get("content-type", "").startswith("application/json"):
|
||||
try:
|
||||
content = response.json()
|
||||
except:
|
||||
content = {"message": "Invalid JSON response from service"}
|
||||
else:
|
||||
content = response.text
|
||||
|
||||
return JSONResponse(
|
||||
status_code=response.status_code,
|
||||
content=content
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error proxying to POS service {target_path}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Internal gateway error"
|
||||
)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -55,9 +55,18 @@ class OnboardingRepository:
|
||||
user_id: str,
|
||||
step_name: str,
|
||||
completed: bool,
|
||||
step_data: Dict[str, Any] = None
|
||||
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 {}
|
||||
@@ -86,12 +95,19 @@ class OnboardingRepository:
|
||||
# Return the updated record
|
||||
stmt = stmt.returning(UserOnboardingProgress)
|
||||
result = await self.db.execute(stmt)
|
||||
|
||||
# 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}")
|
||||
if auto_commit:
|
||||
await self.db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@@ -109,15 +109,14 @@ 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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
421
services/forecasting/app/api/scenario_operations.py
Normal file
421
services/forecasting/app/api/scenario_operations.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
Scenario Simulation Operations API - PROFESSIONAL/ENTERPRISE ONLY
|
||||
Business operations for "what-if" scenario testing and strategic planning
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Path, Request
|
||||
from typing import List, Dict, Any
|
||||
from datetime import date, datetime, timedelta
|
||||
import uuid
|
||||
|
||||
from app.schemas.forecasts import (
|
||||
ScenarioSimulationRequest,
|
||||
ScenarioSimulationResponse,
|
||||
ScenarioComparisonRequest,
|
||||
ScenarioComparisonResponse,
|
||||
ScenarioType,
|
||||
ScenarioImpact,
|
||||
ForecastResponse,
|
||||
ForecastRequest
|
||||
)
|
||||
from app.services.forecasting_service import EnhancedForecastingService
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from shared.database.base import create_database_manager
|
||||
from shared.monitoring.decorators import track_execution_time
|
||||
from shared.monitoring.metrics import get_metrics_collector
|
||||
from app.core.config import settings
|
||||
from shared.routing import RouteBuilder
|
||||
from shared.auth.access_control import require_user_role
|
||||
|
||||
route_builder = RouteBuilder('forecasting')
|
||||
logger = structlog.get_logger()
|
||||
router = APIRouter(tags=["scenario-simulation"])
|
||||
|
||||
|
||||
def get_enhanced_forecasting_service():
|
||||
"""Dependency injection for EnhancedForecastingService"""
|
||||
database_manager = create_database_manager(settings.DATABASE_URL, "forecasting-service")
|
||||
return EnhancedForecastingService(database_manager)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_analytics_route("scenario-simulation"),
|
||||
response_model=ScenarioSimulationResponse
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@track_execution_time("scenario_simulation_duration_seconds", "forecasting-service")
|
||||
async def simulate_scenario(
|
||||
request: ScenarioSimulationRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
request_obj: Request = None,
|
||||
forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
|
||||
):
|
||||
"""
|
||||
Run a "what-if" scenario simulation on forecasts
|
||||
|
||||
This endpoint allows users to test how different scenarios might impact demand:
|
||||
- Weather events (heatwaves, cold snaps, rain)
|
||||
- Competition (new competitors opening nearby)
|
||||
- Events (festivals, concerts, sports events)
|
||||
- Pricing changes
|
||||
- Promotions
|
||||
- Supply disruptions
|
||||
|
||||
**PROFESSIONAL/ENTERPRISE ONLY**
|
||||
"""
|
||||
metrics = get_metrics_collector(request_obj)
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
try:
|
||||
logger.info("Starting scenario simulation",
|
||||
tenant_id=tenant_id,
|
||||
scenario_name=request.scenario_name,
|
||||
scenario_type=request.scenario_type.value,
|
||||
products=len(request.inventory_product_ids))
|
||||
|
||||
if metrics:
|
||||
metrics.increment_counter(f"scenario_simulations_total")
|
||||
metrics.increment_counter(f"scenario_simulations_{request.scenario_type.value}_total")
|
||||
|
||||
# Generate simulation ID
|
||||
simulation_id = str(uuid.uuid4())
|
||||
end_date = request.start_date + timedelta(days=request.duration_days - 1)
|
||||
|
||||
# Step 1: Generate baseline forecasts
|
||||
baseline_forecasts = []
|
||||
if request.include_baseline:
|
||||
logger.info("Generating baseline forecasts", tenant_id=tenant_id)
|
||||
for product_id in request.inventory_product_ids:
|
||||
forecast_request = ForecastRequest(
|
||||
inventory_product_id=product_id,
|
||||
forecast_date=request.start_date,
|
||||
forecast_days=request.duration_days,
|
||||
location="default" # TODO: Get from tenant settings
|
||||
)
|
||||
multi_day_result = await forecasting_service.generate_multi_day_forecast(
|
||||
tenant_id=tenant_id,
|
||||
request=forecast_request
|
||||
)
|
||||
baseline_forecasts.extend(multi_day_result.get("forecasts", []))
|
||||
|
||||
# Step 2: Apply scenario adjustments to generate scenario forecasts
|
||||
scenario_forecasts = await _apply_scenario_adjustments(
|
||||
tenant_id=tenant_id,
|
||||
request=request,
|
||||
baseline_forecasts=baseline_forecasts if request.include_baseline else [],
|
||||
forecasting_service=forecasting_service
|
||||
)
|
||||
|
||||
# Step 3: Calculate impacts
|
||||
product_impacts = _calculate_product_impacts(
|
||||
baseline_forecasts,
|
||||
scenario_forecasts,
|
||||
request.inventory_product_ids
|
||||
)
|
||||
|
||||
# Step 4: Calculate totals
|
||||
total_baseline_demand = sum(f.predicted_demand for f in baseline_forecasts) if baseline_forecasts else 0
|
||||
total_scenario_demand = sum(f.predicted_demand for f in scenario_forecasts)
|
||||
overall_impact_percent = (
|
||||
((total_scenario_demand - total_baseline_demand) / total_baseline_demand * 100)
|
||||
if total_baseline_demand > 0 else 0
|
||||
)
|
||||
|
||||
# Step 5: Generate insights and recommendations
|
||||
insights, recommendations, risk_level = _generate_insights(
|
||||
request.scenario_type,
|
||||
request,
|
||||
product_impacts,
|
||||
overall_impact_percent
|
||||
)
|
||||
|
||||
# Calculate processing time
|
||||
processing_time_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
|
||||
|
||||
if metrics:
|
||||
metrics.increment_counter("scenario_simulations_success_total")
|
||||
metrics.observe_histogram("scenario_simulation_processing_time_ms", processing_time_ms)
|
||||
|
||||
logger.info("Scenario simulation completed successfully",
|
||||
tenant_id=tenant_id,
|
||||
simulation_id=simulation_id,
|
||||
overall_impact=f"{overall_impact_percent:.2f}%",
|
||||
processing_time_ms=processing_time_ms)
|
||||
|
||||
return ScenarioSimulationResponse(
|
||||
id=simulation_id,
|
||||
tenant_id=tenant_id,
|
||||
scenario_name=request.scenario_name,
|
||||
scenario_type=request.scenario_type,
|
||||
start_date=request.start_date,
|
||||
end_date=end_date,
|
||||
duration_days=request.duration_days,
|
||||
baseline_forecasts=baseline_forecasts if request.include_baseline else None,
|
||||
scenario_forecasts=scenario_forecasts,
|
||||
total_baseline_demand=total_baseline_demand,
|
||||
total_scenario_demand=total_scenario_demand,
|
||||
overall_impact_percent=overall_impact_percent,
|
||||
product_impacts=product_impacts,
|
||||
insights=insights,
|
||||
recommendations=recommendations,
|
||||
risk_level=risk_level,
|
||||
created_at=datetime.utcnow(),
|
||||
processing_time_ms=processing_time_ms
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
if metrics:
|
||||
metrics.increment_counter("scenario_simulation_validation_errors_total")
|
||||
logger.error("Scenario simulation validation error", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
if metrics:
|
||||
metrics.increment_counter("scenario_simulations_errors_total")
|
||||
logger.error("Scenario simulation failed", error=str(e), tenant_id=tenant_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Scenario simulation failed"
|
||||
)
|
||||
|
||||
|
||||
async def _apply_scenario_adjustments(
|
||||
tenant_id: str,
|
||||
request: ScenarioSimulationRequest,
|
||||
baseline_forecasts: List[ForecastResponse],
|
||||
forecasting_service: EnhancedForecastingService
|
||||
) -> List[ForecastResponse]:
|
||||
"""
|
||||
Apply scenario-specific adjustments to forecasts
|
||||
"""
|
||||
scenario_forecasts = []
|
||||
|
||||
# If no baseline, generate fresh forecasts
|
||||
if not baseline_forecasts:
|
||||
for product_id in request.inventory_product_ids:
|
||||
forecast_request = ForecastRequest(
|
||||
inventory_product_id=product_id,
|
||||
forecast_date=request.start_date,
|
||||
forecast_days=request.duration_days,
|
||||
location="default"
|
||||
)
|
||||
multi_day_result = await forecasting_service.generate_multi_day_forecast(
|
||||
tenant_id=tenant_id,
|
||||
request=forecast_request
|
||||
)
|
||||
baseline_forecasts = multi_day_result.get("forecasts", [])
|
||||
|
||||
# Apply multipliers based on scenario type
|
||||
for forecast in baseline_forecasts:
|
||||
adjusted_forecast = forecast.copy()
|
||||
multiplier = _get_scenario_multiplier(request)
|
||||
|
||||
# Adjust predicted demand
|
||||
adjusted_forecast.predicted_demand *= multiplier
|
||||
adjusted_forecast.confidence_lower *= multiplier
|
||||
adjusted_forecast.confidence_upper *= multiplier
|
||||
|
||||
scenario_forecasts.append(adjusted_forecast)
|
||||
|
||||
return scenario_forecasts
|
||||
|
||||
|
||||
def _get_scenario_multiplier(request: ScenarioSimulationRequest) -> float:
|
||||
"""
|
||||
Calculate demand multiplier based on scenario type and parameters
|
||||
"""
|
||||
if request.scenario_type == ScenarioType.WEATHER:
|
||||
if request.weather_params:
|
||||
# Heatwave increases demand for cold items, decreases for hot items
|
||||
if request.weather_params.temperature_change and request.weather_params.temperature_change > 10:
|
||||
return 1.25 # 25% increase during heatwave
|
||||
elif request.weather_params.temperature_change and request.weather_params.temperature_change < -10:
|
||||
return 0.85 # 15% decrease during cold snap
|
||||
elif request.weather_params.precipitation_change and request.weather_params.precipitation_change > 10:
|
||||
return 0.90 # 10% decrease during heavy rain
|
||||
return 1.0
|
||||
|
||||
elif request.scenario_type == ScenarioType.COMPETITION:
|
||||
if request.competition_params:
|
||||
# New competition reduces demand based on market share loss
|
||||
return 1.0 - request.competition_params.estimated_market_share_loss
|
||||
return 0.85 # Default 15% reduction
|
||||
|
||||
elif request.scenario_type == ScenarioType.EVENT:
|
||||
if request.event_params:
|
||||
# Events increase demand based on attendance and proximity
|
||||
if request.event_params.distance_km < 1.0:
|
||||
return 1.5 # 50% increase for very close events
|
||||
elif request.event_params.distance_km < 5.0:
|
||||
return 1.2 # 20% increase for nearby events
|
||||
return 1.15 # Default 15% increase
|
||||
|
||||
elif request.scenario_type == ScenarioType.PRICING:
|
||||
if request.pricing_params:
|
||||
# Price elasticity: typically -0.5 to -2.0
|
||||
# 10% price increase = 5-20% demand decrease
|
||||
elasticity = -1.0 # Average elasticity
|
||||
return 1.0 + (request.pricing_params.price_change_percent / 100) * elasticity
|
||||
return 1.0
|
||||
|
||||
elif request.scenario_type == ScenarioType.PROMOTION:
|
||||
if request.promotion_params:
|
||||
# Promotions increase traffic and conversion
|
||||
traffic_boost = 1.0 + request.promotion_params.expected_traffic_increase
|
||||
discount_boost = 1.0 + (request.promotion_params.discount_percent / 100) * 0.5
|
||||
return traffic_boost * discount_boost
|
||||
return 1.3 # Default 30% increase
|
||||
|
||||
elif request.scenario_type == ScenarioType.SUPPLY_DISRUPTION:
|
||||
return 0.6 # 40% reduction due to limited supply
|
||||
|
||||
elif request.scenario_type == ScenarioType.CUSTOM:
|
||||
if request.custom_multipliers and 'demand' in request.custom_multipliers:
|
||||
return request.custom_multipliers['demand']
|
||||
return 1.0
|
||||
|
||||
return 1.0
|
||||
|
||||
|
||||
def _calculate_product_impacts(
|
||||
baseline_forecasts: List[ForecastResponse],
|
||||
scenario_forecasts: List[ForecastResponse],
|
||||
product_ids: List[str]
|
||||
) -> List[ScenarioImpact]:
|
||||
"""
|
||||
Calculate per-product impact of the scenario
|
||||
"""
|
||||
impacts = []
|
||||
|
||||
for product_id in product_ids:
|
||||
baseline_total = sum(
|
||||
f.predicted_demand for f in baseline_forecasts
|
||||
if f.inventory_product_id == product_id
|
||||
)
|
||||
scenario_total = sum(
|
||||
f.predicted_demand for f in scenario_forecasts
|
||||
if f.inventory_product_id == product_id
|
||||
)
|
||||
|
||||
if baseline_total > 0:
|
||||
change_percent = ((scenario_total - baseline_total) / baseline_total) * 100
|
||||
else:
|
||||
change_percent = 0
|
||||
|
||||
# Get confidence ranges
|
||||
scenario_product_forecasts = [
|
||||
f for f in scenario_forecasts if f.inventory_product_id == product_id
|
||||
]
|
||||
avg_lower = sum(f.confidence_lower for f in scenario_product_forecasts) / len(scenario_product_forecasts) if scenario_product_forecasts else 0
|
||||
avg_upper = sum(f.confidence_upper for f in scenario_product_forecasts) / len(scenario_product_forecasts) if scenario_product_forecasts else 0
|
||||
|
||||
impacts.append(ScenarioImpact(
|
||||
inventory_product_id=product_id,
|
||||
baseline_demand=baseline_total,
|
||||
simulated_demand=scenario_total,
|
||||
demand_change_percent=change_percent,
|
||||
confidence_range=(avg_lower, avg_upper),
|
||||
impact_factors={"primary_driver": "scenario_adjustment"}
|
||||
))
|
||||
|
||||
return impacts
|
||||
|
||||
|
||||
def _generate_insights(
|
||||
scenario_type: ScenarioType,
|
||||
request: ScenarioSimulationRequest,
|
||||
impacts: List[ScenarioImpact],
|
||||
overall_impact: float
|
||||
) -> tuple[List[str], List[str], str]:
|
||||
"""
|
||||
Generate AI-powered insights and recommendations
|
||||
"""
|
||||
insights = []
|
||||
recommendations = []
|
||||
risk_level = "low"
|
||||
|
||||
# Determine risk level
|
||||
if abs(overall_impact) > 30:
|
||||
risk_level = "high"
|
||||
elif abs(overall_impact) > 15:
|
||||
risk_level = "medium"
|
||||
|
||||
# Generate scenario-specific insights
|
||||
if scenario_type == ScenarioType.WEATHER:
|
||||
if request.weather_params:
|
||||
if request.weather_params.temperature_change and request.weather_params.temperature_change > 10:
|
||||
insights.append(f"Heatwave of +{request.weather_params.temperature_change}°C expected to increase demand by {overall_impact:.1f}%")
|
||||
recommendations.append("Increase inventory of cold beverages and refrigerated items")
|
||||
recommendations.append("Extend operating hours to capture increased evening traffic")
|
||||
elif request.weather_params.temperature_change and request.weather_params.temperature_change < -10:
|
||||
insights.append(f"Cold snap of {request.weather_params.temperature_change}°C expected to decrease demand by {abs(overall_impact):.1f}%")
|
||||
recommendations.append("Increase production of warm comfort foods")
|
||||
recommendations.append("Reduce inventory of cold items")
|
||||
|
||||
elif scenario_type == ScenarioType.COMPETITION:
|
||||
insights.append(f"New competitor expected to reduce demand by {abs(overall_impact):.1f}%")
|
||||
recommendations.append("Consider launching loyalty program to retain customers")
|
||||
recommendations.append("Differentiate with unique product offerings")
|
||||
recommendations.append("Focus on customer service excellence")
|
||||
|
||||
elif scenario_type == ScenarioType.EVENT:
|
||||
insights.append(f"Local event expected to increase demand by {overall_impact:.1f}%")
|
||||
recommendations.append("Increase staffing for the event period")
|
||||
recommendations.append("Stock additional inventory of popular items")
|
||||
recommendations.append("Consider event-specific promotions")
|
||||
|
||||
elif scenario_type == ScenarioType.PRICING:
|
||||
if overall_impact < 0:
|
||||
insights.append(f"Price increase expected to reduce demand by {abs(overall_impact):.1f}%")
|
||||
recommendations.append("Consider smaller price increases")
|
||||
recommendations.append("Communicate value proposition to customers")
|
||||
else:
|
||||
insights.append(f"Price decrease expected to increase demand by {overall_impact:.1f}%")
|
||||
recommendations.append("Ensure adequate inventory to meet increased demand")
|
||||
|
||||
elif scenario_type == ScenarioType.PROMOTION:
|
||||
insights.append(f"Promotion expected to increase demand by {overall_impact:.1f}%")
|
||||
recommendations.append("Stock additional inventory before promotion starts")
|
||||
recommendations.append("Increase staffing during promotion period")
|
||||
recommendations.append("Prepare marketing materials and signage")
|
||||
|
||||
# Add product-specific insights
|
||||
high_impact_products = [
|
||||
impact for impact in impacts
|
||||
if abs(impact.demand_change_percent) > 20
|
||||
]
|
||||
if high_impact_products:
|
||||
insights.append(f"{len(high_impact_products)} products show significant impact (>20% change)")
|
||||
|
||||
# Add general recommendation
|
||||
if risk_level == "high":
|
||||
recommendations.append("⚠️ High-impact scenario - review and adjust operational plans immediately")
|
||||
elif risk_level == "medium":
|
||||
recommendations.append("Monitor situation closely and prepare contingency plans")
|
||||
|
||||
return insights, recommendations, risk_level
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_analytics_route("scenario-comparison"),
|
||||
response_model=ScenarioComparisonResponse
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
async def compare_scenarios(
|
||||
request: ScenarioComparisonRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID")
|
||||
):
|
||||
"""
|
||||
Compare multiple scenario simulations
|
||||
|
||||
**PROFESSIONAL/ENTERPRISE ONLY**
|
||||
"""
|
||||
# TODO: Implement scenario comparison
|
||||
# This would retrieve saved scenarios and compare them
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Scenario comparison not yet implemented"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
):
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user