Add procurement management logic

This commit is contained in:
Urtzi Alfaro
2025-08-23 19:47:08 +02:00
parent 62ff755f25
commit 5077a45a25
22 changed files with 4011 additions and 79 deletions

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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(

View File

@@ -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<ProcurementPlan | null> {
return this.client.get('/procurement-plans/current');
}
/**
* Get procurement plan for a specific date
*/
async getPlanByDate(date: string): Promise<ProcurementPlan | null> {
return this.client.get(`/procurement-plans/${date}`);
}
/**
* Get procurement plan by ID
*/
async getPlanById(planId: string): Promise<ProcurementPlan | null> {
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<PaginatedProcurementPlans> {
return this.client.get('/procurement-plans/', { params });
}
/**
* Generate a new procurement plan
*/
async generatePlan(request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
return this.client.post('/procurement-plans/generate', request);
}
/**
* Update procurement plan status
*/
async updatePlanStatus(planId: string, status: string): Promise<ProcurementPlan> {
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<ProcurementRequirement[]> {
return this.client.get(`/procurement-plans/${planId}/requirements`, { params });
}
/**
* Get all critical priority requirements
*/
async getCriticalRequirements(): Promise<ProcurementRequirement[]> {
return this.client.get('/procurement-plans/requirements/critical');
}
// ================================================================
// DASHBOARD OPERATIONS
// ================================================================
/**
* Get procurement dashboard data
*/
async getDashboardData(): Promise<DashboardData | null> {
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());

View File

@@ -10,4 +10,5 @@ export * from './tenant';
export * from './data';
export * from './training';
export * from './forecasting';
export * from './notification';
export * from './notification';
export * from './procurement';

View File

@@ -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<string, any>;
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<Record<string, any>>;
critical_items: Array<Record<string, any>>;
}
export interface DashboardData {
current_plan?: ProcurementPlan;
summary: ProcurementSummary;
upcoming_deliveries: Array<Record<string, any>>;
overdue_requirements: Array<Record<string, any>>;
low_stock_alerts: Array<Record<string, any>>;
performance_metrics: Record<string, any>;
}
// ================================================================
// 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'
}

View File

@@ -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<CriticalRequirementsProps> = ({
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 (
<div className="text-center py-8 text-gray-500">
<p>No critical requirements at this time</p>
</div>
);
}
return (
<div className="space-y-3">
{requirements.map((requirement) => (
<div
key={requirement.id}
className="border border-red-200 rounded-lg p-4 bg-red-50 hover:bg-red-100 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-2">
<h4 className="font-medium text-gray-900">
{requirement.product_name}
</h4>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(requirement.status)}`}>
{requirement.status.replace('_', ' ').toUpperCase()}
</span>
<span className="px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-700">
CRITICAL
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Required:</span>
<div className="font-medium">
{requirement.net_requirement} {requirement.unit_of_measure}
</div>
</div>
<div>
<span className="text-gray-500">Current Stock:</span>
<div className={getStockLevelColor(requirement.current_stock_level, requirement.net_requirement)}>
{requirement.current_stock_level} {requirement.unit_of_measure}
</div>
</div>
<div>
<span className="text-gray-500">Due Date:</span>
<div className={getDueDateColor(requirement.required_by_date)}>
{formatDate(requirement.required_by_date)}
</div>
</div>
<div>
<span className="text-gray-500">Est. Cost:</span>
<div className="font-medium">
{formatCurrency(requirement.estimated_total_cost)}
</div>
</div>
</div>
{requirement.supplier_name && (
<div className="mt-2 text-sm">
<span className="text-gray-500">Supplier:</span>
<span className="ml-1 font-medium">{requirement.supplier_name}</span>
{requirement.supplier_lead_time_days && (
<span className="ml-2 text-gray-500">
({requirement.supplier_lead_time_days} days lead time)
</span>
)}
</div>
)}
{requirement.special_requirements && (
<div className="mt-2 p-2 bg-yellow-50 rounded border border-yellow-200">
<span className="text-xs text-yellow-700 font-medium">Special Requirements:</span>
<p className="text-xs text-yellow-600 mt-1">
{requirement.special_requirements}
</p>
</div>
)}
</div>
<div className="flex flex-col space-y-2 ml-4">
{requirement.status === RequirementStatus.PENDING && (
<button
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.APPROVED)}
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors"
>
Approve
</button>
)}
{requirement.status === RequirementStatus.APPROVED && (
<button
onClick={() => onUpdateStatus?.(requirement.id, RequirementStatus.ORDERED)}
className="px-3 py-1 bg-purple-600 text-white text-xs rounded hover:bg-purple-700 transition-colors"
>
Order Now
</button>
)}
<button
onClick={() => onViewDetails?.(requirement.id)}
className="px-3 py-1 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300 transition-colors"
>
Details
</button>
</div>
</div>
{/* Progress indicator for ordered items */}
{requirement.status === RequirementStatus.ORDERED && requirement.ordered_quantity > 0 && (
<div className="mt-3 pt-3 border-t border-red-200">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>Order Progress</span>
<span>
{requirement.received_quantity} / {requirement.ordered_quantity} {requirement.unit_of_measure}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${Math.min(100, (requirement.received_quantity / requirement.ordered_quantity) * 100)}%`
}}
/>
</div>
{requirement.expected_delivery_date && (
<div className="text-xs text-gray-500 mt-1">
Expected: {formatDate(requirement.expected_delivery_date)}
</div>
)}
</div>
)}
</div>
))}
</div>
);
};

