diff --git a/frontend/src/api/hooks/index.ts b/frontend/src/api/hooks/index.ts index e91ed69f..bb855104 100644 --- a/frontend/src/api/hooks/index.ts +++ b/frontend/src/api/hooks/index.ts @@ -13,6 +13,21 @@ export { useNotification } from './useNotification'; export { useOnboarding, useOnboardingStep } from './useOnboarding'; export { useInventory, useInventoryDashboard, useInventoryItem, useInventoryProducts } from './useInventory'; export { useRecipes, useProduction } from './useRecipes'; +export { + useCurrentProcurementPlan, + useProcurementPlanByDate, + useProcurementPlan, + useProcurementPlans, + usePlanRequirements, + useCriticalRequirements, + useProcurementDashboard, + useGenerateProcurementPlan, + useUpdatePlanStatus, + useTriggerDailyScheduler, + useProcurementHealth, + useProcurementPlanDashboard, + useProcurementPlanActions +} from './useProcurement'; // Import hooks for combined usage import { useAuth } from './useAuth'; diff --git a/frontend/src/api/hooks/useProcurement.ts b/frontend/src/api/hooks/useProcurement.ts new file mode 100644 index 00000000..067fd9ec --- /dev/null +++ b/frontend/src/api/hooks/useProcurement.ts @@ -0,0 +1,294 @@ +// ================================================================ +// frontend/src/api/hooks/useProcurement.ts +// ================================================================ +/** + * React hooks for procurement planning functionality + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { procurementService } from '../services/procurement.service'; +import type { + ProcurementPlan, + GeneratePlanRequest, + GeneratePlanResponse, + DashboardData, + ProcurementRequirement, + PaginatedProcurementPlans +} from '../types/procurement'; + +// ================================================================ +// QUERY KEYS +// ================================================================ + +export const procurementKeys = { + all: ['procurement'] as const, + plans: () => [...procurementKeys.all, 'plans'] as const, + plan: (id: string) => [...procurementKeys.plans(), id] as const, + currentPlan: () => [...procurementKeys.plans(), 'current'] as const, + planByDate: (date: string) => [...procurementKeys.plans(), 'date', date] as const, + plansList: (filters?: any) => [...procurementKeys.plans(), 'list', filters] as const, + requirements: () => [...procurementKeys.all, 'requirements'] as const, + planRequirements: (planId: string) => [...procurementKeys.requirements(), 'plan', planId] as const, + criticalRequirements: () => [...procurementKeys.requirements(), 'critical'] as const, + dashboard: () => [...procurementKeys.all, 'dashboard'] as const, +}; + +// ================================================================ +// PROCUREMENT PLAN HOOKS +// ================================================================ + +/** + * Hook to fetch the current day's procurement plan + */ +export function useCurrentProcurementPlan() { + return useQuery({ + queryKey: procurementKeys.currentPlan(), + queryFn: () => procurementService.getCurrentPlan(), + staleTime: 5 * 60 * 1000, // 5 minutes + refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes + }); +} + +/** + * Hook to fetch procurement plan by date + */ +export function useProcurementPlanByDate(date: string, enabled = true) { + return useQuery({ + queryKey: procurementKeys.planByDate(date), + queryFn: () => procurementService.getPlanByDate(date), + enabled: enabled && !!date, + staleTime: 30 * 60 * 1000, // 30 minutes for historical data + }); +} + +/** + * Hook to fetch procurement plan by ID + */ +export function useProcurementPlan(planId: string, enabled = true) { + return useQuery({ + queryKey: procurementKeys.plan(planId), + queryFn: () => procurementService.getPlanById(planId), + enabled: enabled && !!planId, + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} + +/** + * Hook to fetch paginated list of procurement plans + */ +export function useProcurementPlans(params?: { + status?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; +}) { + return useQuery({ + queryKey: procurementKeys.plansList(params), + queryFn: () => procurementService.listPlans(params), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +// ================================================================ +// REQUIREMENTS HOOKS +// ================================================================ + +/** + * Hook to fetch requirements for a specific plan + */ +export function usePlanRequirements( + planId: string, + filters?: { + status?: string; + priority?: string; + }, + enabled = true +) { + return useQuery({ + queryKey: procurementKeys.planRequirements(planId), + queryFn: () => procurementService.getPlanRequirements(planId, filters), + enabled: enabled && !!planId, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Hook to fetch critical requirements across all plans + */ +export function useCriticalRequirements() { + return useQuery({ + queryKey: procurementKeys.criticalRequirements(), + queryFn: () => procurementService.getCriticalRequirements(), + staleTime: 2 * 60 * 1000, // 2 minutes for critical data + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + }); +} + +// ================================================================ +// DASHBOARD HOOKS +// ================================================================ + +/** + * Hook to fetch procurement dashboard data + */ +export function useProcurementDashboard() { + return useQuery({ + queryKey: procurementKeys.dashboard(), + queryFn: () => procurementService.getDashboardData(), + staleTime: 2 * 60 * 1000, // 2 minutes + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + }); +} + +// ================================================================ +// MUTATION HOOKS +// ================================================================ + +/** + * Hook to generate a new procurement plan + */ +export function useGenerateProcurementPlan() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (request: GeneratePlanRequest) => + procurementService.generatePlan(request), + onSuccess: (data: GeneratePlanResponse) => { + // Invalidate relevant queries + queryClient.invalidateQueries({ queryKey: procurementKeys.plans() }); + queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() }); + + // If plan was generated successfully, update the cache + if (data.success && data.plan) { + queryClient.setQueryData( + procurementKeys.plan(data.plan.id), + data.plan + ); + + // Update current plan cache if this is today's plan + const today = new Date().toISOString().split('T')[0]; + if (data.plan.plan_date === today) { + queryClient.setQueryData( + procurementKeys.currentPlan(), + data.plan + ); + } + } + }, + }); +} + +/** + * Hook to update procurement plan status + */ +export function useUpdatePlanStatus() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ planId, status }: { planId: string; status: string }) => + procurementService.updatePlanStatus(planId, status), + onSuccess: (updatedPlan: ProcurementPlan) => { + // Update the specific plan in cache + queryClient.setQueryData( + procurementKeys.plan(updatedPlan.id), + updatedPlan + ); + + // Update current plan if this is the current plan + const today = new Date().toISOString().split('T')[0]; + if (updatedPlan.plan_date === today) { + queryClient.setQueryData( + procurementKeys.currentPlan(), + updatedPlan + ); + } + + // Invalidate lists to ensure they're refreshed + queryClient.invalidateQueries({ queryKey: procurementKeys.plansList() }); + queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() }); + }, + }); +} + +/** + * Hook to trigger the daily scheduler manually + */ +export function useTriggerDailyScheduler() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => procurementService.triggerDailyScheduler(), + onSuccess: () => { + // Invalidate all procurement data + queryClient.invalidateQueries({ queryKey: procurementKeys.all }); + }, + }); +} + +// ================================================================ +// UTILITY HOOKS +// ================================================================ + +/** + * Hook to check procurement service health + */ +export function useProcurementHealth() { + return useQuery({ + queryKey: [...procurementKeys.all, 'health'], + queryFn: () => procurementService.healthCheck(), + staleTime: 60 * 1000, // 1 minute + refetchInterval: 5 * 60 * 1000, // Check every 5 minutes + }); +} + +// ================================================================ +// COMBINED HOOKS +// ================================================================ + +/** + * Combined hook for procurement plan dashboard + * Fetches current plan, dashboard data, and critical requirements + */ +export function useProcurementPlanDashboard() { + const currentPlan = useCurrentProcurementPlan(); + const dashboard = useProcurementDashboard(); + const criticalRequirements = useCriticalRequirements(); + const health = useProcurementHealth(); + + return { + currentPlan, + dashboard, + criticalRequirements, + health, + isLoading: currentPlan.isLoading || dashboard.isLoading, + error: currentPlan.error || dashboard.error || criticalRequirements.error, + refetchAll: () => { + currentPlan.refetch(); + dashboard.refetch(); + criticalRequirements.refetch(); + health.refetch(); + }, + }; +} + +/** + * Hook for managing procurement plan lifecycle + */ +export function useProcurementPlanActions() { + const generatePlan = useGenerateProcurementPlan(); + const updateStatus = useUpdatePlanStatus(); + const triggerScheduler = useTriggerDailyScheduler(); + + return { + generatePlan: generatePlan.mutate, + updateStatus: updateStatus.mutate, + triggerScheduler: triggerScheduler.mutate, + isGenerating: generatePlan.isPending, + isUpdating: updateStatus.isPending, + isTriggering: triggerScheduler.isPending, + generateError: generatePlan.error, + updateError: updateStatus.error, + triggerError: triggerScheduler.error, + }; +} \ No newline at end of file diff --git a/frontend/src/api/services/index.ts b/frontend/src/api/services/index.ts index 7b132785..bb993705 100644 --- a/frontend/src/api/services/index.ts +++ b/frontend/src/api/services/index.ts @@ -18,6 +18,7 @@ import { RecipesService } from './recipes.service'; import { ProductionService } from './production.service'; import { OrdersService } from './orders.service'; import { SuppliersService } from './suppliers.service'; +import { ProcurementService } from './procurement.service'; // Create service instances export const authService = new AuthService(); @@ -33,6 +34,7 @@ export const recipesService = new RecipesService(); export const productionService = new ProductionService(); export const ordersService = new OrdersService(); export const suppliersService = new SuppliersService(); +export const procurementService = new ProcurementService(); // Export the classes as well export { @@ -48,7 +50,8 @@ export { RecipesService, ProductionService, OrdersService, - SuppliersService + SuppliersService, + ProcurementService }; // Import base client @@ -73,6 +76,7 @@ export const api = { production: productionService, orders: ordersService, suppliers: suppliersService, + procurement: procurementService, } as const; // Service status checking @@ -98,6 +102,7 @@ export class HealthService { { name: 'Suppliers', endpoint: '/suppliers/health' }, { name: 'Forecasting', endpoint: '/forecasting/health' }, { name: 'Notification', endpoint: '/notifications/health' }, + { name: 'Procurement', endpoint: '/procurement-plans/health' }, ]; const healthChecks = await Promise.allSettled( diff --git a/frontend/src/api/services/procurement.service.ts b/frontend/src/api/services/procurement.service.ts new file mode 100644 index 00000000..ab6e9d77 --- /dev/null +++ b/frontend/src/api/services/procurement.service.ts @@ -0,0 +1,135 @@ +// ================================================================ +// frontend/src/api/services/procurement.service.ts +// ================================================================ +/** + * Procurement Service - API client for procurement planning endpoints + */ + +import { ApiClient } from '../client'; +import type { + ProcurementPlan, + GeneratePlanRequest, + GeneratePlanResponse, + DashboardData, + ProcurementRequirement, + PaginatedProcurementPlans +} from '../types/procurement'; + +export class ProcurementService { + constructor(private client: ApiClient) {} + + // ================================================================ + // PROCUREMENT PLAN OPERATIONS + // ================================================================ + + /** + * Get the procurement plan for the current day + */ + async getCurrentPlan(): Promise { + return this.client.get('/procurement-plans/current'); + } + + /** + * Get procurement plan for a specific date + */ + async getPlanByDate(date: string): Promise { + return this.client.get(`/procurement-plans/${date}`); + } + + /** + * Get procurement plan by ID + */ + async getPlanById(planId: string): Promise { + return this.client.get(`/procurement-plans/id/${planId}`); + } + + /** + * List procurement plans with optional filters + */ + async listPlans(params?: { + status?: string; + startDate?: string; + endDate?: string; + limit?: number; + offset?: number; + }): Promise { + return this.client.get('/procurement-plans/', { params }); + } + + /** + * Generate a new procurement plan + */ + async generatePlan(request: GeneratePlanRequest): Promise { + return this.client.post('/procurement-plans/generate', request); + } + + /** + * Update procurement plan status + */ + async updatePlanStatus(planId: string, status: string): Promise { + return this.client.put(`/procurement-plans/${planId}/status`, null, { + params: { status } + }); + } + + // ================================================================ + // REQUIREMENTS OPERATIONS + // ================================================================ + + /** + * Get all requirements for a specific procurement plan + */ + async getPlanRequirements( + planId: string, + params?: { + status?: string; + priority?: string; + } + ): Promise { + return this.client.get(`/procurement-plans/${planId}/requirements`, { params }); + } + + /** + * Get all critical priority requirements + */ + async getCriticalRequirements(): Promise { + return this.client.get('/procurement-plans/requirements/critical'); + } + + // ================================================================ + // DASHBOARD OPERATIONS + // ================================================================ + + /** + * Get procurement dashboard data + */ + async getDashboardData(): Promise { + return this.client.get('/procurement-plans/dashboard/data'); + } + + // ================================================================ + // UTILITY OPERATIONS + // ================================================================ + + /** + * Manually trigger the daily scheduler + */ + async triggerDailyScheduler(): Promise<{ success: boolean; message: string; tenant_id: string }> { + return this.client.post('/procurement-plans/scheduler/trigger'); + } + + /** + * Health check for procurement service + */ + async healthCheck(): Promise<{ + status: string; + service: string; + procurement_enabled: boolean; + timestamp: string; + }> { + return this.client.get('/procurement-plans/health'); + } +} + +// Export singleton instance +export const procurementService = new ProcurementService(new ApiClient()); \ No newline at end of file diff --git a/frontend/src/api/types/index.ts b/frontend/src/api/types/index.ts index 2f63612b..52107a9f 100644 --- a/frontend/src/api/types/index.ts +++ b/frontend/src/api/types/index.ts @@ -10,4 +10,5 @@ export * from './tenant'; export * from './data'; export * from './training'; export * from './forecasting'; -export * from './notification'; \ No newline at end of file +export * from './notification'; +export * from './procurement'; \ No newline at end of file diff --git a/frontend/src/api/types/procurement.ts b/frontend/src/api/types/procurement.ts new file mode 100644 index 00000000..b00ffe9e --- /dev/null +++ b/frontend/src/api/types/procurement.ts @@ -0,0 +1,330 @@ +// ================================================================ +// frontend/src/api/types/procurement.ts +// ================================================================ +/** + * TypeScript types for procurement planning API + */ + +// ================================================================ +// BASE TYPES +// ================================================================ + +export interface ProcurementRequirement { + id: string; + plan_id: string; + requirement_number: string; + + // Product information + product_id: string; + product_name: string; + product_sku?: string; + product_category?: string; + product_type: string; + + // Quantity requirements + required_quantity: number; + unit_of_measure: string; + safety_stock_quantity: number; + total_quantity_needed: number; + + // Current inventory situation + current_stock_level: number; + reserved_stock: number; + available_stock: number; + net_requirement: number; + + // Demand breakdown + order_demand: number; + production_demand: number; + forecast_demand: number; + buffer_demand: number; + + // Supplier information + preferred_supplier_id?: string; + backup_supplier_id?: string; + supplier_name?: string; + supplier_lead_time_days?: number; + minimum_order_quantity?: number; + + // Pricing + estimated_unit_cost?: number; + estimated_total_cost?: number; + last_purchase_cost?: number; + cost_variance: number; + + // Timing + required_by_date: string; + lead_time_buffer_days: number; + suggested_order_date: string; + latest_order_date: string; + + // Status and priority + status: string; + priority: string; + risk_level: string; + + // Purchase tracking + purchase_order_id?: string; + purchase_order_number?: string; + ordered_quantity: number; + ordered_at?: string; + + // Delivery tracking + expected_delivery_date?: string; + actual_delivery_date?: string; + received_quantity: number; + delivery_status: string; + + // Performance metrics + fulfillment_rate?: number; + on_time_delivery?: boolean; + quality_rating?: number; + + // Approval + approved_quantity?: number; + approved_cost?: number; + approved_at?: string; + approved_by?: string; + + // Additional info + special_requirements?: string; + storage_requirements?: string; + shelf_life_days?: number; + quality_specifications?: Record; + procurement_notes?: string; +} + +export interface ProcurementPlan { + id: string; + tenant_id: string; + plan_number: string; + + // Plan scope and timing + plan_date: string; + plan_period_start: string; + plan_period_end: string; + planning_horizon_days: number; + + // Plan status and lifecycle + status: string; + plan_type: string; + priority: string; + + // Business context + business_model?: string; + procurement_strategy: string; + + // Plan totals and summary + total_requirements: number; + total_estimated_cost: number; + total_approved_cost: number; + cost_variance: number; + + // Demand analysis + total_demand_orders: number; + total_demand_quantity: number; + total_production_requirements: number; + safety_stock_buffer: number; + + // Supplier coordination + primary_suppliers_count: number; + backup_suppliers_count: number; + supplier_diversification_score?: number; + + // Risk assessment + supply_risk_level: string; + demand_forecast_confidence?: number; + seasonality_adjustment: number; + + // Execution tracking + approved_at?: string; + approved_by?: string; + execution_started_at?: string; + execution_completed_at?: string; + + // Performance metrics + fulfillment_rate?: number; + on_time_delivery_rate?: number; + cost_accuracy?: number; + quality_score?: number; + + // Metadata + created_at: string; + updated_at: string; + created_by?: string; + updated_by?: string; + + // Additional info + special_requirements?: string; + + // Relationships + requirements: ProcurementRequirement[]; +} + +// ================================================================ +// REQUEST TYPES +// ================================================================ + +export interface GeneratePlanRequest { + plan_date?: string; + force_regenerate?: boolean; + planning_horizon_days?: number; + include_safety_stock?: boolean; + safety_stock_percentage?: number; +} + +export interface ForecastRequest { + target_date: string; + horizon_days?: number; + include_confidence_intervals?: boolean; + product_ids?: string[]; +} + +// ================================================================ +// RESPONSE TYPES +// ================================================================ + +export interface GeneratePlanResponse { + success: boolean; + message: string; + plan?: ProcurementPlan; + warnings?: string[]; + errors?: string[]; +} + +export interface PaginatedProcurementPlans { + plans: ProcurementPlan[]; + total: number; + page: number; + limit: number; + has_more: boolean; +} + +// ================================================================ +// DASHBOARD TYPES +// ================================================================ + +export interface ProcurementSummary { + total_plans: number; + active_plans: number; + total_requirements: number; + pending_requirements: number; + critical_requirements: number; + + total_estimated_cost: number; + total_approved_cost: number; + cost_variance: number; + + average_fulfillment_rate?: number; + average_on_time_delivery?: number; + + top_suppliers: Array>; + critical_items: Array>; +} + +export interface DashboardData { + current_plan?: ProcurementPlan; + summary: ProcurementSummary; + + upcoming_deliveries: Array>; + overdue_requirements: Array>; + low_stock_alerts: Array>; + + performance_metrics: Record; +} + +// ================================================================ +// FILTER AND SEARCH TYPES +// ================================================================ + +export interface ProcurementFilters { + status?: string[]; + priority?: string[]; + risk_level?: string[]; + supplier_id?: string; + product_category?: string; + date_range?: { + start: string; + end: string; + }; +} + +export interface RequirementFilters { + status?: string[]; + priority?: string[]; + product_type?: string[]; + overdue_only?: boolean; + critical_only?: boolean; +} + +// ================================================================ +// UI COMPONENT TYPES +// ================================================================ + +export interface ProcurementPlanCardProps { + plan: ProcurementPlan; + onViewDetails?: (planId: string) => void; + onUpdateStatus?: (planId: string, status: string) => void; + showActions?: boolean; +} + +export interface RequirementCardProps { + requirement: ProcurementRequirement; + onViewDetails?: (requirementId: string) => void; + onUpdateStatus?: (requirementId: string, status: string) => void; + showSupplierInfo?: boolean; +} + +export interface ProcurementDashboardProps { + showFilters?: boolean; + refreshInterval?: number; + onPlanGenerated?: (plan: ProcurementPlan) => void; +} + +// ================================================================ +// ENUMS +// ================================================================ + +export enum PlanStatus { + DRAFT = 'draft', + PENDING_APPROVAL = 'pending_approval', + APPROVED = 'approved', + IN_EXECUTION = 'in_execution', + COMPLETED = 'completed', + CANCELLED = 'cancelled' +} + +export enum RequirementStatus { + PENDING = 'pending', + APPROVED = 'approved', + ORDERED = 'ordered', + PARTIALLY_RECEIVED = 'partially_received', + RECEIVED = 'received', + CANCELLED = 'cancelled' +} + +export enum Priority { + CRITICAL = 'critical', + HIGH = 'high', + NORMAL = 'normal', + LOW = 'low' +} + +export enum RiskLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical' +} + +export enum PlanType { + REGULAR = 'regular', + EMERGENCY = 'emergency', + SEASONAL = 'seasonal' +} + +export enum ProductType { + INGREDIENT = 'ingredient', + PACKAGING = 'packaging', + SUPPLIES = 'supplies' +} \ No newline at end of file diff --git a/frontend/src/components/procurement/CriticalRequirements.tsx b/frontend/src/components/procurement/CriticalRequirements.tsx new file mode 100644 index 00000000..2b7c7940 --- /dev/null +++ b/frontend/src/components/procurement/CriticalRequirements.tsx @@ -0,0 +1,216 @@ +// ================================================================ +// frontend/src/components/procurement/CriticalRequirements.tsx +// ================================================================ +/** + * Critical Requirements Component + * Displays urgent procurement requirements that need immediate attention + */ + +import React from 'react'; +import type { ProcurementRequirement } from '@/api/types/procurement'; +import { Priority, RequirementStatus } from '@/api/types/procurement'; + +export interface CriticalRequirementsProps { + requirements: ProcurementRequirement[]; + onViewDetails?: (requirementId: string) => void; + onUpdateStatus?: (requirementId: string, status: string) => void; +} + +export const CriticalRequirements: React.FC = ({ + requirements, + onViewDetails, + onUpdateStatus, +}) => { + const formatCurrency = (amount: number | undefined) => { + if (!amount) return 'N/A'; + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR' + }).format(amount); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const today = new Date(); + const diffTime = date.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return `Overdue by ${Math.abs(diffDays)} days`; + } else if (diffDays === 0) { + return 'Due today'; + } else if (diffDays === 1) { + return 'Due tomorrow'; + } else { + return `Due in ${diffDays} days`; + } + }; + + const getStatusColor = (status: string) => { + const colors = { + [RequirementStatus.PENDING]: 'bg-yellow-100 text-yellow-800', + [RequirementStatus.APPROVED]: 'bg-blue-100 text-blue-800', + [RequirementStatus.ORDERED]: 'bg-purple-100 text-purple-800', + [RequirementStatus.PARTIALLY_RECEIVED]: 'bg-orange-100 text-orange-800', + [RequirementStatus.RECEIVED]: 'bg-green-100 text-green-800', + [RequirementStatus.CANCELLED]: 'bg-red-100 text-red-800', + }; + return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800'; + }; + + const getDueDateColor = (dateString: string) => { + const date = new Date(dateString); + const today = new Date(); + const diffTime = date.getTime() - today.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) return 'text-red-600 font-medium'; // Overdue + if (diffDays <= 1) return 'text-orange-600 font-medium'; // Due soon + return 'text-gray-600'; + }; + + const getStockLevelColor = (current: number, needed: number) => { + const ratio = current / needed; + if (ratio <= 0.1) return 'text-red-600 font-medium'; // Critical + if (ratio <= 0.3) return 'text-orange-600 font-medium'; // Low + return 'text-gray-600'; + }; + + if (requirements.length === 0) { + return ( +
+