View File

@@ -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<GeneratePlanModalProps> = ({
onGenerate,
onClose,
isGenerating,
error,
}) => {
const [formData, setFormData] = useState<GeneratePlanRequest>({
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Generate Procurement Plan
</h2>
<button
onClick={onClose}
disabled={isGenerating}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Plan Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Plan Date
</label>
<Input
type="date"
value={formData.plan_date || ''}
onChange={(e) => handleInputChange('plan_date', e.target.value)}
disabled={isGenerating}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Date for which to generate the procurement plan
</p>
</div>
{/* Planning Horizon */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Planning Horizon (days)
</label>
<Input
type="number"
min="1"
max="30"
value={formData.planning_horizon_days || 14}
onChange={(e) => handleInputChange('planning_horizon_days', parseInt(e.target.value))}
disabled={isGenerating}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Number of days to plan ahead (1-30)
</p>
</div>
{/* Safety Stock */}
<div>
<div className="flex items-center mb-2">
<input
type="checkbox"
id="include_safety_stock"
checked={formData.include_safety_stock || false}
onChange={(e) => handleInputChange('include_safety_stock', e.target.checked)}
disabled={isGenerating}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="include_safety_stock" className="ml-2 text-sm font-medium text-gray-700">
Include Safety Stock
</label>
</div>
{formData.include_safety_stock && (
<div className="ml-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
Safety Stock Percentage
</label>
<Input
type="number"
min="0"
max="100"
step="5"
value={formData.safety_stock_percentage || 20}
onChange={(e) => handleInputChange('safety_stock_percentage', parseFloat(e.target.value))}
disabled={isGenerating}
className="w-full"
/>
<p className="text-xs text-gray-500 mt-1">
Additional buffer stock as percentage of demand (0-100%)
</p>
</div>
)}
</div>
{/* Force Regenerate */}
<div className="flex items-center">
<input
type="checkbox"
id="force_regenerate"
checked={formData.force_regenerate || false}
onChange={(e) => handleInputChange('force_regenerate', e.target.checked)}
disabled={isGenerating}
className="h-4 w-4 text-blue-600 rounded border-gray-300"
/>
<label htmlFor="force_regenerate" className="ml-2 text-sm font-medium text-gray-700">
Force Regenerate
</label>
</div>
<p className="text-xs text-gray-500">
Regenerate plan even if one already exists for this date
</p>
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 rounded p-3">
<p className="text-red-600 text-sm">
{error.message || 'Failed to generate plan'}
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isGenerating}
>
Cancel
</Button>
<Button
type="submit"
disabled={isGenerating}
>
{isGenerating ? 'Generating...' : 'Generate Plan'}
</Button>
</div>
</form>
{/* Generation Progress */}
{isGenerating && (
<div className="mt-4 p-3 bg-blue-50 rounded">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
<span className="text-sm text-blue-700">
Generating procurement plan...
</span>
</div>
<div className="mt-2 text-xs text-blue-600">
This may take a few moments while we analyze inventory and forecast demand.
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -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<ProcurementDashboardProps> = ({
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 (
<div className="flex items-center justify-center py-8">
<LoadingSpinner size="lg" />
<span className="ml-2">Loading procurement dashboard...</span>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h3 className="text-red-800 font-medium">Error Loading Dashboard</h3>
<p className="text-red-600 mt-1">
{error.message || 'Unable to load procurement dashboard data'}
</p>
<Button
onClick={refetchAll}
className="mt-2"
variant="outline"
>
Retry
</Button>
</div>
);
}
const dashboardData = dashboard.data;
const currentPlanData = currentPlan.data;
const criticalReqs = criticalRequirements.data || [];
const serviceHealth = health.data;
return (
<div className="space-y-6">
{/* Header with Actions */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">
Procurement Planning
</h1>
<p className="text-gray-600 mt-1">
Manage daily procurement plans and requirements
</p>
</div>
<div className="flex items-center space-x-3">
{serviceHealth && !serviceHealth.procurement_enabled && (
<div className="bg-yellow-100 border border-yellow-300 rounded px-3 py-1">
<span className="text-yellow-800 text-sm">
Service Disabled
</span>
</div>
)}
<Button
onClick={handleTriggerScheduler}
variant="outline"
disabled={isGenerating}
>
Run Scheduler
</Button>
<Button
onClick={() => setShowGenerateModal(true)}
disabled={isGenerating}
>
{isGenerating ? 'Generating...' : 'Generate Plan'}
</Button>
</div>
</div>
{/* Current Plan Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Today's Procurement Plan</h2>
<Button
onClick={refetchAll}
variant="ghost"
size="sm"
>
Refresh
</Button>
</div>
{currentPlanData ? (
<ProcurementPlanCard
plan={currentPlanData}
onUpdateStatus={handleStatusUpdate}
showActions={true}
/>
) : (
<div className="text-center py-8 text-gray-500">
<p>No procurement plan for today</p>
<Button
onClick={() => setShowGenerateModal(true)}
className="mt-2"
size="sm"
>
Generate Plan
</Button>
</div>
)}
</Card>
</div>
{/* Summary Statistics */}
<div>
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4">Summary</h2>
{dashboardData?.summary ? (
<ProcurementSummary summary={dashboardData.summary} />
) : (
<div className="text-gray-500">No summary data available</div>
)}
</Card>
</div>
</div>
{/* Critical Requirements */}
{criticalReqs.length > 0 && (
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-red-600">
Critical Requirements ({criticalReqs.length})
</h2>
<CriticalRequirements requirements={criticalReqs} />
</Card>
)}
{/* Additional Dashboard Widgets */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Upcoming Deliveries */}
{dashboardData?.upcoming_deliveries?.length > 0 && (
<Card className="p-6">
<h3 className="text-md font-semibold mb-3">Upcoming Deliveries</h3>
<div className="space-y-2">
{dashboardData.upcoming_deliveries.slice(0, 5).map((delivery, index) => (
<div key={index} className="flex justify-between text-sm">
<span>{delivery.product_name}</span>
<span className="text-gray-500">{delivery.expected_date}</span>
</div>
))}
</div>
</Card>
)}
{/* Low Stock Alerts */}
{dashboardData?.low_stock_alerts?.length > 0 && (
<Card className="p-6">
<h3 className="text-md font-semibold mb-3 text-orange-600">
Low Stock Alerts
</h3>
<div className="space-y-2">
{dashboardData.low_stock_alerts.slice(0, 5).map((alert, index) => (
<div key={index} className="flex justify-between text-sm">
<span>{alert.product_name}</span>
<span className="text-orange-600">{alert.current_stock}</span>
</div>
))}
</div>
</Card>
)}
{/* Performance Metrics */}
{dashboardData?.performance_metrics && (
<Card className="p-6">
<h3 className="text-md font-semibold mb-3">Performance</h3>
<div className="space-y-3">
{Object.entries(dashboardData.performance_metrics).map(([key, value]) => (
<div key={key} className="flex justify-between text-sm">
<span className="capitalize">{key.replace('_', ' ')}</span>
<span className="font-medium">{value as string}</span>
</div>
))}
</div>
</Card>
)}
</div>
{/* Generate Plan Modal */}
{showGenerateModal && (
<GeneratePlanModal
onGenerate={handleGeneratePlan}
onClose={() => setShowGenerateModal(false)}
isGenerating={isGenerating}
error={generateError}
/>
)}
</div>
);
};

View File

@@ -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<ProcurementPlanCardProps> = ({
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 (
<Card className="border border-gray-200">
<div className="p-6">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-gray-900">
{plan.plan_number}
</h3>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(plan.status)}`}>
{plan.status.replace('_', ' ').toUpperCase()}
</span>
</div>
<p className="text-sm text-gray-600 mt-1">
Plan Date: {formatDate(plan.plan_date)} |
Period: {formatDate(plan.plan_period_start)} - {formatDate(plan.plan_period_end)}
</p>
</div>
<div className="text-right">
<div className={`text-sm font-medium ${getPriorityColor(plan.priority)}`}>
{plan.priority.toUpperCase()} Priority
</div>
<div className={`text-xs ${getRiskColor(plan.supply_risk_level)}`}>
{plan.supply_risk_level.toUpperCase()} Risk
</div>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{plan.total_requirements}
</div>
<div className="text-xs text-gray-500">Requirements</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{formatCurrency(plan.total_estimated_cost)}
</div>
<div className="text-xs text-gray-500">Est. Cost</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{plan.primary_suppliers_count}
</div>
<div className="text-xs text-gray-500">Suppliers</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{plan.safety_stock_buffer}%
</div>
<div className="text-xs text-gray-500">Safety Buffer</div>
</div>
</div>
{/* Requirements Summary */}
{plan.requirements && plan.requirements.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Top Requirements ({plan.requirements.length} total)
</h4>
<div className="space-y-1">
{plan.requirements.slice(0, 3).map((req) => (
<div key={req.id} className="flex justify-between items-center text-sm">
<span className="truncate">{req.product_name}</span>
<div className="flex items-center space-x-2">
<span className="text-gray-500">
{req.net_requirement} {req.unit_of_measure}
</span>
<span className={`px-1 py-0.5 rounded text-xs ${
req.priority === Priority.CRITICAL ? 'bg-red-100 text-red-700' :
req.priority === Priority.HIGH ? 'bg-orange-100 text-orange-700' :
'bg-gray-100 text-gray-700'
}`}>
{req.priority}
</span>
</div>
</div>
))}
{plan.requirements.length > 3 && (
<div className="text-xs text-gray-500 text-center">
+{plan.requirements.length - 3} more requirements
</div>
)}
</div>
</div>
)}
{/* Performance Metrics */}
{(plan.fulfillment_rate || plan.on_time_delivery_rate) && (
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-4">
{plan.fulfillment_rate && (
<span>Fulfillment: {plan.fulfillment_rate}%</span>
)}
{plan.on_time_delivery_rate && (
<span>On-time: {plan.on_time_delivery_rate}%</span>
)}
</div>
)}
{/* Actions */}
{showActions && (
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="flex space-x-2">
{nextStatusOptions().map((status) => (
<Button
key={status}
size="sm"
variant="outline"
onClick={() => onUpdateStatus?.(plan.id, status)}
>
{status === PlanStatus.PENDING_APPROVAL && 'Submit for Approval'}
{status === PlanStatus.APPROVED && 'Approve'}
{status === PlanStatus.IN_EXECUTION && 'Start Execution'}
{status === PlanStatus.COMPLETED && 'Mark Complete'}
{status === PlanStatus.CANCELLED && 'Cancel'}
</Button>
))}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => onViewDetails?.(plan.id)}
>
View Details
</Button>
</div>
)}
{/* Special Requirements */}
{plan.special_requirements && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<h5 className="text-sm font-medium text-blue-800 mb-1">
Special Requirements
</h5>
<p className="text-sm text-blue-700">{plan.special_requirements}</p>
</div>
)}
</div>
</Card>
);
};

View File

@@ -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<ProcurementSummaryProps> = ({
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 (
<div className="space-y-6">
{/* Plan Metrics */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Plan Overview</h4>
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-xl font-bold text-blue-600">
{summary.total_plans}
</div>
<div className="text-xs text-gray-500">Total Plans</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-green-600">
{summary.active_plans}
</div>
<div className="text-xs text-gray-500">Active Plans</div>
</div>
</div>
</div>
{/* Requirements Metrics */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Requirements</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Total</span>
<span className="text-sm font-medium">{summary.total_requirements}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Pending</span>
<span className="text-sm font-medium text-yellow-600">
{summary.pending_requirements}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Critical</span>
<span className="text-sm font-medium text-red-600">
{summary.critical_requirements}
</span>
</div>
</div>
</div>
{/* Cost Metrics */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Financial</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Estimated</span>
<span className="text-sm font-medium text-blue-600">
{formatCurrency(summary.total_estimated_cost)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Approved</span>
<span className="text-sm font-medium text-green-600">
{formatCurrency(summary.total_approved_cost)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Variance</span>
<span className={`text-sm font-medium ${
summary.cost_variance >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{summary.cost_variance >= 0 ? '+' : ''}
{formatCurrency(summary.cost_variance)}
</span>
</div>
</div>
</div>
{/* Performance Metrics */}
{(summary.average_fulfillment_rate || summary.average_on_time_delivery) && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Performance</h4>
<div className="space-y-2">
{summary.average_fulfillment_rate && (
<div className="flex justify-between">
<span className="text-sm text-gray-600">Fulfillment Rate</span>
<span className="text-sm font-medium">
{formatPercentage(summary.average_fulfillment_rate)}
</span>
</div>
)}
{summary.average_on_time_delivery && (
<div className="flex justify-between">
<span className="text-sm text-gray-600">On-Time Delivery</span>
<span className="text-sm font-medium">
{formatPercentage(summary.average_on_time_delivery)}
</span>
</div>
)}
</div>
</div>
)}
{/* Top Suppliers */}
{summary.top_suppliers.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3">Top Suppliers</h4>
<div className="space-y-1">
{summary.top_suppliers.slice(0, 3).map((supplier, index) => (
<div key={index} className="flex justify-between text-sm">
<span className="truncate">{supplier.name}</span>
<span className="text-gray-500">
{supplier.count || 0} orders
</span>
</div>
))}
</div>
</div>
)}
{/* Critical Items */}
{summary.critical_items.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3 text-red-600">
Critical Items
</h4>
<div className="space-y-1">
{summary.critical_items.slice(0, 3).map((item, index) => (
<div key={index} className="flex justify-between text-sm">
<span className="truncate">{item.name}</span>
<span className="text-red-500">
{item.stock || 0} left
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -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';

View File

@@ -0,0 +1 @@
export { default as ProcurementPage } from './ProcurementPage';

View File

@@ -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()
}

View File

@@ -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")

View File

@@ -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}"

View File

@@ -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

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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)}")

View File

@@ -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