No critical requirements at this time

+
+ ); + } + + return ( +
+ {requirements.map((requirement) => ( +
+
+
+
+

+ {requirement.product_name} +

+ + {requirement.status.replace('_', ' ').toUpperCase()} + + + CRITICAL + +
+ +
+
+ Required: +
+ {requirement.net_requirement} {requirement.unit_of_measure} +
+
+ +
+ Current Stock: +
+ {requirement.current_stock_level} {requirement.unit_of_measure} +
+
+ +
+ Due Date: +
+ {formatDate(requirement.required_by_date)} +
+
+ +
+ Est. Cost: +
+ {formatCurrency(requirement.estimated_total_cost)} +
+
+
+ + {requirement.supplier_name && ( +
+ Supplier: + {requirement.supplier_name} + {requirement.supplier_lead_time_days && ( + + ({requirement.supplier_lead_time_days} days lead time) + + )} +
+ )} + + {requirement.special_requirements && ( +
+ Special Requirements: +

+ {requirement.special_requirements} +

+
+ )} +
+ +
+ {requirement.status === RequirementStatus.PENDING && ( + + )} + + {requirement.status === RequirementStatus.APPROVED && ( + + )} + + +
+
+ + {/* Progress indicator for ordered items */} + {requirement.status === RequirementStatus.ORDERED && requirement.ordered_quantity > 0 && ( +
+
+ Order Progress + + {requirement.received_quantity} / {requirement.ordered_quantity} {requirement.unit_of_measure} + +
+
+
+
+ {requirement.expected_delivery_date && ( +
+ Expected: {formatDate(requirement.expected_delivery_date)} +
+ )} +
+ )} +
+ ))} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/procurement/GeneratePlanModal.tsx b/frontend/src/components/procurement/GeneratePlanModal.tsx new file mode 100644 index 00000000..579f5532 --- /dev/null +++ b/frontend/src/components/procurement/GeneratePlanModal.tsx @@ -0,0 +1,202 @@ +// ================================================================ +// frontend/src/components/procurement/GeneratePlanModal.tsx +// ================================================================ +/** + * Generate Plan Modal Component + * Modal for configuring and generating new procurement plans + */ + +import React, { useState } from 'react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import type { GeneratePlanRequest } from '@/api/types/procurement'; + +export interface GeneratePlanModalProps { + onGenerate: (request: GeneratePlanRequest) => void; + onClose: () => void; + isGenerating: boolean; + error?: Error | null; +} + +export const GeneratePlanModal: React.FC = ({ + onGenerate, + onClose, + isGenerating, + error, +}) => { + const [formData, setFormData] = useState({ + plan_date: new Date().toISOString().split('T')[0], // Today + force_regenerate: false, + planning_horizon_days: 14, + include_safety_stock: true, + safety_stock_percentage: 20, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onGenerate(formData); + }; + + const handleInputChange = (field: keyof GeneratePlanRequest, value: any) => { + setFormData(prev => ({ + ...prev, + [field]: value, + })); + }; + + return ( +
+
+
+

+ Generate Procurement Plan +

+ +
+ +
+ {/* Plan Date */} +
+ + handleInputChange('plan_date', e.target.value)} + disabled={isGenerating} + className="w-full" + /> +

+ Date for which to generate the procurement plan +

+
+ + {/* Planning Horizon */} +
+ + handleInputChange('planning_horizon_days', parseInt(e.target.value))} + disabled={isGenerating} + className="w-full" + /> +

+ Number of days to plan ahead (1-30) +

+
+ + {/* Safety Stock */} +
+
+ handleInputChange('include_safety_stock', e.target.checked)} + disabled={isGenerating} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + +
+ + {formData.include_safety_stock && ( +
+ + handleInputChange('safety_stock_percentage', parseFloat(e.target.value))} + disabled={isGenerating} + className="w-full" + /> +

+ Additional buffer stock as percentage of demand (0-100%) +

+
+ )} +
+ + {/* Force Regenerate */} +
+ handleInputChange('force_regenerate', e.target.checked)} + disabled={isGenerating} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + +
+

+ Regenerate plan even if one already exists for this date +

+ + {/* Error Display */} + {error && ( +
+

+ {error.message || 'Failed to generate plan'} +

+
+ )} + + {/* Action Buttons */} +
+ + + +
+
+ + {/* Generation Progress */} + {isGenerating && ( +
+
+
+ + Generating procurement plan... + +
+
+ This may take a few moments while we analyze inventory and forecast demand. +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/procurement/ProcurementDashboard.tsx b/frontend/src/components/procurement/ProcurementDashboard.tsx new file mode 100644 index 00000000..3bdae085 --- /dev/null +++ b/frontend/src/components/procurement/ProcurementDashboard.tsx @@ -0,0 +1,268 @@ +// ================================================================ +// frontend/src/components/procurement/ProcurementDashboard.tsx +// ================================================================ +/** + * Procurement Dashboard Component + * Main dashboard for procurement planning functionality + */ + +import React, { useState } from 'react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { + useProcurementPlanDashboard, + useProcurementPlanActions +} from '@/api/hooks/useProcurement'; +import type { + ProcurementPlan, + ProcurementRequirement, + GeneratePlanRequest +} from '@/api/types/procurement'; +import { ProcurementPlanCard } from './ProcurementPlanCard'; +import { ProcurementSummary } from './ProcurementSummary'; +import { CriticalRequirements } from './CriticalRequirements'; +import { GeneratePlanModal } from './GeneratePlanModal'; + +export interface ProcurementDashboardProps { + showFilters?: boolean; + refreshInterval?: number; + onPlanGenerated?: (plan: ProcurementPlan) => void; +} + +export const ProcurementDashboard: React.FC = ({ + showFilters = true, + refreshInterval = 5 * 60 * 1000, // 5 minutes + onPlanGenerated, +}) => { + const [showGenerateModal, setShowGenerateModal] = useState(false); + + const { + currentPlan, + dashboard, + criticalRequirements, + health, + isLoading, + error, + refetchAll + } = useProcurementPlanDashboard(); + + const { + generatePlan, + updateStatus, + triggerScheduler, + isGenerating, + generateError + } = useProcurementPlanActions(); + + const handleGeneratePlan = (request: GeneratePlanRequest) => { + generatePlan(request, { + onSuccess: (response) => { + if (response.success && response.plan) { + onPlanGenerated?.(response.plan); + setShowGenerateModal(false); + } + } + }); + }; + + const handleStatusUpdate = (planId: string, status: string) => { + updateStatus({ planId, status }); + }; + + const handleTriggerScheduler = () => { + triggerScheduler(); + }; + + if (isLoading) { + return ( +
+ + Loading procurement dashboard... +
+ ); + } + + if (error) { + return ( +
+

Error Loading Dashboard

+

+ {error.message || 'Unable to load procurement dashboard data'} +

+ +
+ ); + } + + const dashboardData = dashboard.data; + const currentPlanData = currentPlan.data; + const criticalReqs = criticalRequirements.data || []; + const serviceHealth = health.data; + + return ( +
+ {/* Header with Actions */} +
+
+

+ Procurement Planning +

+

+ Manage daily procurement plans and requirements +

+
+ +
+ {serviceHealth && !serviceHealth.procurement_enabled && ( +
+ + Service Disabled + +
+ )} + + + + +
+
+ + {/* Current Plan Section */} +
+
+ +
+

Today's Procurement Plan

+ +
+ + {currentPlanData ? ( + + ) : ( +
+

No procurement plan for today

+ +
+ )} +
+
+ + {/* Summary Statistics */} +
+ +

Summary

+ {dashboardData?.summary ? ( + + ) : ( +
No summary data available
+ )} +
+
+
+ + {/* Critical Requirements */} + {criticalReqs.length > 0 && ( + +

+ Critical Requirements ({criticalReqs.length}) +

+ +
+ )} + + {/* Additional Dashboard Widgets */} +
+ {/* Upcoming Deliveries */} + {dashboardData?.upcoming_deliveries?.length > 0 && ( + +

Upcoming Deliveries

+
+ {dashboardData.upcoming_deliveries.slice(0, 5).map((delivery, index) => ( +
+ {delivery.product_name} + {delivery.expected_date} +
+ ))} +
+
+ )} + + {/* Low Stock Alerts */} + {dashboardData?.low_stock_alerts?.length > 0 && ( + +

+ Low Stock Alerts +

+
+ {dashboardData.low_stock_alerts.slice(0, 5).map((alert, index) => ( +
+ {alert.product_name} + {alert.current_stock} +
+ ))} +
+
+ )} + + {/* Performance Metrics */} + {dashboardData?.performance_metrics && ( + +

Performance

+
+ {Object.entries(dashboardData.performance_metrics).map(([key, value]) => ( +
+ {key.replace('_', ' ')} + {value as string} +
+ ))} +
+
+ )} +
+ + {/* Generate Plan Modal */} + {showGenerateModal && ( + setShowGenerateModal(false)} + isGenerating={isGenerating} + error={generateError} + /> + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/procurement/ProcurementPlanCard.tsx b/frontend/src/components/procurement/ProcurementPlanCard.tsx new file mode 100644 index 00000000..85ae32d0 --- /dev/null +++ b/frontend/src/components/procurement/ProcurementPlanCard.tsx @@ -0,0 +1,235 @@ +// ================================================================ +// frontend/src/components/procurement/ProcurementPlanCard.tsx +// ================================================================ +/** + * Procurement Plan Card Component + * Displays a procurement plan with key information and actions + */ + +import React from 'react'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import type { ProcurementPlan } from '@/api/types/procurement'; +import { PlanStatus, Priority } from '@/api/types/procurement'; + +export interface ProcurementPlanCardProps { + plan: ProcurementPlan; + onViewDetails?: (planId: string) => void; + onUpdateStatus?: (planId: string, status: string) => void; + showActions?: boolean; +} + +export const ProcurementPlanCard: React.FC = ({ + plan, + onViewDetails, + onUpdateStatus, + showActions = false, +}) => { + const getStatusColor = (status: string) => { + const colors = { + [PlanStatus.DRAFT]: 'bg-gray-100 text-gray-800', + [PlanStatus.PENDING_APPROVAL]: 'bg-yellow-100 text-yellow-800', + [PlanStatus.APPROVED]: 'bg-blue-100 text-blue-800', + [PlanStatus.IN_EXECUTION]: 'bg-green-100 text-green-800', + [PlanStatus.COMPLETED]: 'bg-green-100 text-green-800', + [PlanStatus.CANCELLED]: 'bg-red-100 text-red-800', + }; + return colors[status as keyof typeof colors] || 'bg-gray-100 text-gray-800'; + }; + + const getPriorityColor = (priority: string) => { + const colors = { + [Priority.CRITICAL]: 'text-red-600', + [Priority.HIGH]: 'text-orange-600', + [Priority.NORMAL]: 'text-blue-600', + [Priority.LOW]: 'text-gray-600', + }; + return colors[priority as keyof typeof colors] || 'text-gray-600'; + }; + + const getRiskColor = (risk: string) => { + const colors = { + 'critical': 'text-red-600', + 'high': 'text-orange-600', + 'medium': 'text-yellow-600', + 'low': 'text-green-600', + }; + return colors[risk as keyof typeof colors] || 'text-gray-600'; + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR' + }).format(amount); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('es-ES', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + }; + + const nextStatusOptions = () => { + const options = { + [PlanStatus.DRAFT]: [PlanStatus.PENDING_APPROVAL, PlanStatus.CANCELLED], + [PlanStatus.PENDING_APPROVAL]: [PlanStatus.APPROVED, PlanStatus.CANCELLED], + [PlanStatus.APPROVED]: [PlanStatus.IN_EXECUTION, PlanStatus.CANCELLED], + [PlanStatus.IN_EXECUTION]: [PlanStatus.COMPLETED, PlanStatus.CANCELLED], + [PlanStatus.COMPLETED]: [], + [PlanStatus.CANCELLED]: [], + }; + return options[plan.status as keyof typeof options] || []; + }; + + return ( + +
+ {/* Header */} +
+
+
+

+ {plan.plan_number} +

+ + {plan.status.replace('_', ' ').toUpperCase()} + +
+

+ Plan Date: {formatDate(plan.plan_date)} | + Period: {formatDate(plan.plan_period_start)} - {formatDate(plan.plan_period_end)} +

+
+ +
+
+ {plan.priority.toUpperCase()} Priority +
+
+ {plan.supply_risk_level.toUpperCase()} Risk +
+
+
+ + {/* Key Metrics */} +
+
+
+ {plan.total_requirements} +
+
Requirements
+
+ +
+
+ {formatCurrency(plan.total_estimated_cost)} +
+
Est. Cost
+
+ +
+
+ {plan.primary_suppliers_count} +
+
Suppliers
+
+ +
+
+ {plan.safety_stock_buffer}% +
+
Safety Buffer
+
+
+ + {/* Requirements Summary */} + {plan.requirements && plan.requirements.length > 0 && ( +
+

+ Top Requirements ({plan.requirements.length} total) +

+
+ {plan.requirements.slice(0, 3).map((req) => ( +
+ {req.product_name} +
+ + {req.net_requirement} {req.unit_of_measure} + + + {req.priority} + +
+
+ ))} + {plan.requirements.length > 3 && ( +
+ +{plan.requirements.length - 3} more requirements +
+ )} +
+
+ )} + + {/* Performance Metrics */} + {(plan.fulfillment_rate || plan.on_time_delivery_rate) && ( +
+ {plan.fulfillment_rate && ( + Fulfillment: {plan.fulfillment_rate}% + )} + {plan.on_time_delivery_rate && ( + On-time: {plan.on_time_delivery_rate}% + )} +
+ )} + + {/* Actions */} + {showActions && ( +
+
+ {nextStatusOptions().map((status) => ( + + ))} +
+ + +
+ )} + + {/* Special Requirements */} + {plan.special_requirements && ( +
+
+ Special Requirements +
+

{plan.special_requirements}

+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/procurement/ProcurementSummary.tsx b/frontend/src/components/procurement/ProcurementSummary.tsx new file mode 100644 index 00000000..3aa50ca4 --- /dev/null +++ b/frontend/src/components/procurement/ProcurementSummary.tsx @@ -0,0 +1,171 @@ +// ================================================================ +// frontend/src/components/procurement/ProcurementSummary.tsx +// ================================================================ +/** + * Procurement Summary Component + * Displays key metrics and summary information + */ + +import React from 'react'; +import type { ProcurementSummary } from '@/api/types/procurement'; + +export interface ProcurementSummaryProps { + summary: ProcurementSummary; +} + +export const ProcurementSummary: React.FC = ({ + summary, +}) => { + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR' + }).format(amount); + }; + + const formatPercentage = (value: number | undefined) => { + if (value === undefined || value === null) return 'N/A'; + return `${value.toFixed(1)}%`; + }; + + return ( +
+ {/* Plan Metrics */} +
+

Plan Overview

+
+
+
+ {summary.total_plans} +
+
Total Plans
+
+ +
+
+ {summary.active_plans} +
+
Active Plans
+
+
+
+ + {/* Requirements Metrics */} +
+

Requirements

+
+
+ Total + {summary.total_requirements} +
+ +
+ Pending + + {summary.pending_requirements} + +
+ +
+ Critical + + {summary.critical_requirements} + +
+
+
+ + {/* Cost Metrics */} +
+

Financial

+
+
+ Estimated + + {formatCurrency(summary.total_estimated_cost)} + +
+ +
+ Approved + + {formatCurrency(summary.total_approved_cost)} + +
+ +
+ Variance + = 0 ? 'text-green-600' : 'text-red-600' + }`}> + {summary.cost_variance >= 0 ? '+' : ''} + {formatCurrency(summary.cost_variance)} + +
+
+
+ + {/* Performance Metrics */} + {(summary.average_fulfillment_rate || summary.average_on_time_delivery) && ( +
+

Performance

+
+ {summary.average_fulfillment_rate && ( +
+ Fulfillment Rate + + {formatPercentage(summary.average_fulfillment_rate)} + +
+ )} + + {summary.average_on_time_delivery && ( +
+ On-Time Delivery + + {formatPercentage(summary.average_on_time_delivery)} + +
+ )} +
+
+ )} + + {/* Top Suppliers */} + {summary.top_suppliers.length > 0 && ( +
+

Top Suppliers

+
+ {summary.top_suppliers.slice(0, 3).map((supplier, index) => ( +
+ {supplier.name} + + {supplier.count || 0} orders + +
+ ))} +
+
+ )} + + {/* Critical Items */} + {summary.critical_items.length > 0 && ( +
+

+ Critical Items +

+
+ {summary.critical_items.slice(0, 3).map((item, index) => ( +
+ {item.name} + + {item.stock || 0} left + +
+ ))} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/procurement/index.ts b/frontend/src/components/procurement/index.ts new file mode 100644 index 00000000..95c914c8 --- /dev/null +++ b/frontend/src/components/procurement/index.ts @@ -0,0 +1,18 @@ +// ================================================================ +// frontend/src/components/procurement/index.ts +// ================================================================ +/** + * Procurement Components Export + * Main export point for procurement planning components + */ + +export { ProcurementDashboard } from './ProcurementDashboard'; +export { ProcurementPlanCard } from './ProcurementPlanCard'; +export { ProcurementSummary } from './ProcurementSummary'; +export { CriticalRequirements } from './CriticalRequirements'; +export { GeneratePlanModal } from './GeneratePlanModal'; + +export type { + ProcurementDashboardProps, + ProcurementPlanCardProps +} from './ProcurementDashboard'; \ No newline at end of file diff --git a/frontend/src/pages/procurement/index.ts b/frontend/src/pages/procurement/index.ts new file mode 100644 index 00000000..447b8410 --- /dev/null +++ b/frontend/src/pages/procurement/index.ts @@ -0,0 +1 @@ +export { default as ProcurementPage } from './ProcurementPage'; \ No newline at end of file diff --git a/services/orders/app/api/procurement.py b/services/orders/app/api/procurement.py new file mode 100644 index 00000000..f569605f --- /dev/null +++ b/services/orders/app/api/procurement.py @@ -0,0 +1,432 @@ +# ================================================================ +# services/orders/app/api/procurement.py +# ================================================================ +""" +Procurement API Endpoints - RESTful APIs for procurement planning +""" + +import uuid +from datetime import date +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.config import settings +from app.services.procurement_service import ProcurementService +from app.schemas.procurement_schemas import ( + ProcurementPlanResponse, GeneratePlanRequest, GeneratePlanResponse, + DashboardData, PaginatedProcurementPlans +) +from shared.auth.decorators import require_authentication, get_current_user_dep +from fastapi import Depends, Request +from typing import Dict, Any +import uuid +from shared.clients.inventory_client import InventoryServiceClient +from shared.clients.forecast_client import ForecastServiceClient +from shared.config.base import BaseServiceSettings +from shared.monitoring.decorators import monitor_performance + +# Create router +router = APIRouter(prefix="/procurement-plans", tags=["Procurement Planning"]) + +# Create service settings +service_settings = BaseServiceSettings() + +# Simple TenantAccess class for compatibility +class TenantAccess: + def __init__(self, tenant_id: uuid.UUID, user_id: str): + self.tenant_id = tenant_id + self.user_id = user_id + +async def get_current_tenant( + request: Request, + current_user: Dict[str, Any] = Depends(get_current_user_dep) +) -> TenantAccess: + """Get current tenant from user context""" + # For now, create a simple tenant access from user data + # In a real implementation, this would validate tenant access + tenant_id = current_user.get('tenant_id') + if not tenant_id: + # Try to get from headers as fallback + tenant_id = request.headers.get('x-tenant-id') + + if not tenant_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Tenant access required" + ) + + try: + tenant_uuid = uuid.UUID(tenant_id) + except (ValueError, TypeError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid tenant ID format" + ) + + return TenantAccess( + tenant_id=tenant_uuid, + user_id=current_user['user_id'] + ) + + +async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService: + """Get procurement service instance""" + inventory_client = InventoryServiceClient(service_settings) + forecast_client = ForecastServiceClient(service_settings, "orders-service") + return ProcurementService(db, service_settings, inventory_client, forecast_client) + + +# ================================================================ +# PROCUREMENT PLAN ENDPOINTS +# ================================================================ + +@router.get("/current", response_model=Optional[ProcurementPlanResponse]) +@monitor_performance("get_current_procurement_plan") +async def get_current_procurement_plan( + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Get the procurement plan for the current day (forecasting for the next day) + + Returns the plan details, including requirements per item. + """ + try: + plan = await procurement_service.get_current_plan(tenant_access.tenant_id) + return plan + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving current procurement plan: {str(e)}" + ) + + +@router.get("/{plan_date}", response_model=Optional[ProcurementPlanResponse]) +@monitor_performance("get_procurement_plan_by_date") +async def get_procurement_plan_by_date( + plan_date: date, + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Get the procurement plan for a specific date (format: YYYY-MM-DD) + + Returns the plan details, including requirements per item for the specified date. + """ + try: + plan = await procurement_service.get_plan_by_date(tenant_access.tenant_id, plan_date) + return plan + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving procurement plan for {plan_date}: {str(e)}" + ) + + +@router.get("/", response_model=PaginatedProcurementPlans) +@monitor_performance("list_procurement_plans") +async def list_procurement_plans( + status: Optional[str] = Query(None, description="Filter by plan status"), + start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"), + end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"), + limit: int = Query(50, ge=1, le=100, description="Number of plans to return"), + offset: int = Query(0, ge=0, description="Number of plans to skip"), + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + List procurement plans with optional filters + + Supports filtering by status, date range, and pagination. + """ + try: + # Get plans from repository directly for listing + plans = await procurement_service.plan_repo.list_plans( + tenant_access.tenant_id, + status=status, + start_date=start_date, + end_date=end_date, + limit=limit, + offset=offset + ) + + # Convert to response models + plan_responses = [ProcurementPlanResponse.model_validate(plan) for plan in plans] + + # For simplicity, we'll use the returned count as total + # In a production system, you'd want a separate count query + total = len(plan_responses) + has_more = len(plan_responses) == limit + + return PaginatedProcurementPlans( + plans=plan_responses, + total=total, + page=offset // limit + 1 if limit > 0 else 1, + limit=limit, + has_more=has_more + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error listing procurement plans: {str(e)}" + ) + + +@router.post("/generate", response_model=GeneratePlanResponse) +@monitor_performance("generate_procurement_plan") +async def generate_procurement_plan( + request: GeneratePlanRequest, + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Manually trigger the generation of a procurement plan + + This can serve as a fallback if the daily scheduler hasn't run, + or for testing purposes. Can be forced to regenerate an existing plan. + """ + try: + if not settings.PROCUREMENT_PLANNING_ENABLED: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Procurement planning is currently disabled" + ) + + result = await procurement_service.generate_procurement_plan( + tenant_access.tenant_id, + request + ) + + if not result.success: + # Return the result with errors but don't raise an exception + # since the service method handles the error state properly + return result + + return result + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error generating procurement plan: {str(e)}" + ) + + +@router.put("/{plan_id}/status") +@monitor_performance("update_procurement_plan_status") +async def update_procurement_plan_status( + plan_id: uuid.UUID, + status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"), + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Update the status of a procurement plan + + Valid statuses: draft, pending_approval, approved, in_execution, completed, cancelled + """ + try: + updated_plan = await procurement_service.update_plan_status( + tenant_access.tenant_id, + plan_id, + status, + tenant_access.user_id + ) + + if not updated_plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Procurement plan not found" + ) + + return updated_plan + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error updating procurement plan status: {str(e)}" + ) + + +@router.get("/id/{plan_id}", response_model=Optional[ProcurementPlanResponse]) +@monitor_performance("get_procurement_plan_by_id") +async def get_procurement_plan_by_id( + plan_id: uuid.UUID, + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Get a specific procurement plan by its ID + + Returns detailed plan information including all requirements. + """ + try: + plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id) + + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Procurement plan not found" + ) + + return plan + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving procurement plan: {str(e)}" + ) + + +# ================================================================ +# DASHBOARD ENDPOINTS +# ================================================================ + +@router.get("/dashboard/data", response_model=Optional[DashboardData]) +@monitor_performance("get_procurement_dashboard") +async def get_procurement_dashboard( + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Get procurement dashboard data + + Returns comprehensive dashboard information including: + - Current plan + - Summary statistics + - Upcoming deliveries + - Overdue requirements + - Low stock alerts + - Performance metrics + """ + try: + dashboard_data = await procurement_service.get_dashboard_data(tenant_access.tenant_id) + return dashboard_data + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving dashboard data: {str(e)}" + ) + + +# ================================================================ +# REQUIREMENT MANAGEMENT ENDPOINTS +# ================================================================ + +@router.get("/{plan_id}/requirements") +@monitor_performance("get_plan_requirements") +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"), + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Get all requirements for a specific procurement plan + + Supports filtering by status and priority level. + """ + try: + # Verify plan exists and belongs to tenant + plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id) + if not plan: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Procurement plan not found" + ) + + # Get requirements from repository + requirements = await procurement_service.requirement_repo.get_requirements_by_plan(plan_id) + + # Apply filters if provided + if status: + requirements = [r for r in requirements if r.status == status] + if priority: + requirements = [r for r in requirements if r.priority == priority] + + return requirements + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving plan requirements: {str(e)}" + ) + + +@router.get("/requirements/critical") +@monitor_performance("get_critical_requirements") +async def get_critical_requirements( + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Get all critical priority requirements across all active plans + + Returns requirements that need immediate attention. + """ + try: + requirements = await procurement_service.requirement_repo.get_critical_requirements( + tenant_access.tenant_id + ) + return requirements + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error retrieving critical requirements: {str(e)}" + ) + + +# ================================================================ +# UTILITY ENDPOINTS +# ================================================================ + +@router.post("/scheduler/trigger") +@monitor_performance("trigger_daily_scheduler") +async def trigger_daily_scheduler( + tenant_access: TenantAccess = Depends(get_current_tenant), + procurement_service: ProcurementService = Depends(get_procurement_service) +): + """ + Manually trigger the daily scheduler for the current tenant + + This endpoint is primarily for testing and maintenance purposes. + """ + try: + # Process daily plan for current tenant only + await procurement_service._process_daily_plan_for_tenant(tenant_access.tenant_id) + + return { + "success": True, + "message": "Daily scheduler executed successfully", + "tenant_id": str(tenant_access.tenant_id) + } + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error triggering daily scheduler: {str(e)}" + ) + + +@router.get("/health") +async def procurement_health_check(): + """ + Health check endpoint for procurement service + """ + return { + "status": "healthy", + "service": "procurement-planning", + "procurement_enabled": settings.PROCUREMENT_PLANNING_ENABLED, + "timestamp": date.today().isoformat() + } \ No newline at end of file diff --git a/services/orders/app/main.py b/services/orders/app/main.py index 1b774fad..842271aa 100644 --- a/services/orders/app/main.py +++ b/services/orders/app/main.py @@ -14,6 +14,7 @@ import structlog from app.core.config import settings from app.core.database import init_database, get_db_health from app.api.orders import router as orders_router +from app.api.procurement import router as procurement_router # Configure logging logger = structlog.get_logger() @@ -55,6 +56,7 @@ app.add_middleware( # Include routers app.include_router(orders_router, prefix="/api/v1") +app.include_router(procurement_router, prefix="/api/v1") @app.get("/health") diff --git a/services/orders/app/repositories/procurement_repository.py b/services/orders/app/repositories/procurement_repository.py new file mode 100644 index 00000000..4311f20b --- /dev/null +++ b/services/orders/app/repositories/procurement_repository.py @@ -0,0 +1,248 @@ +# ================================================================ +# services/orders/app/repositories/procurement_repository.py +# ================================================================ +""" +Procurement Repository - Database operations for procurement plans and requirements +""" + +import uuid +from datetime import datetime, date +from decimal import Decimal +from typing import List, Optional, Dict, Any +from sqlalchemy import select, and_, or_, desc, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.procurement import ProcurementPlan, ProcurementRequirement +from app.repositories.base_repository import BaseRepository + + +class ProcurementPlanRepository(BaseRepository): + """Repository for procurement plan operations""" + + def __init__(self, db: AsyncSession): + super().__init__(db, ProcurementPlan) + + async def create_plan(self, plan_data: Dict[str, Any]) -> ProcurementPlan: + """Create a new procurement plan""" + plan = ProcurementPlan(**plan_data) + self.db.add(plan) + await self.db.flush() + return plan + + async def get_plan_by_id(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]: + """Get procurement plan by ID""" + stmt = select(ProcurementPlan).where( + and_( + ProcurementPlan.id == plan_id, + ProcurementPlan.tenant_id == tenant_id + ) + ).options(selectinload(ProcurementPlan.requirements)) + + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + + async def get_plan_by_date(self, plan_date: date, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]: + """Get procurement plan for a specific date""" + stmt = select(ProcurementPlan).where( + and_( + ProcurementPlan.plan_date == plan_date, + ProcurementPlan.tenant_id == tenant_id + ) + ).options(selectinload(ProcurementPlan.requirements)) + + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + + async def get_current_plan(self, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]: + """Get the current day's procurement plan""" + today = date.today() + return await self.get_plan_by_date(today, tenant_id) + + async def list_plans( + self, + tenant_id: uuid.UUID, + status: Optional[str] = None, + start_date: Optional[date] = None, + end_date: Optional[date] = None, + limit: int = 50, + offset: int = 0 + ) -> List[ProcurementPlan]: + """List procurement plans with filters""" + conditions = [ProcurementPlan.tenant_id == tenant_id] + + if status: + conditions.append(ProcurementPlan.status == status) + if start_date: + conditions.append(ProcurementPlan.plan_date >= start_date) + if end_date: + conditions.append(ProcurementPlan.plan_date <= end_date) + + stmt = ( + select(ProcurementPlan) + .where(and_(*conditions)) + .order_by(desc(ProcurementPlan.plan_date)) + .limit(limit) + .offset(offset) + .options(selectinload(ProcurementPlan.requirements)) + ) + + result = await self.db.execute(stmt) + return result.scalars().all() + + async def update_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[ProcurementPlan]: + """Update procurement plan""" + plan = await self.get_plan_by_id(plan_id, tenant_id) + if not plan: + return None + + for key, value in updates.items(): + if hasattr(plan, key): + setattr(plan, key, value) + + plan.updated_at = datetime.utcnow() + await self.db.flush() + return plan + + async def delete_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> bool: + """Delete procurement plan""" + plan = await self.get_plan_by_id(plan_id, tenant_id) + if not plan: + return False + + await self.db.delete(plan) + return True + + async def generate_plan_number(self, tenant_id: uuid.UUID, plan_date: date) -> str: + """Generate unique plan number""" + date_str = plan_date.strftime("%Y%m%d") + + # Count existing plans for the same date + stmt = select(func.count(ProcurementPlan.id)).where( + and_( + ProcurementPlan.tenant_id == tenant_id, + ProcurementPlan.plan_date == plan_date + ) + ) + result = await self.db.execute(stmt) + count = result.scalar() or 0 + + return f"PP-{date_str}-{count + 1:03d}" + + +class ProcurementRequirementRepository(BaseRepository): + """Repository for procurement requirement operations""" + + def __init__(self, db: AsyncSession): + super().__init__(db, ProcurementRequirement) + + async def create_requirement(self, requirement_data: Dict[str, Any]) -> ProcurementRequirement: + """Create a new procurement requirement""" + requirement = ProcurementRequirement(**requirement_data) + self.db.add(requirement) + await self.db.flush() + return requirement + + async def create_requirements_batch(self, requirements_data: List[Dict[str, Any]]) -> List[ProcurementRequirement]: + """Create multiple procurement requirements""" + requirements = [ProcurementRequirement(**data) for data in requirements_data] + self.db.add_all(requirements) + await self.db.flush() + return requirements + + async def get_requirement_by_id(self, requirement_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ProcurementRequirement]: + """Get procurement requirement by ID""" + stmt = select(ProcurementRequirement).join(ProcurementPlan).where( + and_( + ProcurementRequirement.id == requirement_id, + ProcurementPlan.tenant_id == tenant_id + ) + ) + + result = await self.db.execute(stmt) + return result.scalar_one_or_none() + + async def get_requirements_by_plan(self, plan_id: uuid.UUID) -> List[ProcurementRequirement]: + """Get all requirements for a specific plan""" + stmt = select(ProcurementRequirement).where( + ProcurementRequirement.plan_id == plan_id + ).order_by(ProcurementRequirement.priority.desc(), ProcurementRequirement.required_by_date) + + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_requirements_by_product( + self, + tenant_id: uuid.UUID, + product_id: uuid.UUID, + status: Optional[str] = None + ) -> List[ProcurementRequirement]: + """Get requirements for a specific product""" + conditions = [ + ProcurementPlan.tenant_id == tenant_id, + ProcurementRequirement.product_id == product_id + ] + + if status: + conditions.append(ProcurementRequirement.status == status) + + stmt = select(ProcurementRequirement).join(ProcurementPlan).where( + and_(*conditions) + ).order_by(desc(ProcurementRequirement.required_by_date)) + + result = await self.db.execute(stmt) + return result.scalars().all() + + async def update_requirement( + self, + requirement_id: uuid.UUID, + tenant_id: uuid.UUID, + updates: Dict[str, Any] + ) -> Optional[ProcurementRequirement]: + """Update procurement requirement""" + requirement = await self.get_requirement_by_id(requirement_id, tenant_id) + if not requirement: + return None + + for key, value in updates.items(): + if hasattr(requirement, key): + setattr(requirement, key, value) + + await self.db.flush() + return requirement + + async def get_pending_requirements(self, tenant_id: uuid.UUID) -> List[ProcurementRequirement]: + """Get all pending requirements across plans""" + stmt = select(ProcurementRequirement).join(ProcurementPlan).where( + and_( + ProcurementPlan.tenant_id == tenant_id, + ProcurementRequirement.status == 'pending' + ) + ).order_by(ProcurementRequirement.priority.desc(), ProcurementRequirement.required_by_date) + + result = await self.db.execute(stmt) + return result.scalars().all() + + async def get_critical_requirements(self, tenant_id: uuid.UUID) -> List[ProcurementRequirement]: + """Get critical priority requirements""" + stmt = select(ProcurementRequirement).join(ProcurementPlan).where( + and_( + ProcurementPlan.tenant_id == tenant_id, + ProcurementRequirement.priority == 'critical', + ProcurementRequirement.status.in_(['pending', 'approved']) + ) + ).order_by(ProcurementRequirement.required_by_date) + + result = await self.db.execute(stmt) + return result.scalars().all() + + async def generate_requirement_number(self, plan_id: uuid.UUID) -> str: + """Generate unique requirement number within a plan""" + # Count existing requirements in the plan + stmt = select(func.count(ProcurementRequirement.id)).where( + ProcurementRequirement.plan_id == plan_id + ) + result = await self.db.execute(stmt) + count = result.scalar() or 0 + + return f"REQ-{count + 1:05d}" \ No newline at end of file diff --git a/services/orders/app/schemas/procurement_schemas.py b/services/orders/app/schemas/procurement_schemas.py new file mode 100644 index 00000000..89f00fb6 --- /dev/null +++ b/services/orders/app/schemas/procurement_schemas.py @@ -0,0 +1,293 @@ +# ================================================================ +# services/orders/app/schemas/procurement_schemas.py +# ================================================================ +""" +Procurement Schemas - Request/response models for procurement plans +""" + +import uuid +from datetime import datetime, date +from decimal import Decimal +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field, ConfigDict + + +# ================================================================ +# BASE SCHEMAS +# ================================================================ + +class ProcurementBase(BaseModel): + """Base schema for procurement entities""" + model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True) + + +# ================================================================ +# PROCUREMENT REQUIREMENT SCHEMAS +# ================================================================ + +class ProcurementRequirementBase(ProcurementBase): + """Base procurement requirement schema""" + product_id: uuid.UUID + product_name: str = Field(..., min_length=1, max_length=200) + product_sku: Optional[str] = Field(None, max_length=100) + product_category: Optional[str] = Field(None, max_length=100) + product_type: str = Field(default="ingredient", max_length=50) + + required_quantity: Decimal = Field(..., gt=0) + unit_of_measure: str = Field(..., min_length=1, max_length=50) + safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0) + total_quantity_needed: Decimal = Field(..., gt=0) + + current_stock_level: Decimal = Field(default=Decimal("0.000"), ge=0) + reserved_stock: Decimal = Field(default=Decimal("0.000"), ge=0) + available_stock: Decimal = Field(default=Decimal("0.000"), ge=0) + net_requirement: Decimal = Field(..., ge=0) + + order_demand: Decimal = Field(default=Decimal("0.000"), ge=0) + production_demand: Decimal = Field(default=Decimal("0.000"), ge=0) + forecast_demand: Decimal = Field(default=Decimal("0.000"), ge=0) + buffer_demand: Decimal = Field(default=Decimal("0.000"), ge=0) + + required_by_date: date + lead_time_buffer_days: int = Field(default=1, ge=0) + suggested_order_date: date + latest_order_date: date + + priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$") + risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$") + + preferred_supplier_id: Optional[uuid.UUID] = None + backup_supplier_id: Optional[uuid.UUID] = None + supplier_name: Optional[str] = Field(None, max_length=200) + supplier_lead_time_days: Optional[int] = Field(None, ge=0) + minimum_order_quantity: Optional[Decimal] = Field(None, ge=0) + + estimated_unit_cost: Optional[Decimal] = Field(None, ge=0) + estimated_total_cost: Optional[Decimal] = Field(None, ge=0) + last_purchase_cost: Optional[Decimal] = Field(None, ge=0) + + +class ProcurementRequirementCreate(ProcurementRequirementBase): + """Schema for creating procurement requirements""" + special_requirements: Optional[str] = None + storage_requirements: Optional[str] = Field(None, max_length=200) + shelf_life_days: Optional[int] = Field(None, gt=0) + quality_specifications: Optional[Dict[str, Any]] = None + procurement_notes: Optional[str] = None + + +class ProcurementRequirementUpdate(ProcurementBase): + """Schema for updating procurement requirements""" + status: Optional[str] = Field(None, pattern="^(pending|approved|ordered|partially_received|received|cancelled)$") + priority: Optional[str] = Field(None, pattern="^(critical|high|normal|low)$") + + approved_quantity: Optional[Decimal] = Field(None, ge=0) + approved_cost: Optional[Decimal] = Field(None, ge=0) + + purchase_order_id: Optional[uuid.UUID] = None + purchase_order_number: Optional[str] = Field(None, max_length=50) + ordered_quantity: Optional[Decimal] = Field(None, ge=0) + + expected_delivery_date: Optional[date] = None + actual_delivery_date: Optional[date] = None + received_quantity: Optional[Decimal] = Field(None, ge=0) + delivery_status: Optional[str] = Field(None, pattern="^(pending|in_transit|delivered|delayed|cancelled)$") + + procurement_notes: Optional[str] = None + + +class ProcurementRequirementResponse(ProcurementRequirementBase): + """Schema for procurement requirement responses""" + id: uuid.UUID + plan_id: uuid.UUID + requirement_number: str + + status: str + created_at: datetime + updated_at: datetime + + purchase_order_id: Optional[uuid.UUID] = None + purchase_order_number: Optional[str] = None + ordered_quantity: Decimal + ordered_at: Optional[datetime] = None + + expected_delivery_date: Optional[date] = None + actual_delivery_date: Optional[date] = None + received_quantity: Decimal + delivery_status: str + + fulfillment_rate: Optional[Decimal] = None + on_time_delivery: Optional[bool] = None + quality_rating: Optional[Decimal] = None + + approved_quantity: Optional[Decimal] = None + approved_cost: Optional[Decimal] = None + approved_at: Optional[datetime] = None + approved_by: Optional[uuid.UUID] = None + + special_requirements: Optional[str] = None + storage_requirements: Optional[str] = None + shelf_life_days: Optional[int] = None + quality_specifications: Optional[Dict[str, Any]] = None + procurement_notes: Optional[str] = None + + +# ================================================================ +# PROCUREMENT PLAN SCHEMAS +# ================================================================ + +class ProcurementPlanBase(ProcurementBase): + """Base procurement plan schema""" + plan_date: date + plan_period_start: date + plan_period_end: date + planning_horizon_days: int = Field(default=14, gt=0) + + plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$") + priority: str = Field(default="normal", pattern="^(high|normal|low)$") + + business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$") + procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$") + + safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100) + supply_risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$") + demand_forecast_confidence: Optional[Decimal] = Field(None, ge=1, le=10) + seasonality_adjustment: Decimal = Field(default=Decimal("0.00")) + + special_requirements: Optional[str] = None + + +class ProcurementPlanCreate(ProcurementPlanBase): + """Schema for creating procurement plans""" + tenant_id: uuid.UUID + requirements: Optional[List[ProcurementRequirementCreate]] = [] + + +class ProcurementPlanUpdate(ProcurementBase): + """Schema for updating procurement plans""" + status: Optional[str] = Field(None, pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$") + priority: Optional[str] = Field(None, pattern="^(high|normal|low)$") + + approved_at: Optional[datetime] = None + approved_by: Optional[uuid.UUID] = None + execution_started_at: Optional[datetime] = None + execution_completed_at: Optional[datetime] = None + + special_requirements: Optional[str] = None + seasonal_adjustments: Optional[Dict[str, Any]] = None + + +class ProcurementPlanResponse(ProcurementPlanBase): + """Schema for procurement plan responses""" + id: uuid.UUID + tenant_id: uuid.UUID + plan_number: str + status: str + + total_requirements: int + total_estimated_cost: Decimal + total_approved_cost: Decimal + cost_variance: Decimal + + total_demand_orders: int + total_demand_quantity: Decimal + total_production_requirements: Decimal + + primary_suppliers_count: int + backup_suppliers_count: int + supplier_diversification_score: Optional[Decimal] = None + + approved_at: Optional[datetime] = None + approved_by: Optional[uuid.UUID] = None + execution_started_at: Optional[datetime] = None + execution_completed_at: Optional[datetime] = None + + fulfillment_rate: Optional[Decimal] = None + on_time_delivery_rate: Optional[Decimal] = None + cost_accuracy: Optional[Decimal] = None + quality_score: Optional[Decimal] = None + + created_at: datetime + updated_at: datetime + created_by: Optional[uuid.UUID] = None + updated_by: Optional[uuid.UUID] = None + + requirements: List[ProcurementRequirementResponse] = [] + + +# ================================================================ +# SUMMARY SCHEMAS +# ================================================================ + +class ProcurementSummary(ProcurementBase): + """Summary of procurement plans""" + total_plans: int + active_plans: int + total_requirements: int + pending_requirements: int + critical_requirements: int + + total_estimated_cost: Decimal + total_approved_cost: Decimal + cost_variance: Decimal + + average_fulfillment_rate: Optional[Decimal] = None + average_on_time_delivery: Optional[Decimal] = None + + top_suppliers: List[Dict[str, Any]] = [] + critical_items: List[Dict[str, Any]] = [] + + +class DashboardData(ProcurementBase): + """Dashboard data for procurement overview""" + current_plan: Optional[ProcurementPlanResponse] = None + summary: ProcurementSummary + + upcoming_deliveries: List[Dict[str, Any]] = [] + overdue_requirements: List[Dict[str, Any]] = [] + low_stock_alerts: List[Dict[str, Any]] = [] + + performance_metrics: Dict[str, Any] = {} + + +# ================================================================ +# REQUEST SCHEMAS +# ================================================================ + +class GeneratePlanRequest(ProcurementBase): + """Request to generate procurement plan""" + plan_date: Optional[date] = None + force_regenerate: bool = False + planning_horizon_days: int = Field(default=14, gt=0, le=30) + include_safety_stock: bool = True + safety_stock_percentage: Decimal = Field(default=Decimal("20.00"), ge=0, le=100) + + +class ForecastRequest(ProcurementBase): + """Request parameters for demand forecasting""" + target_date: date + horizon_days: int = Field(default=1, gt=0, le=7) + include_confidence_intervals: bool = True + product_ids: Optional[List[uuid.UUID]] = None + + +# ================================================================ +# RESPONSE SCHEMAS +# ================================================================ + +class GeneratePlanResponse(ProcurementBase): + """Response from plan generation""" + success: bool + message: str + plan: Optional[ProcurementPlanResponse] = None + warnings: List[str] = [] + errors: List[str] = [] + + +class PaginatedProcurementPlans(ProcurementBase): + """Paginated list of procurement plans""" + plans: List[ProcurementPlanResponse] + total: int + page: int + limit: int + has_more: bool \ No newline at end of file diff --git a/services/orders/app/services/cache_service.py b/services/orders/app/services/cache_service.py new file mode 100644 index 00000000..e922828f --- /dev/null +++ b/services/orders/app/services/cache_service.py @@ -0,0 +1,466 @@ +# ================================================================ +# services/orders/app/services/cache_service.py +# ================================================================ +""" +Cache Service - Redis caching for procurement plans and related data +""" + +import json +import uuid +from datetime import datetime, date, timedelta +from typing import Optional, Dict, Any, List +import redis +import structlog +from pydantic import BaseModel + +from app.core.config import settings +from app.models.procurement import ProcurementPlan +from app.schemas.procurement_schemas import ProcurementPlanResponse + +logger = structlog.get_logger() + + +class CacheService: + """Service for managing Redis cache operations""" + + def __init__(self, redis_url: Optional[str] = None): + """Initialize Redis connection""" + self.redis_url = redis_url or settings.REDIS_URL + self._redis_client = None + self._connect() + + def _connect(self): + """Connect to Redis""" + try: + self._redis_client = redis.from_url( + self.redis_url, + decode_responses=True, + socket_keepalive=True, + socket_keepalive_options={"TCP_KEEPIDLE": 1, "TCP_KEEPINTVL": 3, "TCP_KEEPCNT": 5}, + retry_on_timeout=True, + max_connections=50 + ) + # Test connection + self._redis_client.ping() + logger.info("Redis connection established") + except Exception as e: + logger.error("Failed to connect to Redis", error=str(e)) + self._redis_client = None + + @property + def redis(self): + """Get Redis client with connection check""" + if self._redis_client is None: + self._connect() + return self._redis_client + + def is_available(self) -> bool: + """Check if Redis is available""" + try: + return self.redis is not None and self.redis.ping() + except Exception: + return False + + # ================================================================ + # PROCUREMENT PLAN CACHING + # ================================================================ + + def _get_plan_key(self, tenant_id: uuid.UUID, plan_date: Optional[date] = None, plan_id: Optional[uuid.UUID] = None) -> str: + """Generate cache key for procurement plan""" + if plan_id: + return f"procurement:plan:id:{tenant_id}:{plan_id}" + elif plan_date: + return f"procurement:plan:date:{tenant_id}:{plan_date.isoformat()}" + else: + return f"procurement:plan:current:{tenant_id}" + + def _get_dashboard_key(self, tenant_id: uuid.UUID) -> str: + """Generate cache key for dashboard data""" + return f"procurement:dashboard:{tenant_id}" + + def _get_requirements_key(self, tenant_id: uuid.UUID, plan_id: uuid.UUID) -> str: + """Generate cache key for plan requirements""" + return f"procurement:requirements:{tenant_id}:{plan_id}" + + async def cache_procurement_plan( + self, + plan: ProcurementPlan, + ttl_hours: int = 6 + ) -> bool: + """Cache a procurement plan with multiple keys for different access patterns""" + if not self.is_available(): + logger.warning("Redis not available, skipping cache") + return False + + try: + # Convert plan to cacheable format + plan_data = self._serialize_plan(plan) + ttl_seconds = ttl_hours * 3600 + + # Cache by plan ID + id_key = self._get_plan_key(plan.tenant_id, plan_id=plan.id) + self.redis.setex(id_key, ttl_seconds, plan_data) + + # Cache by plan date + date_key = self._get_plan_key(plan.tenant_id, plan_date=plan.plan_date) + self.redis.setex(date_key, ttl_seconds, plan_data) + + # If this is today's plan, cache as current + if plan.plan_date == date.today(): + current_key = self._get_plan_key(plan.tenant_id) + self.redis.setex(current_key, ttl_seconds, plan_data) + + # Cache requirements separately for faster access + if plan.requirements: + requirements_data = self._serialize_requirements(plan.requirements) + req_key = self._get_requirements_key(plan.tenant_id, plan.id) + self.redis.setex(req_key, ttl_seconds, requirements_data) + + # Update plan list cache + await self._update_plan_list_cache(plan.tenant_id, plan) + + logger.info("Procurement plan cached", plan_id=plan.id, tenant_id=plan.tenant_id) + return True + + except Exception as e: + logger.error("Error caching procurement plan", error=str(e), plan_id=plan.id) + return False + + async def get_cached_plan( + self, + tenant_id: uuid.UUID, + plan_date: Optional[date] = None, + plan_id: Optional[uuid.UUID] = None + ) -> Optional[Dict[str, Any]]: + """Get cached procurement plan""" + if not self.is_available(): + return None + + try: + key = self._get_plan_key(tenant_id, plan_date, plan_id) + cached_data = self.redis.get(key) + + if cached_data: + plan_data = json.loads(cached_data) + logger.debug("Procurement plan retrieved from cache", + tenant_id=tenant_id, key=key) + return plan_data + + return None + + except Exception as e: + logger.error("Error retrieving cached plan", error=str(e)) + return None + + async def get_cached_requirements( + self, + tenant_id: uuid.UUID, + plan_id: uuid.UUID + ) -> Optional[List[Dict[str, Any]]]: + """Get cached plan requirements""" + if not self.is_available(): + return None + + try: + key = self._get_requirements_key(tenant_id, plan_id) + cached_data = self.redis.get(key) + + if cached_data: + requirements_data = json.loads(cached_data) + logger.debug("Requirements retrieved from cache", + tenant_id=tenant_id, plan_id=plan_id) + return requirements_data + + return None + + except Exception as e: + logger.error("Error retrieving cached requirements", error=str(e)) + return None + + async def cache_dashboard_data( + self, + tenant_id: uuid.UUID, + dashboard_data: Dict[str, Any], + ttl_hours: int = 1 + ) -> bool: + """Cache dashboard data with shorter TTL""" + if not self.is_available(): + return False + + try: + key = self._get_dashboard_key(tenant_id) + data_json = json.dumps(dashboard_data, cls=DateTimeEncoder) + ttl_seconds = ttl_hours * 3600 + + self.redis.setex(key, ttl_seconds, data_json) + logger.debug("Dashboard data cached", tenant_id=tenant_id) + return True + + except Exception as e: + logger.error("Error caching dashboard data", error=str(e)) + return False + + async def get_cached_dashboard_data(self, tenant_id: uuid.UUID) -> Optional[Dict[str, Any]]: + """Get cached dashboard data""" + if not self.is_available(): + return None + + try: + key = self._get_dashboard_key(tenant_id) + cached_data = self.redis.get(key) + + if cached_data: + return json.loads(cached_data) + return None + + except Exception as e: + logger.error("Error retrieving cached dashboard data", error=str(e)) + return None + + async def invalidate_plan_cache( + self, + tenant_id: uuid.UUID, + plan_id: Optional[uuid.UUID] = None, + plan_date: Optional[date] = None + ) -> bool: + """Invalidate cached procurement plan data""" + if not self.is_available(): + return False + + try: + keys_to_delete = [] + + if plan_id: + # Delete specific plan cache + keys_to_delete.append(self._get_plan_key(tenant_id, plan_id=plan_id)) + keys_to_delete.append(self._get_requirements_key(tenant_id, plan_id)) + + if plan_date: + keys_to_delete.append(self._get_plan_key(tenant_id, plan_date=plan_date)) + + # Always invalidate current plan cache and dashboard + keys_to_delete.extend([ + self._get_plan_key(tenant_id), + self._get_dashboard_key(tenant_id) + ]) + + # Delete plan list cache + list_key = f"procurement:plans:list:{tenant_id}:*" + list_keys = self.redis.keys(list_key) + keys_to_delete.extend(list_keys) + + if keys_to_delete: + self.redis.delete(*keys_to_delete) + logger.info("Plan cache invalidated", + tenant_id=tenant_id, keys_count=len(keys_to_delete)) + + return True + + except Exception as e: + logger.error("Error invalidating plan cache", error=str(e)) + return False + + # ================================================================ + # LIST CACHING + # ================================================================ + + async def _update_plan_list_cache(self, tenant_id: uuid.UUID, plan: ProcurementPlan) -> None: + """Update cached plan lists""" + try: + # Add plan to various lists + list_keys = [ + f"procurement:plans:list:{tenant_id}:all", + f"procurement:plans:list:{tenant_id}:status:{plan.status}", + f"procurement:plans:list:{tenant_id}:month:{plan.plan_date.strftime('%Y-%m')}" + ] + + plan_summary = { + "id": str(plan.id), + "plan_number": plan.plan_number, + "plan_date": plan.plan_date.isoformat(), + "status": plan.status, + "total_requirements": plan.total_requirements, + "total_estimated_cost": float(plan.total_estimated_cost), + "created_at": plan.created_at.isoformat() + } + + for key in list_keys: + # Use sorted sets for automatic ordering by date + score = plan.plan_date.toordinal() # Use ordinal date as score + self.redis.zadd(key, {json.dumps(plan_summary): score}) + self.redis.expire(key, 3600) # 1 hour TTL + + except Exception as e: + logger.warning("Error updating plan list cache", error=str(e)) + + # ================================================================ + # PERFORMANCE METRICS CACHING + # ================================================================ + + async def cache_performance_metrics( + self, + tenant_id: uuid.UUID, + metrics: Dict[str, Any], + ttl_hours: int = 24 + ) -> bool: + """Cache performance metrics""" + if not self.is_available(): + return False + + try: + key = f"procurement:metrics:{tenant_id}" + data_json = json.dumps(metrics, cls=DateTimeEncoder) + ttl_seconds = ttl_hours * 3600 + + self.redis.setex(key, ttl_seconds, data_json) + return True + + except Exception as e: + logger.error("Error caching performance metrics", error=str(e)) + return False + + async def get_cached_metrics(self, tenant_id: uuid.UUID) -> Optional[Dict[str, Any]]: + """Get cached performance metrics""" + if not self.is_available(): + return None + + try: + key = f"procurement:metrics:{tenant_id}" + cached_data = self.redis.get(key) + + if cached_data: + return json.loads(cached_data) + return None + + except Exception as e: + logger.error("Error retrieving cached metrics", error=str(e)) + return None + + # ================================================================ + # UTILITY METHODS + # ================================================================ + + def _serialize_plan(self, plan: ProcurementPlan) -> str: + """Serialize procurement plan for caching""" + try: + # Convert to dict, handling special types + plan_dict = { + "id": str(plan.id), + "tenant_id": str(plan.tenant_id), + "plan_number": plan.plan_number, + "plan_date": plan.plan_date.isoformat(), + "plan_period_start": plan.plan_period_start.isoformat(), + "plan_period_end": plan.plan_period_end.isoformat(), + "status": plan.status, + "plan_type": plan.plan_type, + "priority": plan.priority, + "total_requirements": plan.total_requirements, + "total_estimated_cost": float(plan.total_estimated_cost), + "total_approved_cost": float(plan.total_approved_cost), + "safety_stock_buffer": float(plan.safety_stock_buffer), + "supply_risk_level": plan.supply_risk_level, + "created_at": plan.created_at.isoformat(), + "updated_at": plan.updated_at.isoformat(), + # Add requirements count for quick reference + "requirements_count": len(plan.requirements) if plan.requirements else 0 + } + + return json.dumps(plan_dict) + + except Exception as e: + logger.error("Error serializing plan", error=str(e)) + raise + + def _serialize_requirements(self, requirements: List) -> str: + """Serialize requirements for caching""" + try: + requirements_data = [] + for req in requirements: + req_dict = { + "id": str(req.id), + "requirement_number": req.requirement_number, + "product_id": str(req.product_id), + "product_name": req.product_name, + "status": req.status, + "priority": req.priority, + "required_quantity": float(req.required_quantity), + "net_requirement": float(req.net_requirement), + "estimated_total_cost": float(req.estimated_total_cost or 0), + "required_by_date": req.required_by_date.isoformat(), + "suggested_order_date": req.suggested_order_date.isoformat() + } + requirements_data.append(req_dict) + + return json.dumps(requirements_data) + + except Exception as e: + logger.error("Error serializing requirements", error=str(e)) + raise + + async def clear_tenant_cache(self, tenant_id: uuid.UUID) -> bool: + """Clear all cached data for a tenant""" + if not self.is_available(): + return False + + try: + pattern = f"*:{tenant_id}*" + keys = self.redis.keys(pattern) + + if keys: + self.redis.delete(*keys) + logger.info("Tenant cache cleared", tenant_id=tenant_id, keys_count=len(keys)) + + return True + + except Exception as e: + logger.error("Error clearing tenant cache", error=str(e)) + return False + + def get_cache_stats(self) -> Dict[str, Any]: + """Get Redis cache statistics""" + if not self.is_available(): + return {"available": False} + + try: + info = self.redis.info() + return { + "available": True, + "used_memory": info.get("used_memory_human"), + "connected_clients": info.get("connected_clients"), + "total_connections_received": info.get("total_connections_received"), + "keyspace_hits": info.get("keyspace_hits", 0), + "keyspace_misses": info.get("keyspace_misses", 0), + "hit_rate": self._calculate_hit_rate( + info.get("keyspace_hits", 0), + info.get("keyspace_misses", 0) + ) + } + except Exception as e: + logger.error("Error getting cache stats", error=str(e)) + return {"available": False, "error": str(e)} + + def _calculate_hit_rate(self, hits: int, misses: int) -> float: + """Calculate cache hit rate percentage""" + total = hits + misses + return (hits / total * 100) if total > 0 else 0.0 + + +class DateTimeEncoder(json.JSONEncoder): + """JSON encoder that handles datetime objects""" + + def default(self, obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return super().default(obj) + + +# Global cache service instance +_cache_service = None + + +def get_cache_service() -> CacheService: + """Get the global cache service instance""" + global _cache_service + if _cache_service is None: + _cache_service = CacheService() + return _cache_service \ No newline at end of file diff --git a/services/orders/app/services/procurement_service.py b/services/orders/app/services/procurement_service.py new file mode 100644 index 00000000..2516f87c --- /dev/null +++ b/services/orders/app/services/procurement_service.py @@ -0,0 +1,580 @@ +# ================================================================ +# services/orders/app/services/procurement_service.py +# ================================================================ +""" +Procurement Service - Business logic for procurement planning and scheduling +""" + +import asyncio +import uuid +from datetime import datetime, date, timedelta +from decimal import Decimal +from typing import List, Optional, Dict, Any, Tuple +import structlog +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.procurement import ProcurementPlan, ProcurementRequirement +from app.repositories.procurement_repository import ProcurementPlanRepository, ProcurementRequirementRepository +from app.schemas.procurement_schemas import ( + ProcurementPlanCreate, ProcurementPlanResponse, ProcurementRequirementCreate, + GeneratePlanRequest, GeneratePlanResponse, DashboardData, ProcurementSummary +) +from app.core.config import settings +from shared.clients.inventory_client import InventoryServiceClient +from shared.clients.forecast_client import ForecastServiceClient +from shared.config.base import BaseServiceSettings +from shared.messaging.rabbitmq import RabbitMQClient +from shared.monitoring.decorators import monitor_performance +from app.services.cache_service import get_cache_service, CacheService + +logger = structlog.get_logger() + + +class ProcurementService: + """Service for managing procurement plans and scheduling""" + + def __init__( + self, + db: AsyncSession, + config: BaseServiceSettings, + inventory_client: Optional[InventoryServiceClient] = None, + forecast_client: Optional[ForecastServiceClient] = None, + cache_service: Optional[CacheService] = None + ): + self.db = db + self.config = config + self.plan_repo = ProcurementPlanRepository(db) + self.requirement_repo = ProcurementRequirementRepository(db) + + # Initialize service clients + self.inventory_client = inventory_client or InventoryServiceClient(config) + self.forecast_client = forecast_client or ForecastServiceClient(config, "orders-service") + self.cache_service = cache_service or get_cache_service() + + # Initialize RabbitMQ client + rabbitmq_url = getattr(config, 'RABBITMQ_URL', 'amqp://guest:guest@localhost:5672/') + self.rabbitmq_client = RabbitMQClient(rabbitmq_url, "orders-service") + + # ================================================================ + # PROCUREMENT PLAN OPERATIONS + # ================================================================ + + async def get_current_plan(self, tenant_id: uuid.UUID) -> Optional[ProcurementPlanResponse]: + """Get the current day's procurement plan""" + try: + # Try cache first + cached_plan = await self.cache_service.get_cached_plan(tenant_id) + if cached_plan: + return ProcurementPlanResponse.model_validate(cached_plan) + + # Get from database + plan = await self.plan_repo.get_current_plan(tenant_id) + if plan: + # Cache the result + await self.cache_service.cache_procurement_plan(plan) + return ProcurementPlanResponse.model_validate(plan) + + return None + except Exception as e: + logger.error("Error getting current plan", error=str(e), tenant_id=tenant_id) + return None + + async def get_plan_by_date(self, tenant_id: uuid.UUID, plan_date: date) -> Optional[ProcurementPlanResponse]: + """Get procurement plan for a specific date""" + try: + plan = await self.plan_repo.get_plan_by_date(plan_date, tenant_id) + return ProcurementPlanResponse.model_validate(plan) if plan else None + except Exception as e: + logger.error("Error getting plan by date", error=str(e), tenant_id=tenant_id, date=plan_date) + return None + + async def get_plan_by_id(self, tenant_id: uuid.UUID, plan_id: uuid.UUID) -> Optional[ProcurementPlanResponse]: + """Get procurement plan by ID""" + try: + plan = await self.plan_repo.get_plan_by_id(plan_id, tenant_id) + return ProcurementPlanResponse.model_validate(plan) if plan else None + except Exception as e: + logger.error("Error getting plan by ID", error=str(e), tenant_id=tenant_id, plan_id=plan_id) + return None + + @monitor_performance("generate_procurement_plan") + async def generate_procurement_plan( + self, + tenant_id: uuid.UUID, + request: GeneratePlanRequest + ) -> GeneratePlanResponse: + """Generate a new procurement plan based on forecasts and inventory""" + try: + plan_date = request.plan_date or date.today() + + # Check if plan already exists + existing_plan = await self.plan_repo.get_plan_by_date(plan_date, tenant_id) + if existing_plan and not request.force_regenerate: + return GeneratePlanResponse( + success=True, + message="Plan already exists for this date", + plan=ProcurementPlanResponse.model_validate(existing_plan), + warnings=["Plan already exists. Use force_regenerate=true to recreate."] + ) + + logger.info("Starting procurement plan generation", tenant_id=tenant_id, plan_date=plan_date) + + # Step 1: Get current inventory + inventory_items = await self._get_inventory_list(tenant_id) + if not inventory_items: + return GeneratePlanResponse( + success=False, + message="No inventory items found", + errors=["Unable to retrieve inventory data"] + ) + + # Step 2: Generate forecasts for each inventory item + forecasts = await self._generate_demand_forecasts( + tenant_id, + inventory_items, + plan_date, + request.planning_horizon_days + ) + + # Step 3: Create procurement plan + plan_data = await self._create_plan_data( + tenant_id, + plan_date, + request, + inventory_items, + forecasts + ) + + # Delete existing plan if force regenerate + if existing_plan and request.force_regenerate: + await self.plan_repo.delete_plan(existing_plan.id, tenant_id) + await self.db.flush() + + # Step 4: Save plan to database + plan = await self.plan_repo.create_plan(plan_data) + + # Step 5: Create requirements + requirements_data = await self._create_requirements_data( + plan.id, + inventory_items, + forecasts, + request + ) + + if requirements_data: + await self.requirement_repo.create_requirements_batch(requirements_data) + + await self.db.commit() + + # Step 6: Cache the plan and publish event + await self._cache_procurement_plan(plan) + await self._publish_plan_generated_event(tenant_id, plan.id) + + logger.info("Procurement plan generated successfully", + tenant_id=tenant_id, plan_id=plan.id, requirements_count=len(requirements_data)) + + # Refresh plan with requirements + saved_plan = await self.plan_repo.get_plan_by_id(plan.id, tenant_id) + + return GeneratePlanResponse( + success=True, + message="Procurement plan generated successfully", + plan=ProcurementPlanResponse.model_validate(saved_plan) + ) + + except Exception as e: + await self.db.rollback() + logger.error("Error generating procurement plan", error=str(e), tenant_id=tenant_id) + return GeneratePlanResponse( + success=False, + message="Failed to generate procurement plan", + errors=[str(e)] + ) + + async def update_plan_status( + self, + tenant_id: uuid.UUID, + plan_id: uuid.UUID, + status: str, + updated_by: Optional[uuid.UUID] = None + ) -> Optional[ProcurementPlanResponse]: + """Update procurement plan status""" + try: + updates = {"status": status, "updated_by": updated_by} + + if status == "approved": + updates["approved_at"] = datetime.utcnow() + updates["approved_by"] = updated_by + elif status == "in_execution": + updates["execution_started_at"] = datetime.utcnow() + elif status in ["completed", "cancelled"]: + updates["execution_completed_at"] = datetime.utcnow() + + plan = await self.plan_repo.update_plan(plan_id, tenant_id, updates) + if plan: + await self.db.commit() + await self._cache_procurement_plan(plan) + return ProcurementPlanResponse.model_validate(plan) + + return None + + except Exception as e: + await self.db.rollback() + logger.error("Error updating plan status", error=str(e), plan_id=plan_id) + return None + + # ================================================================ + # DASHBOARD AND ANALYTICS + # ================================================================ + + async def get_dashboard_data(self, tenant_id: uuid.UUID) -> Optional[DashboardData]: + """Get procurement dashboard data""" + try: + current_plan = await self.get_current_plan(tenant_id) + summary = await self._get_procurement_summary(tenant_id) + + # Get additional dashboard data + upcoming_deliveries = await self._get_upcoming_deliveries(tenant_id) + overdue_requirements = await self._get_overdue_requirements(tenant_id) + low_stock_alerts = await self._get_low_stock_alerts(tenant_id) + performance_metrics = await self._get_performance_metrics(tenant_id) + + return DashboardData( + current_plan=current_plan, + summary=summary, + upcoming_deliveries=upcoming_deliveries, + overdue_requirements=overdue_requirements, + low_stock_alerts=low_stock_alerts, + performance_metrics=performance_metrics + ) + + except Exception as e: + logger.error("Error getting dashboard data", error=str(e), tenant_id=tenant_id) + return None + + # ================================================================ + # DAILY SCHEDULER + # ================================================================ + + async def run_daily_scheduler(self) -> None: + """Run the daily procurement planning scheduler""" + logger.info("Starting daily procurement scheduler") + + try: + # This would typically be called by a cron job or scheduler service + # Get all active tenants (this would come from tenant service) + active_tenants = await self._get_active_tenants() + + for tenant_id in active_tenants: + try: + await self._process_daily_plan_for_tenant(tenant_id) + except Exception as e: + logger.error("Error processing daily plan for tenant", + error=str(e), tenant_id=tenant_id) + continue + + logger.info("Daily procurement scheduler completed", processed_tenants=len(active_tenants)) + + except Exception as e: + logger.error("Error in daily scheduler", error=str(e)) + + async def _process_daily_plan_for_tenant(self, tenant_id: uuid.UUID) -> None: + """Process daily procurement plan for a specific tenant""" + try: + today = date.today() + + # Check if plan already exists for today + existing_plan = await self.plan_repo.get_plan_by_date(today, tenant_id) + if existing_plan: + logger.info("Daily plan already exists", tenant_id=tenant_id, date=today) + return + + # Generate plan for today + request = GeneratePlanRequest( + plan_date=today, + planning_horizon_days=settings.DEMAND_FORECAST_DAYS, + include_safety_stock=True, + safety_stock_percentage=Decimal(str(settings.SAFETY_STOCK_PERCENTAGE)) + ) + + result = await self.generate_procurement_plan(tenant_id, request) + + if result.success: + logger.info("Daily plan generated successfully", tenant_id=tenant_id) + else: + logger.error("Failed to generate daily plan", + tenant_id=tenant_id, errors=result.errors) + + except Exception as e: + logger.error("Error processing daily plan", error=str(e), tenant_id=tenant_id) + + # ================================================================ + # PRIVATE HELPER METHODS + # ================================================================ + + async def _get_inventory_list(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: + """Get current inventory list from inventory service""" + try: + return await self.inventory_client.get_all_ingredients(str(tenant_id)) + except Exception as e: + logger.error("Error fetching inventory", error=str(e), tenant_id=tenant_id) + return [] + + async def _generate_demand_forecasts( + self, + tenant_id: uuid.UUID, + inventory_items: List[Dict[str, Any]], + target_date: date, + horizon_days: int + ) -> Dict[str, Dict[str, Any]]: + """Generate demand forecasts for inventory items""" + forecasts = {} + + try: + # For each inventory item, request forecast + for item in inventory_items: + item_id = item.get('id') + if not item_id: + continue + + try: + # Call forecast service for next day demand + forecast_data = await self.forecast_client.create_realtime_prediction( + tenant_id=str(tenant_id), + model_id="default", # Use default model or tenant-specific model + target_date=target_date.isoformat(), + features={ + "product_id": item_id, + "current_stock": item.get('current_stock', 0), + "historical_usage": item.get('avg_daily_usage', 0), + "seasonality": self._calculate_seasonality_factor(target_date), + "day_of_week": target_date.weekday(), + "is_weekend": target_date.weekday() >= 5 + } + ) + + if forecast_data: + forecasts[item_id] = forecast_data + + except Exception as e: + logger.warning("Error forecasting for item", + item_id=item_id, error=str(e)) + # Use fallback prediction + forecasts[item_id] = self._create_fallback_forecast(item) + + return forecasts + + except Exception as e: + logger.error("Error generating forecasts", error=str(e), tenant_id=tenant_id) + return {} + + async def _create_plan_data( + self, + tenant_id: uuid.UUID, + plan_date: date, + request: GeneratePlanRequest, + inventory_items: List[Dict[str, Any]], + forecasts: Dict[str, Dict[str, Any]] + ) -> Dict[str, Any]: + """Create procurement plan data""" + + plan_number = await self.plan_repo.generate_plan_number(tenant_id, plan_date) + + total_items = len(inventory_items) + total_forecast_demand = sum( + f.get('predicted_value', 0) for f in forecasts.values() + ) + + return { + 'tenant_id': tenant_id, + 'plan_number': plan_number, + 'plan_date': plan_date, + 'plan_period_start': plan_date, + 'plan_period_end': plan_date + timedelta(days=request.planning_horizon_days), + 'planning_horizon_days': request.planning_horizon_days, + 'status': 'draft', + 'plan_type': 'regular', + 'priority': 'normal', + 'procurement_strategy': 'just_in_time', + 'safety_stock_buffer': request.safety_stock_percentage, + 'total_demand_quantity': Decimal(str(total_forecast_demand)), + 'supply_risk_level': 'low', + 'created_at': datetime.utcnow(), + 'updated_at': datetime.utcnow(), + } + + async def _create_requirements_data( + self, + plan_id: uuid.UUID, + inventory_items: List[Dict[str, Any]], + forecasts: Dict[str, Dict[str, Any]], + request: GeneratePlanRequest + ) -> List[Dict[str, Any]]: + """Create procurement requirements data""" + requirements = [] + + for item in inventory_items: + item_id = item.get('id') + if not item_id or item_id not in forecasts: + continue + + forecast = forecasts[item_id] + current_stock = Decimal(str(item.get('current_stock', 0))) + predicted_demand = Decimal(str(forecast.get('predicted_value', 0))) + safety_stock = predicted_demand * (request.safety_stock_percentage / 100) + + total_needed = predicted_demand + safety_stock + net_requirement = max(Decimal('0'), total_needed - current_stock) + + if net_requirement > 0: # Only create requirement if needed + requirement_number = await self.requirement_repo.generate_requirement_number(plan_id) + + required_by_date = request.plan_date or date.today() + suggested_order_date = required_by_date - timedelta(days=settings.PROCUREMENT_LEAD_TIME_DAYS) + latest_order_date = required_by_date - timedelta(days=1) + + requirements.append({ + 'plan_id': plan_id, + 'requirement_number': requirement_number, + 'product_id': uuid.UUID(item_id), + 'product_name': item.get('name', ''), + 'product_sku': item.get('sku', ''), + 'product_category': item.get('category', ''), + 'product_type': 'ingredient', + 'required_quantity': predicted_demand, + 'unit_of_measure': item.get('unit', 'kg'), + 'safety_stock_quantity': safety_stock, + 'total_quantity_needed': total_needed, + 'current_stock_level': current_stock, + 'available_stock': current_stock, + 'net_requirement': net_requirement, + 'forecast_demand': predicted_demand, + 'buffer_demand': safety_stock, + 'required_by_date': required_by_date, + 'suggested_order_date': suggested_order_date, + 'latest_order_date': latest_order_date, + 'priority': self._calculate_priority(net_requirement, current_stock), + 'risk_level': self._calculate_risk_level(item, forecast), + 'status': 'pending', + 'delivery_status': 'pending', + 'ordered_quantity': Decimal('0'), + 'received_quantity': Decimal('0'), + 'estimated_unit_cost': Decimal(str(item.get('avg_cost', 0))), + 'estimated_total_cost': net_requirement * Decimal(str(item.get('avg_cost', 0))) + }) + + return requirements + + def _calculate_priority(self, net_requirement: Decimal, current_stock: Decimal) -> str: + """Calculate requirement priority based on stock levels""" + if current_stock <= 0: + return 'critical' + + stock_ratio = net_requirement / current_stock if current_stock > 0 else float('inf') + + if stock_ratio >= 2: + return 'critical' + elif stock_ratio >= 1: + return 'high' + elif stock_ratio >= 0.5: + return 'normal' + else: + return 'low' + + def _calculate_risk_level(self, item: Dict[str, Any], forecast: Dict[str, Any]) -> str: + """Calculate risk level for procurement requirement""" + confidence = forecast.get('confidence_score', 0.8) + lead_time = item.get('supplier_lead_time', 3) + + if confidence < 0.6 or lead_time > 7: + return 'high' + elif confidence < 0.8 or lead_time > 3: + return 'medium' + else: + return 'low' + + def _calculate_seasonality_factor(self, target_date: date) -> float: + """Calculate seasonality adjustment factor""" + # Simple seasonality based on month + seasonal_factors = { + 12: 1.3, 1: 1.2, 2: 0.9, # Winter + 3: 1.1, 4: 1.2, 5: 1.3, # Spring + 6: 1.4, 7: 1.5, 8: 1.4, # Summer + 9: 1.2, 10: 1.1, 11: 1.2 # Fall + } + return seasonal_factors.get(target_date.month, 1.0) + + def _create_fallback_forecast(self, item: Dict[str, Any]) -> Dict[str, Any]: + """Create fallback forecast when service is unavailable""" + avg_usage = item.get('avg_daily_usage', 0) + return { + 'predicted_value': avg_usage * 1.1, # 10% buffer + 'confidence_score': 0.5, + 'lower_bound': avg_usage * 0.8, + 'upper_bound': avg_usage * 1.3, + 'fallback': True + } + + async def _cache_procurement_plan(self, plan: ProcurementPlan) -> None: + """Cache procurement plan in Redis""" + try: + await self.cache_service.cache_procurement_plan(plan) + except Exception as e: + logger.warning("Failed to cache plan", error=str(e), plan_id=plan.id) + + async def _publish_plan_generated_event(self, tenant_id: uuid.UUID, plan_id: uuid.UUID) -> None: + """Publish plan generated event""" + try: + event_data = { + "tenant_id": str(tenant_id), + "plan_id": str(plan_id), + "timestamp": datetime.utcnow().isoformat(), + "event_type": "procurement.plan.generated" + } + await self.rabbitmq_client.publish_event( + exchange_name="procurement.events", + routing_key="procurement.plan.generated", + event_data=event_data + ) + except Exception as e: + logger.warning("Failed to publish event", error=str(e)) + + async def _get_active_tenants(self) -> List[uuid.UUID]: + """Get list of active tenant IDs""" + # This would typically call the tenant service + # For now, return empty list - would be implemented with actual tenant service + return [] + + async def _get_procurement_summary(self, tenant_id: uuid.UUID) -> ProcurementSummary: + """Get procurement summary for dashboard""" + # Implement summary calculation + return ProcurementSummary( + total_plans=0, + active_plans=0, + total_requirements=0, + pending_requirements=0, + critical_requirements=0, + total_estimated_cost=Decimal('0'), + total_approved_cost=Decimal('0'), + cost_variance=Decimal('0') + ) + + async def _get_upcoming_deliveries(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: + """Get upcoming deliveries""" + return [] + + async def _get_overdue_requirements(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: + """Get overdue requirements""" + return [] + + async def _get_low_stock_alerts(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]: + """Get low stock alerts from inventory service""" + try: + return await self.inventory_client.get_low_stock_alerts(str(tenant_id)) + except Exception as e: + logger.error("Error getting low stock alerts", error=str(e)) + return [] + + async def _get_performance_metrics(self, tenant_id: uuid.UUID) -> Dict[str, Any]: + """Get performance metrics""" + return {} \ No newline at end of file diff --git a/services/orders/requirements.txt b/services/orders/requirements.txt index 81d107ea..8a1967fd 100644 --- a/services/orders/requirements.txt +++ b/services/orders/requirements.txt @@ -13,8 +13,15 @@ alembic==1.13.1 # HTTP clients httpx==0.25.2 +# Redis for caching +redis==5.0.1 + +# Message queuing +aio-pika==9.3.1 + # Logging and monitoring structlog==23.2.0 +prometheus-client==0.19.0 # Date and time utilities python-dateutil==2.8.2 diff --git a/services/sales/app/api/sales.py b/services/sales/app/api/sales.py index 4b50a1fd..dfb05ef5 100644 --- a/services/sales/app/api/sales.py +++ b/services/sales/app/api/sales.py @@ -301,80 +301,3 @@ async def validate_sales_record( except Exception as e: logger.error("Failed to validate sales record", error=str(e), record_id=record_id, tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to validate sales record: {str(e)}") - - -# ================================================================ -# INVENTORY INTEGRATION ENDPOINTS -# ================================================================ - -@router.get("/tenants/{tenant_id}/inventory/products/search") -async def search_inventory_products( - tenant_id: UUID = Path(..., description="Tenant ID"), - search: str = Query(..., description="Search term"), - product_type: Optional[str] = Query(None, description="Product type filter"), - current_tenant: str = Depends(get_current_tenant_id_dep), - sales_service: SalesService = Depends(get_sales_service) -): - """Search products in inventory service""" - try: - # Verify tenant access - if str(tenant_id) != current_tenant: - raise HTTPException(status_code=403, detail="Access denied to this tenant") - - products = await sales_service.search_inventory_products(search, tenant_id, product_type) - - return {"items": products, "count": len(products)} - - except Exception as e: - logger.error("Failed to search inventory products", error=str(e), tenant_id=tenant_id) - raise HTTPException(status_code=500, detail=f"Failed to search inventory products: {str(e)}") - - -@router.get("/tenants/{tenant_id}/inventory/products/{product_id}") -async def get_inventory_product( - tenant_id: UUID = Path(..., description="Tenant ID"), - product_id: UUID = Path(..., description="Product ID from inventory service"), - current_tenant: str = Depends(get_current_tenant_id_dep), - sales_service: SalesService = Depends(get_sales_service) -): - """Get product details from inventory service""" - try: - # Verify tenant access - if str(tenant_id) != current_tenant: - raise HTTPException(status_code=403, detail="Access denied to this tenant") - - product = await sales_service.get_inventory_product(product_id, tenant_id) - - if not product: - raise HTTPException(status_code=404, detail="Product not found in inventory") - - return product - - except HTTPException: - raise - except Exception as e: - logger.error("Failed to get inventory product", error=str(e), product_id=product_id, tenant_id=tenant_id) - raise HTTPException(status_code=500, detail=f"Failed to get inventory product: {str(e)}") - - -@router.get("/tenants/{tenant_id}/inventory/products/category/{category}") -async def get_inventory_products_by_category( - tenant_id: UUID = Path(..., description="Tenant ID"), - category: str = Path(..., description="Product category"), - product_type: Optional[str] = Query(None, description="Product type filter"), - current_tenant: str = Depends(get_current_tenant_id_dep), - sales_service: SalesService = Depends(get_sales_service) -): - """Get products by category from inventory service""" - try: - # Verify tenant access - if str(tenant_id) != current_tenant: - raise HTTPException(status_code=403, detail="Access denied to this tenant") - - products = await sales_service.get_inventory_products_by_category(category, tenant_id, product_type) - - return {"items": products, "count": len(products)} - - except Exception as e: - logger.error("Failed to get inventory products by category", error=str(e), category=category, tenant_id=tenant_id) - raise HTTPException(status_code=500, detail=f"Failed to get inventory products by category: {str(e)}") \ No newline at end of file diff --git a/shared/monitoring/decorators.py b/shared/monitoring/decorators.py index 7a9f3975..39464901 100644 --- a/shared/monitoring/decorators.py +++ b/shared/monitoring/decorators.py @@ -86,4 +86,94 @@ def count_calls(metric_name: str, service_name: str, else: return sync_wrapper + return decorator + + +def monitor_performance(operation_name: str, labels: Optional[dict] = None): + """ + General purpose performance monitoring decorator + Tracks execution time and call counts for the given operation + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def async_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + service_name = "orders-service" # Could be dynamic based on context + + try: + # Count the call + metrics_collector = get_metrics_collector(service_name) + if metrics_collector: + call_labels = {**(labels or {}), "operation": operation_name} + metrics_collector.increment_counter(f"{service_name}_operations_total", labels=call_labels) + + # Execute the function + result = await func(*args, **kwargs) + + # Record success timing + duration = time.time() - start_time + if metrics_collector: + timing_labels = {**(labels or {}), "operation": operation_name, "status": "success"} + metrics_collector.observe_histogram(f"{service_name}_operation_duration_seconds", duration, timing_labels) + + return result + + except Exception as e: + # Record failure timing + duration = time.time() - start_time + metrics_collector = get_metrics_collector(service_name) + if metrics_collector: + timing_labels = {**(labels or {}), "operation": operation_name, "status": "error"} + metrics_collector.observe_histogram(f"{service_name}_operation_duration_seconds", duration, timing_labels) + + error_labels = {**(labels or {}), "operation": operation_name, "error_type": type(e).__name__} + metrics_collector.increment_counter(f"{service_name}_errors_total", labels=error_labels) + + logger.error(f"Operation {operation_name} failed after {duration:.2f}s: {e}") + raise + + @functools.wraps(func) + def sync_wrapper(*args, **kwargs) -> Any: + start_time = time.time() + service_name = "orders-service" # Could be dynamic based on context + + try: + # Count the call + metrics_collector = get_metrics_collector(service_name) + if metrics_collector: + call_labels = {**(labels or {}), "operation": operation_name} + metrics_collector.increment_counter(f"{service_name}_operations_total", labels=call_labels) + + # Execute the function + result = func(*args, **kwargs) + + # Record success timing + duration = time.time() - start_time + if metrics_collector: + timing_labels = {**(labels or {}), "operation": operation_name, "status": "success"} + metrics_collector.observe_histogram(f"{service_name}_operation_duration_seconds", duration, timing_labels) + + return result + + except Exception as e: + # Record failure timing + duration = time.time() - start_time + metrics_collector = get_metrics_collector(service_name) + if metrics_collector: + timing_labels = {**(labels or {}), "operation": operation_name, "status": "error"} + metrics_collector.observe_histogram(f"{service_name}_operation_duration_seconds", duration, timing_labels) + + error_labels = {**(labels or {}), "operation": operation_name, "error_type": type(e).__name__} + metrics_collector.increment_counter(f"{service_name}_errors_total", labels=error_labels) + + logger.error(f"Operation {operation_name} failed after {duration:.2f}s: {e}") + raise + + # Return appropriate wrapper based on function type + import asyncio + if asyncio.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + return decorator \ No newline at end of file